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 +1 -1
- package/core/__pycache__/favorites.cpython-313.pyc +0 -0
- package/core/terminal/__init__.py +27 -0
- package/core/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/audit.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/token.cpython-313.pyc +0 -0
- package/core/terminal/audit.py +58 -0
- package/core/terminal/session.py +288 -0
- package/core/terminal/token.py +38 -0
- package/dashboard/app/pages/agents/index.vue +6 -6
- package/dashboard/app/pages/personas.vue +719 -0
- package/departments/brand/agents/brand-director.yaml +40 -38
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +188 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.67.0
|
|
Binary file
|
|
@@ -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
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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()
|