@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.
@@ -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
- return viewer_data_payload(
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 _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
2632
- if host not in {"0.0.0.0", "::", ""}:
2633
- return None
3654
+ def _render_qr_lines(url: str) -> list[str]:
3655
+ if not url:
3656
+ return []
2634
3657
  try:
2635
- candidate = socket.gethostbyname(socket.gethostname())
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
- if not candidate or candidate.startswith("127."):
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: read-only",
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=args.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
  )