arkaos 3.66.1 → 3.67.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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.66.1
1
+ 3.67.0
@@ -0,0 +1,27 @@
1
+ """ArkaOS terminal — PTY session manager + audit log.
2
+
3
+ PR99a v3.67.0 — replaces the v3.51.0 allowlist runner with a real
4
+ multi-session PTY that lets the operator run claude, codex, anything,
5
+ streamed bidirectionally over a WebSocket and rendered in xterm.js.
6
+
7
+ Security: localhost-only binding (inherits from dashboard-api), origin
8
+ pinning + per-process bearer token, idle-kill 30 min, hard cap on
9
+ concurrent sessions, metadata-only audit log (no input capture).
10
+ """
11
+
12
+ from core.terminal.session import (
13
+ TerminalSession,
14
+ TerminalSessionManager,
15
+ SessionCapacityError,
16
+ )
17
+ from core.terminal.audit import log_end, log_start
18
+ from core.terminal.token import current_token
19
+
20
+ __all__ = [
21
+ "TerminalSession",
22
+ "TerminalSessionManager",
23
+ "SessionCapacityError",
24
+ "log_start",
25
+ "log_end",
26
+ "current_token",
27
+ ]
@@ -0,0 +1,58 @@
1
+ """Metadata-only audit log for terminal sessions.
2
+
3
+ PR99a v3.67.0 — writes start/end events as JSONL to
4
+ ``~/.arkaos/terminal-audit.jsonl``. Deliberately captures NO input or
5
+ output payload — only session lifecycle metadata — because terminal
6
+ input frequently carries secrets (PATs, tokens, passwords) and
7
+ operators paste them in.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import threading
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ _LOCK = threading.Lock()
19
+
20
+
21
+ def _audit_path() -> Path:
22
+ base = Path(os.environ.get("ARKAOS_HOME", Path.home() / ".arkaos"))
23
+ base.mkdir(parents=True, exist_ok=True)
24
+ return base / "terminal-audit.jsonl"
25
+
26
+
27
+ def _now_iso() -> str:
28
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
29
+
30
+
31
+ def _write(event: dict) -> None:
32
+ path = _audit_path()
33
+ line = json.dumps(event, ensure_ascii=False)
34
+ with _LOCK:
35
+ with path.open("a", encoding="utf-8") as fh:
36
+ fh.write(line + "\n")
37
+
38
+
39
+ def log_start(session_id: str, shell: str, cwd: str) -> None:
40
+ """Record that a PTY session has been created."""
41
+ _write({
42
+ "event": "start",
43
+ "session_id": session_id,
44
+ "shell": shell,
45
+ "cwd": cwd,
46
+ "ts": _now_iso(),
47
+ })
48
+
49
+
50
+ def log_end(session_id: str, exit_code: int | None, reason: str = "closed") -> None:
51
+ """Record that a PTY session has terminated."""
52
+ _write({
53
+ "event": "end",
54
+ "session_id": session_id,
55
+ "exit_code": exit_code,
56
+ "reason": reason,
57
+ "ts": _now_iso(),
58
+ })
@@ -0,0 +1,288 @@
1
+ """PTY session + manager for the dashboard terminal.
2
+
3
+ PR99a v3.67.0 — spawns a forked shell on a pseudo-terminal, exposes
4
+ bidirectional read/write/resize over a file descriptor, and offers a
5
+ manager that caps concurrent sessions and reaps dead/idle ones.
6
+
7
+ Design notes
8
+ ------------
9
+ * ``pty.fork`` is used (not ``pty.openpty`` + ``Popen``) because we want
10
+ the child to be a session leader with a controlling tty — that is what
11
+ makes the shell behave interactively (job control, signals, prompts,
12
+ ANSI rendering).
13
+ * The master fd is set non-blocking so the asyncio loop in the
14
+ dashboard-api can ``add_reader`` it and pump bytes to the WebSocket
15
+ without a thread pool.
16
+ * The manager keeps sessions in a dict keyed by url-safe id; nothing
17
+ here imports FastAPI so the unit tests can run without spinning up an
18
+ HTTP app.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import errno
24
+ import fcntl
25
+ import os
26
+ import pty
27
+ import secrets
28
+ import signal
29
+ import struct
30
+ import termios
31
+ import time
32
+ from typing import Any, Optional
33
+
34
+ from core.terminal import audit
35
+
36
+
37
+ class SessionCapacityError(RuntimeError):
38
+ """Raised when ``create()`` is called past the configured cap."""
39
+
40
+
41
+ def _default_shell() -> str:
42
+ return os.environ.get("SHELL") or "/bin/zsh"
43
+
44
+
45
+ def _default_cwd() -> str:
46
+ return os.path.expanduser("~")
47
+
48
+
49
+ class TerminalSession:
50
+ """A single forked PTY + the bookkeeping needed to drive it.
51
+
52
+ Public attributes are read-only from the outside; mutate state via
53
+ the methods so the manager can keep its invariants.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ session_id: str,
59
+ shell: str,
60
+ cwd: str,
61
+ cols: int = 120,
62
+ rows: int = 32,
63
+ ) -> None:
64
+ self.session_id = session_id
65
+ self.shell = shell
66
+ self.cwd = cwd
67
+ self.created_at = time.time()
68
+ self.last_activity = time.monotonic()
69
+ self.exit_code: Optional[int] = None
70
+ self.title: str = ""
71
+ self._closed = False
72
+ self.pid, self.master_fd = pty.fork()
73
+ if self.pid == 0:
74
+ self._child_exec(shell, cwd)
75
+ os.set_blocking(self.master_fd, False)
76
+ self.resize(cols, rows)
77
+
78
+ @staticmethod
79
+ def _child_exec(shell: str, cwd: str) -> None:
80
+ try:
81
+ os.chdir(cwd)
82
+ except OSError:
83
+ pass
84
+ env = os.environ.copy()
85
+ env["TERM"] = env.get("TERM", "xterm-256color")
86
+ env["COLORTERM"] = env.get("COLORTERM", "truecolor")
87
+ try:
88
+ os.execvpe(shell, [shell, "-l"], env)
89
+ except OSError:
90
+ os._exit(127)
91
+
92
+ def read(self, max_bytes: int = 4096) -> bytes:
93
+ """Non-blocking read. Returns ``b""`` when there is nothing yet."""
94
+ if self._closed or self.master_fd < 0:
95
+ return b""
96
+ try:
97
+ data = os.read(self.master_fd, max_bytes)
98
+ except BlockingIOError:
99
+ return b""
100
+ except OSError as exc:
101
+ if exc.errno in (errno.EIO, errno.EBADF):
102
+ self._closed = True
103
+ return b""
104
+ raise
105
+ if data:
106
+ self.last_activity = time.monotonic()
107
+ return data
108
+
109
+ def write(self, data: bytes) -> int:
110
+ if self._closed or self.master_fd < 0 or not data:
111
+ return 0
112
+ try:
113
+ n = os.write(self.master_fd, data)
114
+ except OSError:
115
+ self._closed = True
116
+ return 0
117
+ self.last_activity = time.monotonic()
118
+ return n
119
+
120
+ def resize(self, cols: int, rows: int) -> None:
121
+ if self._closed or self.master_fd < 0:
122
+ return
123
+ cols = max(1, int(cols))
124
+ rows = max(1, int(rows))
125
+ try:
126
+ fcntl.ioctl(
127
+ self.master_fd,
128
+ termios.TIOCSWINSZ,
129
+ struct.pack("HHHH", rows, cols, 0, 0),
130
+ )
131
+ except OSError:
132
+ pass
133
+
134
+ def is_alive(self) -> bool:
135
+ if self._closed:
136
+ return False
137
+ try:
138
+ wpid, status = os.waitpid(self.pid, os.WNOHANG)
139
+ except ChildProcessError:
140
+ self._closed = True
141
+ return False
142
+ except OSError:
143
+ return False
144
+ if wpid == 0:
145
+ return True
146
+ self.exit_code = os.waitstatus_to_exitcode(status)
147
+ self._closed = True
148
+ return False
149
+
150
+ def kill(self, sig: int = signal.SIGTERM) -> None:
151
+ if self._closed:
152
+ return
153
+ try:
154
+ os.kill(self.pid, sig)
155
+ except (ProcessLookupError, PermissionError):
156
+ pass
157
+ for _ in range(10):
158
+ if not self.is_alive():
159
+ break
160
+ time.sleep(0.05)
161
+ if self.is_alive():
162
+ try:
163
+ os.kill(self.pid, signal.SIGKILL)
164
+ except (ProcessLookupError, PermissionError):
165
+ pass
166
+ self._close_fd()
167
+
168
+ def _close_fd(self) -> None:
169
+ if self.master_fd >= 0:
170
+ try:
171
+ os.close(self.master_fd)
172
+ except OSError:
173
+ pass
174
+ self.master_fd = -1
175
+ self._closed = True
176
+
177
+ def to_dict(self) -> dict[str, Any]:
178
+ idle_s = max(0.0, time.monotonic() - self.last_activity)
179
+ return {
180
+ "session_id": self.session_id,
181
+ "shell": self.shell,
182
+ "cwd": self.cwd,
183
+ "title": self.title or self.session_id,
184
+ "created_at": self.created_at,
185
+ "idle_seconds": round(idle_s, 1),
186
+ "alive": self.is_alive(),
187
+ "exit_code": self.exit_code,
188
+ }
189
+
190
+
191
+ class TerminalSessionManager:
192
+ """Owns the dict of live sessions and enforces caps + idle-kill."""
193
+
194
+ DEFAULT_MAX = 8
195
+ DEFAULT_IDLE_S = 1800 # 30 minutes
196
+
197
+ def __init__(
198
+ self,
199
+ max_sessions: Optional[int] = None,
200
+ idle_timeout_s: Optional[int] = None,
201
+ ) -> None:
202
+ max_default = self.DEFAULT_MAX if max_sessions is None else max_sessions
203
+ idle_default = self.DEFAULT_IDLE_S if idle_timeout_s is None else idle_timeout_s
204
+ self.max_sessions = int(
205
+ os.environ.get("ARKAOS_TERMINAL_MAX_SESSIONS", max_default)
206
+ )
207
+ self.idle_timeout_s = int(
208
+ os.environ.get("ARKAOS_TERMINAL_IDLE_S", idle_default)
209
+ )
210
+ self._sessions: dict[str, TerminalSession] = {}
211
+
212
+ def create(
213
+ self,
214
+ shell: Optional[str] = None,
215
+ cwd: Optional[str] = None,
216
+ cols: int = 120,
217
+ rows: int = 32,
218
+ ) -> TerminalSession:
219
+ self.reap_dead()
220
+ if len(self._sessions) >= self.max_sessions:
221
+ raise SessionCapacityError(
222
+ f"max sessions ({self.max_sessions}) reached"
223
+ )
224
+ sid = secrets.token_urlsafe(8)
225
+ chosen_shell = shell or _default_shell()
226
+ chosen_cwd = cwd or _default_cwd()
227
+ session = TerminalSession(
228
+ session_id=sid,
229
+ shell=chosen_shell,
230
+ cwd=chosen_cwd,
231
+ cols=cols,
232
+ rows=rows,
233
+ )
234
+ self._sessions[sid] = session
235
+ audit.log_start(sid, chosen_shell, chosen_cwd)
236
+ return session
237
+
238
+ def get(self, session_id: str) -> Optional[TerminalSession]:
239
+ return self._sessions.get(session_id)
240
+
241
+ def list_all(self) -> list[dict]:
242
+ self.reap_dead()
243
+ return [s.to_dict() for s in self._sessions.values()]
244
+
245
+ def count(self) -> int:
246
+ return len(self._sessions)
247
+
248
+ def close(self, session_id: str, reason: str = "closed") -> bool:
249
+ session = self._sessions.pop(session_id, None)
250
+ if session is None:
251
+ return False
252
+ session.kill()
253
+ audit.log_end(session_id, session.exit_code, reason=reason)
254
+ return True
255
+
256
+ def reap_dead(self) -> int:
257
+ dead = [sid for sid, s in self._sessions.items() if not s.is_alive()]
258
+ for sid in dead:
259
+ session = self._sessions.pop(sid)
260
+ session._close_fd()
261
+ audit.log_end(sid, session.exit_code, reason="exited")
262
+ return len(dead)
263
+
264
+ def reap_idle(self) -> int:
265
+ now = time.monotonic()
266
+ timeout = self.idle_timeout_s
267
+ idle = [
268
+ sid for sid, s in self._sessions.items()
269
+ if now - s.last_activity > timeout
270
+ ]
271
+ for sid in idle:
272
+ self.close(sid, reason="idle-timeout")
273
+ return len(idle)
274
+
275
+ def shutdown(self) -> None:
276
+ for sid in list(self._sessions.keys()):
277
+ self.close(sid, reason="shutdown")
278
+
279
+
280
+ _default_manager: Optional[TerminalSessionManager] = None
281
+
282
+
283
+ def default_manager() -> TerminalSessionManager:
284
+ """Lazy global manager — one per process."""
285
+ global _default_manager
286
+ if _default_manager is None:
287
+ _default_manager = TerminalSessionManager()
288
+ return _default_manager
@@ -0,0 +1,38 @@
1
+ """Per-process bearer token for WebSocket terminal handshake.
2
+
3
+ PR99a v3.67.0 — a random url-safe 32-byte token generated once at
4
+ process startup. The frontend fetches it from `/api/terminal/token`
5
+ (CORS-restricted to localhost) and includes it as a query parameter
6
+ on the WebSocket upgrade. Token rotates whenever the API restarts.
7
+
8
+ Rationale: CORS already blocks cross-origin XHR, but a malicious page
9
+ on `http://localhost:9999` could open a WebSocket without a preflight,
10
+ so we add a second authentication factor that only the dashboard
11
+ (running on localhost too) can read.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hmac
17
+ import secrets
18
+
19
+ _TOKEN: str = secrets.token_urlsafe(32)
20
+
21
+
22
+ def current_token() -> str:
23
+ """Return the per-process terminal bearer token."""
24
+ return _TOKEN
25
+
26
+
27
+ def verify(candidate: str) -> bool:
28
+ """Constant-time compare a candidate against the current token."""
29
+ if not isinstance(candidate, str) or not candidate:
30
+ return False
31
+ return hmac.compare_digest(candidate, _TOKEN)
32
+
33
+
34
+ def rotate() -> str:
35
+ """Rotate the token (used by tests; never called in production)."""
36
+ global _TOKEN
37
+ _TOKEN = secrets.token_urlsafe(32)
38
+ return _TOKEN
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.66.1",
3
+ "version": "3.67.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.66.1"
3
+ version = "3.67.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -2467,6 +2467,194 @@ def workflow_update_yaml(workflow_id: str, body: dict):
2467
2467
  return {"updated": True, "id": workflow_id, "file": str(target)}
