arkaos 3.66.0 → 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.0
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
@@ -79,6 +79,12 @@ const tierFilter = ref(String(route.query.tier ?? 'all'))
79
79
  const page = ref(1)
80
80
  const pageSize = 15
81
81
 
82
+ // PR86a + PR92b — favorites refs must exist BEFORE the filter watcher
83
+ // below references them (otherwise TDZ blows up the whole page).
84
+ const favs = useFavorites()
85
+ await favs.load()
86
+ const favoritesOnly = ref(route.query.fav === '1')
87
+
82
88
  const departments = computed(() => {
83
89
  const depts = new Set(agents.value.map(a => a.department))
84
90
  return [
@@ -262,12 +268,6 @@ defineShortcuts({
262
268
  enter: () => cursorOpen(),
263
269
  })
264
270
 
265
- // PR86a v3.15.0 — favorites.
266
- // PR92b v3.40.0 — favoritesOnly persists in URL (`?fav=1`).
267
- const favs = useFavorites()
268
- await favs.load()
269
- const favoritesOnly = ref(route.query.fav === '1')
270
-
271
271
  // PR83b v3.4.0 — bulk selection + delete.
272
272
  // PR84b v3.8.0 — bulk move department.
273
273
  const confirmDialog = useConfirmDialog()