ccoding 0.2.2__py3-none-any.whl → 0.2.4__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 CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import json
5
+ import secrets
5
6
  import socket
6
7
  import uuid
7
8
  from pathlib import Path
@@ -32,11 +33,13 @@ DEFAULT_REMOTE: dict[str, Any] = {
32
33
  "local": {
33
34
  "host": "127.0.0.1",
34
35
  "port": 8765,
35
- "token": "",
36
+ "appId": "",
37
+ "appKey": "",
36
38
  },
37
39
  "server": {
38
40
  "url": "",
39
- "token": "",
41
+ "appId": "",
42
+ "appKey": "",
40
43
  "device_name": "",
41
44
  "device_id": "",
42
45
  "auto_connect": True,
@@ -103,6 +106,10 @@ def _new_device_id() -> str:
103
106
  return f"ccode-{uuid.uuid4().hex}"
104
107
 
105
108
 
109
+ def _new_app_id() -> str:
110
+ return f"app_{secrets.token_urlsafe(9)}"
111
+
112
+
106
113
  def normalize_remote(remote: Any) -> dict[str, Any]:
107
114
  normalized = copy.deepcopy(DEFAULT_REMOTE)
108
115
  if not isinstance(remote, dict):
@@ -132,18 +139,31 @@ def normalize_remote(remote: Any) -> dict[str, Any]:
132
139
  port = remote.get("port")
133
140
  if isinstance(port, int):
134
141
  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
142
+ local_app_id = local.get("appId") if isinstance(local, dict) else None
143
+ local_app_key = local.get("appKey") if isinstance(local, dict) else None
144
+ local_token = local.get("token") if isinstance(local, dict) else None
145
+ if not isinstance(local_token, str):
146
+ local_token = remote.get("token")
147
+ if isinstance(local_app_id, str):
148
+ normalized["local"]["appId"] = local_app_id
149
+ elif isinstance(local_token, str) and local_token:
150
+ normalized["local"]["appId"] = _new_app_id()
151
+ if isinstance(local_app_key, str):
152
+ normalized["local"]["appKey"] = local_app_key
153
+ elif isinstance(local_token, str):
154
+ normalized["local"]["appKey"] = local_token
140
155
 
141
156
  server = remote.get("server") if isinstance(remote.get("server"), dict) else {}
142
157
  if isinstance(server, dict):
143
- for key in ("url", "token", "device_name", "device_id"):
158
+ server_token = server.get("token")
159
+ for key in ("url", "appId", "appKey", "device_name", "device_id"):
144
160
  value = server.get(key)
145
161
  if isinstance(value, str):
146
162
  normalized["server"][key] = value
163
+ if not normalized["server"].get("appId") and isinstance(server_token, str) and server_token:
164
+ normalized["server"]["appId"] = _new_app_id()
165
+ if not normalized["server"].get("appKey") and isinstance(server_token, str):
166
+ normalized["server"]["appKey"] = server_token
147
167
  for key in ("auto_connect", "verify_tls"):
148
168
  value = server.get(key)
149
169
  if isinstance(value, bool):
ccode/remote.py CHANGED
@@ -15,20 +15,20 @@ from .remote_common import (
15
15
  _new_session_id,
16
16
  _sanitize_session_name,
17
17
  _utc_now,
18
+ AuthResult,
18
19
  asset_text,
19
- auth_token_matches,
20
20
  file_lock,
21
21
  missing_extra_message,
22
+ remote_local_app_id,
23
+ remote_local_app_key,
22
24
  remote_local_host_port,
23
- remote_local_token,
24
25
  remote_local_url,
25
26
  remote_mode,
26
27
  remote_server_config,
27
28
  remote_server_url,
28
29
  rotate_log,
29
- token_matches,
30
+ verify_auth_payload,
30
31
  web_asset_text,
31
- websocket_auth_token,
32
32
  )
33
33
 
34
34
  REMOTE_SESSIONS_PATH = CONFIG_DIR / "remote_sessions.json"
@@ -321,29 +321,33 @@ def ensure_remote_hub(config: dict[str, Any]) -> str | None:
321
321
  return f"Failed to start remote hub. See {REMOTE_SERVER_LOG_PATH} for details."
322
322
 
323
323
 
324
- _TOKEN_CACHE: tuple[float | None, str] = (None, "")
324
+ _AUTH_CACHE: tuple[float | None, str, str] = (None, "", "")
325
325
 
326
326
 
327
- def _current_remote_token() -> str:
328
- global _TOKEN_CACHE
327
+ def _current_remote_credentials() -> tuple[str, str]:
328
+ global _AUTH_CACHE
329
329
  try:
330
330
  mtime = CONFIG_PATH.stat().st_mtime
331
331
  except FileNotFoundError:
332
332
  mtime = None
333
- cached_mtime, cached_token = _TOKEN_CACHE
333
+ cached_mtime, app_id, app_key = _AUTH_CACHE
334
334
  if cached_mtime == mtime:
335
- return cached_token
336
- token = remote_local_token(load_config()).strip()
337
- _TOKEN_CACHE = (mtime, token)
338
- return token
335
+ return app_id, app_key
336
+ config = load_config()
337
+ app_id = remote_local_app_id(config).strip()
338
+ app_key = remote_local_app_key(config).strip()
339
+ _AUTH_CACHE = (mtime, app_id, app_key)
340
+ return app_id, app_key
339
341
 
340
342
 
341
- def _valid_token(token: str | None) -> bool:
342
- return token_matches(_current_remote_token(), token)
343
+ def _valid_auth_payload(payload: dict[str, Any], scope: str) -> AuthResult:
344
+ app_id, app_key = _current_remote_credentials()
345
+ return verify_auth_payload(app_id, app_key, payload, scope)
343
346
 
344
347
 
345
- def _valid_auth_token(token: str | None, authorization: str | None = None) -> bool:
346
- return auth_token_matches(_current_remote_token(), token, authorization)
348
+ def _auth_response(result: AuthResult, response_class: Any) -> Any:
349
+ status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
350
+ return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
347
351
 
348
352
 
349
353
  async def _attach_websocket_to_session(websocket: Any, session: dict[str, Any]) -> None:
@@ -402,11 +406,11 @@ def create_app() -> Any:
402
406
 
403
407
  @app.get("/app.js")
404
408
  async def app_js() -> Response:
405
- return Response(asset_text("app.js"), media_type="application/javascript")
409
+ return Response(asset_text("app.js"), media_type="application/javascript", headers={"Cache-Control": "no-store"})
406
410
 
407
411
  @app.get("/style.css")
408
412
  async def style_css() -> Response:
409
- return Response(asset_text("style.css"), media_type="text/css")
413
+ return Response(asset_text("style.css"), media_type="text/css", headers={"Cache-Control": "no-store"})
410
414
 
411
415
  @app.get("/assets/{asset_name}")
412
416
  async def web_asset(asset_name: str) -> Response:
@@ -418,20 +422,22 @@ def create_app() -> Any:
418
422
  media_type = media_types.get(asset_name)
419
423
  if media_type is None:
420
424
  return Response(status_code=404)
421
- return Response(web_asset_text(asset_name), media_type=media_type)
425
+ return Response(web_asset_text(asset_name), media_type=media_type, headers={"Cache-Control": "no-store"})
422
426
 
423
427
  @app.get("/health")
424
428
  async def health() -> dict[str, Any]:
425
- return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True}
429
+ return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True, "remote_kind": "local"}
426
430
 
427
431
  @app.get("/api/sessions")
428
432
  async def sessions(
429
- token: str | None = None,
430
- authorization: str | None = Header(default=None),
433
+ x_ccode_app_id: str | None = Header(default=None),
434
+ x_ccode_timestamp: str | None = Header(default=None),
435
+ x_ccode_sign: str | None = Header(default=None),
431
436
  ) -> Response:
432
- if not _valid_auth_token(token, authorization):
433
- _log_event("api sessions unauthorized")
434
- return JSONResponse({"error": "unauthorized"}, status_code=401)
437
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
438
+ if not auth_result.ok:
439
+ _log_event(f"api sessions unauthorized error={auth_result.error}")
440
+ return _auth_response(auth_result, JSONResponse)
435
441
  sessions = list_remote_sessions()
436
442
  _log_event(f"api sessions returned count={len(sessions)}")
437
443
  return JSONResponse({"sessions": sessions})
@@ -439,12 +445,14 @@ def create_app() -> Any:
439
445
  @app.get("/api/sessions/{session_id}")
440
446
  async def session_detail(
441
447
  session_id: str,
442
- token: str | None = None,
443
- authorization: str | None = Header(default=None),
448
+ x_ccode_app_id: str | None = Header(default=None),
449
+ x_ccode_timestamp: str | None = Header(default=None),
450
+ x_ccode_sign: str | None = Header(default=None),
444
451
  ) -> Response:
445
- if not _valid_auth_token(token, authorization):
446
- _log_event(f"api session detail unauthorized session_id={session_id}")
447
- return JSONResponse({"error": "unauthorized"}, status_code=401)
452
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
453
+ if not auth_result.ok:
454
+ _log_event(f"api session detail unauthorized session_id={session_id} error={auth_result.error}")
455
+ return _auth_response(auth_result, JSONResponse)
448
456
  session = get_remote_session(session_id)
449
457
  if session is None or not _session_is_running(session):
450
458
  _log_event(f"api session detail not_found session_id={session_id}")
@@ -454,12 +462,14 @@ def create_app() -> Any:
454
462
 
455
463
  @app.get("/api/session")
456
464
  async def legacy_session(
457
- token: str | None = None,
458
- authorization: str | None = Header(default=None),
465
+ x_ccode_app_id: str | None = Header(default=None),
466
+ x_ccode_timestamp: str | None = Header(default=None),
467
+ x_ccode_sign: str | None = Header(default=None),
459
468
  ) -> Response:
460
- if not _valid_auth_token(token, authorization):
461
- _log_event("legacy api session unauthorized")
462
- return JSONResponse({"error": "unauthorized"}, status_code=401)
469
+ auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
470
+ if not auth_result.ok:
471
+ _log_event(f"legacy api session unauthorized error={auth_result.error}")
472
+ return _auth_response(auth_result, JSONResponse)
463
473
  sessions = list_remote_sessions()
464
474
  _log_event(f"legacy api session requested running_count={len(sessions)}")
465
475
  if len(sessions) == 1:
@@ -471,9 +481,10 @@ def create_app() -> Any:
471
481
  async def websocket_session(websocket: FastAPIWebSocket, session_id: str) -> None:
472
482
  await websocket.accept()
473
483
  _log_event(f"websocket accepted session_id={session_id}")
474
- if not _valid_token(await websocket_auth_token(websocket)):
475
- _log_event(f"websocket unauthorized session_id={session_id}")
476
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
484
+ auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
485
+ if not auth_result.ok:
486
+ _log_event(f"websocket unauthorized session_id={session_id} error={auth_result.error}")
487
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
477
488
  await websocket.close(code=1008)
478
489
  return
479
490
  session = get_remote_session(session_id)
@@ -488,9 +499,10 @@ def create_app() -> Any:
488
499
  async def legacy_websocket(websocket: FastAPIWebSocket) -> None:
489
500
  await websocket.accept()
490
501
  _log_event("legacy websocket accepted")
491
- if not _valid_token(await websocket_auth_token(websocket)):
492
- _log_event("legacy websocket unauthorized")
493
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
502
+ auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
503
+ if not auth_result.ok:
504
+ _log_event(f"legacy websocket unauthorized error={auth_result.error}")
505
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
494
506
  await websocket.close(code=1008)
495
507
  return
496
508
  sessions = list_remote_sessions()
@@ -530,11 +542,11 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
530
542
 
531
543
  def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
532
544
  mode = remote_mode(config)
533
- if mode == "local" and not remote_local_token(config).strip():
534
- return "Remote token is required. Set it in the config screen or press g on the TOKEN field to generate one."
545
+ if mode == "local" and (not remote_local_app_id(config).strip() or not remote_local_app_key(config).strip()):
546
+ return "Remote app ID and app key are required. Set them in the config screen or press g on the APP ID / APP KEY fields to generate them."
535
547
  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."
548
+ if mode == "server" and (not remote_server_url(config) or not str(server.get("appId") or "").strip() or not str(server.get("appKey") or "").strip()):
549
+ return "Remote server URL, client app ID, and client app key are required in server mode."
538
550
  error = _check_runtime()
539
551
  if error is not None:
540
552
  return error
@@ -604,7 +616,7 @@ def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
604
616
  print(f" {session_name}\n")
605
617
  if host == "0.0.0.0":
606
618
  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")
619
+ print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and app credentials.\n")
608
620
  print("This terminal will attach to this tmux session. The web hub stays running independently.")
609
621
  print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
610
622
 
ccode/remote_cli.py CHANGED
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  from .config import load_config, mask_secret
11
- from .remote_common import pid_from_path, remote_display_url, remote_local_host_port, remote_local_token, remote_mode, remote_server_config, remote_server_url
11
+ from .remote_common import pid_from_path, remote_display_url, remote_local_app_id, remote_local_app_key, remote_local_host_port, remote_mode, remote_server_config, remote_server_url
12
12
 
13
13
 
14
14
  def _format_bytes(value: int) -> str:
@@ -90,12 +90,14 @@ def print_status(kind: str | None = None) -> int:
90
90
  server = remote_server_config(config)
91
91
  print(f" device name: {server.get('device_name') or '<unset>'}")
92
92
  print(f" device id: {server.get('device_id') or '<unset>'}")
93
- print(f" client token: {mask_secret(str(server.get('token') or ''))}")
93
+ print(f" client app id: {server.get('appId') or '<unset>'}")
94
+ print(f" client app key: {mask_secret(str(server.get('appKey') or ''))}")
94
95
  else:
95
96
  host, port = remote_local_host_port(config)
96
97
  print(f" host: {host}")
97
98
  print(f" port: {port}")
98
- print(f" token: {mask_secret(remote_local_token(config))}")
99
+ print(f" app id: {remote_local_app_id(config) or '<unset>'}")
100
+ print(f" app key: {mask_secret(remote_local_app_key(config))}")
99
101
  print()
100
102
 
101
103
  if effective == "client":
ccode/remote_client.py CHANGED
@@ -10,7 +10,7 @@ import time
10
10
  from typing import Any
11
11
 
12
12
  from .config import CONFIG_DIR, load_config
13
- from .remote_common import file_lock, http_to_ws_url, missing_extra_message, pid_from_path, remote_server_config, remote_server_url, rotate_log
13
+ from .remote_common import build_auth_payload, file_lock, http_to_ws_url, missing_extra_message, pid_from_path, remote_server_app_id, remote_server_app_key, remote_server_config, remote_server_url, rotate_log
14
14
 
15
15
  REMOTE_CLIENT_LOCK_PATH = CONFIG_DIR / "remote_client.lock"
16
16
  REMOTE_CLIENT_PID_PATH = CONFIG_DIR / "remote_client.pid"