2468
2468
 
2469
2469
 
2470
+ # ============================================================================
2471
+ # PR99a v3.67.0 — Terminal PTY WebSocket + REST.
2472
+ #
2473
+ # Replaces the v3.51.0 allowlist runner. Spawns a real PTY per session
2474
+ # (`pty.fork` + user's $SHELL), streams bidirectionally over a WS to
2475
+ # xterm.js, and exposes thin REST endpoints to list / create / close
2476
+ # sessions. Origin pinning + bearer token in handshake. Idle-kill is
2477
+ # driven by a 60s background coroutine.
2478
+ # ============================================================================
2479
+
2480
+
2481
+ import re as _terminal_re
2482
+
2483
+
2484
+ def _terminal_origin_ok(origin: str) -> bool:
2485
+ """Allow only http(s)://localhost or 127.0.0.1 (any port)."""
2486
+ if not origin:
2487
+ return False
2488
+ return bool(
2489
+ _terminal_re.match(r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", origin)
2490
+ )
2491
+
2492
+
2493
+ @app.get("/api/terminal/token")
2494
+ def terminal_token_endpoint():
2495
+ """Return the per-process bearer token used in WS handshakes.
2496
+
2497
+ The token rotates whenever the API restarts. CORS already pins
2498
+ origins to localhost; this is the second factor that protects
2499
+ against a malicious page on another localhost port opening the WS
2500
+ without a CORS preflight.
2501
+ """
2502
+ from core.terminal import token as _token_mod
2503
+ return {"token": _token_mod.current_token()}
2504
+
2505
+
2506
+ @app.get("/api/terminal/sessions")
2507
+ def terminal_sessions_list():
2508
+ from core.terminal.session import default_manager
2509
+ mgr = default_manager()
2510
+ return {
2511
+ "sessions": mgr.list_all(),
2512
+ "max_sessions": mgr.max_sessions,
2513
+ "idle_timeout_seconds": mgr.idle_timeout_s,
2514
+ }
2515
+
2516
+
2517
+ @app.post("/api/terminal/sessions")
2518
+ def terminal_sessions_create(body: dict):
2519
+ from core.terminal.session import default_manager, SessionCapacityError
2520
+ from core.terminal import token as _token_mod
2521
+ body = body if isinstance(body, dict) else {}
2522
+ cwd = body.get("cwd")
2523
+ shell = body.get("shell")
2524
+ cols = int(body.get("cols") or 120)
2525
+ rows = int(body.get("rows") or 32)
2526
+ mgr = default_manager()
2527
+ try:
2528
+ s = mgr.create(shell=shell, cwd=cwd, cols=cols, rows=rows)
2529
+ except SessionCapacityError as exc:
2530
+ from fastapi import HTTPException
2531
+ raise HTTPException(status_code=429, detail=str(exc))
2532
+ return {
2533
+ "session_id": s.session_id,
2534
+ "shell": s.shell,
2535
+ "cwd": s.cwd,
2536
+ "token": _token_mod.current_token(),
2537
+ "ws_path": f"/ws/terminal/{s.session_id}",
2538
+ "max_sessions": mgr.max_sessions,
2539
+ "active_count": mgr.count(),
2540
+ }
2541
+
2542
+
2543
+ @app.delete("/api/terminal/sessions/{session_id}")
2544
+ def terminal_sessions_delete(session_id: str):
2545
+ from core.terminal.session import default_manager
2546
+ ok = default_manager().close(session_id, reason="manual-close")
2547
+ return {"closed": ok, "session_id": session_id}
2548
+
2549
+
2550
+ @app.websocket("/ws/terminal/{session_id}")
2551
+ async def ws_terminal(ws: WebSocket, session_id: str, token: str = Query("")):
2552
+ """Bidirectional PTY pump.
2553
+
2554
+ Client → server: either text frames `{"type": "resize", "cols", "rows"}`
2555
+ / `{"type": "input", "data": "..."}` or raw binary frames (input bytes).
2556
+ Server → client: raw binary frames of PTY output.
2557
+
2558
+ Closes with code 4401 on bad token, 4403 on bad origin, 4404 when the
2559
+ session is not found.
2560
+ """
2561
+ origin = ws.headers.get("origin", "")
2562
+ if not _terminal_origin_ok(origin):
2563
+ await ws.close(code=4403, reason="origin not allowed")
2564
+ return
2565
+ from core.terminal import token as _token_mod
2566
+ if not _token_mod.verify(token):
2567
+ await ws.close(code=4401, reason="bad token")
2568
+ return
2569
+ from core.terminal.session import default_manager
2570
+ session = default_manager().get(session_id)
2571
+ if session is None:
2572
+ await ws.close(code=4404, reason="session not found")
2573
+ return
2574
+
2575
+ await ws.accept()
2576
+ loop = asyncio.get_event_loop()
2577
+ output_queue: asyncio.Queue = asyncio.Queue()
2578
+
2579
+ def _on_readable():
2580
+ try:
2581
+ data = session.read(8192)
2582
+ except OSError:
2583
+ data = b""
2584
+ if data:
2585
+ output_queue.put_nowait(data)
2586
+
2587
+ try:
2588
+ loop.add_reader(session.master_fd, _on_readable)
2589
+ except (ValueError, OSError):
2590
+ await ws.close(code=1011, reason="pty unavailable")
2591
+ return
2592
+
2593
+ async def pump_to_client():
2594
+ while True:
2595
+ data = await output_queue.get()
2596
+ try:
2597
+ await ws.send_bytes(data)
2598
+ except Exception:
2599
+ break
2600
+
2601
+ pump_task = asyncio.create_task(pump_to_client())
2602
+
2603
+ try:
2604
+ while True:
2605
+ msg = await ws.receive()
2606
+ mtype = msg.get("type")
2607
+ if mtype == "websocket.disconnect":
2608
+ break
2609
+ text = msg.get("text")
2610
+ data = msg.get("bytes")
2611
+ if text is not None:
2612
+ try:
2613
+ payload = json.loads(text)
2614
+ except json.JSONDecodeError:
2615
+ continue
2616
+ if not isinstance(payload, dict):
2617
+ continue
2618
+ kind = payload.get("type")
2619
+ if kind == "resize":
2620
+ session.resize(
2621
+ int(payload.get("cols") or 80),
2622
+ int(payload.get("rows") or 24),
2623
+ )
2624
+ elif kind == "input":
2625
+ session.write(str(payload.get("data") or "").encode("utf-8"))
2626
+ elif data is not None:
2627
+ session.write(data)
2628
+ except WebSocketDisconnect:
2629
+ pass
2630
+ except Exception:
2631
+ pass
2632
+ finally:
2633
+ pump_task.cancel()
2634
+ try:
2635
+ loop.remove_reader(session.master_fd)
2636
+ except (ValueError, OSError):
2637
+ pass
2638
+
2639
+
2640
+ @app.on_event("startup")
2641
+ async def _terminal_reaper_startup():
2642
+ """Background loop that reaps dead and idle sessions every 60s."""
2643
+ async def _loop():
2644
+ from core.terminal.session import default_manager
2645
+ while True:
2646
+ try:
2647
+ await asyncio.sleep(60)
2648
+ mgr = default_manager()
2649
+ mgr.reap_dead()
2650
+ mgr.reap_idle()
2651
+ except asyncio.CancelledError:
2652
+ break
2653
+ except Exception:
2654
+ continue
2655
+ asyncio.create_task(_loop())
2656
+
2657
+
2470
2658
  def _resolve_workflow_yaml(workflow_id: str):
2471
2659
  """Return the YAML path for a workflow id, or None when missing."""
2472
2660
  try: