arkaos 3.66.1 → 3.68.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/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/components/Terminal.vue +150 -0
- package/dashboard/app/composables/useTerminalSession.ts +138 -0
- package/dashboard/app/pages/terminal.vue +43 -270
- package/dashboard/package.json +4 -0
- 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.68.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
|
+
]
|
|
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
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR99b v3.68.0 — xterm.js terminal mount.
|
|
3
|
+
//
|
|
4
|
+
// Glues @xterm/xterm to a single PTY session from useTerminalSession.
|
|
5
|
+
// One terminal per component instance; the multi-tab page in PR99c
|
|
6
|
+
// will spawn N of these.
|
|
7
|
+
|
|
8
|
+
import { Terminal as XTerm } from '@xterm/xterm'
|
|
9
|
+
import { FitAddon } from '@xterm/addon-fit'
|
|
10
|
+
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
11
|
+
import { SearchAddon } from '@xterm/addon-search'
|
|
12
|
+
import '@xterm/xterm/css/xterm.css'
|
|
13
|
+
|
|
14
|
+
const container = ref<HTMLDivElement | null>(null)
|
|
15
|
+
const session = useTerminalSession()
|
|
16
|
+
const term = shallowRef<XTerm | null>(null)
|
|
17
|
+
const fit = shallowRef<FitAddon | null>(null)
|
|
18
|
+
const search = shallowRef<SearchAddon | null>(null)
|
|
19
|
+
|
|
20
|
+
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
21
|
+
|
|
22
|
+
const themeArkaOSDark = {
|
|
23
|
+
background: '#0a0a0f',
|
|
24
|
+
foreground: '#e6e6f0',
|
|
25
|
+
cursor: '#7dd3fc',
|
|
26
|
+
cursorAccent: '#0a0a0f',
|
|
27
|
+
selectionBackground: '#1e3a5f',
|
|
28
|
+
black: '#0a0a0f',
|
|
29
|
+
red: '#f87171',
|
|
30
|
+
green: '#86efac',
|
|
31
|
+
yellow: '#fde68a',
|
|
32
|
+
blue: '#7dd3fc',
|
|
33
|
+
magenta: '#f0abfc',
|
|
34
|
+
cyan: '#67e8f9',
|
|
35
|
+
white: '#e6e6f0',
|
|
36
|
+
brightBlack: '#3f3f46',
|
|
37
|
+
brightRed: '#fca5a5',
|
|
38
|
+
brightGreen: '#bbf7d0',
|
|
39
|
+
brightYellow: '#fef3c7',
|
|
40
|
+
brightBlue: '#bae6fd',
|
|
41
|
+
brightMagenta: '#f5d0fe',
|
|
42
|
+
brightCyan: '#a5f3fc',
|
|
43
|
+
brightWhite: '#fafafa',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let unsubscribeOutput: (() => void) | null = null
|
|
47
|
+
let resizeObserver: ResizeObserver | null = null
|
|
48
|
+
|
|
49
|
+
onMounted(async () => {
|
|
50
|
+
if (!container.value) return
|
|
51
|
+
|
|
52
|
+
const t = new XTerm({
|
|
53
|
+
cursorBlink: true,
|
|
54
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
|
55
|
+
fontSize: 13,
|
|
56
|
+
lineHeight: 1.2,
|
|
57
|
+
scrollback: 5000,
|
|
58
|
+
theme: themeArkaOSDark,
|
|
59
|
+
allowProposedApi: true,
|
|
60
|
+
})
|
|
61
|
+
const fitAddon = new FitAddon()
|
|
62
|
+
const searchAddon = new SearchAddon()
|
|
63
|
+
t.loadAddon(fitAddon)
|
|
64
|
+
t.loadAddon(new WebLinksAddon())
|
|
65
|
+
t.loadAddon(searchAddon)
|
|
66
|
+
t.open(container.value)
|
|
67
|
+
fitAddon.fit()
|
|
68
|
+
|
|
69
|
+
term.value = t
|
|
70
|
+
fit.value = fitAddon
|
|
71
|
+
search.value = searchAddon
|
|
72
|
+
|
|
73
|
+
await session.open()
|
|
74
|
+
|
|
75
|
+
unsubscribeOutput = session.onOutput((chunk) => {
|
|
76
|
+
const text = decoder.decode(chunk, { stream: true })
|
|
77
|
+
t.write(text)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
t.onData((data) => {
|
|
81
|
+
session.sendInput(data)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Initial size sync once the WS is open.
|
|
85
|
+
watch(session.status, (s) => {
|
|
86
|
+
if (s === 'open') {
|
|
87
|
+
const { cols, rows } = t
|
|
88
|
+
session.sendResize(cols, rows)
|
|
89
|
+
}
|
|
90
|
+
}, { immediate: true })
|
|
91
|
+
|
|
92
|
+
resizeObserver = new ResizeObserver(() => {
|
|
93
|
+
try {
|
|
94
|
+
fitAddon.fit()
|
|
95
|
+
session.sendResize(t.cols, t.rows)
|
|
96
|
+
} catch (_e) {
|
|
97
|
+
// dom may have unmounted
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
resizeObserver.observe(container.value)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
onBeforeUnmount(async () => {
|
|
104
|
+
unsubscribeOutput?.()
|
|
105
|
+
resizeObserver?.disconnect()
|
|
106
|
+
await session.close()
|
|
107
|
+
term.value?.dispose()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
defineExpose({
|
|
111
|
+
status: session.status,
|
|
112
|
+
error: session.error,
|
|
113
|
+
meta: session.meta,
|
|
114
|
+
})
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<div class="relative h-full w-full bg-[#0a0a0f] rounded-lg overflow-hidden border border-default">
|
|
119
|
+
<div
|
|
120
|
+
v-if="session.status.value === 'connecting'"
|
|
121
|
+
class="absolute inset-0 z-10 grid place-items-center text-muted text-sm bg-[#0a0a0f]/80 backdrop-blur"
|
|
122
|
+
>
|
|
123
|
+
<div class="flex items-center gap-2">
|
|
124
|
+
<UIcon name="i-lucide-loader" class="animate-spin size-4" />
|
|
125
|
+
Spawning PTY…
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div
|
|
129
|
+
v-else-if="session.status.value === 'error' || session.status.value === 'closed'"
|
|
130
|
+
class="absolute top-2 right-2 z-10 text-xs rounded-md bg-elevated/90 px-2 py-1 border border-default"
|
|
131
|
+
>
|
|
132
|
+
<span v-if="session.status.value === 'error'" class="text-red-400">
|
|
133
|
+
{{ session.error.value || 'error' }}
|
|
134
|
+
</span>
|
|
135
|
+
<span v-else class="text-muted">closed</span>
|
|
136
|
+
</div>
|
|
137
|
+
<div ref="container" class="absolute inset-0 p-2" />
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<style scoped>
|
|
142
|
+
:deep(.xterm) {
|
|
143
|
+
height: 100%;
|
|
144
|
+
width: 100%;
|
|
145
|
+
padding: 4px;
|
|
146
|
+
}
|
|
147
|
+
:deep(.xterm-viewport) {
|
|
148
|
+
background-color: transparent !important;
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// PR99b v3.68.0 — single PTY session lifecycle.
|
|
2
|
+
//
|
|
3
|
+
// Encapsulates the REST + WebSocket handshake against /api/terminal/*.
|
|
4
|
+
// The composable owns no DOM and no xterm instance — it just produces
|
|
5
|
+
// the bytes-in/bytes-out duplex. The Terminal.vue component glues this
|
|
6
|
+
// to an xterm.js canvas.
|
|
7
|
+
|
|
8
|
+
export interface TerminalSessionMeta {
|
|
9
|
+
session_id: string
|
|
10
|
+
shell: string
|
|
11
|
+
cwd: string
|
|
12
|
+
token: string
|
|
13
|
+
ws_path: string
|
|
14
|
+
max_sessions: number
|
|
15
|
+
active_count: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TerminalSessionHandle {
|
|
19
|
+
meta: Ref<TerminalSessionMeta | null>
|
|
20
|
+
status: Ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>
|
|
21
|
+
error: Ref<string | null>
|
|
22
|
+
open: () => Promise<void>
|
|
23
|
+
sendInput: (data: string) => void
|
|
24
|
+
sendResize: (cols: number, rows: number) => void
|
|
25
|
+
close: () => Promise<void>
|
|
26
|
+
onOutput: (cb: (chunk: Uint8Array) => void) => () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useTerminalSession(): TerminalSessionHandle {
|
|
30
|
+
const { apiBase } = useApi()
|
|
31
|
+
const meta = ref<TerminalSessionMeta | null>(null)
|
|
32
|
+
const status = ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>('idle')
|
|
33
|
+
const error = ref<string | null>(null)
|
|
34
|
+
|
|
35
|
+
let ws: WebSocket | null = null
|
|
36
|
+
const listeners: Array<(chunk: Uint8Array) => void> = []
|
|
37
|
+
|
|
38
|
+
function wsUrl(path: string, token: string): string {
|
|
39
|
+
const base = apiBase.replace(/^http/, 'ws')
|
|
40
|
+
return `${base}${path}?token=${encodeURIComponent(token)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createSession(): Promise<TerminalSessionMeta> {
|
|
44
|
+
const r = await fetch(`${apiBase}/api/terminal/sessions`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ cols: 120, rows: 32 }),
|
|
48
|
+
})
|
|
49
|
+
if (!r.ok) {
|
|
50
|
+
const body = await r.text()
|
|
51
|
+
throw new Error(`create session failed: ${r.status} ${body}`)
|
|
52
|
+
}
|
|
53
|
+
return await r.json()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function open() {
|
|
57
|
+
if (status.value === 'open' || status.value === 'connecting') return
|
|
58
|
+
status.value = 'connecting'
|
|
59
|
+
error.value = null
|
|
60
|
+
try {
|
|
61
|
+
const m = await createSession()
|
|
62
|
+
meta.value = m
|
|
63
|
+
ws = new WebSocket(wsUrl(m.ws_path, m.token))
|
|
64
|
+
ws.binaryType = 'arraybuffer'
|
|
65
|
+
ws.onopen = () => {
|
|
66
|
+
status.value = 'open'
|
|
67
|
+
}
|
|
68
|
+
ws.onmessage = (ev) => {
|
|
69
|
+
if (ev.data instanceof ArrayBuffer) {
|
|
70
|
+
const chunk = new Uint8Array(ev.data)
|
|
71
|
+
for (const cb of listeners) cb(chunk)
|
|
72
|
+
} else if (typeof ev.data === 'string') {
|
|
73
|
+
const enc = new TextEncoder().encode(ev.data)
|
|
74
|
+
for (const cb of listeners) cb(enc)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
ws.onerror = () => {
|
|
78
|
+
status.value = 'error'
|
|
79
|
+
error.value = 'websocket error'
|
|
80
|
+
}
|
|
81
|
+
ws.onclose = (ev) => {
|
|
82
|
+
status.value = 'closed'
|
|
83
|
+
if (ev.code !== 1000 && ev.code !== 1005) {
|
|
84
|
+
error.value = `closed (${ev.code}) ${ev.reason || ''}`.trim()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (e) {
|
|
88
|
+
status.value = 'error'
|
|
89
|
+
error.value = e instanceof Error ? e.message : String(e)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sendInput(data: string) {
|
|
94
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
95
|
+
ws.send(JSON.stringify({ type: 'input', data }))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sendResize(cols: number, rows: number) {
|
|
99
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
100
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function close() {
|
|
104
|
+
try {
|
|
105
|
+
ws?.close(1000, 'client close')
|
|
106
|
+
} catch (_e) {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
const id = meta.value?.session_id
|
|
110
|
+
if (id) {
|
|
111
|
+
try {
|
|
112
|
+
await fetch(`${apiBase}/api/terminal/sessions/${id}`, { method: 'DELETE' })
|
|
113
|
+
} catch (_e) {
|
|
114
|
+
// ignore — best-effort cleanup; backend reaper will catch it
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
status.value = 'closed'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function onOutput(cb: (chunk: Uint8Array) => void) {
|
|
121
|
+
listeners.push(cb)
|
|
122
|
+
return () => {
|
|
123
|
+
const idx = listeners.indexOf(cb)
|
|
124
|
+
if (idx >= 0) listeners.splice(idx, 1)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
meta,
|
|
130
|
+
status,
|
|
131
|
+
error,
|
|
132
|
+
open,
|
|
133
|
+
sendInput,
|
|
134
|
+
sendResize,
|
|
135
|
+
close,
|
|
136
|
+
onOutput,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1,278 +1,51 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
//
|
|
2
|
+
// PR99b v3.68.0 — Real-shell terminal (single session).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// not for embedding an arbitrary shell in a browser. The dashboard
|
|
9
|
-
// instead ships a controlled command runner with allowlist + capped
|
|
10
|
-
// output. xterm.js-style PTY can be a later upgrade if needed.
|
|
11
|
-
|
|
12
|
-
interface CommandArg {
|
|
13
|
-
name: string
|
|
14
|
-
label: string
|
|
15
|
-
choices: string[]
|
|
16
|
-
default: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface CommandEntry {
|
|
20
|
-
id: string
|
|
21
|
-
label: string
|
|
22
|
-
description: string
|
|
23
|
-
args?: CommandArg[]
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ExecResult {
|
|
27
|
-
stdout: string
|
|
28
|
-
stderr: string
|
|
29
|
-
exit_code: number
|
|
30
|
-
duration_ms: number
|
|
31
|
-
command: string
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface HistoryEntry {
|
|
35
|
-
id: string
|
|
36
|
-
label: string
|
|
37
|
-
result: ExecResult
|
|
38
|
-
ts: string
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const { fetchApi, apiBase } = useApi()
|
|
42
|
-
const toast = useToast()
|
|
43
|
-
|
|
44
|
-
const { data: cmdData, status } = await fetchApi<{ commands: CommandEntry[] }>(
|
|
45
|
-
'/api/terminal/commands',
|
|
46
|
-
)
|
|
47
|
-
const commands = computed<CommandEntry[]>(() => cmdData.value?.commands ?? [])
|
|
48
|
-
|
|
49
|
-
const running = ref<string | null>(null)
|
|
50
|
-
const history = ref<HistoryEntry[]>([])
|
|
4
|
+
// Replaces the v3.51.0 allowlist UI. Each visit spawns a fresh PTY
|
|
5
|
+
// session on the backend, wired to xterm.js. Multi-session tabs and
|
|
6
|
+
// history land in PR99c. The old allowlist endpoints stay in parallel
|
|
7
|
+
// until PR99d, but the UI is gone as of this release.
|
|
51
8
|
|
|
52
|
-
|
|
53
|
-
// the operator's current selection for each arg.
|
|
54
|
-
const argState = reactive<Record<string, Record<string, string>>>({})
|
|
9
|
+
definePageMeta({ layout: 'default' })
|
|
55
10
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!argState[cmd.id]) argState[cmd.id] = {}
|
|
59
|
-
for (const arg of cmd.args) {
|
|
60
|
-
if (argState[cmd.id][arg.name] === undefined) {
|
|
61
|
-
argState[cmd.id][arg.name] = arg.default
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async function run(cmd: CommandEntry) {
|
|
67
|
-
running.value = cmd.id
|
|
68
|
-
ensureArgState(cmd)
|
|
69
|
-
try {
|
|
70
|
-
const res = await $fetch<ExecResult & { error?: string }>(
|
|
71
|
-
`${apiBase}/api/terminal/exec`,
|
|
72
|
-
{
|
|
73
|
-
method: 'POST',
|
|
74
|
-
body: {
|
|
75
|
-
command_id: cmd.id,
|
|
76
|
-
args: cmd.args ? argState[cmd.id] : undefined,
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
)
|
|
80
|
-
if (res.error) throw new Error(res.error)
|
|
81
|
-
history.value = [
|
|
82
|
-
{
|
|
83
|
-
id: cmd.id,
|
|
84
|
-
label: cmd.label,
|
|
85
|
-
result: res,
|
|
86
|
-
ts: new Date().toISOString(),
|
|
87
|
-
},
|
|
88
|
-
...history.value,
|
|
89
|
-
].slice(0, 20)
|
|
90
|
-
if (res.exit_code === 0) {
|
|
91
|
-
toast.add({
|
|
92
|
-
title: `${cmd.label} · ok`,
|
|
93
|
-
description: `${res.duration_ms}ms`,
|
|
94
|
-
color: 'success',
|
|
95
|
-
icon: 'i-lucide-check',
|
|
96
|
-
})
|
|
97
|
-
} else {
|
|
98
|
-
toast.add({
|
|
99
|
-
title: `${cmd.label} · exit ${res.exit_code}`,
|
|
100
|
-
description: `${res.duration_ms}ms · ${res.stderr.slice(0, 80) || 'no stderr'}`,
|
|
101
|
-
color: 'warning',
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
} catch (err) {
|
|
105
|
-
toast.add({
|
|
106
|
-
title: 'Run failed',
|
|
107
|
-
description: err instanceof Error ? err.message : 'unknown error',
|
|
108
|
-
color: 'error',
|
|
109
|
-
})
|
|
110
|
-
} finally {
|
|
111
|
-
running.value = null
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function copyOutput(entry: HistoryEntry) {
|
|
116
|
-
if (typeof navigator === 'undefined' || !navigator.clipboard) return
|
|
117
|
-
const body = entry.result.stdout || entry.result.stderr
|
|
118
|
-
void navigator.clipboard.writeText(body)
|
|
119
|
-
toast.add({ title: 'Copied to clipboard', color: 'success', icon: 'i-lucide-clipboard-check' })
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function clearHistory() {
|
|
123
|
-
history.value = []
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function relative(iso: string): string {
|
|
127
|
-
const ts = Date.parse(iso)
|
|
128
|
-
if (Number.isNaN(ts)) return iso
|
|
129
|
-
const diff = Date.now() - ts
|
|
130
|
-
const s = Math.floor(diff / 1000)
|
|
131
|
-
if (s < 60) return `${s}s ago`
|
|
132
|
-
const m = Math.floor(s / 60)
|
|
133
|
-
if (m < 60) return `${m}m ago`
|
|
134
|
-
return `${Math.floor(m / 60)}h ago`
|
|
135
|
-
}
|
|
11
|
+
const terminalRef = ref<InstanceType<typeof import('~/components/Terminal.vue').default> | null>(null)
|
|
12
|
+
const expanded = ref(false)
|
|
136
13
|
</script>
|
|
137
14
|
|
|
138
15
|
<template>
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
<
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
:key="cmd.id"
|
|
175
|
-
class="rounded-lg border border-default p-3 space-y-2"
|
|
176
|
-
>
|
|
177
|
-
<div>
|
|
178
|
-
<p class="text-sm font-semibold">{{ cmd.label }}</p>
|
|
179
|
-
<p class="text-xs text-muted">{{ cmd.description }}</p>
|
|
180
|
-
</div>
|
|
181
|
-
<!-- PR96b v3.56.0 — arg pickers for parameterised commands -->
|
|
182
|
-
<div v-if="cmd.args && cmd.args.length > 0" class="grid grid-cols-2 gap-2">
|
|
183
|
-
<UFormField
|
|
184
|
-
v-for="arg in cmd.args"
|
|
185
|
-
:key="arg.name"
|
|
186
|
-
:label="arg.label"
|
|
187
|
-
size="xs"
|
|
188
|
-
>
|
|
189
|
-
<USelect
|
|
190
|
-
:model-value="(argState[cmd.id] || {})[arg.name] || arg.default"
|
|
191
|
-
:items="arg.choices.map((c) => ({ label: c, value: c }))"
|
|
192
|
-
size="xs"
|
|
193
|
-
class="w-full"
|
|
194
|
-
@update:model-value="(v) => {
|
|
195
|
-
ensureArgState(cmd)
|
|
196
|
-
argState[cmd.id][arg.name] = String(v)
|
|
197
|
-
}"
|
|
198
|
-
/>
|
|
199
|
-
</UFormField>
|
|
200
|
-
</div>
|
|
201
|
-
<UButton
|
|
202
|
-
label="Run"
|
|
203
|
-
icon="i-lucide-play"
|
|
204
|
-
color="primary"
|
|
205
|
-
size="sm"
|
|
206
|
-
block
|
|
207
|
-
:loading="running === cmd.id"
|
|
208
|
-
:disabled="running !== null && running !== cmd.id"
|
|
209
|
-
@click="run(cmd)"
|
|
210
|
-
/>
|
|
211
|
-
</div>
|
|
212
|
-
</div>
|
|
213
|
-
</UCard>
|
|
214
|
-
|
|
215
|
-
<UCard v-if="history.length > 0">
|
|
216
|
-
<template #header>
|
|
217
|
-
<div class="flex items-center justify-between gap-3">
|
|
218
|
-
<div>
|
|
219
|
-
<h3 class="text-lg font-bold">Recent runs</h3>
|
|
220
|
-
<p class="text-xs text-muted mt-0.5">
|
|
221
|
-
Last {{ history.length }} commands · most recent first
|
|
222
|
-
</p>
|
|
223
|
-
</div>
|
|
224
|
-
<UButton label="Clear" variant="ghost" size="xs" @click="clearHistory" />
|
|
225
|
-
</div>
|
|
226
|
-
</template>
|
|
227
|
-
<ul class="space-y-4">
|
|
228
|
-
<li
|
|
229
|
-
v-for="entry in history"
|
|
230
|
-
:key="`${entry.ts}-${entry.id}`"
|
|
231
|
-
class="rounded-lg border border-default overflow-hidden"
|
|
232
|
-
>
|
|
233
|
-
<div class="px-3 py-2 bg-elevated/30 flex items-center justify-between gap-3 text-xs">
|
|
234
|
-
<div class="min-w-0 flex items-center gap-2">
|
|
235
|
-
<UBadge
|
|
236
|
-
:label="entry.result.exit_code === 0 ? 'ok' : `exit ${entry.result.exit_code}`"
|
|
237
|
-
:color="entry.result.exit_code === 0 ? 'success' : 'warning'"
|
|
238
|
-
variant="subtle"
|
|
239
|
-
size="xs"
|
|
240
|
-
/>
|
|
241
|
-
<span class="font-mono truncate">{{ entry.result.command }}</span>
|
|
242
|
-
</div>
|
|
243
|
-
<div class="flex items-center gap-2 shrink-0">
|
|
244
|
-
<span class="text-muted font-mono">{{ entry.result.duration_ms }}ms</span>
|
|
245
|
-
<span class="text-muted">{{ relative(entry.ts) }}</span>
|
|
246
|
-
<UButton
|
|
247
|
-
icon="i-lucide-clipboard-copy"
|
|
248
|
-
variant="ghost"
|
|
249
|
-
size="xs"
|
|
250
|
-
aria-label="Copy output"
|
|
251
|
-
@click="copyOutput(entry)"
|
|
252
|
-
/>
|
|
253
|
-
</div>
|
|
254
|
-
</div>
|
|
255
|
-
<pre
|
|
256
|
-
v-if="entry.result.stdout"
|
|
257
|
-
class="p-3 text-xs font-mono whitespace-pre overflow-x-auto"
|
|
258
|
-
>{{ entry.result.stdout }}</pre>
|
|
259
|
-
<pre
|
|
260
|
-
v-if="entry.result.stderr"
|
|
261
|
-
class="p-3 text-xs font-mono whitespace-pre overflow-x-auto text-rose-500 border-t border-default"
|
|
262
|
-
>{{ entry.result.stderr }}</pre>
|
|
263
|
-
</li>
|
|
264
|
-
</ul>
|
|
265
|
-
</UCard>
|
|
266
|
-
|
|
267
|
-
<p class="text-xs text-muted">
|
|
268
|
-
Want a different command? Add it to
|
|
269
|
-
<code class="font-mono">TERMINAL_ALLOWLIST</code> in
|
|
270
|
-
<code class="font-mono">scripts/dashboard-api.py</code> and
|
|
271
|
-
restart the backend. Arbitrary shell execution from the
|
|
272
|
-
dashboard is intentionally not supported.
|
|
273
|
-
</p>
|
|
274
|
-
</div>
|
|
275
|
-
</DashboardState>
|
|
276
|
-
</template>
|
|
277
|
-
</UDashboardPanel>
|
|
16
|
+
<div class="flex flex-col gap-3 h-full">
|
|
17
|
+
<header class="flex items-center justify-between">
|
|
18
|
+
<div>
|
|
19
|
+
<h1 class="text-2xl font-semibold">Terminal</h1>
|
|
20
|
+
<p class="text-sm text-muted">
|
|
21
|
+
Real shell, PTY-backed. Runs the same commands as your local zsh —
|
|
22
|
+
claude, codex, git, anything. Session ends when you leave this page.
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="flex items-center gap-2">
|
|
26
|
+
<UBadge color="warning" variant="soft" size="sm">
|
|
27
|
+
<UIcon name="i-lucide-shield" class="size-3 mr-1" />
|
|
28
|
+
localhost only
|
|
29
|
+
</UBadge>
|
|
30
|
+
<UButton
|
|
31
|
+
size="xs"
|
|
32
|
+
variant="ghost"
|
|
33
|
+
:icon="expanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
|
|
34
|
+
@click="expanded = !expanded"
|
|
35
|
+
>
|
|
36
|
+
{{ expanded ? 'Restore' : 'Expand' }}
|
|
37
|
+
</UButton>
|
|
38
|
+
</div>
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
<Terminal
|
|
42
|
+
ref="terminalRef"
|
|
43
|
+
:class="expanded ? 'fixed inset-4 z-40' : 'flex-1 min-h-[520px]'"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
<footer class="text-xs text-muted">
|
|
47
|
+
Multi-session tabs, command history search and theme picker arrive in
|
|
48
|
+
PR99c / PR99d. PR99a (backend PTY) is live.
|
|
49
|
+
</footer>
|
|
50
|
+
</div>
|
|
278
51
|
</template>
|
package/dashboard/package.json
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"@unovis/vue": "^1.6.4",
|
|
21
21
|
"@vueuse/core": "^14.2.1",
|
|
22
22
|
"@vueuse/nuxt": "^14.2.1",
|
|
23
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
24
|
+
"@xterm/addon-search": "^0.16.0",
|
|
25
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
26
|
+
"@xterm/xterm": "^6.0.0",
|
|
23
27
|
"date-fns": "^4.1.0",
|
|
24
28
|
"marked": "^15.0.0",
|
|
25
29
|
"nuxt": "^4.4.2",
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -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:
|