@grifhinz/logics-manager 2.8.1 → 2.9.0
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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/clients/shared-web/media/vendor/xterm/PROVENANCE.md +31 -0
- package/clients/shared-web/media/vendor/xterm/xterm-addon-fit.js +2 -0
- package/clients/shared-web/media/vendor/xterm/xterm-addon-web-links.js +2 -0
- package/clients/shared-web/media/vendor/xterm/xterm.css +209 -0
- package/clients/shared-web/media/vendor/xterm/xterm.js +2 -0
- package/clients/viewer/browser-host.js +893 -12
- package/clients/viewer/index.html +14 -0
- package/clients/viewer/viewer.css +642 -25
- package/logics_manager/viewer.py +1119 -8
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import hashlib
|
|
5
|
+
import hmac
|
|
5
6
|
import json
|
|
7
|
+
import secrets
|
|
6
8
|
import mimetypes
|
|
7
9
|
import os
|
|
8
10
|
import re
|
|
@@ -10,6 +12,7 @@ import shutil
|
|
|
10
12
|
import socket
|
|
11
13
|
import subprocess
|
|
12
14
|
import sys
|
|
15
|
+
import tomllib
|
|
13
16
|
import webbrowser
|
|
14
17
|
from dataclasses import dataclass
|
|
15
18
|
from datetime import datetime
|
|
@@ -641,10 +644,25 @@ def viewer_project_capabilities(
|
|
|
641
644
|
message="Workspace root can be inspected." if repo_root.is_dir() else "Workspace root is unavailable.",
|
|
642
645
|
detail={"root": str(repo_root.resolve())} if repo_root.is_dir() else {},
|
|
643
646
|
)
|
|
647
|
+
workshop_available = repo_root.is_dir()
|
|
648
|
+
terminals_available = workshop_available and workshop_terminals_available()
|
|
649
|
+
if terminals_available:
|
|
650
|
+
workshop_message = "Workshop command runner and PTY terminals are available."
|
|
651
|
+
elif workshop_available:
|
|
652
|
+
workshop_message = "Workshop command runner is available; PTY terminals require a Unix host with stdlib pty support."
|
|
653
|
+
else:
|
|
654
|
+
workshop_message = "Workshop is not available without a workspace root."
|
|
655
|
+
workshop = _viewer_capability(
|
|
656
|
+
"ready" if workshop_available else "missing",
|
|
657
|
+
available=workshop_available,
|
|
658
|
+
message=workshop_message,
|
|
659
|
+
detail={"terminalsAvailable": terminals_available, "commandsAvailable": workshop_available},
|
|
660
|
+
)
|
|
644
661
|
|
|
645
662
|
return {
|
|
646
663
|
"logics": logics,
|
|
647
664
|
"workspace": workspace,
|
|
665
|
+
"workshop": workshop,
|
|
648
666
|
"git": git,
|
|
649
667
|
"ci": ci,
|
|
650
668
|
"cdx": cdx,
|
|
@@ -1349,6 +1367,116 @@ def workspace_preview_payload(
|
|
|
1349
1367
|
}
|
|
1350
1368
|
|
|
1351
1369
|
|
|
1370
|
+
WORKSHOP_COMMAND_MAX = 200
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _workshop_command_id(group: str, name: str) -> str:
|
|
1374
|
+
safe = re.sub(r"[^a-z0-9._-]+", "-", f"{group}:{name}".lower()).strip("-") or "command"
|
|
1375
|
+
return safe[:80]
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _discover_package_json_scripts(repo_root: Path) -> list[dict[str, Any]]:
|
|
1379
|
+
target = repo_root / "package.json"
|
|
1380
|
+
if not target.is_file():
|
|
1381
|
+
return []
|
|
1382
|
+
try:
|
|
1383
|
+
with target.open("rb") as handle:
|
|
1384
|
+
payload = json.load(handle)
|
|
1385
|
+
except (OSError, ValueError):
|
|
1386
|
+
return []
|
|
1387
|
+
scripts = payload.get("scripts") if isinstance(payload, dict) else None
|
|
1388
|
+
if not isinstance(scripts, dict):
|
|
1389
|
+
return []
|
|
1390
|
+
entries: list[dict[str, Any]] = []
|
|
1391
|
+
for name, command in scripts.items():
|
|
1392
|
+
if not isinstance(name, str) or not isinstance(command, str):
|
|
1393
|
+
continue
|
|
1394
|
+
entries.append(
|
|
1395
|
+
{
|
|
1396
|
+
"id": _workshop_command_id("npm", name),
|
|
1397
|
+
"source": "package.json",
|
|
1398
|
+
"group": "npm scripts",
|
|
1399
|
+
"name": name,
|
|
1400
|
+
"command": command,
|
|
1401
|
+
"runner": ["npm", "run", name],
|
|
1402
|
+
}
|
|
1403
|
+
)
|
|
1404
|
+
if len(entries) >= WORKSHOP_COMMAND_MAX:
|
|
1405
|
+
break
|
|
1406
|
+
return entries
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _discover_pyproject_scripts(repo_root: Path) -> list[dict[str, Any]]:
|
|
1410
|
+
target = repo_root / "pyproject.toml"
|
|
1411
|
+
if not target.is_file():
|
|
1412
|
+
return []
|
|
1413
|
+
try:
|
|
1414
|
+
with target.open("rb") as handle:
|
|
1415
|
+
payload = tomllib.load(handle)
|
|
1416
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
1417
|
+
return []
|
|
1418
|
+
entries: list[dict[str, Any]] = []
|
|
1419
|
+
project_scripts = (payload.get("project") or {}).get("scripts")
|
|
1420
|
+
if isinstance(project_scripts, dict):
|
|
1421
|
+
for name, target_ref in project_scripts.items():
|
|
1422
|
+
if not isinstance(name, str) or not isinstance(target_ref, str):
|
|
1423
|
+
continue
|
|
1424
|
+
entries.append(
|
|
1425
|
+
{
|
|
1426
|
+
"id": _workshop_command_id("pyproject", name),
|
|
1427
|
+
"source": "pyproject.toml [project.scripts]",
|
|
1428
|
+
"group": "Project scripts",
|
|
1429
|
+
"name": name,
|
|
1430
|
+
"command": target_ref,
|
|
1431
|
+
"runner": [name],
|
|
1432
|
+
}
|
|
1433
|
+
)
|
|
1434
|
+
if len(entries) >= WORKSHOP_COMMAND_MAX:
|
|
1435
|
+
return entries
|
|
1436
|
+
poetry_scripts = (
|
|
1437
|
+
((payload.get("tool") or {}).get("poetry") or {}).get("scripts")
|
|
1438
|
+
if isinstance(payload.get("tool"), dict)
|
|
1439
|
+
else None
|
|
1440
|
+
)
|
|
1441
|
+
if isinstance(poetry_scripts, dict):
|
|
1442
|
+
for name, target_ref in poetry_scripts.items():
|
|
1443
|
+
if not isinstance(name, str) or not isinstance(target_ref, str):
|
|
1444
|
+
continue
|
|
1445
|
+
entries.append(
|
|
1446
|
+
{
|
|
1447
|
+
"id": _workshop_command_id("poetry", name),
|
|
1448
|
+
"source": "pyproject.toml [tool.poetry.scripts]",
|
|
1449
|
+
"group": "Poetry scripts",
|
|
1450
|
+
"name": name,
|
|
1451
|
+
"command": target_ref,
|
|
1452
|
+
"runner": ["poetry", "run", name],
|
|
1453
|
+
}
|
|
1454
|
+
)
|
|
1455
|
+
if len(entries) >= WORKSHOP_COMMAND_MAX:
|
|
1456
|
+
break
|
|
1457
|
+
return entries
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def workshop_commands_payload(repo_root: Path) -> dict[str, Any]:
|
|
1461
|
+
if not repo_root.is_dir():
|
|
1462
|
+
return {"state": "unavailable", "commands": [], "message": "Workspace root is unavailable."}
|
|
1463
|
+
commands: list[dict[str, Any]] = []
|
|
1464
|
+
commands.extend(_discover_package_json_scripts(repo_root))
|
|
1465
|
+
commands.extend(_discover_pyproject_scripts(repo_root))
|
|
1466
|
+
seen: set[str] = set()
|
|
1467
|
+
deduped: list[dict[str, Any]] = []
|
|
1468
|
+
for entry in commands:
|
|
1469
|
+
if entry["id"] in seen:
|
|
1470
|
+
continue
|
|
1471
|
+
seen.add(entry["id"])
|
|
1472
|
+
deduped.append(entry)
|
|
1473
|
+
return {
|
|
1474
|
+
"state": "ok" if deduped else "empty",
|
|
1475
|
+
"commands": deduped,
|
|
1476
|
+
"message": "" if deduped else "No package.json or pyproject.toml entry points were found in the workspace root.",
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
|
|
1352
1480
|
def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
|
|
1353
1481
|
context = {"branch": "", "headSha": "", "subject": "", "author": ""}
|
|
1354
1482
|
commands = {
|
|
@@ -2311,6 +2439,556 @@ def _json_bytes(payload: Any) -> bytes:
|
|
|
2311
2439
|
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
2312
2440
|
|
|
2313
2441
|
|
|
2442
|
+
VIEWER_MUTATING_ROUTES = frozenset(
|
|
2443
|
+
{
|
|
2444
|
+
"/api/edit",
|
|
2445
|
+
"/api/open-file",
|
|
2446
|
+
"/api/open-repo-folder",
|
|
2447
|
+
"/api/bootstrap-logics",
|
|
2448
|
+
"/api/switch-project",
|
|
2449
|
+
"/api/cdx-report-request",
|
|
2450
|
+
"/api/cdx-mission-run",
|
|
2451
|
+
"/api/cdx-mission-apply-plan",
|
|
2452
|
+
"/api/workshop-command-start",
|
|
2453
|
+
"/api/workshop-command-stop",
|
|
2454
|
+
"/api/workshop-terminal-start",
|
|
2455
|
+
"/api/workshop-terminal-stop",
|
|
2456
|
+
"/api/workshop-terminal-input",
|
|
2457
|
+
"/api/workshop-terminal-resize",
|
|
2458
|
+
}
|
|
2459
|
+
)
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
_WORKSHOP_SESSION_BUFFER_MAX = 4000
|
|
2463
|
+
_WORKSHOP_SESSION_TTL_SECONDS = 600
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
class WorkshopCommandSession:
|
|
2467
|
+
"""Sandboxed subprocess for a Workshop command run.
|
|
2468
|
+
|
|
2469
|
+
Captures merged stdout+stderr into a ring buffer that SSE consumers can
|
|
2470
|
+
tail incrementally via a monotonically increasing sequence number. Stop
|
|
2471
|
+
delivers SIGTERM (Unix) or CTRL_BREAK_EVENT (Windows) to the process
|
|
2472
|
+
group and falls back to SIGKILL if the process refuses to exit.
|
|
2473
|
+
"""
|
|
2474
|
+
|
|
2475
|
+
def __init__(self, session_id: str, command_id: str, runner: list[str], cwd: Path):
|
|
2476
|
+
import collections
|
|
2477
|
+
import threading
|
|
2478
|
+
self.session_id = session_id
|
|
2479
|
+
self.command_id = command_id
|
|
2480
|
+
self.runner = list(runner)
|
|
2481
|
+
self.cwd = cwd
|
|
2482
|
+
self.started_at = ""
|
|
2483
|
+
self.finished_at = ""
|
|
2484
|
+
self.exit_code: int | None = None
|
|
2485
|
+
self.state = "starting"
|
|
2486
|
+
self.error: str = ""
|
|
2487
|
+
self._buffer: collections.deque[tuple[int, str]] = collections.deque(maxlen=_WORKSHOP_SESSION_BUFFER_MAX)
|
|
2488
|
+
self._seq = 0
|
|
2489
|
+
self._lock = threading.Lock()
|
|
2490
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
2491
|
+
self._reader: threading.Thread | None = None
|
|
2492
|
+
self._waiter: threading.Thread | None = None
|
|
2493
|
+
self._created_at = self._now()
|
|
2494
|
+
self._last_activity = self._created_at
|
|
2495
|
+
|
|
2496
|
+
@staticmethod
|
|
2497
|
+
def _now() -> float:
|
|
2498
|
+
import time
|
|
2499
|
+
return time.monotonic()
|
|
2500
|
+
|
|
2501
|
+
@staticmethod
|
|
2502
|
+
def _iso_now() -> str:
|
|
2503
|
+
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
2504
|
+
|
|
2505
|
+
def append_line(self, channel: str, text: str) -> None:
|
|
2506
|
+
with self._lock:
|
|
2507
|
+
self._seq += 1
|
|
2508
|
+
self._buffer.append((self._seq, f"{channel}\t{text}"))
|
|
2509
|
+
self._last_activity = self._now()
|
|
2510
|
+
|
|
2511
|
+
def tail(self, since_seq: int) -> tuple[int, list[tuple[int, str]]]:
|
|
2512
|
+
with self._lock:
|
|
2513
|
+
snapshot = [(seq, line) for (seq, line) in self._buffer if seq > since_seq]
|
|
2514
|
+
return self._seq, snapshot
|
|
2515
|
+
|
|
2516
|
+
def status_payload(self) -> dict[str, Any]:
|
|
2517
|
+
with self._lock:
|
|
2518
|
+
return {
|
|
2519
|
+
"id": self.session_id,
|
|
2520
|
+
"commandId": self.command_id,
|
|
2521
|
+
"runner": list(self.runner),
|
|
2522
|
+
"state": self.state,
|
|
2523
|
+
"exitCode": self.exit_code,
|
|
2524
|
+
"startedAt": self.started_at,
|
|
2525
|
+
"finishedAt": self.finished_at,
|
|
2526
|
+
"lastSeq": self._seq,
|
|
2527
|
+
"error": self.error,
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
def is_expired(self, ttl_seconds: float = _WORKSHOP_SESSION_TTL_SECONDS) -> bool:
|
|
2531
|
+
if self.state in {"running", "starting"}:
|
|
2532
|
+
return False
|
|
2533
|
+
return (self._now() - self._last_activity) > ttl_seconds
|
|
2534
|
+
|
|
2535
|
+
def start(self) -> None:
|
|
2536
|
+
import threading
|
|
2537
|
+
creation_flags = 0
|
|
2538
|
+
popen_kwargs: dict[str, Any] = {
|
|
2539
|
+
"cwd": str(self.cwd),
|
|
2540
|
+
"stdout": subprocess.PIPE,
|
|
2541
|
+
"stderr": subprocess.STDOUT,
|
|
2542
|
+
"stdin": subprocess.DEVNULL,
|
|
2543
|
+
}
|
|
2544
|
+
if sys.platform == "win32":
|
|
2545
|
+
creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
2546
|
+
popen_kwargs["creationflags"] = creation_flags
|
|
2547
|
+
else:
|
|
2548
|
+
popen_kwargs["start_new_session"] = True
|
|
2549
|
+
try:
|
|
2550
|
+
self._proc = subprocess.Popen(self.runner, **popen_kwargs)
|
|
2551
|
+
except (OSError, ValueError) as exc:
|
|
2552
|
+
self.state = "error"
|
|
2553
|
+
self.error = f"Unable to start command: {exc}"
|
|
2554
|
+
self.finished_at = self._iso_now()
|
|
2555
|
+
return
|
|
2556
|
+
self.started_at = self._iso_now()
|
|
2557
|
+
self.state = "running"
|
|
2558
|
+
self._reader = threading.Thread(target=self._read_loop, name=f"workshop-reader-{self.session_id}", daemon=True)
|
|
2559
|
+
self._reader.start()
|
|
2560
|
+
self._waiter = threading.Thread(target=self._wait_loop, name=f"workshop-waiter-{self.session_id}", daemon=True)
|
|
2561
|
+
self._waiter.start()
|
|
2562
|
+
|
|
2563
|
+
def _read_loop(self) -> None:
|
|
2564
|
+
proc = self._proc
|
|
2565
|
+
if proc is None or proc.stdout is None:
|
|
2566
|
+
return
|
|
2567
|
+
try:
|
|
2568
|
+
for raw in iter(proc.stdout.readline, b""):
|
|
2569
|
+
try:
|
|
2570
|
+
text = raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
2571
|
+
except Exception:
|
|
2572
|
+
continue
|
|
2573
|
+
self.append_line("stdout", text)
|
|
2574
|
+
except (OSError, ValueError):
|
|
2575
|
+
pass
|
|
2576
|
+
|
|
2577
|
+
def _wait_loop(self) -> None:
|
|
2578
|
+
proc = self._proc
|
|
2579
|
+
if proc is None:
|
|
2580
|
+
return
|
|
2581
|
+
try:
|
|
2582
|
+
code = proc.wait()
|
|
2583
|
+
except Exception as exc:
|
|
2584
|
+
self.error = f"Wait failed: {exc}"
|
|
2585
|
+
code = -1
|
|
2586
|
+
with self._lock:
|
|
2587
|
+
self.exit_code = code
|
|
2588
|
+
self.finished_at = self._iso_now()
|
|
2589
|
+
self.state = "finished" if code == 0 else ("stopped" if code in (-15, 143, -9, 137) else "failed")
|
|
2590
|
+
self._last_activity = self._now()
|
|
2591
|
+
|
|
2592
|
+
def stop(self, *, timeout: float = 5.0) -> None:
|
|
2593
|
+
proc = self._proc
|
|
2594
|
+
if proc is None or proc.poll() is not None:
|
|
2595
|
+
return
|
|
2596
|
+
try:
|
|
2597
|
+
if sys.platform == "win32":
|
|
2598
|
+
sig = getattr(__import__("signal"), "CTRL_BREAK_EVENT", None)
|
|
2599
|
+
if sig is not None:
|
|
2600
|
+
proc.send_signal(sig)
|
|
2601
|
+
else:
|
|
2602
|
+
proc.terminate()
|
|
2603
|
+
else:
|
|
2604
|
+
import os as _os
|
|
2605
|
+
import signal as _signal
|
|
2606
|
+
try:
|
|
2607
|
+
_os.killpg(proc.pid, _signal.SIGTERM)
|
|
2608
|
+
except (OSError, ProcessLookupError):
|
|
2609
|
+
proc.terminate()
|
|
2610
|
+
except Exception:
|
|
2611
|
+
proc.terminate()
|
|
2612
|
+
try:
|
|
2613
|
+
proc.wait(timeout=timeout)
|
|
2614
|
+
except subprocess.TimeoutExpired:
|
|
2615
|
+
try:
|
|
2616
|
+
if sys.platform == "win32":
|
|
2617
|
+
proc.kill()
|
|
2618
|
+
else:
|
|
2619
|
+
import os as _os
|
|
2620
|
+
import signal as _signal
|
|
2621
|
+
_os.killpg(proc.pid, _signal.SIGKILL)
|
|
2622
|
+
except Exception:
|
|
2623
|
+
proc.kill()
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
class WorkshopSessionRegistry:
|
|
2627
|
+
def __init__(self) -> None:
|
|
2628
|
+
import threading
|
|
2629
|
+
self._sessions: dict[str, WorkshopCommandSession] = {}
|
|
2630
|
+
self._lock = threading.Lock()
|
|
2631
|
+
self._counter = 0
|
|
2632
|
+
|
|
2633
|
+
def create(self, command_entry: dict[str, Any], repo_root: Path) -> WorkshopCommandSession:
|
|
2634
|
+
runner = command_entry.get("runner")
|
|
2635
|
+
if not isinstance(runner, list) or not runner or not all(isinstance(part, str) and part for part in runner):
|
|
2636
|
+
raise ValueError("Command entry is missing a valid runner.")
|
|
2637
|
+
if not repo_root.is_dir():
|
|
2638
|
+
raise ValueError("Workspace root is unavailable.")
|
|
2639
|
+
with self._lock:
|
|
2640
|
+
self._counter += 1
|
|
2641
|
+
session_id = f"ws-{self._counter:06d}"
|
|
2642
|
+
session = WorkshopCommandSession(
|
|
2643
|
+
session_id=session_id,
|
|
2644
|
+
command_id=str(command_entry.get("id") or ""),
|
|
2645
|
+
runner=runner,
|
|
2646
|
+
cwd=repo_root,
|
|
2647
|
+
)
|
|
2648
|
+
with self._lock:
|
|
2649
|
+
self._prune_locked()
|
|
2650
|
+
self._sessions[session_id] = session
|
|
2651
|
+
session.start()
|
|
2652
|
+
return session
|
|
2653
|
+
|
|
2654
|
+
def get(self, session_id: str) -> WorkshopCommandSession | None:
|
|
2655
|
+
with self._lock:
|
|
2656
|
+
return self._sessions.get(session_id)
|
|
2657
|
+
|
|
2658
|
+
def list(self) -> list[dict[str, Any]]:
|
|
2659
|
+
with self._lock:
|
|
2660
|
+
self._prune_locked()
|
|
2661
|
+
return [session.status_payload() for session in self._sessions.values()]
|
|
2662
|
+
|
|
2663
|
+
def _prune_locked(self) -> None:
|
|
2664
|
+
for sid in list(self._sessions.keys()):
|
|
2665
|
+
if self._sessions[sid].is_expired():
|
|
2666
|
+
del self._sessions[sid]
|
|
2667
|
+
|
|
2668
|
+
def shutdown(self) -> None:
|
|
2669
|
+
with self._lock:
|
|
2670
|
+
sessions = list(self._sessions.values())
|
|
2671
|
+
for session in sessions:
|
|
2672
|
+
session.stop(timeout=1.0)
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
_WORKSHOP_TERMINAL_BUFFER_MAX = 8000
|
|
2676
|
+
_WORKSHOP_TERMINAL_TTL_SECONDS = 1800
|
|
2677
|
+
_WORKSHOP_TERMINAL_IDLE_KILL_SECONDS = 60.0
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
def workshop_terminals_available() -> bool:
|
|
2681
|
+
"""True when the host can spawn PTY sessions through the stdlib backend."""
|
|
2682
|
+
if sys.platform == "win32":
|
|
2683
|
+
return False
|
|
2684
|
+
try:
|
|
2685
|
+
import pty # noqa: F401
|
|
2686
|
+
import termios # noqa: F401
|
|
2687
|
+
except ImportError:
|
|
2688
|
+
return False
|
|
2689
|
+
return True
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
def _default_workshop_shell() -> list[str]:
|
|
2693
|
+
candidate = os.environ.get("SHELL") or ""
|
|
2694
|
+
if candidate and os.access(candidate, os.X_OK):
|
|
2695
|
+
return [candidate, "-i"]
|
|
2696
|
+
for fallback in ("/bin/zsh", "/bin/bash", "/bin/sh"):
|
|
2697
|
+
if os.access(fallback, os.X_OK):
|
|
2698
|
+
return [fallback, "-i"]
|
|
2699
|
+
return ["/bin/sh"]
|
|
2700
|
+
|
|
2701
|
+
|
|
2702
|
+
class WorkshopTerminalSession:
|
|
2703
|
+
"""Interactive PTY-backed terminal session using stdlib `pty`.
|
|
2704
|
+
|
|
2705
|
+
Reads from the master fd in a daemon thread, buffers output bytes
|
|
2706
|
+
(decoded utf-8 with replace) into a ring; writes from the client land
|
|
2707
|
+
on the master fd directly. Resize uses TIOCSWINSZ ioctl. Stop sends
|
|
2708
|
+
SIGTERM to the session leader and falls back to SIGKILL after a grace
|
|
2709
|
+
window. Unix-only: Windows callers must check
|
|
2710
|
+
workshop_terminals_available() before instantiating.
|
|
2711
|
+
"""
|
|
2712
|
+
|
|
2713
|
+
def __init__(self, session_id: str, command: list[str], cwd: Path, *, label: str = ""):
|
|
2714
|
+
import collections
|
|
2715
|
+
import threading
|
|
2716
|
+
self.session_id = session_id
|
|
2717
|
+
self.command = list(command)
|
|
2718
|
+
self.cwd = cwd
|
|
2719
|
+
self.label = label or (command[0] if command else "shell")
|
|
2720
|
+
self.started_at = ""
|
|
2721
|
+
self.finished_at = ""
|
|
2722
|
+
self.exit_code: int | None = None
|
|
2723
|
+
self.state = "starting"
|
|
2724
|
+
self.error: str = ""
|
|
2725
|
+
self._buffer: collections.deque[tuple[int, str]] = collections.deque(maxlen=_WORKSHOP_TERMINAL_BUFFER_MAX)
|
|
2726
|
+
self._seq = 0
|
|
2727
|
+
self._lock = threading.Lock()
|
|
2728
|
+
self._master_fd: int | None = None
|
|
2729
|
+
self._pid: int | None = None
|
|
2730
|
+
self._reader: threading.Thread | None = None
|
|
2731
|
+
self._reaper: threading.Thread | None = None
|
|
2732
|
+
self._created_at = self._now()
|
|
2733
|
+
self._last_activity = self._created_at
|
|
2734
|
+
self._listeners = 0
|
|
2735
|
+
self._idle_timer: threading.Timer | None = None
|
|
2736
|
+
self._idle_kill_seconds = _WORKSHOP_TERMINAL_IDLE_KILL_SECONDS
|
|
2737
|
+
|
|
2738
|
+
@staticmethod
|
|
2739
|
+
def _now() -> float:
|
|
2740
|
+
import time
|
|
2741
|
+
return time.monotonic()
|
|
2742
|
+
|
|
2743
|
+
@staticmethod
|
|
2744
|
+
def _iso_now() -> str:
|
|
2745
|
+
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
2746
|
+
|
|
2747
|
+
def _append(self, text: str) -> None:
|
|
2748
|
+
with self._lock:
|
|
2749
|
+
self._seq += 1
|
|
2750
|
+
self._buffer.append((self._seq, text))
|
|
2751
|
+
self._last_activity = self._now()
|
|
2752
|
+
|
|
2753
|
+
def tail(self, since_seq: int) -> tuple[int, list[tuple[int, str]]]:
|
|
2754
|
+
with self._lock:
|
|
2755
|
+
snapshot = [(seq, chunk) for (seq, chunk) in self._buffer if seq > since_seq]
|
|
2756
|
+
return self._seq, snapshot
|
|
2757
|
+
|
|
2758
|
+
def status_payload(self) -> dict[str, Any]:
|
|
2759
|
+
with self._lock:
|
|
2760
|
+
return {
|
|
2761
|
+
"id": self.session_id,
|
|
2762
|
+
"label": self.label,
|
|
2763
|
+
"command": list(self.command),
|
|
2764
|
+
"state": self.state,
|
|
2765
|
+
"exitCode": self.exit_code,
|
|
2766
|
+
"startedAt": self.started_at,
|
|
2767
|
+
"finishedAt": self.finished_at,
|
|
2768
|
+
"lastSeq": self._seq,
|
|
2769
|
+
"error": self.error,
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
def is_expired(self, ttl_seconds: float = _WORKSHOP_TERMINAL_TTL_SECONDS) -> bool:
|
|
2773
|
+
if self.state in {"running", "starting"}:
|
|
2774
|
+
return False
|
|
2775
|
+
return (self._now() - self._last_activity) > ttl_seconds
|
|
2776
|
+
|
|
2777
|
+
def attach_listener(self) -> None:
|
|
2778
|
+
"""Register a live SSE consumer for this session."""
|
|
2779
|
+
with self._lock:
|
|
2780
|
+
self._listeners += 1
|
|
2781
|
+
if self._idle_timer is not None:
|
|
2782
|
+
self._idle_timer.cancel()
|
|
2783
|
+
self._idle_timer = None
|
|
2784
|
+
|
|
2785
|
+
def detach_listener(self) -> None:
|
|
2786
|
+
"""Release an SSE consumer; arm the idle-kill timer if none remain."""
|
|
2787
|
+
import threading
|
|
2788
|
+
arm = False
|
|
2789
|
+
with self._lock:
|
|
2790
|
+
self._listeners = max(0, self._listeners - 1)
|
|
2791
|
+
if self._listeners == 0 and self.state in {"running", "starting"}:
|
|
2792
|
+
if self._idle_timer is not None:
|
|
2793
|
+
self._idle_timer.cancel()
|
|
2794
|
+
self._idle_timer = threading.Timer(self._idle_kill_seconds, self._on_idle_timeout)
|
|
2795
|
+
self._idle_timer.daemon = True
|
|
2796
|
+
arm = True
|
|
2797
|
+
if arm and self._idle_timer is not None:
|
|
2798
|
+
self._idle_timer.start()
|
|
2799
|
+
|
|
2800
|
+
def _on_idle_timeout(self) -> None:
|
|
2801
|
+
with self._lock:
|
|
2802
|
+
still_idle = self._listeners == 0 and self.state in {"running", "starting"}
|
|
2803
|
+
self._idle_timer = None
|
|
2804
|
+
if not still_idle:
|
|
2805
|
+
return
|
|
2806
|
+
# Best-effort: SIGTERM the session group, falling back to SIGKILL.
|
|
2807
|
+
self.stop(timeout=3.0)
|
|
2808
|
+
|
|
2809
|
+
def start(self) -> None:
|
|
2810
|
+
import threading
|
|
2811
|
+
if not workshop_terminals_available():
|
|
2812
|
+
self.state = "error"
|
|
2813
|
+
self.error = "PTY backend is not available on this host."
|
|
2814
|
+
self.finished_at = self._iso_now()
|
|
2815
|
+
return
|
|
2816
|
+
import pty
|
|
2817
|
+
try:
|
|
2818
|
+
pid, master_fd = pty.fork()
|
|
2819
|
+
except (OSError, RuntimeError) as exc:
|
|
2820
|
+
self.state = "error"
|
|
2821
|
+
self.error = f"Unable to fork PTY: {exc}"
|
|
2822
|
+
self.finished_at = self._iso_now()
|
|
2823
|
+
return
|
|
2824
|
+
if pid == 0:
|
|
2825
|
+
try:
|
|
2826
|
+
os.chdir(str(self.cwd))
|
|
2827
|
+
except OSError:
|
|
2828
|
+
pass
|
|
2829
|
+
env = os.environ.copy()
|
|
2830
|
+
env.setdefault("TERM", "xterm-256color")
|
|
2831
|
+
env.setdefault("COLORTERM", "truecolor")
|
|
2832
|
+
try:
|
|
2833
|
+
os.execvpe(self.command[0], self.command, env)
|
|
2834
|
+
except Exception as exc: # noqa: BLE001
|
|
2835
|
+
sys.stderr.write(f"Unable to exec {self.command[0]}: {exc}\n")
|
|
2836
|
+
os._exit(127)
|
|
2837
|
+
self._pid = pid
|
|
2838
|
+
self._master_fd = master_fd
|
|
2839
|
+
self.started_at = self._iso_now()
|
|
2840
|
+
self.state = "running"
|
|
2841
|
+
self._reader = threading.Thread(target=self._read_loop, name=f"workshop-pty-reader-{self.session_id}", daemon=True)
|
|
2842
|
+
self._reader.start()
|
|
2843
|
+
self._reaper = threading.Thread(target=self._reap_loop, name=f"workshop-pty-reaper-{self.session_id}", daemon=True)
|
|
2844
|
+
self._reaper.start()
|
|
2845
|
+
|
|
2846
|
+
def write(self, data: str) -> None:
|
|
2847
|
+
if not data or self._master_fd is None:
|
|
2848
|
+
return
|
|
2849
|
+
try:
|
|
2850
|
+
os.write(self._master_fd, data.encode("utf-8"))
|
|
2851
|
+
except OSError as exc:
|
|
2852
|
+
self.error = f"Write failed: {exc}"
|
|
2853
|
+
|
|
2854
|
+
def resize(self, rows: int, cols: int) -> None:
|
|
2855
|
+
if self._master_fd is None or rows <= 0 or cols <= 0:
|
|
2856
|
+
return
|
|
2857
|
+
try:
|
|
2858
|
+
import fcntl
|
|
2859
|
+
import struct
|
|
2860
|
+
import termios
|
|
2861
|
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
2862
|
+
except (OSError, ImportError):
|
|
2863
|
+
return
|
|
2864
|
+
|
|
2865
|
+
def _read_loop(self) -> None:
|
|
2866
|
+
fd = self._master_fd
|
|
2867
|
+
if fd is None:
|
|
2868
|
+
return
|
|
2869
|
+
try:
|
|
2870
|
+
while True:
|
|
2871
|
+
try:
|
|
2872
|
+
chunk = os.read(fd, 4096)
|
|
2873
|
+
except OSError:
|
|
2874
|
+
break
|
|
2875
|
+
if not chunk:
|
|
2876
|
+
break
|
|
2877
|
+
try:
|
|
2878
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
2879
|
+
except Exception: # noqa: BLE001
|
|
2880
|
+
continue
|
|
2881
|
+
self._append(text)
|
|
2882
|
+
finally:
|
|
2883
|
+
try:
|
|
2884
|
+
os.close(fd)
|
|
2885
|
+
except OSError:
|
|
2886
|
+
pass
|
|
2887
|
+
|
|
2888
|
+
def _reap_loop(self) -> None:
|
|
2889
|
+
pid = self._pid
|
|
2890
|
+
if pid is None:
|
|
2891
|
+
return
|
|
2892
|
+
try:
|
|
2893
|
+
_, status = os.waitpid(pid, 0)
|
|
2894
|
+
except OSError as exc:
|
|
2895
|
+
self.error = f"waitpid failed: {exc}"
|
|
2896
|
+
status = -1
|
|
2897
|
+
if isinstance(status, int):
|
|
2898
|
+
if os.WIFEXITED(status):
|
|
2899
|
+
code = os.WEXITSTATUS(status)
|
|
2900
|
+
elif os.WIFSIGNALED(status):
|
|
2901
|
+
code = -os.WTERMSIG(status)
|
|
2902
|
+
else:
|
|
2903
|
+
code = -1
|
|
2904
|
+
else:
|
|
2905
|
+
code = -1
|
|
2906
|
+
with self._lock:
|
|
2907
|
+
self.exit_code = code
|
|
2908
|
+
self.finished_at = self._iso_now()
|
|
2909
|
+
self.state = "finished" if code == 0 else ("stopped" if code in (-15, -9) else "failed")
|
|
2910
|
+
self._last_activity = self._now()
|
|
2911
|
+
if self._idle_timer is not None:
|
|
2912
|
+
self._idle_timer.cancel()
|
|
2913
|
+
self._idle_timer = None
|
|
2914
|
+
|
|
2915
|
+
def stop(self, *, timeout: float = 3.0) -> None:
|
|
2916
|
+
pid = self._pid
|
|
2917
|
+
if pid is None:
|
|
2918
|
+
return
|
|
2919
|
+
import signal as _signal
|
|
2920
|
+
import time as _time
|
|
2921
|
+
try:
|
|
2922
|
+
os.killpg(os.getpgid(pid), _signal.SIGTERM)
|
|
2923
|
+
except (OSError, ProcessLookupError):
|
|
2924
|
+
try:
|
|
2925
|
+
os.kill(pid, _signal.SIGTERM)
|
|
2926
|
+
except OSError:
|
|
2927
|
+
return
|
|
2928
|
+
deadline = _time.monotonic() + timeout
|
|
2929
|
+
while _time.monotonic() < deadline:
|
|
2930
|
+
with self._lock:
|
|
2931
|
+
if self.state in {"finished", "failed", "stopped"}:
|
|
2932
|
+
return
|
|
2933
|
+
_time.sleep(0.05)
|
|
2934
|
+
try:
|
|
2935
|
+
os.killpg(os.getpgid(pid), _signal.SIGKILL)
|
|
2936
|
+
except (OSError, ProcessLookupError):
|
|
2937
|
+
try:
|
|
2938
|
+
os.kill(pid, _signal.SIGKILL)
|
|
2939
|
+
except OSError:
|
|
2940
|
+
return
|
|
2941
|
+
|
|
2942
|
+
|
|
2943
|
+
class WorkshopTerminalRegistry:
|
|
2944
|
+
def __init__(self) -> None:
|
|
2945
|
+
import threading
|
|
2946
|
+
self._sessions: dict[str, WorkshopTerminalSession] = {}
|
|
2947
|
+
self._lock = threading.Lock()
|
|
2948
|
+
self._counter = 0
|
|
2949
|
+
|
|
2950
|
+
def create(self, command: list[str], cwd: Path, *, label: str = "") -> WorkshopTerminalSession:
|
|
2951
|
+
if not workshop_terminals_available():
|
|
2952
|
+
raise ValueError("PTY backend is not available on this host.")
|
|
2953
|
+
if not command or not isinstance(command, list):
|
|
2954
|
+
raise ValueError("Terminal command must be a non-empty list.")
|
|
2955
|
+
if not cwd.is_dir():
|
|
2956
|
+
raise ValueError("Workspace root is unavailable.")
|
|
2957
|
+
with self._lock:
|
|
2958
|
+
self._counter += 1
|
|
2959
|
+
session_id = f"wt-{self._counter:06d}"
|
|
2960
|
+
session = WorkshopTerminalSession(session_id=session_id, command=command, cwd=cwd, label=label)
|
|
2961
|
+
with self._lock:
|
|
2962
|
+
self._prune_locked()
|
|
2963
|
+
self._sessions[session_id] = session
|
|
2964
|
+
session.start()
|
|
2965
|
+
return session
|
|
2966
|
+
|
|
2967
|
+
def get(self, session_id: str) -> WorkshopTerminalSession | None:
|
|
2968
|
+
with self._lock:
|
|
2969
|
+
return self._sessions.get(session_id)
|
|
2970
|
+
|
|
2971
|
+
def list(self) -> list[dict[str, Any]]:
|
|
2972
|
+
with self._lock:
|
|
2973
|
+
self._prune_locked()
|
|
2974
|
+
return [session.status_payload() for session in self._sessions.values()]
|
|
2975
|
+
|
|
2976
|
+
def _prune_locked(self) -> None:
|
|
2977
|
+
for sid in list(self._sessions.keys()):
|
|
2978
|
+
if self._sessions[sid].is_expired():
|
|
2979
|
+
del self._sessions[sid]
|
|
2980
|
+
|
|
2981
|
+
def shutdown(self) -> None:
|
|
2982
|
+
with self._lock:
|
|
2983
|
+
sessions = list(self._sessions.values())
|
|
2984
|
+
for session in sessions:
|
|
2985
|
+
session.stop(timeout=1.0)
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
def workshop_terminal_default_command() -> list[str]:
|
|
2989
|
+
return _default_workshop_shell()
|
|
2990
|
+
|
|
2991
|
+
|
|
2314
2992
|
class LogicsViewerServer(ThreadingHTTPServer):
|
|
2315
2993
|
def __init__(
|
|
2316
2994
|
self,
|
|
@@ -2319,6 +2997,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
2319
2997
|
*,
|
|
2320
2998
|
auto_refresh_interval_seconds: int = 15,
|
|
2321
2999
|
auto_refresh_interval_forced: bool = False,
|
|
3000
|
+
lan_mode: bool = False,
|
|
2322
3001
|
):
|
|
2323
3002
|
self.launch_repo_root = repo_root.resolve()
|
|
2324
3003
|
self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
|
|
@@ -2327,19 +3006,40 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
2327
3006
|
self.repo_root = self.launch_repo_root
|
|
2328
3007
|
self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
|
|
2329
3008
|
self.auto_refresh_interval_forced = auto_refresh_interval_forced
|
|
3009
|
+
self.lan_mode = bool(lan_mode)
|
|
3010
|
+
self.lan_token = secrets.token_urlsafe(32) if self.lan_mode else ""
|
|
3011
|
+
self.workshop_sessions = WorkshopSessionRegistry()
|
|
3012
|
+
self.workshop_terminals = WorkshopTerminalRegistry()
|
|
2330
3013
|
super().__init__(server_address, LogicsViewerRequestHandler)
|
|
2331
3014
|
|
|
3015
|
+
def server_close(self) -> None:
|
|
3016
|
+
try:
|
|
3017
|
+
self.workshop_sessions.shutdown()
|
|
3018
|
+
finally:
|
|
3019
|
+
try:
|
|
3020
|
+
self.workshop_terminals.shutdown()
|
|
3021
|
+
finally:
|
|
3022
|
+
super().server_close()
|
|
3023
|
+
|
|
2332
3024
|
def project_registry_payload(self) -> list[dict[str, Any]]:
|
|
2333
3025
|
return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
|
|
2334
3026
|
|
|
2335
3027
|
def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
|
|
2336
|
-
|
|
3028
|
+
payload = viewer_data_payload(
|
|
2337
3029
|
self.repo_root,
|
|
2338
3030
|
selected_id=selected_id,
|
|
2339
3031
|
auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
|
|
2340
3032
|
auto_refresh_interval_forced=self.auto_refresh_interval_forced,
|
|
2341
3033
|
projects=self.project_registry_payload(),
|
|
2342
3034
|
)
|
|
3035
|
+
payload["lanMode"] = bool(self.lan_mode)
|
|
3036
|
+
if self.lan_mode and self.lan_token:
|
|
3037
|
+
host, port = self.server_address[:2]
|
|
3038
|
+
lan_url = _network_viewer_url(str(host), int(port)) or build_viewer_url(str(host), int(port))
|
|
3039
|
+
payload["lanShareUrl"] = _append_lan_token(lan_url, self.lan_token)
|
|
3040
|
+
else:
|
|
3041
|
+
payload["lanShareUrl"] = ""
|
|
3042
|
+
return payload
|
|
2343
3043
|
|
|
2344
3044
|
def switch_project(self, project_id: str) -> dict[str, Any]:
|
|
2345
3045
|
target = self.project_root_by_id.get(project_id)
|
|
@@ -2383,8 +3083,175 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
2383
3083
|
content_type = f"{content_type}; charset=utf-8"
|
|
2384
3084
|
self._send_bytes(path.read_bytes(), content_type=content_type)
|
|
2385
3085
|
|
|
3086
|
+
def _stream_workshop_terminal(self, session: "WorkshopTerminalSession", parsed: Any) -> None:
|
|
3087
|
+
import time as _time
|
|
3088
|
+
try:
|
|
3089
|
+
since = int(parse_qs(parsed.query).get("since", ["0"])[0])
|
|
3090
|
+
except (TypeError, ValueError):
|
|
3091
|
+
since = 0
|
|
3092
|
+
try:
|
|
3093
|
+
self.send_response(200)
|
|
3094
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
3095
|
+
self.send_header("Cache-Control", "no-store")
|
|
3096
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
3097
|
+
self.end_headers()
|
|
3098
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3099
|
+
return
|
|
3100
|
+
session.attach_listener()
|
|
3101
|
+
try:
|
|
3102
|
+
last_seq = since
|
|
3103
|
+
idle_ticks = 0
|
|
3104
|
+
while True:
|
|
3105
|
+
latest_seq, snapshot = session.tail(last_seq)
|
|
3106
|
+
if snapshot:
|
|
3107
|
+
idle_ticks = 0
|
|
3108
|
+
for seq, chunk in snapshot:
|
|
3109
|
+
last_seq = seq
|
|
3110
|
+
try:
|
|
3111
|
+
payload = json.dumps({"seq": seq, "data": chunk})
|
|
3112
|
+
self.wfile.write(f"event: data\ndata: {payload}\n\n".encode("utf-8"))
|
|
3113
|
+
self.wfile.flush()
|
|
3114
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3115
|
+
return
|
|
3116
|
+
state = session.state
|
|
3117
|
+
if state in {"finished", "failed", "stopped", "error"} and last_seq >= latest_seq:
|
|
3118
|
+
try:
|
|
3119
|
+
payload = json.dumps(session.status_payload())
|
|
3120
|
+
self.wfile.write(f"event: end\ndata: {payload}\n\n".encode("utf-8"))
|
|
3121
|
+
self.wfile.flush()
|
|
3122
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3123
|
+
return
|
|
3124
|
+
return
|
|
3125
|
+
idle_ticks += 1
|
|
3126
|
+
if idle_ticks >= 30:
|
|
3127
|
+
try:
|
|
3128
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
3129
|
+
self.wfile.flush()
|
|
3130
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3131
|
+
return
|
|
3132
|
+
idle_ticks = 0
|
|
3133
|
+
_time.sleep(0.1)
|
|
3134
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3135
|
+
return
|
|
3136
|
+
finally:
|
|
3137
|
+
session.detach_listener()
|
|
3138
|
+
|
|
3139
|
+
def _stream_workshop_session(self, session: "WorkshopCommandSession", parsed: Any) -> None:
|
|
3140
|
+
import time as _time
|
|
3141
|
+
try:
|
|
3142
|
+
since = int(parse_qs(parsed.query).get("since", ["0"])[0])
|
|
3143
|
+
except (TypeError, ValueError):
|
|
3144
|
+
since = 0
|
|
3145
|
+
try:
|
|
3146
|
+
self.send_response(200)
|
|
3147
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
3148
|
+
self.send_header("Cache-Control", "no-store")
|
|
3149
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
3150
|
+
self.end_headers()
|
|
3151
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3152
|
+
return
|
|
3153
|
+
last_seq = since
|
|
3154
|
+
idle_ticks = 0
|
|
3155
|
+
try:
|
|
3156
|
+
while True:
|
|
3157
|
+
latest_seq, snapshot = session.tail(last_seq)
|
|
3158
|
+
if snapshot:
|
|
3159
|
+
idle_ticks = 0
|
|
3160
|
+
for seq, line in snapshot:
|
|
3161
|
+
last_seq = seq
|
|
3162
|
+
try:
|
|
3163
|
+
channel, _, text = line.partition("\t")
|
|
3164
|
+
payload = json.dumps({"seq": seq, "channel": channel, "line": text})
|
|
3165
|
+
self.wfile.write(f"event: line\ndata: {payload}\n\n".encode("utf-8"))
|
|
3166
|
+
self.wfile.flush()
|
|
3167
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3168
|
+
return
|
|
3169
|
+
state = session.state
|
|
3170
|
+
if state in {"finished", "failed", "stopped", "error"} and last_seq >= latest_seq:
|
|
3171
|
+
try:
|
|
3172
|
+
payload = json.dumps(session.status_payload())
|
|
3173
|
+
self.wfile.write(f"event: end\ndata: {payload}\n\n".encode("utf-8"))
|
|
3174
|
+
self.wfile.flush()
|
|
3175
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3176
|
+
return
|
|
3177
|
+
return
|
|
3178
|
+
idle_ticks += 1
|
|
3179
|
+
if idle_ticks >= 30:
|
|
3180
|
+
try:
|
|
3181
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
3182
|
+
self.wfile.flush()
|
|
3183
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3184
|
+
return
|
|
3185
|
+
idle_ticks = 0
|
|
3186
|
+
_time.sleep(0.2)
|
|
3187
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3188
|
+
return
|
|
3189
|
+
|
|
3190
|
+
def _client_is_loopback(self) -> bool:
|
|
3191
|
+
try:
|
|
3192
|
+
host = self.client_address[0]
|
|
3193
|
+
except (IndexError, AttributeError):
|
|
3194
|
+
return False
|
|
3195
|
+
if not host:
|
|
3196
|
+
return False
|
|
3197
|
+
if host in {"127.0.0.1", "::1"}:
|
|
3198
|
+
return True
|
|
3199
|
+
if host.startswith("127."):
|
|
3200
|
+
return True
|
|
3201
|
+
if host.startswith("::ffff:127."):
|
|
3202
|
+
return True
|
|
3203
|
+
return False
|
|
3204
|
+
|
|
3205
|
+
def _is_public_get_route(self, route: str) -> bool:
|
|
3206
|
+
"""Static UI assets that must load before the JS can attach the bearer.
|
|
3207
|
+
|
|
3208
|
+
Browsers do not auto-attach Authorization headers to <script src>,
|
|
3209
|
+
<link href>, or @font-face fetches, and we cannot put ?t= on every
|
|
3210
|
+
asset URL the page references. We let these routes through
|
|
3211
|
+
unauthenticated; they expose no repository data — every actual
|
|
3212
|
+
payload lives under /api/* which stays gated.
|
|
3213
|
+
"""
|
|
3214
|
+
if route in {"/", "/browser-host.js", "/viewer.css", "/vendor/mermaid.min.js"}:
|
|
3215
|
+
return True
|
|
3216
|
+
if route.startswith("/media/"):
|
|
3217
|
+
return True
|
|
3218
|
+
return False
|
|
3219
|
+
|
|
3220
|
+
def _lan_auth_passes(self, parsed: Any, *, method: str = "GET") -> bool:
|
|
3221
|
+
token = self.server.lan_token
|
|
3222
|
+
if not token:
|
|
3223
|
+
return True
|
|
3224
|
+
if self._client_is_loopback():
|
|
3225
|
+
return True
|
|
3226
|
+
if method == "GET" and self._is_public_get_route(parsed.path):
|
|
3227
|
+
return True
|
|
3228
|
+
header = self.headers.get("Authorization", "")
|
|
3229
|
+
if header.lower().startswith("bearer "):
|
|
3230
|
+
candidate = header.split(" ", 1)[1].strip()
|
|
3231
|
+
if candidate and hmac.compare_digest(candidate, token):
|
|
3232
|
+
return True
|
|
3233
|
+
query_token = (parse_qs(parsed.query).get("t") or [""])[0]
|
|
3234
|
+
if query_token and hmac.compare_digest(query_token, token):
|
|
3235
|
+
return True
|
|
3236
|
+
return False
|
|
3237
|
+
|
|
3238
|
+
def _send_lan_unauthorized(self) -> None:
|
|
3239
|
+
body = _json_bytes({"ok": False, "error": "LAN viewer requires a bearer token. Open the share URL from the launch banner."})
|
|
3240
|
+
try:
|
|
3241
|
+
self.send_response(HTTPStatus.UNAUTHORIZED)
|
|
3242
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
3243
|
+
self.send_header("Cache-Control", "no-store")
|
|
3244
|
+
self.send_header("WWW-Authenticate", 'Bearer realm="logics-viewer"')
|
|
3245
|
+
self.end_headers()
|
|
3246
|
+
self.wfile.write(body)
|
|
3247
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3248
|
+
return
|
|
3249
|
+
|
|
2386
3250
|
def do_GET(self) -> None:
|
|
2387
3251
|
parsed = urlparse(self.path)
|
|
3252
|
+
if not self._lan_auth_passes(parsed, method="GET"):
|
|
3253
|
+
self._send_lan_unauthorized()
|
|
3254
|
+
return
|
|
2388
3255
|
route = parsed.path
|
|
2389
3256
|
if route == "/":
|
|
2390
3257
|
self._serve_file(VIEWER_ROOT / "index.html")
|
|
@@ -2478,6 +3345,52 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
2478
3345
|
except ValueError as exc:
|
|
2479
3346
|
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
2480
3347
|
return
|
|
3348
|
+
if route == "/api/workshop-commands":
|
|
3349
|
+
try:
|
|
3350
|
+
self._send_json({"ok": True, "payload": workshop_commands_payload(self.server.repo_root)})
|
|
3351
|
+
except ValueError as exc:
|
|
3352
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3353
|
+
return
|
|
3354
|
+
if route == "/api/workshop-sessions":
|
|
3355
|
+
self._send_json({"ok": True, "payload": {"sessions": self.server.workshop_sessions.list()}})
|
|
3356
|
+
return
|
|
3357
|
+
if route == "/api/workshop-terminals":
|
|
3358
|
+
self._send_json({"ok": True, "payload": {"sessions": self.server.workshop_terminals.list(), "available": workshop_terminals_available()}})
|
|
3359
|
+
return
|
|
3360
|
+
if route.startswith("/api/workshop-terminal/"):
|
|
3361
|
+
tail = route[len("/api/workshop-terminal/"):]
|
|
3362
|
+
parts = tail.split("/", 1)
|
|
3363
|
+
session_id = parts[0]
|
|
3364
|
+
kind = parts[1] if len(parts) > 1 else "status"
|
|
3365
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3366
|
+
if session is None:
|
|
3367
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3368
|
+
return
|
|
3369
|
+
if kind == "status":
|
|
3370
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3371
|
+
return
|
|
3372
|
+
if kind == "stream":
|
|
3373
|
+
self._stream_workshop_terminal(session, parsed)
|
|
3374
|
+
return
|
|
3375
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown terminal sub-resource.")
|
|
3376
|
+
return
|
|
3377
|
+
if route.startswith("/api/workshop-session/"):
|
|
3378
|
+
tail = route[len("/api/workshop-session/"):]
|
|
3379
|
+
parts = tail.split("/", 1)
|
|
3380
|
+
session_id = parts[0]
|
|
3381
|
+
kind = parts[1] if len(parts) > 1 else "status"
|
|
3382
|
+
session = self.server.workshop_sessions.get(session_id)
|
|
3383
|
+
if session is None:
|
|
3384
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop session not found.")
|
|
3385
|
+
return
|
|
3386
|
+
if kind == "status":
|
|
3387
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3388
|
+
return
|
|
3389
|
+
if kind == "stream":
|
|
3390
|
+
self._stream_workshop_session(session, parsed)
|
|
3391
|
+
return
|
|
3392
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown session sub-resource.")
|
|
3393
|
+
return
|
|
2481
3394
|
if route == "/api/workspace-file":
|
|
2482
3395
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
2483
3396
|
try:
|
|
@@ -2494,6 +3407,15 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
2494
3407
|
|
|
2495
3408
|
def do_POST(self) -> None:
|
|
2496
3409
|
parsed = urlparse(self.path)
|
|
3410
|
+
if not self._lan_auth_passes(parsed, method="POST"):
|
|
3411
|
+
self._send_lan_unauthorized()
|
|
3412
|
+
return
|
|
3413
|
+
if self.server.lan_mode and parsed.path in VIEWER_MUTATING_ROUTES:
|
|
3414
|
+
self._send_error_json(
|
|
3415
|
+
HTTPStatus.FORBIDDEN,
|
|
3416
|
+
"Mutating endpoint refused: the viewer is exposed on the LAN in read-only mode.",
|
|
3417
|
+
)
|
|
3418
|
+
return
|
|
2497
3419
|
if parsed.path == "/api/refresh":
|
|
2498
3420
|
self._send_json(
|
|
2499
3421
|
{
|
|
@@ -2516,6 +3438,105 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
2516
3438
|
except FileNotFoundError as exc:
|
|
2517
3439
|
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2518
3440
|
return
|
|
3441
|
+
if parsed.path == "/api/workshop-command-start":
|
|
3442
|
+
try:
|
|
3443
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3444
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3445
|
+
body = json.loads(raw_body or "{}")
|
|
3446
|
+
command_id = str(body.get("commandId") or "")
|
|
3447
|
+
if not command_id:
|
|
3448
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Missing commandId.")
|
|
3449
|
+
return
|
|
3450
|
+
catalog = workshop_commands_payload(self.server.repo_root)
|
|
3451
|
+
entry = next((c for c in catalog.get("commands", []) if c.get("id") == command_id), None)
|
|
3452
|
+
if entry is None:
|
|
3453
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown command id.")
|
|
3454
|
+
return
|
|
3455
|
+
session = self.server.workshop_sessions.create(entry, self.server.repo_root)
|
|
3456
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3457
|
+
except json.JSONDecodeError:
|
|
3458
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3459
|
+
except ValueError as exc:
|
|
3460
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3461
|
+
return
|
|
3462
|
+
if parsed.path == "/api/workshop-terminal-start":
|
|
3463
|
+
try:
|
|
3464
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3465
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3466
|
+
body = json.loads(raw_body or "{}")
|
|
3467
|
+
command_override = body.get("command")
|
|
3468
|
+
label = str(body.get("label") or "")
|
|
3469
|
+
command = command_override if isinstance(command_override, list) and all(isinstance(p, str) for p in command_override) and command_override else workshop_terminal_default_command()
|
|
3470
|
+
session = self.server.workshop_terminals.create(command, self.server.repo_root, label=label)
|
|
3471
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3472
|
+
except json.JSONDecodeError:
|
|
3473
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3474
|
+
except ValueError as exc:
|
|
3475
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3476
|
+
return
|
|
3477
|
+
if parsed.path == "/api/workshop-terminal-input":
|
|
3478
|
+
try:
|
|
3479
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3480
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3481
|
+
body = json.loads(raw_body or "{}")
|
|
3482
|
+
session_id = str(body.get("sessionId") or "")
|
|
3483
|
+
data = str(body.get("data") or "")
|
|
3484
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3485
|
+
if session is None:
|
|
3486
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3487
|
+
return
|
|
3488
|
+
session.write(data)
|
|
3489
|
+
self._send_json({"ok": True})
|
|
3490
|
+
except json.JSONDecodeError:
|
|
3491
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3492
|
+
return
|
|
3493
|
+
if parsed.path == "/api/workshop-terminal-resize":
|
|
3494
|
+
try:
|
|
3495
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3496
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3497
|
+
body = json.loads(raw_body or "{}")
|
|
3498
|
+
session_id = str(body.get("sessionId") or "")
|
|
3499
|
+
rows = int(body.get("rows") or 0)
|
|
3500
|
+
cols = int(body.get("cols") or 0)
|
|
3501
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3502
|
+
if session is None:
|
|
3503
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3504
|
+
return
|
|
3505
|
+
session.resize(rows, cols)
|
|
3506
|
+
self._send_json({"ok": True})
|
|
3507
|
+
except (json.JSONDecodeError, ValueError):
|
|
3508
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid resize body.")
|
|
3509
|
+
return
|
|
3510
|
+
if parsed.path == "/api/workshop-terminal-stop":
|
|
3511
|
+
try:
|
|
3512
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3513
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3514
|
+
body = json.loads(raw_body or "{}")
|
|
3515
|
+
session_id = str(body.get("sessionId") or "")
|
|
3516
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3517
|
+
if session is None:
|
|
3518
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3519
|
+
return
|
|
3520
|
+
session.stop()
|
|
3521
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3522
|
+
except json.JSONDecodeError:
|
|
3523
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3524
|
+
return
|
|
3525
|
+
if parsed.path == "/api/workshop-command-stop":
|
|
3526
|
+
try:
|
|
3527
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3528
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3529
|
+
body = json.loads(raw_body or "{}")
|
|
3530
|
+
session_id = str(body.get("sessionId") or "")
|
|
3531
|
+
session = self.server.workshop_sessions.get(session_id)
|
|
3532
|
+
if session is None:
|
|
3533
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop session not found.")
|
|
3534
|
+
return
|
|
3535
|
+
session.stop()
|
|
3536
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3537
|
+
except json.JSONDecodeError:
|
|
3538
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3539
|
+
return
|
|
2519
3540
|
if parsed.path == "/api/bootstrap-logics":
|
|
2520
3541
|
try:
|
|
2521
3542
|
bootstrap = bootstrap_payload(self.server.repo_root, check=False)
|
|
@@ -2619,23 +3640,81 @@ def create_viewer_server(
|
|
|
2619
3640
|
*,
|
|
2620
3641
|
auto_refresh_interval_seconds: int = 15,
|
|
2621
3642
|
auto_refresh_interval_forced: bool = False,
|
|
3643
|
+
lan_mode: bool = False,
|
|
2622
3644
|
) -> LogicsViewerServer:
|
|
2623
3645
|
return LogicsViewerServer(
|
|
2624
3646
|
(host, port),
|
|
2625
3647
|
repo_root,
|
|
2626
3648
|
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
|
|
2627
3649
|
auto_refresh_interval_forced=auto_refresh_interval_forced,
|
|
3650
|
+
lan_mode=lan_mode,
|
|
2628
3651
|
)
|
|
2629
3652
|
|
|
2630
3653
|
|
|
2631
|
-
def
|
|
2632
|
-
if
|
|
2633
|
-
return
|
|
3654
|
+
def _render_qr_lines(url: str) -> list[str]:
|
|
3655
|
+
if not url:
|
|
3656
|
+
return []
|
|
2634
3657
|
try:
|
|
2635
|
-
|
|
3658
|
+
import segno # type: ignore
|
|
3659
|
+
except ImportError:
|
|
3660
|
+
return [
|
|
3661
|
+
"+" + "-" * (len(url) + 2) + "+",
|
|
3662
|
+
"| " + url + " |",
|
|
3663
|
+
"+" + "-" * (len(url) + 2) + "+",
|
|
3664
|
+
"(Install the optional `segno` package to render a scannable QR matrix.)",
|
|
3665
|
+
]
|
|
3666
|
+
try:
|
|
3667
|
+
qr = segno.make(url, error="m")
|
|
3668
|
+
buffer: list[str] = []
|
|
3669
|
+
qr.terminal(out=type("Buf", (), {"write": lambda self, value: buffer.append(value)})(), border=1)
|
|
3670
|
+
# segno's terminal output ends each line with newline; flatten back into lines.
|
|
3671
|
+
return ("".join(buffer)).splitlines() or [url]
|
|
3672
|
+
except Exception:
|
|
3673
|
+
return [url]
|
|
3674
|
+
|
|
3675
|
+
|
|
3676
|
+
def _append_lan_token(url: str, token: str) -> str:
|
|
3677
|
+
if not url or not token:
|
|
3678
|
+
return url
|
|
3679
|
+
sep = "&" if "?" in url else "?"
|
|
3680
|
+
return f"{url}{sep}t={quote(token, safe='')}"
|
|
3681
|
+
|
|
3682
|
+
|
|
3683
|
+
def _detect_lan_ip() -> str:
|
|
3684
|
+
"""Best-effort detection of the host's primary LAN IPv4 address.
|
|
3685
|
+
|
|
3686
|
+
Uses the standard UDP-socket trick: open a non-blocking connection to a
|
|
3687
|
+
routable but unreachable target and read the local socket name. This
|
|
3688
|
+
yields the address the OS would use for outbound traffic, which is the
|
|
3689
|
+
one a phone on the same LAN should target.
|
|
3690
|
+
"""
|
|
3691
|
+
candidate = ""
|
|
3692
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
3693
|
+
try:
|
|
3694
|
+
s.setblocking(False)
|
|
3695
|
+
try:
|
|
3696
|
+
s.connect(("10.255.255.255", 1))
|
|
3697
|
+
candidate = s.getsockname()[0]
|
|
3698
|
+
except OSError:
|
|
3699
|
+
candidate = ""
|
|
3700
|
+
finally:
|
|
3701
|
+
s.close()
|
|
3702
|
+
if candidate and not candidate.startswith("127."):
|
|
3703
|
+
return candidate
|
|
3704
|
+
try:
|
|
3705
|
+
fallback = socket.gethostbyname(socket.gethostname())
|
|
2636
3706
|
except OSError:
|
|
3707
|
+
return ""
|
|
3708
|
+
if fallback and not fallback.startswith("127."):
|
|
3709
|
+
return fallback
|
|
3710
|
+
return ""
|
|
3711
|
+
|
|
3712
|
+
|
|
3713
|
+
def _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
|
|
3714
|
+
if host not in {"0.0.0.0", "::", ""}:
|
|
2637
3715
|
return None
|
|
2638
|
-
|
|
3716
|
+
candidate = _detect_lan_ip()
|
|
3717
|
+
if not candidate:
|
|
2639
3718
|
return None
|
|
2640
3719
|
return build_viewer_url(candidate, port, focus=focus, read=read)
|
|
2641
3720
|
|
|
@@ -2648,13 +3727,18 @@ def render_start_status(
|
|
|
2648
3727
|
network_url: str | None = None,
|
|
2649
3728
|
bind_host: str = "localhost",
|
|
2650
3729
|
auto_refresh_interval_seconds: int = 15,
|
|
3730
|
+
lan_mode: bool = False,
|
|
3731
|
+
lan_token: str | None = None,
|
|
3732
|
+
lan_url: str | None = None,
|
|
3733
|
+
qr_lines: list[str] | None = None,
|
|
2651
3734
|
) -> str:
|
|
3735
|
+
mode_label = "LAN read-only (token required)" if lan_mode else "read-only"
|
|
2652
3736
|
lines = [
|
|
2653
3737
|
"Logics viewer running:",
|
|
2654
3738
|
f"Local: {url}",
|
|
2655
3739
|
"",
|
|
2656
3740
|
f"Repo: {repo_root.name}",
|
|
2657
|
-
"Mode:
|
|
3741
|
+
f"Mode: {mode_label}",
|
|
2658
3742
|
f"Bind: {bind_host}",
|
|
2659
3743
|
f"Auto refresh: {auto_refresh_interval_seconds}s",
|
|
2660
3744
|
]
|
|
@@ -2662,6 +3746,16 @@ def render_start_status(
|
|
|
2662
3746
|
lines.insert(2, f"Network: {network_url}")
|
|
2663
3747
|
if focus:
|
|
2664
3748
|
lines.append(f"Focus: {focus}")
|
|
3749
|
+
if lan_mode:
|
|
3750
|
+
lines.append("")
|
|
3751
|
+
lines.append("LAN exposure is active. Mutating endpoints are refused; non-loopback clients must present the session token below.")
|
|
3752
|
+
if lan_url:
|
|
3753
|
+
lines.append(f"Share URL: {lan_url}")
|
|
3754
|
+
if lan_token:
|
|
3755
|
+
lines.append(f"Token: {lan_token}")
|
|
3756
|
+
if qr_lines:
|
|
3757
|
+
lines.append("")
|
|
3758
|
+
lines.extend(qr_lines)
|
|
2665
3759
|
return "\n".join(lines)
|
|
2666
3760
|
|
|
2667
3761
|
|
|
@@ -2669,6 +3763,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2669
3763
|
parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
|
|
2670
3764
|
parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
|
|
2671
3765
|
parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
|
|
3766
|
+
parser.add_argument(
|
|
3767
|
+
"--lan",
|
|
3768
|
+
action="store_true",
|
|
3769
|
+
help="Expose the viewer on the local network (0.0.0.0). Enforces read-only access and requires a per-session bearer token for non-loopback requests.",
|
|
3770
|
+
)
|
|
2672
3771
|
parser.add_argument(
|
|
2673
3772
|
"--refresh-interval",
|
|
2674
3773
|
type=int,
|
|
@@ -2695,16 +3794,24 @@ def main(argv: list[str]) -> int:
|
|
|
2695
3794
|
focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
|
|
2696
3795
|
except ValueError as exc:
|
|
2697
3796
|
raise SystemExit(str(exc)) from exc
|
|
3797
|
+
bind_host = "0.0.0.0" if args.lan and args.host == "127.0.0.1" else args.host
|
|
2698
3798
|
server = create_viewer_server(
|
|
2699
3799
|
repo_root,
|
|
2700
|
-
host=
|
|
3800
|
+
host=bind_host,
|
|
2701
3801
|
port=args.port,
|
|
2702
3802
|
auto_refresh_interval_seconds=refresh_interval,
|
|
2703
3803
|
auto_refresh_interval_forced=refresh_interval_forced,
|
|
3804
|
+
lan_mode=bool(args.lan),
|
|
2704
3805
|
)
|
|
2705
3806
|
host, port = server.server_address[:2]
|
|
2706
3807
|
url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
2707
3808
|
network_url = _network_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
3809
|
+
lan_share_url = ""
|
|
3810
|
+
qr_lines: list[str] = []
|
|
3811
|
+
if args.lan and server.lan_token:
|
|
3812
|
+
base_for_lan = network_url or url
|
|
3813
|
+
lan_share_url = _append_lan_token(base_for_lan, server.lan_token)
|
|
3814
|
+
qr_lines = _render_qr_lines(lan_share_url)
|
|
2708
3815
|
print(
|
|
2709
3816
|
render_start_status(
|
|
2710
3817
|
url,
|
|
@@ -2713,6 +3820,10 @@ def main(argv: list[str]) -> int:
|
|
|
2713
3820
|
network_url=network_url,
|
|
2714
3821
|
bind_host=str(host),
|
|
2715
3822
|
auto_refresh_interval_seconds=refresh_interval,
|
|
3823
|
+
lan_mode=bool(args.lan),
|
|
3824
|
+
lan_token=server.lan_token if args.lan else None,
|
|
3825
|
+
lan_url=lan_share_url or None,
|
|
3826
|
+
qr_lines=qr_lines or None,
|
|
2716
3827
|
),
|
|
2717
3828
|
flush=True,
|
|
2718
3829
|
)
|