ccoding 0.2.0__py3-none-any.whl → 0.2.2__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.
- ccode/config.py +85 -24
- ccode/remote.py +125 -164
- ccode/remote_cli.py +141 -97
- ccode/remote_client.py +274 -0
- ccode/remote_common.py +194 -0
- ccode/remote_server.py +302 -0
- ccode/ui.py +104 -36
- ccode/web/app.js +205 -21
- ccode/web/index.html +17 -1
- ccode/web/style.css +111 -0
- ccoding-0.2.2.dist-info/METADATA +455 -0
- ccoding-0.2.2.dist-info/RECORD +27 -0
- {ccoding-0.2.0.dist-info → ccoding-0.2.2.dist-info}/entry_points.txt +1 -0
- ccoding-0.2.0.dist-info/METADATA +0 -189
- ccoding-0.2.0.dist-info/RECORD +0 -24
- {ccoding-0.2.0.dist-info → ccoding-0.2.2.dist-info}/WHEEL +0 -0
- {ccoding-0.2.0.dist-info → ccoding-0.2.2.dist-info}/licenses/LICENSE +0 -0
ccode/config.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import copy
|
|
3
4
|
import json
|
|
5
|
+
import socket
|
|
6
|
+
import uuid
|
|
4
7
|
from pathlib import Path
|
|
5
8
|
from typing import Any
|
|
6
9
|
|
|
@@ -23,11 +26,22 @@ DEFAULT_TOGGLES: dict[str, str] = {
|
|
|
23
26
|
|
|
24
27
|
DEFAULT_REMOTE: dict[str, Any] = {
|
|
25
28
|
"enabled": False,
|
|
26
|
-
"
|
|
27
|
-
"port": 8765,
|
|
28
|
-
"token": "",
|
|
29
|
+
"mode": "local",
|
|
29
30
|
"session_name": "ccode-claude",
|
|
30
31
|
"reuse_session": True,
|
|
32
|
+
"local": {
|
|
33
|
+
"host": "127.0.0.1",
|
|
34
|
+
"port": 8765,
|
|
35
|
+
"token": "",
|
|
36
|
+
},
|
|
37
|
+
"server": {
|
|
38
|
+
"url": "",
|
|
39
|
+
"token": "",
|
|
40
|
+
"device_name": "",
|
|
41
|
+
"device_id": "",
|
|
42
|
+
"auto_connect": True,
|
|
43
|
+
"verify_tls": True,
|
|
44
|
+
},
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
_ENV_SCHEMA: dict[str, Any] = {}
|
|
@@ -74,10 +88,75 @@ def default_config() -> dict[str, Any]:
|
|
|
74
88
|
"haiku": {"owned_by": None, "id": None},
|
|
75
89
|
},
|
|
76
90
|
"toggles": DEFAULT_TOGGLES.copy(),
|
|
77
|
-
"remote":
|
|
91
|
+
"remote": copy.deepcopy(DEFAULT_REMOTE),
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
|
|
95
|
+
def _device_name_default() -> str:
|
|
96
|
+
try:
|
|
97
|
+
return socket.gethostname() or "ccode-device"
|
|
98
|
+
except OSError:
|
|
99
|
+
return "ccode-device"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _new_device_id() -> str:
|
|
103
|
+
return f"ccode-{uuid.uuid4().hex}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalize_remote(remote: Any) -> dict[str, Any]:
|
|
107
|
+
normalized = copy.deepcopy(DEFAULT_REMOTE)
|
|
108
|
+
if not isinstance(remote, dict):
|
|
109
|
+
return normalized
|
|
110
|
+
|
|
111
|
+
enabled = remote.get("enabled")
|
|
112
|
+
if isinstance(enabled, bool):
|
|
113
|
+
normalized["enabled"] = enabled
|
|
114
|
+
mode = remote.get("mode")
|
|
115
|
+
if isinstance(mode, str) and mode in {"local", "server"}:
|
|
116
|
+
normalized["mode"] = mode
|
|
117
|
+
session_name = remote.get("session_name")
|
|
118
|
+
if isinstance(session_name, str):
|
|
119
|
+
normalized["session_name"] = session_name
|
|
120
|
+
reuse_session = remote.get("reuse_session")
|
|
121
|
+
if isinstance(reuse_session, bool):
|
|
122
|
+
normalized["reuse_session"] = reuse_session
|
|
123
|
+
|
|
124
|
+
local = remote.get("local") if isinstance(remote.get("local"), dict) else {}
|
|
125
|
+
host = local.get("host") if isinstance(local, dict) else None
|
|
126
|
+
if not isinstance(host, str):
|
|
127
|
+
host = remote.get("host")
|
|
128
|
+
if isinstance(host, str):
|
|
129
|
+
normalized["local"]["host"] = host
|
|
130
|
+
port = local.get("port") if isinstance(local, dict) else None
|
|
131
|
+
if not isinstance(port, int):
|
|
132
|
+
port = remote.get("port")
|
|
133
|
+
if isinstance(port, int):
|
|
134
|
+
normalized["local"]["port"] = port
|
|
135
|
+
token = local.get("token") if isinstance(local, dict) else None
|
|
136
|
+
if not isinstance(token, str):
|
|
137
|
+
token = remote.get("token")
|
|
138
|
+
if isinstance(token, str):
|
|
139
|
+
normalized["local"]["token"] = token
|
|
140
|
+
|
|
141
|
+
server = remote.get("server") if isinstance(remote.get("server"), dict) else {}
|
|
142
|
+
if isinstance(server, dict):
|
|
143
|
+
for key in ("url", "token", "device_name", "device_id"):
|
|
144
|
+
value = server.get(key)
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
normalized["server"][key] = value
|
|
147
|
+
for key in ("auto_connect", "verify_tls"):
|
|
148
|
+
value = server.get(key)
|
|
149
|
+
if isinstance(value, bool):
|
|
150
|
+
normalized["server"][key] = value
|
|
151
|
+
|
|
152
|
+
if normalized["mode"] == "server":
|
|
153
|
+
if not normalized["server"].get("device_id"):
|
|
154
|
+
normalized["server"]["device_id"] = _new_device_id()
|
|
155
|
+
if not normalized["server"].get("device_name"):
|
|
156
|
+
normalized["server"]["device_name"] = _device_name_default()
|
|
157
|
+
return normalized
|
|
158
|
+
|
|
159
|
+
|
|
81
160
|
def load_config() -> dict[str, Any]:
|
|
82
161
|
config = default_config()
|
|
83
162
|
try:
|
|
@@ -124,31 +203,13 @@ def load_config() -> dict[str, Any]:
|
|
|
124
203
|
if new_toggles:
|
|
125
204
|
config["toggles"] = new_toggles
|
|
126
205
|
|
|
127
|
-
remote = data.get("remote")
|
|
128
|
-
if isinstance(remote, dict):
|
|
129
|
-
enabled = remote.get("enabled")
|
|
130
|
-
if isinstance(enabled, bool):
|
|
131
|
-
config["remote"]["enabled"] = enabled
|
|
132
|
-
host = remote.get("host")
|
|
133
|
-
if isinstance(host, str):
|
|
134
|
-
config["remote"]["host"] = host
|
|
135
|
-
port = remote.get("port")
|
|
136
|
-
if isinstance(port, int):
|
|
137
|
-
config["remote"]["port"] = port
|
|
138
|
-
token = remote.get("token")
|
|
139
|
-
if isinstance(token, str):
|
|
140
|
-
config["remote"]["token"] = token
|
|
141
|
-
session_name = remote.get("session_name")
|
|
142
|
-
if isinstance(session_name, str):
|
|
143
|
-
config["remote"]["session_name"] = session_name
|
|
144
|
-
reuse_session = remote.get("reuse_session")
|
|
145
|
-
if isinstance(reuse_session, bool):
|
|
146
|
-
config["remote"]["reuse_session"] = reuse_session
|
|
206
|
+
config["remote"] = normalize_remote(data.get("remote"))
|
|
147
207
|
|
|
148
208
|
return config
|
|
149
209
|
|
|
150
210
|
|
|
151
211
|
def save_config(config: dict[str, Any]) -> None:
|
|
212
|
+
config["remote"] = normalize_remote(config.get("remote"))
|
|
152
213
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
153
214
|
CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
|
154
215
|
|
ccode/remote.py
CHANGED
|
@@ -1,27 +1,35 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
import asyncio
|
|
4
2
|
import contextlib
|
|
5
|
-
import datetime as dt
|
|
6
|
-
import fcntl
|
|
7
3
|
import json
|
|
8
4
|
import os
|
|
9
|
-
import re
|
|
10
|
-
import secrets
|
|
11
5
|
import shutil
|
|
12
6
|
import subprocess
|
|
13
7
|
import sys
|
|
14
8
|
import time
|
|
15
|
-
from importlib import resources
|
|
16
9
|
from typing import Any, Iterator
|
|
17
|
-
from urllib.parse import quote
|
|
18
10
|
from urllib.request import urlopen
|
|
19
11
|
|
|
20
|
-
from fastapi import FastAPI, Header, WebSocket, WebSocketDisconnect
|
|
21
|
-
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
22
|
-
|
|
23
12
|
from .api import build_claude_command, build_env
|
|
24
13
|
from .config import CONFIG_DIR, CONFIG_PATH, load_config
|
|
14
|
+
from .remote_common import (
|
|
15
|
+
_new_session_id,
|
|
16
|
+
_sanitize_session_name,
|
|
17
|
+
_utc_now,
|
|
18
|
+
asset_text,
|
|
19
|
+
auth_token_matches,
|
|
20
|
+
file_lock,
|
|
21
|
+
missing_extra_message,
|
|
22
|
+
remote_local_host_port,
|
|
23
|
+
remote_local_token,
|
|
24
|
+
remote_local_url,
|
|
25
|
+
remote_mode,
|
|
26
|
+
remote_server_config,
|
|
27
|
+
remote_server_url,
|
|
28
|
+
rotate_log,
|
|
29
|
+
token_matches,
|
|
30
|
+
web_asset_text,
|
|
31
|
+
websocket_auth_token,
|
|
32
|
+
)
|
|
25
33
|
|
|
26
34
|
REMOTE_SESSIONS_PATH = CONFIG_DIR / "remote_sessions.json"
|
|
27
35
|
REMOTE_SESSIONS_LOCK_PATH = CONFIG_DIR / "remote_sessions.lock"
|
|
@@ -78,17 +86,11 @@ class TerminalBridge:
|
|
|
78
86
|
|
|
79
87
|
|
|
80
88
|
def remote_host_port(config: dict[str, Any]) -> tuple[str, int]:
|
|
81
|
-
|
|
82
|
-
return str(remote.get("host") or "127.0.0.1"), int(remote.get("port") or 8765)
|
|
89
|
+
return remote_local_host_port(config)
|
|
83
90
|
|
|
84
91
|
|
|
85
92
|
def remote_url(config: dict[str, Any], include_token: bool = False) -> str:
|
|
86
|
-
|
|
87
|
-
url = f"http://{host}:{port}/"
|
|
88
|
-
token = str(config.get("remote", {}).get("token") or "")
|
|
89
|
-
if include_token and token:
|
|
90
|
-
return f"{url}?token={quote(token)}"
|
|
91
|
-
return url
|
|
93
|
+
return remote_local_url(config, include_token=include_token)
|
|
92
94
|
|
|
93
95
|
|
|
94
96
|
def _check_runtime() -> str | None:
|
|
@@ -111,22 +113,6 @@ def _session_exists(session_name: str) -> bool:
|
|
|
111
113
|
return result.returncode == 0
|
|
112
114
|
|
|
113
115
|
|
|
114
|
-
def _sanitize_session_name(value: str) -> str:
|
|
115
|
-
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-")
|
|
116
|
-
return sanitized or "ccode-claude"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _new_session_id(config: dict[str, Any]) -> str:
|
|
120
|
-
remote = config.get("remote", {})
|
|
121
|
-
prefix = _sanitize_session_name(str(remote.get("session_name") or "ccode-claude"))
|
|
122
|
-
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
123
|
-
return f"{prefix}-{stamp}-{os.getpid()}"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def _utc_now() -> str:
|
|
127
|
-
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
128
|
-
|
|
129
|
-
|
|
130
116
|
def _log_line(message: str) -> str:
|
|
131
117
|
return f"[{_utc_now()}] {message}"
|
|
132
118
|
|
|
@@ -141,26 +127,15 @@ def _append_log_event(message: str) -> None:
|
|
|
141
127
|
log_file.write(_log_line(message) + "\n")
|
|
142
128
|
|
|
143
129
|
|
|
144
|
-
@contextlib.contextmanager
|
|
145
|
-
def _file_lock(path: Any) -> Iterator[None]:
|
|
146
|
-
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
147
|
-
with path.open("a+") as lock_file:
|
|
148
|
-
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
149
|
-
try:
|
|
150
|
-
yield
|
|
151
|
-
finally:
|
|
152
|
-
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
153
|
-
|
|
154
|
-
|
|
155
130
|
@contextlib.contextmanager
|
|
156
131
|
def with_session_registry_lock() -> Iterator[None]:
|
|
157
|
-
with
|
|
132
|
+
with file_lock(REMOTE_SESSIONS_LOCK_PATH):
|
|
158
133
|
yield
|
|
159
134
|
|
|
160
135
|
|
|
161
136
|
@contextlib.contextmanager
|
|
162
137
|
def with_remote_hub_lock() -> Iterator[None]:
|
|
163
|
-
with
|
|
138
|
+
with file_lock(REMOTE_HUB_LOCK_PATH):
|
|
164
139
|
yield
|
|
165
140
|
|
|
166
141
|
|
|
@@ -213,30 +188,36 @@ def _running_tmux_sessions() -> set[str]:
|
|
|
213
188
|
def scan_remote_sessions(prune: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
214
189
|
with with_session_registry_lock():
|
|
215
190
|
registry = load_session_registry()
|
|
216
|
-
|
|
217
|
-
if not isinstance(
|
|
191
|
+
raw_sessions = registry.setdefault("sessions", {})
|
|
192
|
+
if not isinstance(raw_sessions, dict):
|
|
218
193
|
return [], []
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
194
|
+
session_items = list(raw_sessions.items())
|
|
195
|
+
|
|
196
|
+
tmux_sessions = _running_tmux_sessions()
|
|
197
|
+
running: list[dict[str, Any]] = []
|
|
198
|
+
dead: list[dict[str, Any]] = []
|
|
199
|
+
dead_ids: list[str] = []
|
|
200
|
+
for session_id, session in session_items:
|
|
201
|
+
if not isinstance(session, dict):
|
|
202
|
+
dead_ids.append(session_id)
|
|
203
|
+
continue
|
|
204
|
+
tmux_session = session.get("tmux_session")
|
|
205
|
+
if isinstance(tmux_session, str) and tmux_session in tmux_sessions:
|
|
206
|
+
running.append(session)
|
|
207
|
+
else:
|
|
208
|
+
dead.append(session)
|
|
209
|
+
dead_ids.append(session_id)
|
|
210
|
+
if prune and dead_ids:
|
|
211
|
+
with with_session_registry_lock():
|
|
212
|
+
registry = load_session_registry()
|
|
213
|
+
sessions = registry.setdefault("sessions", {})
|
|
214
|
+
if isinstance(sessions, dict):
|
|
215
|
+
for session_id in dead_ids:
|
|
216
|
+
sessions.pop(session_id, None)
|
|
217
|
+
save_session_registry(registry)
|
|
218
|
+
running.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
|
219
|
+
dead.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
|
220
|
+
return running, dead
|
|
240
221
|
|
|
241
222
|
|
|
242
223
|
def prune_dead_sessions() -> None:
|
|
@@ -300,21 +281,7 @@ def _hub_is_running(config: dict[str, Any]) -> bool:
|
|
|
300
281
|
|
|
301
282
|
|
|
302
283
|
def rotate_remote_server_log() -> None:
|
|
303
|
-
|
|
304
|
-
size = REMOTE_SERVER_LOG_PATH.stat().st_size
|
|
305
|
-
except FileNotFoundError:
|
|
306
|
-
return
|
|
307
|
-
if size < REMOTE_SERVER_LOG_MAX_BYTES:
|
|
308
|
-
return
|
|
309
|
-
|
|
310
|
-
for index in range(REMOTE_SERVER_LOG_BACKUPS, 0, -1):
|
|
311
|
-
source = REMOTE_SERVER_LOG_PATH if index == 1 else REMOTE_SERVER_LOG_PATH.with_name(f"{REMOTE_SERVER_LOG_PATH.name}.{index - 1}")
|
|
312
|
-
target = REMOTE_SERVER_LOG_PATH.with_name(f"{REMOTE_SERVER_LOG_PATH.name}.{index}")
|
|
313
|
-
if not source.exists():
|
|
314
|
-
continue
|
|
315
|
-
if index == REMOTE_SERVER_LOG_BACKUPS and target.exists():
|
|
316
|
-
target.unlink()
|
|
317
|
-
source.replace(target)
|
|
284
|
+
rotate_log(REMOTE_SERVER_LOG_PATH, REMOTE_SERVER_LOG_MAX_BYTES, REMOTE_SERVER_LOG_BACKUPS)
|
|
318
285
|
|
|
319
286
|
|
|
320
287
|
def ensure_remote_hub(config: dict[str, Any]) -> str | None:
|
|
@@ -366,62 +333,20 @@ def _current_remote_token() -> str:
|
|
|
366
333
|
cached_mtime, cached_token = _TOKEN_CACHE
|
|
367
334
|
if cached_mtime == mtime:
|
|
368
335
|
return cached_token
|
|
369
|
-
token =
|
|
336
|
+
token = remote_local_token(load_config()).strip()
|
|
370
337
|
_TOKEN_CACHE = (mtime, token)
|
|
371
338
|
return token
|
|
372
339
|
|
|
373
340
|
|
|
374
|
-
def _token_matches(expected: str, provided: str | None) -> bool:
|
|
375
|
-
value = (provided or "").strip()
|
|
376
|
-
return bool(value) and bool(expected) and secrets.compare_digest(value, expected)
|
|
377
|
-
|
|
378
|
-
|
|
379
341
|
def _valid_token(token: str | None) -> bool:
|
|
380
|
-
return
|
|
342
|
+
return token_matches(_current_remote_token(), token)
|
|
381
343
|
|
|
382
344
|
|
|
383
345
|
def _valid_auth_token(token: str | None, authorization: str | None = None) -> bool:
|
|
384
|
-
|
|
385
|
-
if _token_matches(expected, token):
|
|
386
|
-
return True
|
|
387
|
-
if not authorization:
|
|
388
|
-
return False
|
|
389
|
-
scheme, _, value = authorization.partition(" ")
|
|
390
|
-
return scheme.lower() == "bearer" and _token_matches(expected, value)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
async def _websocket_token(websocket: WebSocket) -> str | None:
|
|
394
|
-
try:
|
|
395
|
-
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
|
|
396
|
-
payload = json.loads(raw)
|
|
397
|
-
except (asyncio.TimeoutError, json.JSONDecodeError):
|
|
398
|
-
return None
|
|
399
|
-
if payload.get("type") != "auth":
|
|
400
|
-
return None
|
|
401
|
-
token = payload.get("token")
|
|
402
|
-
return token if isinstance(token, str) else None
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
_ASSET_TEXT_CACHE: dict[str, str] = {}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def _cached_asset_text(key: str, path: Any) -> str:
|
|
409
|
-
cached = _ASSET_TEXT_CACHE.get(key)
|
|
410
|
-
if cached is None:
|
|
411
|
-
cached = path.read_text(encoding="utf-8")
|
|
412
|
-
_ASSET_TEXT_CACHE[key] = cached
|
|
413
|
-
return cached
|
|
346
|
+
return auth_token_matches(_current_remote_token(), token, authorization)
|
|
414
347
|
|
|
415
348
|
|
|
416
|
-
def
|
|
417
|
-
return _cached_asset_text(name, resources.files("ccode.web").joinpath(name))
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def _web_asset_text(name: str) -> str:
|
|
421
|
-
return _cached_asset_text(f"assets/{name}", resources.files("ccode.web").joinpath("assets", name))
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
async def _attach_websocket_to_session(websocket: WebSocket, session: dict[str, Any]) -> None:
|
|
349
|
+
async def _attach_websocket_to_session(websocket: Any, session: dict[str, Any]) -> None:
|
|
425
350
|
session_id = str(session.get("id") or "<unknown>")
|
|
426
351
|
tmux_session = session.get("tmux_session")
|
|
427
352
|
if not isinstance(tmux_session, str) or not _session_exists(tmux_session):
|
|
@@ -452,7 +377,7 @@ async def _attach_websocket_to_session(websocket: WebSocket, session: dict[str,
|
|
|
452
377
|
cols = payload.get("cols")
|
|
453
378
|
if isinstance(rows, int) and isinstance(cols, int):
|
|
454
379
|
await bridge.resize(rows, cols)
|
|
455
|
-
except
|
|
380
|
+
except Exception:
|
|
456
381
|
_log_event(f"websocket disconnected session_id={session_id}")
|
|
457
382
|
finally:
|
|
458
383
|
if reader is not None:
|
|
@@ -461,21 +386,27 @@ async def _attach_websocket_to_session(websocket: WebSocket, session: dict[str,
|
|
|
461
386
|
_log_event(f"websocket bridge closed session_id={session_id}")
|
|
462
387
|
|
|
463
388
|
|
|
464
|
-
def create_app() ->
|
|
389
|
+
def create_app() -> Any:
|
|
390
|
+
try:
|
|
391
|
+
from fastapi import FastAPI, Header, WebSocket as FastAPIWebSocket
|
|
392
|
+
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
|
393
|
+
except ImportError:
|
|
394
|
+
raise RuntimeError(missing_extra_message("remote-server", "fastapi, uvicorn")) from None
|
|
395
|
+
|
|
465
396
|
app = FastAPI()
|
|
466
397
|
|
|
467
398
|
@app.get("/")
|
|
468
399
|
async def index() -> Response:
|
|
469
400
|
_log_event("served remote index")
|
|
470
|
-
return HTMLResponse(
|
|
401
|
+
return HTMLResponse(asset_text("index.html"), headers={"Cache-Control": "no-store"})
|
|
471
402
|
|
|
472
403
|
@app.get("/app.js")
|
|
473
404
|
async def app_js() -> Response:
|
|
474
|
-
return Response(
|
|
405
|
+
return Response(asset_text("app.js"), media_type="application/javascript")
|
|
475
406
|
|
|
476
407
|
@app.get("/style.css")
|
|
477
408
|
async def style_css() -> Response:
|
|
478
|
-
return Response(
|
|
409
|
+
return Response(asset_text("style.css"), media_type="text/css")
|
|
479
410
|
|
|
480
411
|
@app.get("/assets/{asset_name}")
|
|
481
412
|
async def web_asset(asset_name: str) -> Response:
|
|
@@ -487,7 +418,7 @@ def create_app() -> FastAPI:
|
|
|
487
418
|
media_type = media_types.get(asset_name)
|
|
488
419
|
if media_type is None:
|
|
489
420
|
return Response(status_code=404)
|
|
490
|
-
return Response(
|
|
421
|
+
return Response(web_asset_text(asset_name), media_type=media_type)
|
|
491
422
|
|
|
492
423
|
@app.get("/health")
|
|
493
424
|
async def health() -> dict[str, Any]:
|
|
@@ -537,10 +468,10 @@ def create_app() -> FastAPI:
|
|
|
537
468
|
return JSONResponse({"deprecated": True, "sessions": len(sessions)})
|
|
538
469
|
|
|
539
470
|
@app.websocket("/ws/{session_id}")
|
|
540
|
-
async def websocket_session(websocket:
|
|
471
|
+
async def websocket_session(websocket: FastAPIWebSocket, session_id: str) -> None:
|
|
541
472
|
await websocket.accept()
|
|
542
473
|
_log_event(f"websocket accepted session_id={session_id}")
|
|
543
|
-
if not _valid_token(await
|
|
474
|
+
if not _valid_token(await websocket_auth_token(websocket)):
|
|
544
475
|
_log_event(f"websocket unauthorized session_id={session_id}")
|
|
545
476
|
await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
|
|
546
477
|
await websocket.close(code=1008)
|
|
@@ -554,10 +485,10 @@ def create_app() -> FastAPI:
|
|
|
554
485
|
await _attach_websocket_to_session(websocket, session)
|
|
555
486
|
|
|
556
487
|
@app.websocket("/ws")
|
|
557
|
-
async def legacy_websocket(websocket:
|
|
488
|
+
async def legacy_websocket(websocket: FastAPIWebSocket) -> None:
|
|
558
489
|
await websocket.accept()
|
|
559
490
|
_log_event("legacy websocket accepted")
|
|
560
|
-
if not _valid_token(await
|
|
491
|
+
if not _valid_token(await websocket_auth_token(websocket)):
|
|
561
492
|
_log_event("legacy websocket unauthorized")
|
|
562
493
|
await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
|
|
563
494
|
await websocket.close(code=1008)
|
|
@@ -587,7 +518,7 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
|
|
|
587
518
|
try:
|
|
588
519
|
import uvicorn
|
|
589
520
|
except ImportError:
|
|
590
|
-
return "
|
|
521
|
+
return missing_extra_message("remote-server", "uvicorn")
|
|
591
522
|
|
|
592
523
|
host, port = remote_host_port(config)
|
|
593
524
|
_log_event(f"remote hub serving host={host} port={port} pid={os.getpid()}")
|
|
@@ -598,8 +529,12 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
|
|
|
598
529
|
|
|
599
530
|
|
|
600
531
|
def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
|
|
601
|
-
|
|
532
|
+
mode = remote_mode(config)
|
|
533
|
+
if mode == "local" and not remote_local_token(config).strip():
|
|
602
534
|
return "Remote token is required. Set it in the config screen or press g on the TOKEN field to generate one."
|
|
535
|
+
server = remote_server_config(config)
|
|
536
|
+
if mode == "server" and (not remote_server_url(config) or not str(server.get("token") or "").strip()):
|
|
537
|
+
return "Remote server URL and client token are required in server mode."
|
|
603
538
|
error = _check_runtime()
|
|
604
539
|
if error is not None:
|
|
605
540
|
return error
|
|
@@ -626,26 +561,52 @@ def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
|
|
|
626
561
|
)
|
|
627
562
|
_append_log_event(f"registered remote session id={session_name} cwd={os.getcwd()} args={len(args)}")
|
|
628
563
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
564
|
+
if mode == "server":
|
|
565
|
+
server_url = remote_server_url(config)
|
|
566
|
+
if server.get("auto_connect", True):
|
|
567
|
+
try:
|
|
568
|
+
from .remote_client import ensure_remote_client_connector
|
|
569
|
+
except ImportError:
|
|
570
|
+
return missing_extra_message("remote-client", "websockets, pexpect")
|
|
571
|
+
error = ensure_remote_client_connector(config)
|
|
572
|
+
if error is not None:
|
|
573
|
+
return error
|
|
574
|
+
print("ccode remote session started\n")
|
|
575
|
+
print("Server:")
|
|
576
|
+
print(f" {server_url}")
|
|
577
|
+
print(f" device: {server.get('device_name') or '<unnamed>'} ({server.get('device_id') or '<unset>'})\n")
|
|
578
|
+
print("Tmux session:")
|
|
579
|
+
print(f" {session_name}\n")
|
|
580
|
+
if server_url.startswith("http://"):
|
|
581
|
+
print("Security warning:")
|
|
582
|
+
print(" Server URL uses plain HTTP/WS. Use HTTPS/WSS in production.\n")
|
|
583
|
+
if server.get("auto_connect", True):
|
|
584
|
+
print("This terminal will attach to this tmux session. The connector stays running independently.")
|
|
585
|
+
print("Detach with Ctrl+B then D. Browser users can choose this device/session from the server.\n")
|
|
586
|
+
else:
|
|
587
|
+
print("This terminal will attach to this tmux session. Start the connector with `ccode-remote client start`.")
|
|
588
|
+
print("Detach with Ctrl+B then D.\n")
|
|
589
|
+
else:
|
|
590
|
+
error = ensure_remote_hub(config)
|
|
591
|
+
if error is not None:
|
|
592
|
+
return error
|
|
593
|
+
|
|
594
|
+
host, _port = remote_host_port(config)
|
|
595
|
+
url = remote_url(config, include_token=False)
|
|
596
|
+
base_url = remote_url(config, include_token=False).rstrip("/")
|
|
597
|
+
|
|
598
|
+
print("ccode remote session started\n")
|
|
599
|
+
print("Local:")
|
|
600
|
+
print(f" {url}\n")
|
|
601
|
+
print("Cloudflare Tunnel:")
|
|
602
|
+
print(f" cloudflared tunnel --url {base_url}\n")
|
|
603
|
+
print("Tmux session:")
|
|
604
|
+
print(f" {session_name}\n")
|
|
605
|
+
if host == "0.0.0.0":
|
|
606
|
+
print("Security warning:")
|
|
607
|
+
print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and token.\n")
|
|
608
|
+
print("This terminal will attach to this tmux session. The web hub stays running independently.")
|
|
609
|
+
print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
|
|
649
610
|
|
|
650
611
|
try:
|
|
651
612
|
return _attach_tmux_session(session_name)
|