@@ -179,9 +179,9 @@ async def open_attach_socket(attach_id: str, session_id: str, config: dict[str,
179
179
  except ImportError:
180
180
  _log_event(missing_extra_message("remote-client", "websockets"))
181
181
  return
182
- server = remote_server_config(config)
182
+ auth_payload = build_auth_payload(remote_server_app_id(config), remote_server_app_key(config), "client.attach")
183
183
  async with websockets.connect(_attach_url(config, attach_id), **_connect_kwargs(config)) as ws:
184
- await ws.send(json.dumps({"type": "auth", "token": server.get("token") or "", "attach_id": attach_id}))
184
+ await ws.send(json.dumps({"type": "auth", **auth_payload, "attach_id": attach_id}))
185
185
  await attach_local_session_to_websocket(ws, session_id)
186
186
 
187
187
 
@@ -217,10 +217,11 @@ async def connect_control_loop(config: dict[str, Any]) -> None:
217
217
  from .remote import scan_remote_sessions
218
218
 
219
219
  running, _dead = scan_remote_sessions(prune=True)
220
+ auth_payload = build_auth_payload(remote_server_app_id(config), remote_server_app_key(config), "client.control")
220
221
  async with websockets.connect(_control_url(config), **_connect_kwargs(config)) as ws:
221
222
  await ws.send(json.dumps({
222
223
  "type": "hello",
223
- "token": server.get("token") or "",
224
+ **auth_payload,
224
225
  "device_id": server.get("device_id") or "",
225
226
  "device_name": server.get("device_name") or "",
226
227
  "version": "0.2.0",
@@ -246,9 +247,8 @@ async def _run_loop(config: dict[str, Any]) -> None:
246
247
  def run_remote_client_forever(config: dict[str, Any]) -> str | None:
247
248
  if not remote_server_url(config):
248
249
  return "Remote server URL is required."
249
- server = remote_server_config(config)
250
- if not str(server.get("token") or "").strip():
251
- return "Remote server client token is required."
250
+ if not remote_server_app_id(config).strip() or not remote_server_app_key(config).strip():
251
+ return "Remote server client app ID and app key are required."
252
252
  if remote_server_url(config).startswith("http://"):
253
253
  _log_event("warning: remote server URL uses plain HTTP/WS")
254
254
  try:
ccode/remote_common.py CHANGED
@@ -1,16 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
3
+ import base64
4
4
  import contextlib
5
5
  import datetime as dt
6
6
  import fcntl
7
- import json
7
+ import hashlib
8
+ import hmac
8
9
  import os
9
10
  import re
10
11
  import secrets
12
+ import time
13
+ from dataclasses import dataclass
11
14
  from importlib import resources
12
15
  from typing import Any, Iterator
13
- from urllib.parse import quote, urlparse
16
+ from urllib.parse import urlencode, urlparse
14
17
 
15
18
  from .config import CONFIG_DIR
16
19
 
@@ -42,17 +45,25 @@ def remote_local_host_port(config: dict[str, Any]) -> tuple[str, int]:
42
45
  return host, port
43
46
 
44
47
 
45
- def remote_local_token(config: dict[str, Any]) -> str:
46
- return str(remote_local_config(config).get("token") or "")
48
+ def remote_local_app_id(config: dict[str, Any]) -> str:
49
+ return str(remote_local_config(config).get("appId") or "")
50
+
51
+
52
+ def remote_local_app_key(config: dict[str, Any]) -> str:
53
+ return str(remote_local_config(config).get("appKey") or "")
54
+
55
+
56
+ def remote_server_app_id(config: dict[str, Any]) -> str:
57
+ return str(remote_server_config(config).get("appId") or "")
58
+
59
+
60
+ def remote_server_app_key(config: dict[str, Any]) -> str:
61
+ return str(remote_server_config(config).get("appKey") or "")
47
62
 
48
63
 
49
64
  def remote_local_url(config: dict[str, Any], include_token: bool = False) -> str:
50
65
  host, port = remote_local_host_port(config)
51
- url = f"http://{host}:{port}/"
52
- token = remote_local_token(config)
53
- if include_token and token:
54
- return f"{url}?token={quote(token)}"
55
- return url
66
+ return f"http://{host}:{port}/"
56
67
 
57
68
 
58
69
  def remote_server_url(config: dict[str, Any]) -> str:
@@ -98,35 +109,81 @@ def _utc_now() -> str:
98
109
  return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
99
110
 
100
111
 
101
- def token_matches(expected: str, provided: str | None) -> bool:
102
- value = (provided or "").strip()
103
- expected = (expected or "").strip()
104
- return bool(value) and bool(expected) and secrets.compare_digest(value, expected)
112
+ AUTH_MAX_AGE_SECONDS = 24 * 60 * 60
113
+ AUTH_FUTURE_SKEW_SECONDS = 5 * 60
105
114
 
106
115
 
107
- def bearer_token(authorization: str | None) -> str | None:
108
- if not authorization:
109
- return None
110
- scheme, _, value = authorization.partition(" ")
111
- if scheme.lower() != "bearer":
112
- return None
113
- return value.strip() or None
116
+ @dataclass(frozen=True)
117
+ class AuthResult:
118
+ ok: bool
119
+ error: str = ""
120
+ message: str = ""
121
+
122
+
123
+ AUTH_OK = AuthResult(True)
124
+
125
+
126
+ def new_app_id() -> str:
127
+ return f"app_{secrets.token_urlsafe(9)}"
114
128
 
115
129
 
116
- def auth_token_matches(expected: str, token: str | None = None, authorization: str | None = None) -> bool:
117
- return token_matches(expected, token) or token_matches(expected, bearer_token(authorization))
130
+ def new_app_key() -> str:
131
+ return secrets.token_urlsafe(32)
118
132
 
119
133
 
120
- async def websocket_auth_token(websocket: Any) -> str | None:
134
+ def _auth_message(app_id: str, timestamp: str, scope: str) -> bytes:
135
+ return f"v1\nappId={app_id}\ntimestamp={timestamp}\nscope={scope}".encode()
136
+
137
+
138
+ def make_auth_signature(app_key: str, app_id: str, timestamp: str, scope: str) -> str:
139
+ digest = hmac.new(app_key.encode(), _auth_message(app_id, timestamp, scope), hashlib.sha256).digest()
140
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
141
+
142
+
143
+ def build_auth_payload(app_id: str, app_key: str, scope: str, timestamp: int | None = None) -> dict[str, str]:
144
+ timestamp_value = str(int(time.time() if timestamp is None else timestamp))
145
+ return {
146
+ "appId": app_id,
147
+ "timestamp": timestamp_value,
148
+ "sign": make_auth_signature(app_key, app_id, timestamp_value, scope),
149
+ }
150
+
151
+
152
+ def auth_headers(payload: dict[str, str]) -> dict[str, str]:
153
+ return {
154
+ "X-CCODE-App-Id": payload.get("appId", ""),
155
+ "X-CCODE-Timestamp": payload.get("timestamp", ""),
156
+ "X-CCODE-Sign": payload.get("sign", ""),
157
+ }
158
+
159
+
160
+ def auth_query(payload: dict[str, str]) -> str:
161
+ return urlencode({"appId": payload.get("appId", ""), "timestamp": payload.get("timestamp", ""), "sign": payload.get("sign", "")})
162
+
163
+
164
+ def verify_auth_payload(expected_app_id: str, expected_app_key: str, payload: dict[str, Any], scope: str) -> AuthResult:
165
+ app_id = str(payload.get("appId") or "").strip()
166
+ timestamp = str(payload.get("timestamp") or "").strip()
167
+ sign = str(payload.get("sign") or "").strip()
168
+ expected_app_id = (expected_app_id or "").strip()
169
+ expected_app_key = (expected_app_key or "").strip()
170
+ if not expected_app_id or not expected_app_key or not app_id or not timestamp or not sign:
171
+ return AuthResult(False, "auth_required", "Authentication required.")
172
+ if not hmac.compare_digest(expected_app_id, app_id):
173
+ return AuthResult(False, "auth_failed", "Authentication failed.")
121
174
  try:
122
- raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
123
- payload = json.loads(raw)
124
- except (asyncio.TimeoutError, json.JSONDecodeError):
125
- return None
126
- if payload.get("type") != "auth":
127
- return None
128
- token = payload.get("token")
129
- return token if isinstance(token, str) else None
175
+ timestamp_int = int(timestamp)
176
+ except ValueError:
177
+ return AuthResult(False, "auth_required", "Invalid authentication timestamp.")
178
+ age = int(time.time()) - timestamp_int
179
+ if age > AUTH_MAX_AGE_SECONDS:
180
+ return AuthResult(False, "reauth_required", "Authentication expired, please re-authenticate.")
181
+ if age < -AUTH_FUTURE_SKEW_SECONDS:
182
+ return AuthResult(False, "auth_failed", "Authentication timestamp is invalid.")
183
+ expected_sign = make_auth_signature(expected_app_key, app_id, timestamp, scope)
184
+ if not hmac.compare_digest(expected_sign, sign):
185
+ return AuthResult(False, "auth_failed", "Authentication failed.")
186
+ return AUTH_OK
130
187
 
131
188
 
132
189
  _ASSET_TEXT_CACHE: dict[str, str] = {}
ccode/remote_server.py CHANGED
@@ -8,7 +8,7 @@ import time
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any
10
10
 
11
- from .remote_common import asset_text, auth_token_matches, missing_extra_message, token_matches, web_asset_text, websocket_auth_token
11
+ from .remote_common import AuthResult, asset_text, missing_extra_message, verify_auth_payload, web_asset_text
12
12
 
13
13
  REMOTE_SERVER_NAME = "ccode-remote-server"
14
14
  ATTACH_TTL_SECONDS = 30
@@ -41,22 +41,33 @@ _PENDING_ATTACHES: dict[str, PendingAttach] = {}
41
41
  DEVICE_OFFLINE_TTL_SECONDS = 24 * 60 * 60
42
42
 
43
43
 
44
- def _admin_token() -> str:
45
- return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_TOKEN", "").strip()
44
+ def _admin_app_id() -> str:
45
+ return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_APP_ID", "").strip()
46
46
 
47
47
 
48
- def _client_token() -> str:
49
- return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_TOKEN", "").strip()
48
+ def _admin_app_key() -> str:
49
+ return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_APP_KEY", "").strip()
50
50
 
51
51
 
52
- def _valid_admin(token: str | None = None, authorization: str | None = None) -> bool:
53
- expected = _admin_token()
54
- return bool(expected) and auth_token_matches(expected, token, authorization)
52
+ def _client_app_id() -> str:
53
+ return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_APP_ID", "").strip()
55
54
 
56
55
 
57
- def _valid_client(token: str | None) -> bool:
58
- expected = _client_token()
59
- return bool(expected) and token_matches(expected, token)
56
+ def _client_app_key() -> str:
57
+ return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_APP_KEY", "").strip()
58
+
59
+
60
+ def _valid_admin(payload: dict[str, Any], scope: str) -> AuthResult:
61
+ return verify_auth_payload(_admin_app_id(), _admin_app_key(), payload, scope)
62
+
63
+
64
+ def _valid_client(payload: dict[str, Any], scope: str) -> AuthResult:
65
+ return verify_auth_payload(_client_app_id(), _client_app_key(), payload, scope)
66
+
67
+
68
+ def _auth_response(result: AuthResult, response_class: Any) -> Any:
69
+ status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
70
+ return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
60
71
 
61
72
 
62
73
  def _device_payload(device: DeviceConnection) -> dict[str, Any]:
@@ -147,11 +158,11 @@ def create_server_app() -> Any:
147
158
 
148
159
  @app.get("/app.js")
149
160
  async def app_js() -> Response:
150
- return Response(asset_text("app.js"), media_type="application/javascript")
161
+ return Response(asset_text("app.js"), media_type="application/javascript", headers={"Cache-Control": "no-store"})
151
162
 
152
163
  @app.get("/style.css")
153
164
  async def style_css() -> Response:
154
- return Response(asset_text("style.css"), media_type="text/css")
165
+ return Response(asset_text("style.css"), media_type="text/css", headers={"Cache-Control": "no-store"})
155
166
 
156
167
  @app.get("/assets/{asset_name}")
157
168
  async def web_asset(asset_name: str) -> Response:
@@ -163,30 +174,47 @@ def create_server_app() -> Any:
163
174
  media_type = media_types.get(asset_name)
164
175
  if media_type is None:
165
176
  return Response(status_code=404)
166
- return Response(web_asset_text(asset_name), media_type=media_type)
177
+ return Response(web_asset_text(asset_name), media_type=media_type, headers={"Cache-Control": "no-store"})
167
178
 
168
179
  @app.get("/health")
169
180
  async def health() -> dict[str, Any]:
170
181
  _prune_offline_devices()
171
- return {"ok": True, "name": REMOTE_SERVER_NAME, "devices": len(_DEVICES)}
182
+ return {"ok": True, "name": REMOTE_SERVER_NAME, "devices": len(_DEVICES), "remote_kind": "server"}
172
183
 
173
184
  @app.get("/api/devices")
174
- async def devices(token: str | None = None, authorization: str | None = Header(default=None)) -> Response:
175
- if not _valid_admin(token, authorization):
176
- return JSONResponse({"error": "unauthorized"}, status_code=401)
185
+ async def devices(
186
+ x_ccode_app_id: str | None = Header(default=None),
187
+ x_ccode_timestamp: str | None = Header(default=None),
188
+ x_ccode_sign: str | None = Header(default=None),
189
+ ) -> Response:
190
+ auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
191
+ if not auth_result.ok:
192
+ return _auth_response(auth_result, JSONResponse)
177
193
  _prune_offline_devices()
178
194
  return JSONResponse({"devices": [_device_payload(device) for device in _DEVICES.values()]})
179
195
 
180
196
  @app.get("/api/sessions")
181
- async def sessions(token: str | None = None, authorization: str | None = Header(default=None)) -> Response:
182
- if not _valid_admin(token, authorization):
183
- return JSONResponse({"error": "unauthorized"}, status_code=401)
197
+ async def sessions(
198
+ x_ccode_app_id: str | None = Header(default=None),
199
+ x_ccode_timestamp: str | None = Header(default=None),
200
+ x_ccode_sign: str | None = Header(default=None),
201
+ ) -> Response:
202
+ auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
203
+ if not auth_result.ok:
204
+ return _auth_response(auth_result, JSONResponse)
184
205
  return JSONResponse({"sessions": _flatten_sessions()})
185
206
 
186
207
  @app.get("/api/devices/{device_id}/sessions/{session_id}")
187
- async def session_detail(device_id: str, session_id: str, token: str | None = None, authorization: str | None = Header(default=None)) -> Response:
188
- if not _valid_admin(token, authorization):
189
- return JSONResponse({"error": "unauthorized"}, status_code=401)
208
+ async def session_detail(
209
+ device_id: str,
210
+ session_id: str,
211
+ x_ccode_app_id: str | None = Header(default=None),
212
+ x_ccode_timestamp: str | None = Header(default=None),
213
+ x_ccode_sign: str | None = Header(default=None),
214
+ ) -> Response:
215
+ auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
216
+ if not auth_result.ok:
217
+ return _auth_response(auth_result, JSONResponse)
190
218
  device, session = _get_device_session(device_id, session_id)
191
219
  if device is None or session is None:
192
220
  return JSONResponse({"error": "not_found"}, status_code=404)
@@ -201,7 +229,9 @@ def create_server_app() -> Any:
201
229
  except (asyncio.TimeoutError, json.JSONDecodeError):
202
230
  await websocket.close(code=1008)
203
231
  return
204
- if hello.get("type") != "hello" or not _valid_client(hello.get("token") if isinstance(hello.get("token"), str) else None):
232
+ auth_result = _valid_client(hello, "client.control")
233
+ if hello.get("type") != "hello" or not auth_result.ok:
234
+ await websocket.send_json({"type": "error", "error": auth_result.error or "auth_failed", "message": auth_result.message})
205
235
  await websocket.close(code=1008)
206
236
  return
207
237
  device_id = str(hello.get("device_id") or secrets.token_urlsafe(16))
@@ -237,8 +267,15 @@ def create_server_app() -> Any:
237
267
  await websocket.accept()
238
268
  _prune_pending()
239
269
  pending = _PENDING_ATTACHES.get(attach_id)
240
- token = await websocket_auth_token(websocket)
241
- if pending is None or not _valid_client(token):
270
+ try:
271
+ raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
272
+ payload = json.loads(raw)
273
+ except (asyncio.TimeoutError, json.JSONDecodeError):
274
+ await websocket.close(code=1008)
275
+ return
276
+ auth_result = _valid_client(payload, "client.attach")
277
+ if pending is None or payload.get("type") != "auth" or not auth_result.ok:
278
+ await websocket.send_json({"type": "error", "error": auth_result.error or "auth_failed", "message": auth_result.message})
242
279
  await websocket.close(code=1008)
243
280
  return
244
281
  pending.client_ws = websocket
@@ -249,9 +286,9 @@ def create_server_app() -> Any:
249
286
  @app.websocket("/ws/devices/{device_id}/sessions/{session_id}")
250
287
  async def browser_attach(websocket: FastAPIWebSocket, device_id: str, session_id: str) -> None:
251
288
  await websocket.accept()
252
- token = await websocket_auth_token(websocket)
253
- if not _valid_admin(token):
254
- await websocket.send_json({"type": "status", "message": "unauthorized: invalid token"})
289
+ auth_result = _valid_admin(dict(websocket.query_params), "server.ws")
290
+ if not auth_result.ok:
291
+ await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
255
292
  await websocket.close(code=1008)
256
293
  return
257
294
  device, session = _get_device_session(device_id, session_id)
ccode/ui.py CHANGED
@@ -42,9 +42,11 @@ _FIELD_REMOTE_ENABLED = "remote.enabled"
42
42
  _FIELD_REMOTE_MODE = "remote.mode"
43
43
  _FIELD_REMOTE_LOCAL_HOST = "remote.local.host"
44
44
  _FIELD_REMOTE_LOCAL_PORT = "remote.local.port"
45
- _FIELD_REMOTE_LOCAL_TOKEN = "remote.local.token"
45
+ _FIELD_REMOTE_LOCAL_APP_ID = "remote.local.appId"
46
+ _FIELD_REMOTE_LOCAL_APP_KEY = "remote.local.appKey"
46
47
  _FIELD_REMOTE_SERVER_URL = "remote.server.url"
47
- _FIELD_REMOTE_SERVER_TOKEN = "remote.server.token"
48
+ _FIELD_REMOTE_SERVER_APP_ID = "remote.server.appId"
49
+ _FIELD_REMOTE_SERVER_APP_KEY = "remote.server.appKey"
48
50
  _FIELD_REMOTE_SERVER_DEVICE_NAME = "remote.server.device_name"
49
51
  _FIELD_REMOTE_SERVER_DEVICE_ID = "remote.server.device_id"
50
52
  _FIELD_REMOTE_SERVER_AUTO_CONNECT = "remote.server.auto_connect"
@@ -93,9 +95,11 @@ class CursesApp:
93
95
  _FIELD_API_KEY: len(self.config.get("api_key", "")),
94
96
  _FIELD_REMOTE_LOCAL_HOST: len(str(local.get("host", ""))),
95
97
  _FIELD_REMOTE_LOCAL_PORT: len(str(local.get("port", ""))),
96
- _FIELD_REMOTE_LOCAL_TOKEN: len(str(local.get("token", ""))),
98
+ _FIELD_REMOTE_LOCAL_APP_ID: len(str(local.get("appId", ""))),
99
+ _FIELD_REMOTE_LOCAL_APP_KEY: len(str(local.get("appKey", ""))),
97
100
  _FIELD_REMOTE_SERVER_URL: len(str(server.get("url", ""))),
98
- _FIELD_REMOTE_SERVER_TOKEN: len(str(server.get("token", ""))),
101
+ _FIELD_REMOTE_SERVER_APP_ID: len(str(server.get("appId", ""))),
102
+ _FIELD_REMOTE_SERVER_APP_KEY: len(str(server.get("appKey", ""))),
99
103
  _FIELD_REMOTE_SERVER_DEVICE_NAME: len(str(server.get("device_name", ""))),
100
104
  _FIELD_REMOTE_SERVER_DEVICE_ID: len(str(server.get("device_id", ""))),
101
105
  _FIELD_REMOTE_SESSION_NAME: len(str(remote.get("session_name", ""))),
@@ -136,9 +140,11 @@ class CursesApp:
136
140
  _FIELD_REMOTE_MODE,
137
141
  _FIELD_REMOTE_LOCAL_HOST,
138
142
  _FIELD_REMOTE_LOCAL_PORT,
139
- _FIELD_REMOTE_LOCAL_TOKEN,
143
+ _FIELD_REMOTE_LOCAL_APP_ID,
144
+ _FIELD_REMOTE_LOCAL_APP_KEY,
140
145
  _FIELD_REMOTE_SERVER_URL,
141
- _FIELD_REMOTE_SERVER_TOKEN,
146
+ _FIELD_REMOTE_SERVER_APP_ID,
147
+ _FIELD_REMOTE_SERVER_APP_KEY,
142
148
  _FIELD_REMOTE_SERVER_DEVICE_NAME,
143
149
  _FIELD_REMOTE_SERVER_DEVICE_ID,
144
150
  _FIELD_REMOTE_SERVER_AUTO_CONNECT,
@@ -355,7 +361,7 @@ class CursesApp:
355
361
  y += 1
356
362
 
357
363
  y += 1
358
- addstr_safe(stdscr, y, x, "─── Remote/Web [space/enter]=toggle [g]=generate token ─────────────")
364
+ addstr_safe(stdscr, y, x, "─── Remote/Web [space/enter]=toggle [g]=generate app id/key ───────")
359
365
  y += 1
360
366
  remote = self.config.setdefault("remote", {})
361
367
  local = remote.setdefault("local", {})
@@ -366,9 +372,11 @@ class CursesApp:
366
372
  (_FIELD_REMOTE_MODE, "MODE:", str(remote.get("mode", "local"))),
367
373
  (_FIELD_REMOTE_LOCAL_HOST, "LOCAL HOST:", str(local.get("host", ""))),
368
374
  (_FIELD_REMOTE_LOCAL_PORT, "LOCAL PORT:", str(local.get("port", ""))),
369
- (_FIELD_REMOTE_LOCAL_TOKEN, "LOCAL TOKEN:", mask_secret(str(local.get("token", "")))),
375
+ (_FIELD_REMOTE_LOCAL_APP_ID, "LOCAL APP ID:", str(local.get("appId", ""))),
376
+ (_FIELD_REMOTE_LOCAL_APP_KEY, "LOCAL APP KEY:", mask_secret(str(local.get("appKey", "")))),
370
377
  (_FIELD_REMOTE_SERVER_URL, "SERVER URL:", str(server.get("url", ""))),
371
- (_FIELD_REMOTE_SERVER_TOKEN, "CLIENT TOKEN:", mask_secret(str(server.get("token", "")))),
378
+ (_FIELD_REMOTE_SERVER_APP_ID, "CLIENT APP ID:", str(server.get("appId", ""))),
379
+ (_FIELD_REMOTE_SERVER_APP_KEY, "CLIENT APP KEY:", mask_secret(str(server.get("appKey", "")))),
372
380
  (_FIELD_REMOTE_SERVER_DEVICE_NAME, "DEVICE NAME:", str(server.get("device_name", ""))),
373
381
  (_FIELD_REMOTE_SERVER_DEVICE_ID, "DEVICE ID:", str(server.get("device_id", ""))),
374
382
  (_FIELD_REMOTE_SERVER_AUTO_CONNECT, "AUTO CONNECT:", "on" if server.get("auto_connect") else "off"),
@@ -379,10 +387,10 @@ class CursesApp:
379
387
  is_focused = focused == field
380
388
  label_x = x
381
389
  value_x = x + 22
382
- if field == _FIELD_REMOTE_LOCAL_TOKEN and is_focused:
383
- display = str(local.get("token", ""))
384
- if field == _FIELD_REMOTE_SERVER_TOKEN and is_focused:
385
- display = str(server.get("token", ""))
390
+ if field == _FIELD_REMOTE_LOCAL_APP_KEY and is_focused:
391
+ display = str(local.get("appKey", ""))
392
+ if field == _FIELD_REMOTE_SERVER_APP_KEY and is_focused:
393
+ display = str(server.get("appKey", ""))
386
394
  addstr_safe(stdscr, y, label_x, label)
387
395
  addstr_safe(stdscr, y, value_x, display or "<unset>", curses.A_REVERSE if is_focused else 0)
388
396
  if field == _FIELD_REMOTE_LOCAL_HOST and str(local.get("host", "")) == "0.0.0.0":
@@ -484,9 +492,11 @@ class CursesApp:
484
492
  elif focused in (
485
493
  _FIELD_REMOTE_LOCAL_HOST,
486
494
  _FIELD_REMOTE_LOCAL_PORT,
487
- _FIELD_REMOTE_LOCAL_TOKEN,
495
+ _FIELD_REMOTE_LOCAL_APP_ID,
496
+ _FIELD_REMOTE_LOCAL_APP_KEY,
488
497
  _FIELD_REMOTE_SERVER_URL,
489
- _FIELD_REMOTE_SERVER_TOKEN,
498
+ _FIELD_REMOTE_SERVER_APP_ID,
499
+ _FIELD_REMOTE_SERVER_APP_KEY,
490
500
  _FIELD_REMOTE_SERVER_DEVICE_NAME,
491
501
  _FIELD_REMOTE_SERVER_DEVICE_ID,
492
502
  _FIELD_REMOTE_SESSION_NAME,
@@ -569,14 +579,14 @@ class CursesApp:
569
579
  save_config(self.config)
570
580
  return
571
581
 
572
- if focused in (_FIELD_REMOTE_LOCAL_TOKEN, _FIELD_REMOTE_SERVER_TOKEN) and key in (ord("g"), ord("G")):
573
- token = secrets.token_urlsafe(24)
582
+ if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_SERVER_APP_ID, _FIELD_REMOTE_LOCAL_APP_KEY, _FIELD_REMOTE_SERVER_APP_KEY) and key in (ord("g"), ord("G")):
583
+ value = f"app_{secrets.token_urlsafe(9)}" if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_SERVER_APP_ID) else secrets.token_urlsafe(32)
574
584
  remote = self.config.setdefault("remote", {})
575
- target = remote.setdefault("local" if focused == _FIELD_REMOTE_LOCAL_TOKEN else "server", {})
576
- target["token"] = token
577
- self.config_cursor[focused] = len(token)
585
+ target = remote.setdefault("local" if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_LOCAL_APP_KEY) else "server", {})
586
+ target["appId" if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_SERVER_APP_ID) else "appKey"] = value
587
+ self.config_cursor[focused] = len(value)
578
588
  save_config(self.config)
579
- self.status_message = "Remote token regenerated."
589
+ self.status_message = "Remote app credential regenerated."
580
590
  return
581
591
 
582
592
  # 文本字段
@@ -586,9 +596,11 @@ class CursesApp:
586
596
  if focused in (
587
597
  _FIELD_REMOTE_LOCAL_HOST,
588
598
  _FIELD_REMOTE_LOCAL_PORT,
589
- _FIELD_REMOTE_LOCAL_TOKEN,
599
+ _FIELD_REMOTE_LOCAL_APP_ID,
600
+ _FIELD_REMOTE_LOCAL_APP_KEY,
590
601
  _FIELD_REMOTE_SERVER_URL,
591
- _FIELD_REMOTE_SERVER_TOKEN,
602
+ _FIELD_REMOTE_SERVER_APP_ID,
603
+ _FIELD_REMOTE_SERVER_APP_KEY,
592
604
  _FIELD_REMOTE_SERVER_DEVICE_NAME,
593
605
  _FIELD_REMOTE_SERVER_DEVICE_ID,
594
606
  _FIELD_REMOTE_SESSION_NAME,
ccode/web/app.js CHANGED
@@ -1,7 +1,8 @@
1
1
  const terminalEl = document.getElementById("terminal");
2
2
  const sessionListEl = document.getElementById("session-list");
3
3
  const tokenFormEl = document.getElementById("token-form");
4
- const tokenInputEl = document.getElementById("token-input");
4
+ const appIdInputEl = document.getElementById("app-id-input");
5
+ const appKeyInputEl = document.getElementById("app-key-input");
5
6
  const statusEl = document.getElementById("status");
6
7
  const actionsEl = document.getElementById("actions");
7
8
  const actionsToggleButton = document.getElementById("actions-toggle");
@@ -26,9 +27,11 @@ const inputSequences = {
26
27
  left: "\x1b[D",
27
28
  };
28
29
 
29
- const tokenStorageKey = "ccode.remote.adminToken";
30
+ const authStorageKey = "ccode.remote.authTicket";
31
+ const appIdStorageKey = "ccode.remote.appId";
30
32
  const fontSizeStorageKey = "ccode.remote.terminalFontSize";
31
- let token = window.sessionStorage.getItem(tokenStorageKey) || "";
33
+ let authTicket = loadAuthTicket();
34
+ let remoteKind = "local";
32
35
  let socket = null;
33
36
  let term = null;
34
37
  let fitAddon = null;
@@ -40,10 +43,66 @@ let resizeFrame = null;
40
43
  let viewportHeight = "";
41
44
  let keyboardInset = "";
42
45
 
46
+ function loadAuthTicket() {
47
+ try {
48
+ const ticket = JSON.parse(window.sessionStorage.getItem(authStorageKey) || "null");
49
+ return ticket && ticket.appId && ticket.timestamp && ticket.signs ? ticket : null;
50
+ } catch (error) {
51
+ return null;
52
+ }
53
+ }
54
+
43
55
  function setStatus(message) {
44
56
  statusEl.textContent = message;
45
57
  }
46
58
 
59
+ function authMessage(appId, timestamp, scope) {
60
+ return `v1\nappId=${appId}\ntimestamp=${timestamp}\nscope=${scope}`;
61
+ }
62
+
63
+ function base64Url(bytes) {
64
+ let binary = "";
65
+ for (const byte of bytes) {
66
+ binary += String.fromCharCode(byte);
67
+ }
68
+ return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
69
+ }
70
+
71
+ async function signAuth(appId, appKey, timestamp, scope) {
72
+ const encoder = new TextEncoder();
73
+ const key = await window.crypto.subtle.importKey("raw", encoder.encode(appKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
74
+ const signature = await window.crypto.subtle.sign("HMAC", key, encoder.encode(authMessage(appId, timestamp, scope)));
75
+ return base64Url(new Uint8Array(signature));
76
+ }
77
+
78
+ async function createAuthTicket(appId, appKey) {
79
+ const timestamp = String(Math.floor(Date.now() / 1000));
80
+ const scopes = ["local.api", "local.ws", "server.api", "server.ws"];
81
+ const signs = {};
82
+ for (const scope of scopes) {
83
+ signs[scope] = await signAuth(appId, appKey, timestamp, scope);
84
+ }
85
+ return { appId, timestamp, signs };
86
+ }
87
+
88
+ function authHeaders(scope) {
89
+ return {
90
+ "X-CCODE-App-Id": authTicket.appId,
91
+ "X-CCODE-Timestamp": authTicket.timestamp,
92
+ "X-CCODE-Sign": authTicket.signs[scope] || "",
93
+ };
94
+ }
95
+
96
+ function authQuery(scope) {
97
+ return new URLSearchParams({ appId: authTicket.appId, timestamp: authTicket.timestamp, sign: authTicket.signs[scope] || "" }).toString();
98
+ }
99
+
100
+ function clearAuthTicket(message = "enter app credentials") {
101
+ window.sessionStorage.removeItem(authStorageKey);
102
+ authTicket = null;
103
+ showTokenMode(message);
104
+ }
105
+
47
106
  function sendInput(data) {
48
107
  if (!socket || socket.readyState !== WebSocket.OPEN) {
49
108
  return;
@@ -190,7 +249,7 @@ function closeSocket() {
190
249
  }
191
250
  }
192
251
 
193
- function showTokenMode(message = "enter token") {
252
+ function showTokenMode(message = "enter app credentials") {
194
253
  closeSocket();
195
254
  currentSession = null;
196
255
  terminalEl.hidden = true;
@@ -205,8 +264,9 @@ function showTokenMode(message = "enter token") {
205
264
  refreshButton.hidden = true;
206
265
  changeTokenButton.hidden = true;
207
266
  setStatus(message);
208
- tokenInputEl.value = token;
209
- tokenInputEl.focus();
267
+ appIdInputEl.value = window.sessionStorage.getItem(appIdStorageKey) || authTicket?.appId || "";
268
+ appKeyInputEl.value = "";
269
+ appIdInputEl.focus();
210
270
  }
211
271
 
212
272
  function showListMode() {
@@ -297,7 +357,7 @@ function renderSessions(sessions) {
297
357
  }
298
358
 
299
359
  async function loadSessions() {
300
- if (!token) {
360
+ if (!authTicket) {
301
361
  showTokenMode();
302
362
  return;
303
363
  }
@@ -307,12 +367,11 @@ async function loadSessions() {
307
367
  try {
308
368
  const response = await fetch("/api/sessions", {
309
369
  cache: "no-store",
310
- headers: { Authorization: `Bearer ${token}` },
370
+ headers: authHeaders(`${remoteKind}.api`),
311
371
  });
312
372
  if (response.status === 401) {
313
- window.sessionStorage.removeItem(tokenStorageKey);
314
- token = "";
315
- showTokenMode("unauthorized: invalid token");
373
+ const payload = await response.json().catch(() => ({}));
374
+ clearAuthTicket(payload.error === "reauth_required" ? "authentication expired: enter app credentials" : "unauthorized: invalid app credentials");
316
375
  return;
317
376
  }
318
377
  if (!response.ok) {
@@ -334,7 +393,7 @@ function sessionWsPath(session) {
334
393
  }
335
394
 
336
395
  function connect() {
337
- if (!token) {
396
+ if (!authTicket) {
338
397
  showTokenMode();
339
398
  return;
340
399
  }
@@ -347,13 +406,9 @@ function connect() {
347
406
  closeSocket();
348
407
  term.clear();
349
408
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
350
- socket = new WebSocket(`${protocol}//${window.location.host}${sessionWsPath(currentSession)}`);
409
+ socket = new WebSocket(`${protocol}//${window.location.host}${sessionWsPath(currentSession)}?${authQuery(`${remoteKind}.ws`)}`);
351
410
  setStatus("connecting");
352
411
 
353
- socket.addEventListener("open", () => {
354
- socket.send(JSON.stringify({ type: "auth", token }));
355
- });
356
-
357
412
  socket.addEventListener("message", (event) => {
358
413
  const payload = JSON.parse(event.data);
359
414
  if (payload.type === "output") {
@@ -364,15 +419,17 @@ function connect() {
364
419
  fitAndSendResize();
365
420
  term.focus();
366
421
  }
422
+ } else if (payload.type === "error") {
423
+ if (payload.error === "reauth_required" || payload.error === "auth_required" || payload.error === "auth_failed") {
424
+ clearAuthTicket(payload.message || "enter app credentials");
425
+ }
367
426
  }
368
427
  });
369
428
 
370
429
  socket.addEventListener("close", (event) => {
371
430
  if (event.code === 1008) {
372
- if (statusEl.textContent === "unauthorized: invalid token") {
373
- window.sessionStorage.removeItem(tokenStorageKey);
374
- token = "";
375
- showTokenMode("unauthorized: invalid token");
431
+ if (statusEl.textContent !== "connected") {
432
+ clearAuthTicket("enter app credentials");
376
433
  }
377
434
  return;
378
435
  }
@@ -394,21 +451,22 @@ function openSession(session) {
394
451
  connect();
395
452
  }
396
453
 
397
- tokenFormEl.addEventListener("submit", (event) => {
454
+ tokenFormEl.addEventListener("submit", async (event) => {
398
455
  event.preventDefault();
399
- token = tokenInputEl.value.trim();
400
- if (!token) {
401
- showTokenMode("enter token");
456
+ const appId = appIdInputEl.value.trim();
457
+ const appKey = appKeyInputEl.value.trim();
458
+ if (!appId || !appKey) {
459
+ showTokenMode("enter app credentials");
402
460
  return;
403
461
  }
404
- window.sessionStorage.setItem(tokenStorageKey, token);
462
+ authTicket = await createAuthTicket(appId, appKey);
463
+ window.sessionStorage.setItem(authStorageKey, JSON.stringify(authTicket));
464
+ window.sessionStorage.setItem(appIdStorageKey, appId);
405
465
  loadSessions();
406
466
  });
407
467
 
408
468
  changeTokenButton.addEventListener("click", () => {
409
- window.sessionStorage.removeItem(tokenStorageKey);
410
- token = "";
411
- showTokenMode("enter token");
469
+ clearAuthTicket("enter app credentials");
412
470
  });
413
471
  window.addEventListener("resize", requestFitAndSendResize);
414
472
  actionsToggleButton.addEventListener("click", () => {
@@ -451,4 +509,15 @@ if (window.visualViewport) {
451
509
  }
452
510
  updateViewportInsets();
453
511
 
454
- loadSessions();
512
+ async function initialize() {
513
+ try {
514
+ const response = await fetch("/health", { cache: "no-store" });
515
+ const payload = await response.json();
516
+ remoteKind = payload.remote_kind === "server" ? "server" : "local";
517
+ } catch (error) {
518
+ remoteKind = "local";
519
+ }
520
+ loadSessions();
521
+ }
522
+
523
+ initialize();
ccode/web/index.html CHANGED
@@ -12,23 +12,25 @@
12
12
  <header>
13
13
  <div>
14
14
  <h1>ccode remote</h1>
15
- <p id="status">enter token</p>
15
+ <p id="status">enter app credentials</p>
16
16
  </div>
17
17
  <button id="actions-toggle" class="actions-toggle" type="button" hidden>Menu</button>
18
18
  <div id="actions" class="actions">
19
19
  <button id="back" type="button" hidden>Sessions</button>
20
20
  <button id="refresh" type="button" hidden>Refresh</button>
21
- <button id="change-token" type="button" hidden>Change token</button>
21
+ <button id="change-token" type="button" hidden>Change credentials</button>
22
22
  <button id="focus" type="button" hidden>Focus terminal</button>
23
23
  <button id="reconnect" type="button" hidden>Reconnect</button>
24
24
  </div>
25
25
  </header>
26
26
  <form id="token-form" class="token-panel">
27
- <label for="token-input">Remote token</label>
27
+ <label for="app-id-input">Remote app credentials</label>
28
28
  <div class="token-row">
29
- <input id="token-input" type="password" autocomplete="current-password" placeholder="Paste your configured token" />
29
+ <input id="app-id-input" autocomplete="username" placeholder="App ID" />
30
+ <input id="app-key-input" type="password" autocomplete="current-password" placeholder="App Key" />
30
31
  <button type="submit">Continue</button>
31
32
  </div>
33
+ <small>App Key is only used locally to create a 24-hour signature.</small>
32
34
  </form>
33
35
  <section id="session-list" hidden></section>
34
36
  <div id="terminal" tabindex="0" hidden></div>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccoding
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Curses-based Claude Code launcher
5
5
  Project-URL: Homepage, https://github.com/8DE4732A/ccode
6
6
  Project-URL: Repository, https://github.com/8DE4732A/ccode
@@ -173,8 +173,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
173
173
 
174
174
  - `↑ / ↓`:移动字段焦点
175
175
  - `Enter / Space`:切换开关项
176
- - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/token/device/prefix 等字段
177
- - `g`:在 Remote local token 或 server client token 字段重新生成访问 token
176
+ - 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/appId/appKey/device/prefix 等字段
177
+ - `g`:在 Remote appId 或 appKey 字段重新生成对应值
178
178
  - `ESC`:自动保存并返回主界面
179
179
 
180
180
  ## 配置文件
@@ -209,11 +209,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
209
209
  "local": {
210
210
  "host": "127.0.0.1",
211
211
  "port": 8765,
212
- "token": ""
212
+ "appId": "",
213
+ "appKey": ""
213
214
  },
214
215
  "server": {
215
216
  "url": "",
216
- "token": "",
217
+ "appId": "",
218
+ "appKey": "",
217
219
  "device_name": "",
218
220
  "device_id": "",
219
221
  "auto_connect": true,
@@ -232,6 +234,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
232
234
  | `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
233
235
  | `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
234
236
 
237
+ Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
238
+
235
239
  ### local 模式
236
240
 
237
241
  local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
@@ -262,23 +266,23 @@ claude
262
266
  - `MODE`:`local`
263
267
  - `LOCAL HOST`:默认 `127.0.0.1`
264
268
  - `LOCAL PORT`:默认 `8765`
265
- - `LOCAL TOKEN`:手动输入,或在该字段按 `g` 生成
269
+ - `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
266
270
 
267
271
  3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
268
272
 
269
- 4. 浏览器打开终端输出的本地地址,输入 `LOCAL TOKEN`,选择 session 后进入 terminal。
273
+ 4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
270
274
 
271
- local 模式默认只监听 `127.0.0.1`。终端只输出不带 token 的本地访问地址。浏览器打开页面后需要手动输入 token,前端会用 `sessionStorage` 做浏览器会话级缓存;关闭该浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要 token。
275
+ local 模式默认只监听 `127.0.0.1`。终端只输出不带认证信息的本地访问地址。浏览器打开页面后需要手动输入 appId/appKey,前端只保存由 appKey 生成的 24 小时 HMAC 签名票据,不保存 appKey;票据过期或关闭浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要签名认证。
272
276
 
273
- 每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/token。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
277
+ 每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/appId/appKey。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
274
278
 
275
- 如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 token。
279
+ 如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 app credentials。
276
280
 
277
281
  ```bash
278
282
  cloudflared tunnel --url http://127.0.0.1:8765
279
283
  ```
280
284
 
281
- local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 token。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
285
+ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 appKey。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
282
286
 
283
287
  ### server 模式和 `ccode-remote-server`
284
288
 
@@ -289,7 +293,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
289
293
  ```text
290
294
  Browser
291
295
  │
292
- │ HTTPS/WSS + admin token
296
+ │ HTTPS/WSS + admin HMAC
293
297
  ▼
294
298
  ┌───────────────────────┐
295
299
  │ ccode-remote-server │
@@ -314,31 +318,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
314
318
 
315
319
  server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
316
320
 
317
- #### server token
321
+ #### server app credentials
318
322
 
319
- server token 不会自动生成,必须通过环境变量显式设置:
323
+ server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
320
324
 
321
- - `CCODE_REMOTE_SERVER_ADMIN_TOKEN`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理 token。
322
- - `CCODE_REMOTE_SERVER_CLIENT_TOKEN`:各设备客户端 connector 注册和心跳使用的 token。
325
+ - `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理凭据。
326
+ - `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY`:各设备客户端 connector 注册和心跳使用的客户端凭据。
323
327
  - `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
324
328
 
325
329
  启动中心 server:
326
330
 
327
331
  ```bash
328
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
329
- CCODE_REMOTE_SERVER_CLIENT_TOKEN="client-secret" \
332
+ CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
333
+ CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
334
+ CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
335
+ CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
330
336
  ccode-remote-server --host 127.0.0.1 --port 8765
331
337
  ```
332
338
 
333
339
  如果使用源码开发环境运行:
334
340
 
335
341
  ```bash
336
- CCODE_REMOTE_SERVER_ADMIN_TOKEN="admin-secret" \
337
- CCODE_REMOTE_SERVER_CLIENT_TOKEN="client-secret" \
342
+ CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
343
+ CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
344
+ CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
345
+ CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
338
346
  uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
339
347
  ```
340
348
 
341
- 浏览器访问 server 页面时输入 `admin-secret`。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_TOKEN`,管理 API 会返回 401,页面上任何 token 都无法通过认证。
349
+ 浏览器访问 server 页面时输入 admin appId/appKey。前端只保存 24 小时有效的 HMAC 签名票据,不保存 appKey。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`,管理 API 会返回 401。
342
350
 
343
351
  #### 客户端设备配置
344
352
 
@@ -352,7 +360,7 @@ uv tool install 'ccoding[remote-client]'
352
360
 
353
361
  - `MODE`:`server`
354
362
  - `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
355
- - `CLIENT TOKEN`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_TOKEN` 一致
363
+ - `CLIENT APP ID` / `CLIENT APP KEY`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY` 一致
356
364
  - `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
357
365
  - `DEVICE ID`:稳定设备 ID,可留空后自动生成
358
366
  - `AUTO CONNECT`:是否默认自动连接中心 server
@@ -384,7 +392,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
384
392
 
385
393
  #### Cloudflare Tunnel / 反向代理
386
394
 
387
- 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT TOKEN`:
395
+ 如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
388
396
 
389
397
  ```bash
390
398
  cloudflared tunnel --url http://127.0.0.1:8765
@@ -2,26 +2,26 @@ ccode/__init__.py,sha256=bbPH1IiDNbRuQvhS_fNfXhPDKeMQ5R6oS4ngv_7zHZ8,170
2
2
  ccode/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
3
  ccode/api.py,sha256=ZohlOtb64zU9f7A-CGhPcSluT8D30Be9OG6yAvucBnE,5126
4
4
  ccode/cli.py,sha256=SovAIOMH75HW2Ci8Kl6EKnJ2_lwl7Fx-l7WVWDSiAxY,247
5
- ccode/config.py,sha256=7z5AOrMBkJ0y8oeoLkVlQjwkDoH-V5fACd4hTcO6jow,7559
5
+ ccode/config.py,sha256=B14xX1e34YEUA4gqhl5dJt-55McnaJcXCpPU6Zakhss,8522
6
6
  ccode/env_schema.json,sha256=4_VpMYRwzxMbvO-i9loPvW6spKiHSWBVE-xRUuWYu8c,35490
7
7
  ccode/logo.py,sha256=L8nkF8Zx4YlrFGnVONpEc7sWF2aAB3cyoQdtISF-SVM,5514
8
- ccode/remote.py,sha256=_jJWtGfH77tykYQvPrRmxADRoRfpjDj0OT0VLWXY0l8,23547
9
- ccode/remote_cli.py,sha256=YwYoSY15JlisEidIYR4lIURvpYwGafBIn-TkZ3Lg9EA,8398
10
- ccode/remote_client.py,sha256=tuh-jDyp3muG-4siZTtNC98sK2NQu80E9lhHtb_fgaY,9366
11
- ccode/remote_common.py,sha256=B2qmlS8n9Ik4-GpGtlIQQKBQM4qkOEMgXqMsijzC6Fc,6093
12
- ccode/remote_server.py,sha256=HjzKEZLeOzsm9Rf-hSLhzlJSIwGjpn_zLbMeHoJRiuk,11894
13
- ccode/ui.py,sha256=R4yKNMoMZCryoDyCHcXrGQ7sh02BYM8tEjY9ndEq4VE,37274
8
+ ccode/remote.py,sha256=X7coJG7JQapFvDWMoOegno7TSbNkq4zuV5MtB4l4b_I,24971
9
+ ccode/remote_cli.py,sha256=nblydZh4XiksnZitLp_9RTmuJuGBVCYK_o1vzyA2PeM,8569
10
+ ccode/remote_client.py,sha256=5UjWK-bTns4jSCON7hTu-S3U5kf4X0ZLH4ql9in3z8Q,9597
11
+ ccode/remote_common.py,sha256=9eJeUJTaQQojTLcF7gz8X0dFM5nO3vM5q7bC-YSd8bE,8244
12
+ ccode/remote_server.py,sha256=CqKKiE55yAfGkYOcXIOqELROCkY5rtr-Z3GnyBq3taQ,13692
13
+ ccode/ui.py,sha256=eoCjHkUAVadsGSAisQ7_BI4DaPdjYnX9cAeh2zzkI58,38274
14
14
  ccode/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- ccode/web/app.js,sha256=w9CbPuf2uAACCaBrXRxUGWphP4Fp_XP7iHY7KSUeEag,13150
16
- ccode/web/index.html,sha256=zLI7h4iLKXUWXvlFqD3h_HZDmnwg8P2agNKCXB9qEJY,2469
15
+ ccode/web/app.js,sha256=ErtM9fmAHvYp1QAks3kObqCkpghA0_nFNXSC2kbxvwo,15823
16
+ ccode/web/index.html,sha256=sBGKxXidj6X4e8LqcITDHsrefZadwZy9JIBLRW5aeoQ,2644
17
17
  ccode/web/style.css,sha256=3RClaSS48-Ne2PQOwG5rhwbPwZKxyp-gG_OmKYRQAzo,4270
18
18
  ccode/web/assets/addon-fit.js,sha256=uj6iVs4GIKCZKhl9bJuupkgj_JPY2gep42bKmUPBhSc,1521
19
19
  ccode/web/assets/xterm-LICENSE,sha256=tWn2KdAPJiaoEA3yoXmCEFNWIeQhZN_UJqb-Wqx7DM0,1261
20
20
  ccode/web/assets/xterm-addon-fit-LICENSE,sha256=4lbwEYivUn5NBtIdBvv3ha6cUNSzKL8Dy-C6fwqkIo8,1103
21
21
  ccode/web/assets/xterm.css,sha256=hUp8D7cOixoIPBZ5ergnKZ-xh0T1rTTyJ7SDN-Myk8Y,7112
22
22
  ccode/web/assets/xterm.js,sha256=FJA1ef9UZkzXL46GmeaWGmJywhhj7Bw7EYzcivXUqXI,488663
23
- ccoding-0.2.2.dist-info/METADATA,sha256=uqOhoUzG38ibC7m7nuarEN8OwOvahN-VC6L0jszTgQ4,18444
24
- ccoding-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
25
- ccoding-0.2.2.dist-info/entry_points.txt,sha256=uxtngaM0Eazhs4dCpcQvAIVdQBRLJ_6u-BpN3pYaGrQ,125
26
- ccoding-0.2.2.dist-info/licenses/LICENSE,sha256=ghzeEUUCG5lu2DDgFe37bgLJk_0_Y6v-ulXwmYWjQ20,1065
27
- ccoding-0.2.2.dist-info/RECORD,,
23
+ ccoding-0.2.4.dist-info/METADATA,sha256=l1dPEwJjue2Bv5sYIHhmRuCNCgc7DBL5y0fUtm4HH3k,19551
24
+ ccoding-0.2.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
25
+ ccoding-0.2.4.dist-info/entry_points.txt,sha256=uxtngaM0Eazhs4dCpcQvAIVdQBRLJ_6u-BpN3pYaGrQ,125
26
+ ccoding-0.2.4.dist-info/licenses/LICENSE,sha256=ghzeEUUCG5lu2DDgFe37bgLJk_0_Y6v-ulXwmYWjQ20,1065
27
+ ccoding-0.2.4.dist-info/RECORD,,