Compare commits
No files matched your search
+10
-1
@@ -1 +1,10 @@
|
||||
SQLX_OFFLINE=true
|
||||
# Live mode (local dev): query! macros validate every query against the real
|
||||
# schema on each build, so the .sqlx cache is only needed for DB-less builds.
|
||||
# Run scripts/prepare_sqlx_sqlite.sh once first — it creates and migrates the DB.
|
||||
# Use an absolute path so builds work from any working directory.
|
||||
# Replace the placeholder below with the DATABASE_URL printed by the script.
|
||||
DATABASE_URL=sqlite:///absolute/path/to/wakey-prepare.sqlite3
|
||||
|
||||
# Offline mode (CI / packaging): force the committed .sqlx cache.
|
||||
# Takes precedence over DATABASE_URL when set.
|
||||
#SQLX_OFFLINE=true
|
||||
@@ -20,3 +20,6 @@ target/
|
||||
.env
|
||||
|
||||
.codex
|
||||
|
||||
# sqlx prepare DB (created by scripts/prepare_sqlx_sqlite.sh)
|
||||
wakey-prepare.sqlite3*
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6ce45cc94cbfdd948aad5fb986f9b5976c79bb63759bc9ecff1a7873e2d70137"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b324587b94ee2e98dc8eff5f6735303a2784b74f49068ce8e946cedcd8d12c4e"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd1666cea8840df04ddcdc8a0c42b9b7771f9bf63c45613c4b6f8b86be479d70"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM audit_events WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ca612fee8c80f815b33dbc088cc7e0a7552f1f4e38390959e14ea5cc765bf2c7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM alert_transitions WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ce850a32c8702595bbc3207bf86d53209c48be145562192b3a8f84903d39eac1"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc7056b7421dcc83648cbd360273ebcce1156ce0f7da2d39f054025ab9b66b6b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e83a43217a3bfaacd2f5e76df1fd05447558646fb2b47963751a7ab53b3a99b6"
|
||||
}
|
||||
Generated
+6
-6
@@ -3624,7 +3624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -3645,7 +3645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3670,7 +3670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3699,7 +3699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-core"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"macaddr",
|
||||
"serde",
|
||||
@@ -3711,7 +3711,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -3728,7 +3728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wakey-vt100"
|
||||
version = "0.16.2-wakey.1"
|
||||
version = "0.16.2-wakey.2"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -207,10 +207,23 @@ Control-plane admin API includes token management endpoints:
|
||||
- `GET /api/v1/control/audit/events?agent_id=<id>&event_type=<type>&limit=<n>`
|
||||
- `GET /api/v1/control/alerts?lookback_seconds=900`
|
||||
- `GET /api/v1/control/alerts/history?since_unix=<ts>&limit=<n>`
|
||||
- `GET /api/v1/control/alerts/ws` (websocket snapshots + recent transitions)
|
||||
- `GET /api/v1/control/alerts/ws` (websocket snapshots, recent transitions, and evaluator health)
|
||||
- `GET /api/v1/control/terminals/events/ws` (websocket session-list invalidations)
|
||||
- `DELETE /api/v1/control/agents/{agent_id}`
|
||||
- `PATCH /api/v1/control/agents/{agent_id}/nickname`
|
||||
|
||||
Alert evaluation runs once per control plane every five seconds. The alerts GET
|
||||
returns `503 alerts_unavailable` until a successful evaluation, after evaluation
|
||||
failure, or when its snapshot is stale. The existing alerts stream also sends
|
||||
`alerts_health` messages with `available` and an optional `reason`; the UI keeps
|
||||
the last results visible with a stale warning until recovery. Custom alert-rule
|
||||
query overrides are rejected with `400 unsupported_alert_override`.
|
||||
|
||||
Terminal session events carry only `sessions_changed` invalidations. Clients
|
||||
refetch the session list after changes or reconnection and retain a 15-second
|
||||
fallback refresh. Events do not grant session ownership; attachment still uses
|
||||
the existing server-side locking and scoped credentials.
|
||||
|
||||
If commands still appear silent, verify both processes are running with `-v`
|
||||
and that `RUST_LOG` is not overriding to a stricter level.
|
||||
|
||||
|
||||
@@ -72,7 +72,11 @@ When the operator socket disconnects, the session becomes detached. Navigation,
|
||||
|
||||
The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane.
|
||||
|
||||
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 5,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session.
|
||||
The protected `/api/v1/control/terminals/events/ws` stream sends `sessions_changed` invalidations when the volatile session catalog or attachment ownership changes. These events contain no terminal bytes or attachment credentials: browsers refetch the ordinary session list, coalescing bursts and repeating a fetch if another event arrives during it. Subscribing and reconnecting also invalidate the list. The 15-second refresh remains a recovery fallback; the stream is a freshness hint, never an authorization or locking mechanism.
|
||||
|
||||
The React page composes three custom hooks rather than owning every resource itself. `useXterm` owns the single renderer, its addons, fitting, fonts, and display operations. `useTerminalSessions` owns the catalog subscription, coalesced refreshes, and fallback timer. `useTerminalSession` owns active attachment, input, restoration, and session operations. The attachment hook waits for renderer readiness; stable renderer operations prevent ordinary state updates from rebuilding the terminal or reconnecting its transport. Each hook cleans up its own resources on unmount, which detaches the browser without deleting the agent-owned PTY.
|
||||
|
||||
After operator attachment, the control plane sends a `snapshot` request. The agent serializes up to 1,000 retained physical rows followed by its parsed current screen and input modes as terminal escape bytes, sends them before subsequent live output, and then signals the PTY's current foreground process group with `SIGWINCH`. Historical rows retain their terminal attributes and are replayed as ordinary terminal output so xterm builds its native scrollback; the final formatted state restores the exact live screen. The signal lets full-screen applications redraw without assuming that the original shell remains in the foreground. Signal failure is logged but never terminates the session. The connected browser independently retains up to 10,000 rows because that memory belongs to the operator device rather than the agent host.
|
||||
|
||||
Wakey carries a small, read-only extension to `vt100` because its public API exposes only the active grid. A snapshot streams bounded history from the primary grid, redraws that grid with its saved cursor and attributes, and then reconstructs the alternate grid when a full-screen application is active. The parser is never resized or switched while taking a snapshot. Consequently, reconnecting during `btop` restores the application while preserving the hidden shell history and state that reappear when it exits.
|
||||
|
||||
@@ -80,6 +84,8 @@ The agent keeps its terminal manager outside the control-WebSocket reconnect loo
|
||||
|
||||
Closing a session must terminate the entire PTY process group, not only the shell process. Cleanup should send a hangup first, then escalate to termination and forced kill after bounded grace periods. Explicit operator close, absolute session timeout, agent process exit, and machine reboot close the session.
|
||||
|
||||
The agent configuration sets `terminal.session_ttl_seconds` for newly created PTYs. The default is 43,200 seconds (12 hours); explicit zero disables automatic expiry. The agent owns and enforces this deadline locally because PTYs survive browser and control-plane disconnection. It advertises the policy to the control plane and includes the creation-time TTL in terminal inventory, allowing CC to mirror expiry and preserve it across CC restart. Missing fields from older agents retain the 12-hour default. Positive values are exact and values that cannot fit the platform timer are rejected rather than clamped.
|
||||
|
||||
An agent process restart or machine reboot still ends every terminal session because PTY file descriptors cannot be recovered by a new process. Surviving that boundary would require an external session host such as `tmux` or a separate terminal daemon and is not part of this design.
|
||||
|
||||
The agent reports a normal exit status or terminating signal when available. The UI must distinguish an exited shell from a transport failure.
|
||||
@@ -88,7 +94,9 @@ The agent reports a normal exit status or terminating signal when available. The
|
||||
|
||||
Terminal transport must not use unbounded queues.
|
||||
|
||||
Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. The agent keeps draining and parsing the PTY while detached so a noisy child cannot stall the agent. Parsed state is bounded by the configured terminal dimensions and a 5,000-row scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
|
||||
Every queue between PTY, agent socket, control-plane relay, and browser socket is bounded. The agent keeps draining and parsing the PTY while detached so a noisy child cannot stall the agent. Parsed state is bounded by the configured terminal dimensions and a 1,000-row reconnect scrollback limit. The control plane retains only a bounded queue of operator controls while an agent relay is reconnecting.
|
||||
|
||||
While a relay is attached, saturation applies lossless backpressure instead of dropping terminal bytes or disconnecting the relay. The agent retains the frame that discovered saturation and pauses further PTY reads until bounded relay capacity returns. Input, resize, close, lifecycle, and reconnect handling remain active while output is backpressured. Reconnect snapshots use the same ordered output path and duplicate snapshot requests are coalesced while one is pending. This is required for terminal protocols such as sixel, where dropping one output frame corrupts the rest of the stream.
|
||||
|
||||
When input/control and output are ready simultaneously, relay loops prioritize input and control. This keeps interrupt, resize, close, and snapshot traffic responsive while commands produce sustained output.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
DB="${SQLX_PREPARE_DB:-/tmp/wakey-sqlx-prepare.sqlite3}"
|
||||
DB="${SQLX_PREPARE_DB:-$ROOT/wakey-prepare.sqlite3}"
|
||||
DATABASE_URL="sqlite://$DB"
|
||||
|
||||
if ! cargo sqlx --version >/dev/null 2>&1; then
|
||||
@@ -23,3 +23,5 @@ cp .sqlx/*.json wakey-control-plane/.sqlx/
|
||||
|
||||
echo "SQLx metadata prepared in $ROOT/.sqlx"
|
||||
echo "SQLx package metadata mirrored in $ROOT/wakey-control-plane/.sqlx"
|
||||
echo "Prepare DB (referenced by .env for live compile-time checking): $DB"
|
||||
echo "DATABASE_URL=$DATABASE_URL"
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "brotli==1.2.0",
|
||||
# "fonttools==4.63.0",
|
||||
# ]
|
||||
# ///
|
||||
"""Rebuild Wakey's self-hosted terminal webfont from Nerd Fonts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
|
||||
NERD_FONTS_VERSION = "v3.4.0"
|
||||
ARCHIVE_SHA256 = "ef552a3e638f25125c6ad4c51176a6adcdce295ab1d2ffacf0db060caf8c1582"
|
||||
WOFF2_SHA256 = "50680a64466fbcc3eff68e00f84505f55681c86f8450c042416af5a8dbc1cce1"
|
||||
FONT_FILE = "JetBrainsMonoNerdFontMono-Regular"
|
||||
ARCHIVE_URL = (
|
||||
"https://github.com/ryanoasis/nerd-fonts/releases/download/"
|
||||
f"{NERD_FONTS_VERSION}/JetBrainsMono.tar.xz"
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUTPUT_DIR = ROOT / "ui/src/assets/fonts/jetbrains-mono-nerd"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_sha256(path: Path, expected: str) -> None:
|
||||
actual = sha256(path)
|
||||
if actual != expected:
|
||||
raise RuntimeError(
|
||||
f"checksum mismatch for {path}\nexpected: {expected}\nactual: {actual}"
|
||||
)
|
||||
|
||||
|
||||
def download(url: str, destination: Path) -> None:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "wakey-font-preparer"})
|
||||
with (
|
||||
urllib.request.urlopen(request, timeout=60) as response,
|
||||
destination.open("wb") as output,
|
||||
):
|
||||
shutil.copyfileobj(response, output)
|
||||
|
||||
|
||||
def extract_member(archive: tarfile.TarFile, name: str, destination: Path) -> None:
|
||||
source = archive.extractfile(name)
|
||||
if source is None:
|
||||
raise RuntimeError(f"missing {name} in Nerd Fonts archive")
|
||||
with source, destination.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def install_file(source: Path, destination: Path) -> None:
|
||||
"""Atomically replace an asset without exposing a partial file to Vite."""
|
||||
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||
try:
|
||||
shutil.copyfile(source, temporary)
|
||||
os.chmod(temporary, 0o644)
|
||||
temporary.replace(destination)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="wakey-terminal-font.") as temp:
|
||||
work_dir = Path(temp)
|
||||
archive_path = work_dir / "JetBrainsMono.tar.xz"
|
||||
ttf_path = work_dir / f"{FONT_FILE}.ttf"
|
||||
woff2_path = work_dir / f"{FONT_FILE}.woff2"
|
||||
license_path = work_dir / "OFL.txt"
|
||||
|
||||
print(f"Downloading Nerd Fonts {NERD_FONTS_VERSION} JetBrainsMono archive")
|
||||
download(ARCHIVE_URL, archive_path)
|
||||
verify_sha256(archive_path, ARCHIVE_SHA256)
|
||||
|
||||
with tarfile.open(archive_path, "r:xz") as archive:
|
||||
extract_member(archive, ttf_path.name, ttf_path)
|
||||
extract_member(archive, license_path.name, license_path)
|
||||
|
||||
# FontTools otherwise stamps the current time into the head table,
|
||||
# making equivalent rebuilds produce different WOFF2 files.
|
||||
font = TTFont(ttf_path, recalcTimestamp=False)
|
||||
font.flavor = "woff2"
|
||||
font.save(woff2_path)
|
||||
verify_sha256(woff2_path, WOFF2_SHA256)
|
||||
|
||||
install_file(woff2_path, OUTPUT_DIR / woff2_path.name)
|
||||
install_file(license_path, OUTPUT_DIR / license_path.name)
|
||||
|
||||
print(f"Prepared {OUTPUT_DIR / f'{FONT_FILE}.woff2'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -110,6 +110,16 @@ update_existing_repo() {
|
||||
git -C "$repo_dir" pull --ff-only origin "$ref" || fail 'git pull --ff-only failed'
|
||||
}
|
||||
|
||||
update_submodules() {
|
||||
repo_dir=$1
|
||||
|
||||
# Sync first so an updated .gitmodules URL is honored by an existing
|
||||
# checkout, then initialize and pin every nested submodule to the commit
|
||||
# recorded by the parent repository.
|
||||
git -C "$repo_dir" submodule sync --recursive || fail 'git submodule sync failed'
|
||||
git -C "$repo_dir" submodule update --init --recursive || fail 'git submodule update failed'
|
||||
}
|
||||
|
||||
install_executable_force() {
|
||||
src=$1
|
||||
dst=$2
|
||||
@@ -186,6 +196,7 @@ main() {
|
||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$WORKDIR" ||
|
||||
fail 'git clone failed'
|
||||
fi
|
||||
update_submodules "$WORKDIR"
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
|
||||
/>
|
||||
<title>Wakey Operator UI</title>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+7
-6
@@ -15,12 +15,13 @@
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode-graphemes": "^0.4.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xterm/addon-clipboard": "0.3.0-beta.301",
|
||||
"@xterm/addon-fit": "0.12.0-beta.301",
|
||||
"@xterm/addon-image": "0.10.0-beta.301",
|
||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.301",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.301",
|
||||
"@xterm/addon-web-links": "0.13.0-beta.301",
|
||||
"@xterm/xterm": "6.1.0-beta.301",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
|
||||
Generated
+61
-36
@@ -18,23 +18,26 @@ importers:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2([email protected](@types/[email protected])([email protected]))
|
||||
'@xterm/addon-clipboard':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
specifier: 0.3.0-beta.301
|
||||
version: 0.3.0-beta.301(@xterm/[email protected])
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
specifier: 0.12.0-beta.301
|
||||
version: 0.12.0-beta.301(@xterm/[email protected])
|
||||
'@xterm/addon-image':
|
||||
specifier: 0.10.0-beta.301
|
||||
version: 0.10.0-beta.301(@xterm/[email protected])
|
||||
'@xterm/addon-unicode-graphemes':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
specifier: 0.5.0-beta.301
|
||||
version: 0.5.0-beta.301(@xterm/[email protected])
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
specifier: 0.10.0-beta.301
|
||||
version: 0.10.0-beta.301(@xterm/[email protected])
|
||||
'@xterm/addon-web-links':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
specifier: 0.13.0-beta.301
|
||||
version: 0.13.0-beta.301(@xterm/[email protected])
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
specifier: 6.1.0-beta.301
|
||||
version: 6.1.0-beta.301
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -833,23 +836,38 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@xterm/addon-clipboard@0.2.0':
|
||||
resolution: {integrity: sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==}
|
||||
'@xterm/addon-clipboard@0.3.0-beta.301':
|
||||
resolution: {integrity: sha512-DRL0EybPhWzgndaTeakVbh/HvJF7yzxRtfNCnIudFZQaGugFYsEUZNOaV2NZev8z5kLwor6miH1j3DNMI8Paxg==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected]1.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
'@xterm/[email protected]2.0-beta.301':
|
||||
resolution: {integrity: sha512-MukRyJLAFQrW/++aM209jBZ8G9J/Ioe4SGS6c3ba87v+zYuX/I66JrDB7sVfykQ9t7DlT+c1MWl1jTlX2kyx1w==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/addon-[email protected]':
|
||||
resolution: {integrity: sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==}
|
||||
'@xterm/addon-[email protected]':
|
||||
resolution: {integrity: sha512-PJZL4GlQ1ReIgbffFMaOAFxc3wOit21/G2UEM82RpH3hNHjn6MH8ilz3ToDeBjYuOqU7ienBaeIuMbNktX4/HA==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/addon-unicode[email protected]':
|
||||
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
|
||||
'@xterm/addon-unicode[email protected]':
|
||||
resolution: {integrity: sha512-EBZIZYS6RItWTGO/KpLqV6y3IKGYyugbP07lK0x6G7185FVA61L2t0/XVtVEQNpczsbPo8zAxBhpoV72TP8CYw==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/addon-[email protected]':
|
||||
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
||||
'@xterm/addon-[email protected]':
|
||||
resolution: {integrity: sha512-SS5BzfbvfFIikE4xZFAtHu6n0Kl5R1ZCPx41RL2WULLSE+HFo/WmYee53tMxNK/QUjpcQWepeVB7HebCPhHnig==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-BpY297K+FGLsTQemnA6uOg6hk85DwVuUvWCqE9rIAcDxOOC7fP9GKAZTIfaEos/EZk2Ei23vD6w0P5Br6+RU7Q==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.304
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-y1pH42+Mx+h5E2xDgyilYXBKYkn8/6+anToiTmIt8gVww/2gHFqheK0vW+m9LADQsZ5AFs+SQvcK+tOyxWfRJA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
@@ -1439,9 +1457,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xVjhUZlHHeuO2W7w2rDFj/Kl1xLX+HjZxdOQwCsUOifl6UaoH1o1wsbsTMz+r0aeC7gCijvru02j6TfKZWzKg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2847,19 +2862,31 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@xterm/addon-clipboard@0.2.0':
|
||||
'@xterm/addon-clipboard@0.3.0-beta.301(@xterm/[email protected])':
|
||||
dependencies:
|
||||
js-base64: 3.8.1
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected]1.0': {}
|
||||
'@xterm/[email protected]2.0-beta.301(@xterm/[email protected])':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/addon-[email protected]': {}
|
||||
'@xterm/addon-[email protected](@xterm/[email protected])':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/addon-unicode[email protected]': {}
|
||||
'@xterm/addon-unicode[email protected](@xterm/[email protected])':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/addon-web-links@0.12.0': {}
|
||||
'@xterm/addon-[email protected](@xterm/xterm@6.1.0-beta.301)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
'@xterm/[email protected](@xterm/[email protected])':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.301
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3403,8 +3430,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
|
||||
+105
-15
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -40,22 +40,48 @@ export function App() {
|
||||
const [history, setHistory] = useState<AlertTransition[]>([]);
|
||||
const [audit, setAudit] = useState<AuditEvent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [alertsHealth, setAlertsHealth] = useState<{
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
} | null>(null);
|
||||
const [alertsStreamConnected, setAlertsStreamConnected] = useState(false);
|
||||
const streamHealthReceived = useRef(false);
|
||||
const alertsRevision = useRef(0);
|
||||
|
||||
const [state, setState] = useState<LoadState>("idle");
|
||||
|
||||
async function loadAlertsWithoutBlocking() {
|
||||
try {
|
||||
const nextAlerts = await fetchAlerts();
|
||||
if (!streamHealthReceived.current) {
|
||||
setAlertsHealth({ available: true });
|
||||
}
|
||||
return nextAlerts;
|
||||
} catch (err) {
|
||||
if (!streamHealthReceived.current) {
|
||||
setAlertsHealth({ available: false, reason: String(err) });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const revision = alertsRevision.current;
|
||||
setState("loading");
|
||||
try {
|
||||
const [nextAgents, nextAlerts, nextHistory, nextAudit] =
|
||||
await Promise.all([
|
||||
fetchAgents(),
|
||||
fetchAlerts(),
|
||||
loadAlertsWithoutBlocking(),
|
||||
fetchAlertHistory(20),
|
||||
fetchAudit(30),
|
||||
]);
|
||||
setAgents(nextAgents);
|
||||
setAlerts(nextAlerts);
|
||||
// Streamed state wins over HTTP requests started before its arrival.
|
||||
if (revision === alertsRevision.current) {
|
||||
if (nextAlerts) setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
}
|
||||
setAudit(nextAudit);
|
||||
const firstConnectedAgentId =
|
||||
nextAgents.find((agent) => agent.connected)?.agent_id ?? "";
|
||||
@@ -100,43 +126,103 @@ export function App() {
|
||||
}
|
||||
|
||||
async function refreshAlertsAndHistory() {
|
||||
const revision = alertsRevision.current;
|
||||
const [nextAlerts, nextHistory] = await Promise.all([
|
||||
fetchAlerts(),
|
||||
loadAlertsWithoutBlocking(),
|
||||
fetchAlertHistory(20),
|
||||
]);
|
||||
setAlerts(nextAlerts);
|
||||
if (revision === alertsRevision.current) {
|
||||
if (nextAlerts) setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let socket: WebSocket | null = null;
|
||||
let disposed = false;
|
||||
let attempt = 0;
|
||||
let openedAt = 0;
|
||||
let reconnectTimer: number | undefined;
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
const resync = () => {
|
||||
const generation = alertsRevision.current;
|
||||
void Promise.all([fetchAlerts(), fetchAlertHistory(20)])
|
||||
.then(([nextAlerts, nextHistory]) => {
|
||||
// A WS snapshot delivered while this request was in flight is newer.
|
||||
if (disposed || generation !== alertsRevision.current) return;
|
||||
setAlerts(nextAlerts);
|
||||
setHistory(nextHistory);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
const wsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/api/v1/control/alerts/ws`;
|
||||
socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
if (disposed) return;
|
||||
openedAt = Date.now();
|
||||
setAlertsStreamConnected(true);
|
||||
// Pull anything missed while the stream was down.
|
||||
resync();
|
||||
};
|
||||
|
||||
socket.onmessage = (evt) => {
|
||||
if (disposed) return;
|
||||
alertsRevision.current += 1;
|
||||
try {
|
||||
const payload = JSON.parse(String(evt.data)) as {
|
||||
type?: string;
|
||||
available?: boolean;
|
||||
reason?: string;
|
||||
alerts?: Alert[];
|
||||
recent_transitions?: AlertTransition[];
|
||||
};
|
||||
if (payload.type === "alerts_health") {
|
||||
streamHealthReceived.current = true;
|
||||
setAlertsHealth({
|
||||
available: payload.available === true,
|
||||
reason: payload.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (payload.alerts) setAlerts(payload.alerts);
|
||||
if (payload.recent_transitions) setHistory(payload.recent_transitions);
|
||||
if (payload.recent_transitions)
|
||||
setHistory(payload.recent_transitions);
|
||||
} catch {
|
||||
// Ignore malformed stream payloads and keep current UI state.
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
const id = window.setInterval(() => {
|
||||
void refreshAlertsAndHistory().catch(() => undefined);
|
||||
}, 8000);
|
||||
ws.onclose = () => window.clearInterval(id);
|
||||
socket.onclose = () => {
|
||||
socket = null;
|
||||
if (disposed) return;
|
||||
setAlertsStreamConnected(false);
|
||||
// Only a connection that stayed up proves the stream is healthy; an
|
||||
// accept-then-close server must not pin us to the first backoff step.
|
||||
if (openedAt && Date.now() - openedAt > 10_000) attempt = 0;
|
||||
openedAt = 0;
|
||||
// Browsers never reconnect a WebSocket on their own.
|
||||
const delay =
|
||||
Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000;
|
||||
attempt += 1;
|
||||
reconnectTimer = window.setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -175,6 +261,8 @@ export function App() {
|
||||
transitions={history}
|
||||
audit={audit}
|
||||
loading={state === "loading"}
|
||||
alertsHealth={alertsHealth}
|
||||
alertsStreamConnected={alertsStreamConnected}
|
||||
onRefresh={loadAll}
|
||||
/>
|
||||
}
|
||||
@@ -217,6 +305,8 @@ export function App() {
|
||||
<AlertsPage
|
||||
alerts={alerts}
|
||||
transitions={history}
|
||||
alertsHealth={alertsHealth}
|
||||
alertsStreamConnected={alertsStreamConnected}
|
||||
onRefresh={refreshAlertsAndHistory}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ export type Agent = {
|
||||
connected: boolean;
|
||||
nickname?: string | null;
|
||||
capabilities: "terminal"[];
|
||||
capability_options?: {
|
||||
terminal?: { max_sessions: number };
|
||||
};
|
||||
};
|
||||
|
||||
export type TerminalSession = {
|
||||
terminal_id: string;
|
||||
agent_id: string;
|
||||
created_at_unix: number;
|
||||
expires_at_unix?: number | null;
|
||||
agent_attached: boolean;
|
||||
operator_attached: boolean;
|
||||
websocket_url: string;
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
# JetBrainsMono Nerd Font Mono
|
||||
|
||||
`JetBrainsMonoNerdFontMono-Regular.woff2` is the regular monospaced variant
|
||||
from Nerd Fonts v3.4.0 (JetBrains Mono v2.304), converted from the release TTF
|
||||
to WOFF2 without subsetting.
|
||||
|
||||
Source archive:
|
||||
https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/JetBrainsMono.tar.xz
|
||||
|
||||
The font is distributed under the SIL Open Font License in `OFL.txt`.
|
||||
|
||||
Rebuild the checked-in webfont with:
|
||||
|
||||
```sh
|
||||
./scripts/prepare_terminal_font.py
|
||||
```
|
||||
@@ -16,10 +16,18 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
type Props = {
|
||||
alerts: Alert[];
|
||||
transitions: AlertTransition[];
|
||||
alertsHealth: { available: boolean; reason?: string } | null;
|
||||
alertsStreamConnected: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
export function AlertsPage({
|
||||
alerts,
|
||||
transitions,
|
||||
alertsHealth,
|
||||
alertsStreamConnected,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const [severity, setSeverity] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [kind, setKind] = useState("all");
|
||||
@@ -66,9 +74,27 @@ export function AlertsPage({ alerts, transitions, onRefresh }: Props) {
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle>Active Alerts</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => void onRefresh()}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||
{alertsHealth?.available === false && (
|
||||
<div>
|
||||
Alert results may be stale or unavailable
|
||||
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void onRefresh()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
|
||||
@@ -27,6 +27,8 @@ type Props = {
|
||||
transitions: AlertTransition[];
|
||||
audit: AuditEvent[];
|
||||
loading: boolean;
|
||||
alertsHealth: { available: boolean; reason?: string } | null;
|
||||
alertsStreamConnected: boolean;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
@@ -66,6 +68,8 @@ export function DashboardPage({
|
||||
transitions,
|
||||
audit,
|
||||
loading,
|
||||
alertsHealth,
|
||||
alertsStreamConnected,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const connected = agents.filter((a) => a.connected).length;
|
||||
@@ -165,6 +169,20 @@ export function DashboardPage({
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(!alertsStreamConnected || alertsHealth?.available === false) && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="mb-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{!alertsStreamConnected && <div>Alert stream disconnected</div>}
|
||||
{alertsHealth?.available === false && (
|
||||
<div>
|
||||
Alert results may be stale or unavailable
|
||||
{alertsHealth.reason ? `: ${alertsHealth.reason}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold tabular-nums">{alerts.length}</div>
|
||||
{Object.keys(severityBuckets).length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -69,8 +69,12 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||
const loadSeqRef = useRef(0);
|
||||
|
||||
async function loadFleet() {
|
||||
// Latest request wins: filter changes and debounced searches overlap, and
|
||||
// an older response must never overwrite fresher state.
|
||||
const seq = ++loadSeqRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextDevices, nextKnownDevices] = await Promise.all([
|
||||
@@ -84,12 +88,14 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
||||
}),
|
||||
fetchKnownDevices(),
|
||||
]);
|
||||
if (seq !== loadSeqRef.current) return;
|
||||
setDevices(nextDevices);
|
||||
setKnownDevices(nextKnownDevices);
|
||||
} catch (err) {
|
||||
if (seq !== loadSeqRef.current) return;
|
||||
toast.error("Failed to load fleet", { description: String(err) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeqRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +158,13 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presence, known, agentId]);
|
||||
|
||||
// The filter effect above already loads on mount; only react to query edits.
|
||||
const mountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => void loadFleet(), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
+183
-677
@@ -1,22 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { Eraser, Plus, RotateCcw, Terminal, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
APIError,
|
||||
type Agent,
|
||||
type TerminalSession,
|
||||
attachTerminal,
|
||||
closeTerminal,
|
||||
createTerminal,
|
||||
listTerminals,
|
||||
} from "@/api";
|
||||
import type { Agent } from "@/api";
|
||||
import { AgentSelector, displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -25,6 +11,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TerminalAccessoryBar } from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
|
||||
import { useTerminalSession } from "@/pages/terminal/useTerminalSession";
|
||||
import { useTerminalSessions } from "@/pages/terminal/useTerminalSessions";
|
||||
import { useXterm } from "@/pages/terminal/useXterm";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
@@ -32,123 +23,48 @@ type Props = {
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
};
|
||||
|
||||
type ConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "exited";
|
||||
type TerminalTouch = {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||
|
||||
// polyfill for testing in browsers that don't support crypto.randomUUID()
|
||||
const thing = () =>
|
||||
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||
const num = Number(c);
|
||||
return (
|
||||
num ^
|
||||
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
|
||||
function terminalOperatorId(): string {
|
||||
// Session storage survives route unmounts but remains scoped to this browser
|
||||
// tab. The ID coordinates attachment ownership; API authentication remains
|
||||
// the security boundary.
|
||||
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||
if (remembered) return remembered;
|
||||
const created = window.crypto.randomUUID?.() ?? thing();
|
||||
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function mergeTerminalSession(
|
||||
sessions: TerminalSession[],
|
||||
next: TerminalSession,
|
||||
): TerminalSession[] {
|
||||
const remaining = sessions.filter(
|
||||
(item) => item.terminal_id !== next.terminal_id,
|
||||
);
|
||||
return [...remaining, next].sort(
|
||||
(left, right) => left.created_at_unix - right.created_at_unix,
|
||||
);
|
||||
}
|
||||
|
||||
function orderTerminalSessions(sessions: TerminalSession[]): TerminalSession[] {
|
||||
return [...sessions].sort(
|
||||
(left, right) => left.created_at_unix - right.created_at_unix,
|
||||
);
|
||||
}
|
||||
|
||||
function reconcileTerminalSessions(
|
||||
current: TerminalSession[],
|
||||
listed: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||
const retained = current.flatMap((item) => {
|
||||
const updated = listedById.get(item.terminal_id);
|
||||
if (!updated) return [];
|
||||
listedById.delete(item.terminal_id);
|
||||
return [updated];
|
||||
});
|
||||
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||
}
|
||||
|
||||
function restoreCandidates(
|
||||
sessions: TerminalSession[],
|
||||
rememberedId: string | null,
|
||||
selectedAgentId: string,
|
||||
): TerminalSession[] {
|
||||
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||
const available = sessions.filter(
|
||||
(item) => !item.operator_attached && item !== remembered,
|
||||
);
|
||||
available.sort((left, right) => {
|
||||
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||
return (
|
||||
rightPreferred - leftPreferred ||
|
||||
right.created_at_unix - left.created_at_unix
|
||||
);
|
||||
});
|
||||
return remembered ? [remembered, ...available] : available;
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
function terminalCreatedTime(createdAtUnix: number): string {
|
||||
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
const TERMINAL_TOUCH_SLOP_PX = 8;
|
||||
|
||||
export function TerminalPage({
|
||||
agents,
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
}: Props) {
|
||||
const pageRef = useRef<HTMLElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<ConnectionState>("idle");
|
||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||
{},
|
||||
const terminalTouchRef = useRef<TerminalTouch | null>(null);
|
||||
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
|
||||
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [accessoriesOpen, setAccessoriesOpen] = useState(
|
||||
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
|
||||
);
|
||||
const [operatorId] = useState(terminalOperatorId);
|
||||
|
||||
activeSessionRef.current = session;
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
const catalog = useTerminalSessions();
|
||||
const { sessions } = catalog;
|
||||
const terminal = useXterm({
|
||||
pageRef,
|
||||
hostRef,
|
||||
});
|
||||
const terminalSession = useTerminalSession({
|
||||
terminal: terminal.terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const clock = window.setInterval(
|
||||
() => setNowUnix(Math.floor(Date.now() / 1000)),
|
||||
30_000,
|
||||
);
|
||||
return () => window.clearInterval(clock);
|
||||
}, []);
|
||||
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agent_id === selectedAgentId,
|
||||
@@ -156,461 +72,61 @@ export function TerminalPage({
|
||||
const selectedAgentSessionCount = sessions.filter(
|
||||
(item) => item.agent_id === selectedAgentId,
|
||||
).length;
|
||||
const agentAtSessionLimit = selectedAgentSessionCount >= 2;
|
||||
const canRequestStart =
|
||||
const selectedAgentSessionLimit = Math.max(
|
||||
1,
|
||||
selectedAgent?.capability_options?.terminal?.max_sessions ?? 2,
|
||||
);
|
||||
const agentAtSessionLimit =
|
||||
selectedAgentSessionCount >= selectedAgentSessionLimit;
|
||||
const canRequestStart = Boolean(
|
||||
selectedAgent?.connected &&
|
||||
selectedAgent.capabilities.includes("terminal") &&
|
||||
connection !== "connecting";
|
||||
terminalSession.connection !== "connecting" &&
|
||||
terminal.terminal.ready,
|
||||
);
|
||||
const terminalInputReady = terminalSession.connection === "ready";
|
||||
|
||||
const detachTransport = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
socketRef.current = null;
|
||||
socket?.close();
|
||||
}, []);
|
||||
|
||||
const sendResize = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
const socket = socketRef.current;
|
||||
if (!terminal || socket?.readyState !== WebSocket.OPEN) return;
|
||||
if (
|
||||
lastTerminalSizeRef.current.rows === terminal.rows &&
|
||||
lastTerminalSizeRef.current.cols === terminal.cols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastTerminalSizeRef.current = {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
};
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
throw new Error(
|
||||
"Control plane did not issue a terminal attachment token",
|
||||
);
|
||||
}
|
||||
detachTransport();
|
||||
setSession(nextSession);
|
||||
activeSessionRef.current = nextSession;
|
||||
setSessions((current) =>
|
||||
mergeTerminalSession(current, {
|
||||
...nextSession,
|
||||
operator_attached: true,
|
||||
}),
|
||||
);
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
setConnection("connecting");
|
||||
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "attach",
|
||||
attachment_token: nextSession.attachment_token,
|
||||
operator_id: operatorId,
|
||||
}),
|
||||
);
|
||||
fitRef.current?.fit();
|
||||
sendResize();
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (socketRef.current !== socket) return;
|
||||
if (typeof event.data !== "string") {
|
||||
terminalRef.current?.write(new Uint8Array(event.data as ArrayBuffer));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const control = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
exit_code?: number | null;
|
||||
message?: string;
|
||||
};
|
||||
if (control.type === "ready") {
|
||||
setConnection("ready");
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
||||
sendResize();
|
||||
terminalRef.current?.focus();
|
||||
});
|
||||
} else if (control.type === "exited") {
|
||||
setConnection("exited");
|
||||
terminalRef.current?.writeln(
|
||||
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||
);
|
||||
} else if (control.type === "error") {
|
||||
setConnection("exited");
|
||||
terminalRef.current?.writeln(
|
||||
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
terminalRef.current?.writeln("\r\n[invalid terminal control frame]");
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
terminalRef.current?.writeln("\r\n[terminal transport error]");
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socketRef.current = null;
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === nextSession.terminal_id
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setConnection((current) => {
|
||||
if (current === "exited") return current;
|
||||
return "disconnected";
|
||||
});
|
||||
};
|
||||
},
|
||||
[detachTransport, operatorId, sendResize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.1,
|
||||
// xterm uses this width for its internal scrollbar as well as the
|
||||
// overview ruler. Reserve a larger touch target on coarse pointers.
|
||||
overviewRuler: {
|
||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||
},
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
cursor: "#6ee7a8",
|
||||
scrollbarSliderBackground: "#52617099",
|
||||
scrollbarSliderHoverBackground: "#6b7c8dcc",
|
||||
scrollbarSliderActiveBackground: "#8294a6",
|
||||
},
|
||||
allowProposedApi: true, // this for unicode11 addon. FOR SOME reason xtermjs doesnt tell me this in the readme.
|
||||
});
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = "11";
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
const fit = new FitAddon();
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(fit);
|
||||
terminal.open(hostRef.current);
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const copiesTerminalSelection =
|
||||
event.type === "keydown" &&
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "KeyC";
|
||||
if (!copiesTerminalSelection) return true;
|
||||
|
||||
// Chromium reserves Ctrl+Shift+C for DevTools. Other clipboard shortcuts
|
||||
// fall through to xterm's native copy/paste event handlers.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
} else {
|
||||
void navigator.clipboard
|
||||
.writeText(terminal.getSelection())
|
||||
.catch((error) =>
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const titleChange = terminal.onTitleChange((title) => {
|
||||
const terminalId = activeSessionRef.current?.terminal_id;
|
||||
if (!terminalId) return;
|
||||
const normalized = title.trim();
|
||||
setSessionTitles((current) => {
|
||||
if (current[terminalId] === normalized) return current;
|
||||
if (!normalized) {
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [terminalId]: normalized };
|
||||
});
|
||||
});
|
||||
fit.fit();
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fit;
|
||||
|
||||
async function restoreTerminalSession() {
|
||||
try {
|
||||
const listed = await listTerminals();
|
||||
if (cancelled) return;
|
||||
setSessions(orderTerminalSessions(listed));
|
||||
const rememberedId = window.sessionStorage.getItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
);
|
||||
const candidates = restoreCandidates(
|
||||
listed,
|
||||
rememberedId,
|
||||
selectedAgentIdRef.current,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
setConnection("connecting");
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
candidate.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
terminal.reset();
|
||||
connect(attached);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof APIError) ||
|
||||
error.code !== "terminal_operator_already_attached"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
setConnection("idle");
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not restore terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void document.fonts.ready.then(() => {
|
||||
if (cancelled) return;
|
||||
fit.fit();
|
||||
lastTerminalSizeRef.current = { rows: 0, cols: 0 };
|
||||
sendResize();
|
||||
});
|
||||
|
||||
const input = terminal.onData((data) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||
lastHostWidth = width;
|
||||
lastHostHeight = height;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
fit.fit();
|
||||
sendResize();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(hostRef.current);
|
||||
void restoreTerminalSession();
|
||||
const sessionRefresh = window.setInterval(() => {
|
||||
void listTerminals()
|
||||
.then((listed) => {
|
||||
if (!cancelled) {
|
||||
setSessions((current) =>
|
||||
reconcileTerminalSessions(current, listed),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The attached terminal transport remains authoritative while a
|
||||
// background list refresh is temporarily unavailable.
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
input.dispose();
|
||||
titleChange.dispose();
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
window.clearInterval(sessionRefresh);
|
||||
detachTransport();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [connect, detachTransport, sendResize]);
|
||||
|
||||
async function start() {
|
||||
if (!selectedAgentId || !terminalRef.current) return;
|
||||
function start() {
|
||||
if (agentAtSessionLimit) {
|
||||
toast.error("Terminal session limit reached", {
|
||||
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession) {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === previousSession.terminal_id
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
setConnection("connecting");
|
||||
terminalRef.current.reset();
|
||||
try {
|
||||
fitRef.current?.fit();
|
||||
const created = await createTerminal(
|
||||
selectedAgentId,
|
||||
terminalRef.current.rows,
|
||||
terminalRef.current.cols,
|
||||
);
|
||||
connect(created);
|
||||
} catch (error) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
setConnection("idle");
|
||||
toast.error("Could not start terminal", { description: String(error) });
|
||||
}
|
||||
void terminalSession.start();
|
||||
}
|
||||
|
||||
async function activateSession(nextSession: TerminalSession) {
|
||||
if (nextSession.terminal_id === session?.terminal_id) return;
|
||||
if (nextSession.operator_attached) return;
|
||||
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession) {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === previousSession.terminal_id
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
function toggleTerminalKeyboard() {
|
||||
const terminalInstance = terminal.terminal;
|
||||
const blurTerminalInput = () => {
|
||||
terminalInstance.blurInput();
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
hostRef.current?.contains(activeElement)
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
setConnection("connecting");
|
||||
terminalRef.current?.reset();
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
nextSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
setConnection("idle");
|
||||
toast.error("Could not attach terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
void listTerminals()
|
||||
.then((listed) =>
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed)),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
if (!session) return;
|
||||
detachTransport();
|
||||
try {
|
||||
terminalRef.current?.reset();
|
||||
setConnection("connecting");
|
||||
const attached = await attachTerminal(session.terminal_id, operatorId);
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
setConnection("disconnected");
|
||||
toast.error("Terminal session is no longer available", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSession(closingSession: TerminalSession) {
|
||||
const closingId = closingSession.terminal_id;
|
||||
const closesActiveSession =
|
||||
closingId === activeSessionRef.current?.terminal_id;
|
||||
const remaining = sessions.filter((item) => item.terminal_id !== closingId);
|
||||
const fallback = remaining.find((item) => !item.operator_attached);
|
||||
|
||||
if (closesActiveSession) {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "close" }));
|
||||
}
|
||||
detachTransport();
|
||||
}
|
||||
try {
|
||||
await closeTerminal(closingId);
|
||||
} catch (error) {
|
||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||
void listTerminals()
|
||||
.then((listed) =>
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed)),
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessions((current) =>
|
||||
current.filter((item) => item.terminal_id !== closingId),
|
||||
);
|
||||
setSessionTitles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[closingId];
|
||||
return next;
|
||||
});
|
||||
if (window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||
}
|
||||
if (closesActiveSession) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
setConnection("idle");
|
||||
// xterm.clear() deliberately preserves the active cursor line. Closing
|
||||
// a session should discard its complete screen and terminal modes.
|
||||
terminalRef.current?.reset();
|
||||
if (fallback) void activateSession(fallback);
|
||||
};
|
||||
const shouldHide =
|
||||
terminalInputFocusedBeforeAccessoryRef.current ||
|
||||
Boolean(hostRef.current?.contains(document.activeElement));
|
||||
terminalInputFocusedBeforeAccessoryRef.current = false;
|
||||
if (shouldHide) {
|
||||
blurTerminalInput();
|
||||
// Base UI may restore the previously focused element after pointer handling.
|
||||
window.requestAnimationFrame(blurTerminalInput);
|
||||
} else {
|
||||
terminalInstance.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="terminal-page" aria-label="Remote terminal">
|
||||
<section
|
||||
className="terminal-page"
|
||||
aria-label="Remote terminal"
|
||||
ref={pageRef}
|
||||
>
|
||||
<header className="terminal-toolbar">
|
||||
<div className="terminal-heading">
|
||||
<Terminal className="size-5 text-primary" aria-hidden />
|
||||
@@ -625,13 +141,13 @@ export function TerminalPage({
|
||||
)}
|
||||
value={selectedAgentId}
|
||||
onChange={onSelectAgent}
|
||||
disabled={connection === "connecting"}
|
||||
disabled={terminalSession.connection === "connecting"}
|
||||
className="w-full min-w-0 sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!selectedAgent && connection === "idle" ? (
|
||||
{!selectedAgent && terminalSession.connection === "idle" ? (
|
||||
<p className="terminal-notice">
|
||||
No connected terminal-capable agent is selected.
|
||||
</p>
|
||||
@@ -643,128 +159,36 @@ export function TerminalPage({
|
||||
|
||||
<div className="terminal-frame">
|
||||
<div className="terminal-framebar">
|
||||
<div className="terminal-tabs">
|
||||
<div
|
||||
className="terminal-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="terminal-session-label">
|
||||
<Terminal className="size-4" aria-hidden />
|
||||
<span>No active session</span>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((item) => {
|
||||
const agent = agents.find(
|
||||
(candidate) => candidate.agent_id === item.agent_id,
|
||||
);
|
||||
const active = item.terminal_id === session?.terminal_id;
|
||||
const locked = item.operator_attached && !active;
|
||||
const agentLabel = agent
|
||||
? displayAgentLabel(agent)
|
||||
: item.agent_id;
|
||||
const sessionTitle = sessionTitles[item.terminal_id];
|
||||
const tabLabel = sessionTitle
|
||||
? `${agentLabel} · ${sessionTitle}`
|
||||
: agentLabel;
|
||||
return (
|
||||
<div
|
||||
key={item.terminal_id}
|
||||
role="presentation"
|
||||
className="terminal-tab"
|
||||
data-active={active || undefined}
|
||||
data-locked={locked || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className="terminal-tab-select"
|
||||
disabled={locked || connection === "connecting"}
|
||||
title={
|
||||
locked ? "Attached in another browser" : tabLabel
|
||||
}
|
||||
onClick={() => void activateSession(item)}
|
||||
>
|
||||
<span
|
||||
className="terminal-tab-dot"
|
||||
data-state={
|
||||
active
|
||||
? connection
|
||||
: locked
|
||||
? "attached"
|
||||
: item.agent_attached
|
||||
? "detached"
|
||||
: "disconnected"
|
||||
}
|
||||
aria-hidden
|
||||
<TerminalSessionTabs
|
||||
agents={agents}
|
||||
sessions={sessions}
|
||||
activeTerminalId={terminalSession.session?.terminal_id}
|
||||
connection={terminalSession.connection}
|
||||
sessionTitles={terminal.sessionTitles}
|
||||
nowUnix={nowUnix}
|
||||
canRequestStart={canRequestStart}
|
||||
agentAtSessionLimit={agentAtSessionLimit}
|
||||
selectedAgentSessionLimit={selectedAgentSessionLimit}
|
||||
onActivate={(item) => void terminalSession.activate(item)}
|
||||
onClose={(item) => void terminalSession.close(item)}
|
||||
onStart={start}
|
||||
/>
|
||||
<span className="terminal-tab-label">{tabLabel}</span>
|
||||
<time
|
||||
dateTime={new Date(
|
||||
item.created_at_unix * 1000,
|
||||
).toISOString()}
|
||||
>
|
||||
{terminalCreatedTime(item.created_at_unix)}
|
||||
</time>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-tab-close"
|
||||
disabled={locked}
|
||||
aria-label={`Close ${agentLabel} terminal session`}
|
||||
title={
|
||||
locked
|
||||
? "Attached in another browser"
|
||||
: "Close session"
|
||||
}
|
||||
onClick={() => void closeSession(item)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-new-tab"
|
||||
disabled={!canRequestStart}
|
||||
aria-label="New terminal session"
|
||||
onClick={start}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{agentAtSessionLimit
|
||||
? "Two-session limit reached"
|
||||
: "New session"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="terminal-frame-actions">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="terminal-status"
|
||||
data-state={connection}
|
||||
data-state={terminalSession.connection}
|
||||
>
|
||||
<span aria-hidden />
|
||||
{connection}
|
||||
{terminalSession.connection}
|
||||
</Badge>
|
||||
{connection === "disconnected" ? (
|
||||
{terminalSession.connection === "disconnected" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={reconnect}
|
||||
onClick={() => void terminalSession.reconnect()}
|
||||
>
|
||||
<RotateCcw className="size-4" aria-hidden />
|
||||
Reconnect
|
||||
@@ -776,9 +200,30 @@ export function TerminalPage({
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={connection === "idle"}
|
||||
className="terminal-accessories-toggle"
|
||||
data-active={accessoriesOpen || undefined}
|
||||
aria-label={
|
||||
accessoriesOpen
|
||||
? "Hide terminal controls"
|
||||
: "Show terminal controls"
|
||||
}
|
||||
aria-expanded={accessoriesOpen}
|
||||
onClick={() => setAccessoriesOpen((current) => !current)}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Terminal controls</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={terminalSession.connection === "idle"}
|
||||
aria-label="Clear terminal"
|
||||
onClick={() => terminalRef.current?.clear()}
|
||||
onClick={terminal.terminal.clear}
|
||||
>
|
||||
<Eraser className="size-4" aria-hidden />
|
||||
</Button>
|
||||
@@ -787,7 +232,68 @@ export function TerminalPage({
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="terminal-surface" ref={hostRef} />
|
||||
{accessoriesOpen ? (
|
||||
<TerminalAccessoryBar
|
||||
inputReady={terminalInputReady}
|
||||
modifiers={terminalSession.modifiers}
|
||||
fontSize={terminal.fontSize}
|
||||
onRememberInputFocus={() => {
|
||||
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
|
||||
hostRef.current?.contains(document.activeElement),
|
||||
);
|
||||
}}
|
||||
onToggleModifier={terminalSession.toggleModifier}
|
||||
onWriteKey={terminalSession.writeAccessoryKey}
|
||||
onScrollPages={terminal.terminal.scrollPages}
|
||||
onCopy={() => void terminal.terminal.copy()}
|
||||
onPaste={() =>
|
||||
void terminal.terminal.paste(terminalSession.writeTerminalData)
|
||||
}
|
||||
onToggleKeyboard={toggleTerminalKeyboard}
|
||||
onChangeFontSize={terminal.terminal.changeFontSize}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="terminal-surface"
|
||||
ref={hostRef}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "touch") {
|
||||
terminal.terminal.focus();
|
||||
return;
|
||||
}
|
||||
terminalTouchRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
if (!touch || touch.pointerId !== event.pointerId || touch.moved) {
|
||||
return;
|
||||
}
|
||||
touch.moved =
|
||||
Math.hypot(
|
||||
event.clientX - touch.startX,
|
||||
event.clientY - touch.startY,
|
||||
) > TERMINAL_TOUCH_SLOP_PX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const touch = terminalTouchRef.current;
|
||||
terminalTouchRef.current = null;
|
||||
if (
|
||||
touch?.pointerId === event.pointerId &&
|
||||
!touch.moved &&
|
||||
event.pointerType === "touch"
|
||||
) {
|
||||
terminal.terminal.focus();
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
terminalTouchRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Keyboard,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { MouseEvent, PointerEvent } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TerminalModifiers } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
export const MIN_TERMINAL_FONT_SIZE = 10;
|
||||
export const MAX_TERMINAL_FONT_SIZE = 22;
|
||||
|
||||
type Props = {
|
||||
inputReady: boolean;
|
||||
modifiers: TerminalModifiers;
|
||||
fontSize: number;
|
||||
onRememberInputFocus: () => void;
|
||||
onToggleModifier: (modifier: keyof TerminalModifiers) => void;
|
||||
onWriteKey: (data: string) => void;
|
||||
onScrollPages: (pages: number) => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onToggleKeyboard: () => void;
|
||||
onChangeFontSize: (delta: number) => void;
|
||||
};
|
||||
|
||||
const CONTROL_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, title: string]
|
||||
> = [
|
||||
["^C", "\x03", "Interrupt"],
|
||||
["^U", "\x15", "Clear input line"],
|
||||
["^A", "\x01", "Start of line"],
|
||||
["^E", "\x05", "End of line"],
|
||||
];
|
||||
|
||||
const ARROW_KEYS: ReadonlyArray<
|
||||
readonly [label: string, sequence: string, icon: LucideIcon]
|
||||
> = [
|
||||
["Left arrow", "\x1b[D", ArrowLeft],
|
||||
["Down arrow", "\x1b[B", ArrowDown],
|
||||
["Up arrow", "\x1b[A", ArrowUp],
|
||||
["Right arrow", "\x1b[C", ArrowRight],
|
||||
];
|
||||
|
||||
function preserveInputFocus(
|
||||
event: PointerEvent<HTMLDivElement> | MouseEvent<HTMLDivElement>,
|
||||
onRememberInputFocus: () => void,
|
||||
) {
|
||||
if (event.target instanceof Element && event.target.closest("button")) {
|
||||
onRememberInputFocus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalAccessoryBar({
|
||||
inputReady,
|
||||
modifiers,
|
||||
fontSize,
|
||||
onRememberInputFocus,
|
||||
onToggleModifier,
|
||||
onWriteKey,
|
||||
onScrollPages,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onToggleKeyboard,
|
||||
onChangeFontSize,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className="terminal-accessories"
|
||||
role="toolbar"
|
||||
aria-label="Terminal controls"
|
||||
onPointerDownCapture={(event) => {
|
||||
// Preserve whether xterm was focused before the button library moves
|
||||
// focus. The keyboard button consumes this to show or hide mobile IME.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
onMouseDownCapture={(event) => {
|
||||
// Firefox can take the mouse-event path without emitting pointerdown.
|
||||
preserveInputFocus(event, onRememberInputFocus);
|
||||
}}
|
||||
>
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.ctrl || undefined}
|
||||
aria-pressed={modifiers.ctrl}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("ctrl")}
|
||||
>
|
||||
Ctrl
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
data-active={modifiers.meta || undefined}
|
||||
aria-pressed={modifiers.meta}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onToggleModifier("meta")}
|
||||
>
|
||||
Meta
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\x1b")}
|
||||
>
|
||||
Esc
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key"
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey("\t")}
|
||||
>
|
||||
Tab
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{CONTROL_KEYS.map(([label, sequence, title]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--control"
|
||||
aria-label={title}
|
||||
title={title}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
{ARROW_KEYS.map(([label, sequence, Icon]) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label={label}
|
||||
disabled={!inputReady}
|
||||
onClick={() => onWriteKey(sequence)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(-1)}
|
||||
>
|
||||
PgUp
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--wide"
|
||||
onClick={() => onScrollPages(1)}
|
||||
>
|
||||
PgDn
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Copy selection or visible screen"
|
||||
title="Copy selection or visible screen"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<ClipboardCopy className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Paste"
|
||||
title="Paste"
|
||||
disabled={!inputReady}
|
||||
onClick={onPaste}
|
||||
>
|
||||
<ClipboardPaste className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
aria-label="Toggle terminal keyboard"
|
||||
title="Toggle terminal keyboard"
|
||||
disabled={!inputReady}
|
||||
onClick={onToggleKeyboard}
|
||||
>
|
||||
<Keyboard className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="terminal-accessory-group terminal-font-controls">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize <= MIN_TERMINAL_FONT_SIZE}
|
||||
aria-label="Decrease terminal text size"
|
||||
onClick={() => onChangeFontSize(-1)}
|
||||
>
|
||||
<ZoomOut className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<output aria-label="Terminal text size">{fontSize}</output>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-key terminal-key--icon"
|
||||
disabled={fontSize >= MAX_TERMINAL_FONT_SIZE}
|
||||
aria-label="Increase terminal text size"
|
||||
onClick={() => onChangeFontSize(1)}
|
||||
>
|
||||
<ZoomIn className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Plus, Terminal, X } from "lucide-react";
|
||||
|
||||
import type { Agent, TerminalSession } from "@/api";
|
||||
import { displayAgentLabel } from "@/components/AgentSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { TerminalConnectionState } from "@/pages/terminal/sessionUtils";
|
||||
|
||||
type Props = {
|
||||
agents: Agent[];
|
||||
sessions: TerminalSession[];
|
||||
activeTerminalId?: string;
|
||||
connection: TerminalConnectionState;
|
||||
sessionTitles: Record<string, string>;
|
||||
nowUnix: number;
|
||||
canRequestStart: boolean;
|
||||
agentAtSessionLimit: boolean;
|
||||
selectedAgentSessionLimit: number;
|
||||
onActivate: (session: TerminalSession) => void;
|
||||
onClose: (session: TerminalSession) => void;
|
||||
onStart: () => void;
|
||||
};
|
||||
|
||||
function terminalCreatedTime(createdAtUnix: number): string {
|
||||
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function terminalTimestamp(timestampUnix: number): string {
|
||||
return new Date(timestampUnix * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function terminalExpiryDisplay(
|
||||
expiresAtUnix: number | null | undefined,
|
||||
nowUnix: number,
|
||||
): { label: string; warning: boolean } | null {
|
||||
if (expiresAtUnix == null) return null;
|
||||
const remainingSeconds = expiresAtUnix - nowUnix;
|
||||
if (remainingSeconds <= 0) return { label: "expired", warning: true };
|
||||
if (remainingSeconds < 60) return { label: "<1m left", warning: true };
|
||||
if (remainingSeconds < 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 60)}m left`,
|
||||
warning: remainingSeconds < 10 * 60,
|
||||
};
|
||||
}
|
||||
if (remainingSeconds < 24 * 3600) {
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / 3600)}h left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${Math.floor(remainingSeconds / (24 * 3600))}d left`,
|
||||
warning: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function TerminalSessionTabs({
|
||||
agents,
|
||||
sessions,
|
||||
activeTerminalId,
|
||||
connection,
|
||||
sessionTitles,
|
||||
nowUnix,
|
||||
canRequestStart,
|
||||
agentAtSessionLimit,
|
||||
selectedAgentSessionLimit,
|
||||
onActivate,
|
||||
onClose,
|
||||
onStart,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="terminal-tabs">
|
||||
<div
|
||||
className="terminal-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="terminal-session-label">
|
||||
<Terminal className="size-4" aria-hidden />
|
||||
<span>No active session</span>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((item) => {
|
||||
const agent = agents.find(
|
||||
(candidate) => candidate.agent_id === item.agent_id,
|
||||
);
|
||||
const active = item.terminal_id === activeTerminalId;
|
||||
const locked = item.operator_attached && !active;
|
||||
const agentLabel = agent ? displayAgentLabel(agent) : item.agent_id;
|
||||
const sessionTitle = sessionTitles[item.terminal_id];
|
||||
const tabLabel = sessionTitle
|
||||
? `${agentLabel} · ${sessionTitle}`
|
||||
: agentLabel;
|
||||
const expiry = terminalExpiryDisplay(item.expires_at_unix, nowUnix);
|
||||
const timingTitle =
|
||||
item.expires_at_unix != null
|
||||
? `Expires ${terminalTimestamp(item.expires_at_unix)}`
|
||||
: "No expiry";
|
||||
return (
|
||||
<div
|
||||
key={item.terminal_id}
|
||||
role="presentation"
|
||||
className="terminal-tab"
|
||||
data-active={active || undefined}
|
||||
data-locked={locked || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className="terminal-tab-select"
|
||||
disabled={locked || connection === "connecting"}
|
||||
title={`${locked ? "Attached in another browser\n" : ""}${tabLabel}\nCreated ${terminalTimestamp(item.created_at_unix)}\n${timingTitle}`}
|
||||
onClick={() => onActivate(item)}
|
||||
>
|
||||
<span
|
||||
className="terminal-tab-dot"
|
||||
data-state={
|
||||
active
|
||||
? connection
|
||||
: locked
|
||||
? "attached"
|
||||
: item.agent_attached
|
||||
? "detached"
|
||||
: "disconnected"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="terminal-tab-label">{tabLabel}</span>
|
||||
<time
|
||||
className={
|
||||
expiry?.warning ? "text-amber-400" : "text-[#7f8d9a]"
|
||||
}
|
||||
dateTime={new Date(
|
||||
(item.expires_at_unix ?? item.created_at_unix) * 1000,
|
||||
).toISOString()}
|
||||
>
|
||||
{expiry?.label ?? terminalCreatedTime(item.created_at_unix)}
|
||||
</time>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-tab-close"
|
||||
disabled={locked || connection === "connecting"}
|
||||
aria-label={`Close ${agentLabel} terminal session`}
|
||||
title={
|
||||
locked
|
||||
? "Attached in another browser"
|
||||
: connection === "connecting"
|
||||
? "Connecting — wait before closing"
|
||||
: "Close session"
|
||||
}
|
||||
onClick={() => onClose(item)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="terminal-new-tab"
|
||||
disabled={!canRequestStart}
|
||||
aria-label="New terminal session"
|
||||
onClick={onStart}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{agentAtSessionLimit
|
||||
? `${selectedAgentSessionLimit}-session limit reached`
|
||||
: "New session"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import type { TerminalSession } from "@/api";
|
||||
|
||||
export type TerminalConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "exited";
|
||||
|
||||
export type TerminalModifiers = {
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
export const NO_TERMINAL_MODIFIERS: TerminalModifiers = {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
};
|
||||
|
||||
export function mergeTerminalSession(
|
||||
sessions: TerminalSession[],
|
||||
next: TerminalSession,
|
||||
): TerminalSession[] {
|
||||
const existingIndex = sessions.findIndex(
|
||||
(item) => item.terminal_id === next.terminal_id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Attaching updates session state, not identity or tab position. Removing
|
||||
// and re-inserting here made same-second sessions swap places on click.
|
||||
return sessions.map((item, index) =>
|
||||
index === existingIndex ? next : item,
|
||||
);
|
||||
}
|
||||
return orderTerminalSessions([...sessions, next]);
|
||||
}
|
||||
|
||||
function compareTerminalSessions(
|
||||
left: TerminalSession,
|
||||
right: TerminalSession,
|
||||
): number {
|
||||
return (
|
||||
left.created_at_unix - right.created_at_unix ||
|
||||
left.terminal_id.localeCompare(right.terminal_id)
|
||||
);
|
||||
}
|
||||
|
||||
export function orderTerminalSessions(
|
||||
sessions: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
return [...sessions].sort(compareTerminalSessions);
|
||||
}
|
||||
|
||||
export function reconcileTerminalSessions(
|
||||
current: TerminalSession[],
|
||||
listed: TerminalSession[],
|
||||
): TerminalSession[] {
|
||||
const listedById = new Map(listed.map((item) => [item.terminal_id, item]));
|
||||
const retained = current.flatMap((item) => {
|
||||
const updated = listedById.get(item.terminal_id);
|
||||
if (!updated) return [];
|
||||
listedById.delete(item.terminal_id);
|
||||
return [updated];
|
||||
});
|
||||
return [...retained, ...orderTerminalSessions([...listedById.values()])];
|
||||
}
|
||||
|
||||
export function restoreCandidates(
|
||||
sessions: TerminalSession[],
|
||||
rememberedId: string | null,
|
||||
selectedAgentId: string,
|
||||
): TerminalSession[] {
|
||||
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
|
||||
const available = sessions.filter(
|
||||
(item) => !item.operator_attached && item !== remembered,
|
||||
);
|
||||
available.sort((left, right) => {
|
||||
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
|
||||
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
|
||||
return (
|
||||
rightPreferred - leftPreferred ||
|
||||
right.created_at_unix - left.created_at_unix
|
||||
);
|
||||
});
|
||||
return remembered ? [remembered, ...available] : available;
|
||||
}
|
||||
|
||||
export function applyTerminalModifiers(
|
||||
data: string,
|
||||
modifiers: TerminalModifiers,
|
||||
): string {
|
||||
let modified = data;
|
||||
if (modifiers.ctrl && modified.length === 1) {
|
||||
const code = modified.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
modified = String.fromCharCode(code & 0x1f);
|
||||
}
|
||||
}
|
||||
return modifiers.meta ? `\x1b${modified}` : modified;
|
||||
}
|
||||
|
||||
export function visibleTerminalText(terminal: XTerm): string {
|
||||
const buffer = terminal.buffer.active;
|
||||
const firstLine = buffer.viewportY;
|
||||
const lastLine = Math.min(buffer.length, firstLine + terminal.rows);
|
||||
const lines: string[] = [];
|
||||
for (let index = firstLine; index < lastLine; index += 1) {
|
||||
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||
}
|
||||
while (lines.at(-1) === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
APIError,
|
||||
attachTerminal,
|
||||
closeTerminal,
|
||||
createTerminal,
|
||||
type TerminalSession,
|
||||
} from "@/api";
|
||||
import {
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
applyTerminalModifiers,
|
||||
orderTerminalSessions,
|
||||
restoreCandidates,
|
||||
type TerminalConnectionState,
|
||||
type TerminalModifiers,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
import type { TerminalCatalog } from "@/pages/terminal/useTerminalSessions";
|
||||
import type { TerminalPort } from "@/pages/terminal/useXterm";
|
||||
|
||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||
|
||||
// Fallback for browsers that do not implement crypto.randomUUID().
|
||||
const randomId = () =>
|
||||
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||
const num = Number(c);
|
||||
return (
|
||||
num ^
|
||||
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
|
||||
function terminalOperatorId(): string {
|
||||
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||
if (remembered) return remembered;
|
||||
const created = window.crypto.randomUUID?.() ?? randomId();
|
||||
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
type Options = {
|
||||
terminal: TerminalPort;
|
||||
catalog: TerminalCatalog;
|
||||
selectedAgentId: string;
|
||||
};
|
||||
|
||||
export function useTerminalSession({
|
||||
terminal,
|
||||
catalog,
|
||||
selectedAgentId,
|
||||
}: Options) {
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const activeSessionRef = useRef<TerminalSession | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const selectedAgentIdRef = useRef(selectedAgentId);
|
||||
const catalogRef = useRef(catalog);
|
||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
|
||||
const [modifiers, setModifiers] = useState<TerminalModifiers>(
|
||||
NO_TERMINAL_MODIFIERS,
|
||||
);
|
||||
const [operatorId] = useState(terminalOperatorId);
|
||||
|
||||
selectedAgentIdRef.current = selectedAgentId;
|
||||
catalogRef.current = catalog;
|
||||
activeSessionRef.current = session;
|
||||
|
||||
const detachTransport = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
socketRef.current = null;
|
||||
socket?.close();
|
||||
}, []);
|
||||
|
||||
const writeTerminalData = useCallback(
|
||||
(data: string, consumeModifiers = false) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
const output = consumeModifiers
|
||||
? applyTerminalModifiers(data, modifiersRef.current)
|
||||
: data;
|
||||
socket.send(new TextEncoder().encode(output));
|
||||
if (
|
||||
consumeModifiers &&
|
||||
(modifiersRef.current.ctrl || modifiersRef.current.meta)
|
||||
) {
|
||||
modifiersRef.current = NO_TERMINAL_MODIFIERS;
|
||||
setModifiers(NO_TERMINAL_MODIFIERS);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const modifiersRef = useRef(modifiers);
|
||||
modifiersRef.current = modifiers;
|
||||
|
||||
const connect = useCallback(
|
||||
(nextSession: TerminalSession) => {
|
||||
if (!nextSession.attachment_token) {
|
||||
throw new Error(
|
||||
"Control plane did not issue a terminal attachment token",
|
||||
);
|
||||
}
|
||||
detachTransport();
|
||||
setSession(nextSession);
|
||||
activeSessionRef.current = nextSession;
|
||||
terminal.setActiveTerminalId(nextSession.terminal_id);
|
||||
catalogRef.current.merge({ ...nextSession, operator_attached: true });
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
setConnection("connecting");
|
||||
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
|
||||
socket.binaryType = "arraybuffer";
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "attach",
|
||||
attachment_token: nextSession.attachment_token,
|
||||
operator_id: operatorId,
|
||||
}),
|
||||
);
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (socketRef.current !== socket) return;
|
||||
if (typeof event.data !== "string") {
|
||||
terminal.write(new Uint8Array(event.data as ArrayBuffer));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const control = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
exit_code?: number | null;
|
||||
message?: string;
|
||||
};
|
||||
if (control.type === "ready") {
|
||||
setConnection("ready");
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
window.requestAnimationFrame(() => {
|
||||
terminal.fit();
|
||||
terminal.sendResize((message) => socket.send(message), true);
|
||||
terminal.focus();
|
||||
});
|
||||
} else if (control.type === "exited") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[process exited${control.exit_code == null ? "" : ` ${control.exit_code}`}]`,
|
||||
);
|
||||
} else if (control.type === "error") {
|
||||
setConnection("exited");
|
||||
terminal.writeLine(
|
||||
`\r\n[terminal error: ${control.message ?? "unknown error"}]`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
terminal.writeLine("\r\n[invalid terminal control frame]");
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
terminal.writeLine("\r\n[terminal transport error]");
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (socketRef.current !== socket) return;
|
||||
socketRef.current = null;
|
||||
catalogRef.current.markDetached(nextSession.terminal_id);
|
||||
setConnection((current) =>
|
||||
current === "exited" ? current : "disconnected",
|
||||
);
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
};
|
||||
},
|
||||
[detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const activate = useCallback(
|
||||
async (nextSession: TerminalSession) => {
|
||||
if (nextSession.terminal_id === activeSessionRef.current?.terminal_id)
|
||||
return;
|
||||
if (nextSession.operator_attached) return;
|
||||
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
nextSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not attach terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[connect, detachTransport, operatorId, terminal],
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const dimensions = terminal.dimensions();
|
||||
if (!selectedAgentIdRef.current || !dimensions) return;
|
||||
const previousSession = activeSessionRef.current;
|
||||
detachTransport();
|
||||
if (previousSession)
|
||||
catalogRef.current.markDetached(previousSession.terminal_id);
|
||||
setConnection("connecting");
|
||||
terminal.reset();
|
||||
try {
|
||||
terminal.fit();
|
||||
const fittedDimensions = terminal.dimensions() ?? dimensions;
|
||||
const created = await createTerminal(
|
||||
selectedAgentIdRef.current,
|
||||
fittedDimensions.rows,
|
||||
fittedDimensions.cols,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(created);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
toast.error("Could not start terminal", { description: String(error) });
|
||||
}
|
||||
}, [connect, detachTransport, terminal]);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
const currentSession = activeSessionRef.current;
|
||||
if (!currentSession) return;
|
||||
detachTransport();
|
||||
try {
|
||||
terminal.reset();
|
||||
setConnection("connecting");
|
||||
const attached = await attachTerminal(
|
||||
currentSession.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (!mountedRef.current) return;
|
||||
connect(attached);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setConnection("disconnected");
|
||||
toast.error("Terminal session is no longer available", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, [connect, detachTransport, operatorId, terminal]);
|
||||
|
||||
const close = useCallback(
|
||||
async (closingSession: TerminalSession) => {
|
||||
const closingId = closingSession.terminal_id;
|
||||
const closesActiveSession =
|
||||
closingId === activeSessionRef.current?.terminal_id;
|
||||
const remaining = catalogRef.current
|
||||
.getSessions()
|
||||
.filter((item) => item.terminal_id !== closingId);
|
||||
const fallback = remaining.find((item) => !item.operator_attached);
|
||||
|
||||
if (closesActiveSession) {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "close" }));
|
||||
}
|
||||
detachTransport();
|
||||
}
|
||||
try {
|
||||
await closeTerminal(closingId);
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||
void catalogRef.current.refresh().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
catalogRef.current.remove(closingId);
|
||||
terminal.forgetTitle(closingId);
|
||||
if (
|
||||
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
|
||||
) {
|
||||
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||
}
|
||||
if (closesActiveSession) {
|
||||
setSession(null);
|
||||
activeSessionRef.current = null;
|
||||
terminal.setActiveTerminalId(undefined);
|
||||
setConnection("idle");
|
||||
// reset discards the complete screen and terminal modes; clear() does not.
|
||||
terminal.reset();
|
||||
if (fallback) void activate(fallback);
|
||||
}
|
||||
},
|
||||
[activate, detachTransport, terminal],
|
||||
);
|
||||
|
||||
const toggleModifier = useCallback(
|
||||
(modifier: keyof TerminalModifiers) => {
|
||||
setModifiers((current) => {
|
||||
const next = { ...current, [modifier]: !current[modifier] };
|
||||
modifiersRef.current = next;
|
||||
return next;
|
||||
});
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
|
||||
const writeAccessoryKey = useCallback(
|
||||
(data: string) => {
|
||||
writeTerminalData(data);
|
||||
window.requestAnimationFrame(() => terminal.focus());
|
||||
},
|
||||
[terminal, writeTerminalData],
|
||||
);
|
||||
|
||||
// Transport input/resize subscriptions and restoration begin only once xterm is usable.
|
||||
useEffect(() => {
|
||||
if (!terminal.ready) return;
|
||||
mountedRef.current = true;
|
||||
const input = terminal.onInput((data) => writeTerminalData(data, true));
|
||||
const resize = terminal.onResize(() => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
terminal.sendResize((message) => socket.send(message));
|
||||
}
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
async function restoreTerminalSession() {
|
||||
try {
|
||||
const listed = await catalogRef.current.refresh();
|
||||
if (cancelled) return;
|
||||
const rememberedId = window.sessionStorage.getItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
);
|
||||
const candidates = restoreCandidates(
|
||||
orderTerminalSessions(listed),
|
||||
rememberedId,
|
||||
selectedAgentIdRef.current,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (cancelled) return;
|
||||
setConnection("connecting");
|
||||
try {
|
||||
const attached = await attachTerminal(
|
||||
candidate.terminal_id,
|
||||
operatorId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
terminal.reset();
|
||||
connect(attached);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof APIError) ||
|
||||
error.code !== "terminal_operator_already_attached"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelled) setConnection("idle");
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setConnection("idle");
|
||||
toast.error("Could not restore terminal session", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void restoreTerminalSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
input.dispose();
|
||||
resize.dispose();
|
||||
detachTransport();
|
||||
};
|
||||
}, [
|
||||
terminal.ready,
|
||||
terminal.onInput,
|
||||
terminal.onResize,
|
||||
terminal.reset,
|
||||
terminal.sendResize,
|
||||
terminal,
|
||||
connect,
|
||||
detachTransport,
|
||||
operatorId,
|
||||
writeTerminalData,
|
||||
]);
|
||||
|
||||
return {
|
||||
session,
|
||||
connection,
|
||||
modifiers,
|
||||
start,
|
||||
activate,
|
||||
reconnect,
|
||||
close,
|
||||
writeTerminalData,
|
||||
writeAccessoryKey,
|
||||
toggleModifier,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { listTerminals, type TerminalSession } from "@/api";
|
||||
import {
|
||||
mergeTerminalSession,
|
||||
reconcileTerminalSessions,
|
||||
} from "@/pages/terminal/sessionUtils";
|
||||
|
||||
function websocketUrl(path: string): string {
|
||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${scheme}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
export function useTerminalSessions() {
|
||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||
const sessionsRef = useRef(sessions);
|
||||
const refreshRef = useRef<() => Promise<TerminalSession[]>>(async () => []);
|
||||
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
const refresh = useCallback(() => refreshRef.current(), []);
|
||||
const merge = useCallback((session: TerminalSession) => {
|
||||
setSessions((current) => mergeTerminalSession(current, session));
|
||||
}, []);
|
||||
const markDetached = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.map((item) =>
|
||||
item.terminal_id === terminalId
|
||||
? { ...item, operator_attached: false }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
const remove = useCallback((terminalId: string) => {
|
||||
setSessions((current) =>
|
||||
current.filter((item) => item.terminal_id !== terminalId),
|
||||
);
|
||||
}, []);
|
||||
const reconcile = useCallback((listed: TerminalSession[]) => {
|
||||
setSessions((current) => reconcileTerminalSessions(current, listed));
|
||||
}, []);
|
||||
const getSessions = useCallback(() => sessionsRef.current, []);
|
||||
|
||||
// The catalog stream and fallback timer live for the page mount, independent of active sessions.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshInFlight: Promise<TerminalSession[]> | null = null;
|
||||
let refreshRequested = false;
|
||||
let eventsSocket: WebSocket | null = null;
|
||||
let reconnectTimer = 0;
|
||||
let healthyTimer = 0;
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
const refreshSessions = (): Promise<TerminalSession[]> => {
|
||||
if (refreshInFlight) {
|
||||
// Coalesce bursts and fetch again if another invalidation arrives in flight.
|
||||
refreshRequested = true;
|
||||
return refreshInFlight;
|
||||
}
|
||||
refreshInFlight = (async () => {
|
||||
let listed: TerminalSession[] = [];
|
||||
do {
|
||||
refreshRequested = false;
|
||||
listed = await listTerminals();
|
||||
if (!cancelled) {
|
||||
setSessions((current) =>
|
||||
reconcileTerminalSessions(current, listed),
|
||||
);
|
||||
}
|
||||
} while (refreshRequested && !cancelled);
|
||||
return listed;
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
if (refreshRequested && !cancelled) {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
return refreshInFlight;
|
||||
};
|
||||
|
||||
refreshRef.current = refreshSessions;
|
||||
|
||||
const connectEvents = () => {
|
||||
if (cancelled) return;
|
||||
const socket = new WebSocket(
|
||||
websocketUrl("/api/v1/control/terminals/events/ws"),
|
||||
);
|
||||
eventsSocket = socket;
|
||||
socket.onmessage = (event) => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
if (typeof event.data !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(event.data) as { type?: string };
|
||||
if (message.type === "sessions_changed") {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Invalidations are hints; the periodic recovery fetch remains active.
|
||||
}
|
||||
};
|
||||
socket.onopen = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
window.clearTimeout(healthyTimer);
|
||||
healthyTimer = window.setTimeout(() => {
|
||||
if (eventsSocket === socket && socket.readyState === WebSocket.OPEN) {
|
||||
reconnectAttempt = 0;
|
||||
}
|
||||
}, 10_000);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (cancelled || eventsSocket !== socket) return;
|
||||
window.clearTimeout(healthyTimer);
|
||||
const ceiling = Math.min(500 * 2 ** reconnectAttempt, 12_000);
|
||||
const delay = Math.min(
|
||||
Math.round(ceiling * (0.8 + Math.random() * 0.4)),
|
||||
15_000,
|
||||
);
|
||||
reconnectAttempt = Math.min(reconnectAttempt + 1, 5);
|
||||
reconnectTimer = window.setTimeout(connectEvents, delay);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
};
|
||||
|
||||
connectEvents();
|
||||
void refreshSessions().catch(() => undefined);
|
||||
const refreshTimer = window.setInterval(() => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
}, 15_000);
|
||||
const refreshOnFocus = () => {
|
||||
void refreshSessions().catch(() => undefined);
|
||||
};
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(refreshTimer);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
window.clearTimeout(reconnectTimer);
|
||||
window.clearTimeout(healthyTimer);
|
||||
eventsSocket?.close();
|
||||
eventsSocket = null;
|
||||
refreshRef.current = async () => [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
refresh,
|
||||
merge,
|
||||
markDetached,
|
||||
remove,
|
||||
reconcile,
|
||||
getSessions,
|
||||
};
|
||||
}
|
||||
|
||||
export type TerminalCatalog = ReturnType<typeof useTerminalSessions>;
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
MIN_TERMINAL_FONT_SIZE,
|
||||
} from "@/pages/terminal/TerminalAccessoryBar";
|
||||
import { visibleTerminalText } from "@/pages/terminal/sessionUtils";
|
||||
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
|
||||
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 14;
|
||||
|
||||
// Stable behavioral surface shared with session transport and page controls.
|
||||
export type TerminalPort = {
|
||||
clear: () => void;
|
||||
copy: () => Promise<void>;
|
||||
dimensions: () => { rows: number; cols: number } | null;
|
||||
forgetTitle: (terminalId: string) => void;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
blurInput: () => void;
|
||||
onInput: (handler: (data: string) => void) => { dispose: () => void };
|
||||
onResize: (handler: () => void) => { dispose: () => void };
|
||||
paste: (write: (data: string) => void) => Promise<void>;
|
||||
reset: () => void;
|
||||
scrollPages: (pages: number) => void;
|
||||
sendResize: (send: (message: string) => void, force?: boolean) => void;
|
||||
changeFontSize: (delta: number) => void;
|
||||
setActiveTerminalId: (terminalId: string | undefined) => void;
|
||||
write: (data: Uint8Array) => void;
|
||||
writeLine: (line: string) => void;
|
||||
ready: boolean;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
pageRef: RefObject<HTMLElement | null>;
|
||||
hostRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function useXterm({ pageRef, hostRef }: Options) {
|
||||
const terminalRef = useRef<XTerm | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const activeTerminalIdRef = useRef<string | undefined>(undefined);
|
||||
const lastSizeRef = useRef({ rows: 0, cols: 0 });
|
||||
const resizeListenersRef = useRef(new Set<() => void>());
|
||||
const [fontFamily, setFontFamily] = useState<string>();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE);
|
||||
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const setActiveTerminalId = useCallback((terminalId: string | undefined) => {
|
||||
activeTerminalIdRef.current = terminalId;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadTerminalFontFamily().then((loadedFontFamily) => {
|
||||
if (!cancelled) setFontFamily(loadedFontFamily);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const updateViewportHeight = () => {
|
||||
const height = Math.round(viewport?.height ?? window.innerHeight);
|
||||
pageRef.current?.style.setProperty(
|
||||
"--terminal-visual-viewport-height",
|
||||
`${height}px`,
|
||||
);
|
||||
};
|
||||
updateViewportHeight();
|
||||
viewport?.addEventListener("resize", updateViewportHeight);
|
||||
viewport?.addEventListener("scroll", updateViewportHeight);
|
||||
window.addEventListener("resize", updateViewportHeight);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateViewportHeight);
|
||||
viewport?.removeEventListener("scroll", updateViewportHeight);
|
||||
window.removeEventListener("resize", updateViewportHeight);
|
||||
};
|
||||
}, [pageRef]);
|
||||
|
||||
const sendResize = useCallback(
|
||||
(send: (message: string) => void, force = false) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
if (
|
||||
!force &&
|
||||
lastSizeRef.current.rows === terminal.rows &&
|
||||
lastSizeRef.current.cols === terminal.cols
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastSizeRef.current = { rows: terminal.rows, cols: terminal.cols };
|
||||
send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fit = useCallback(() => fitRef.current?.fit(), []);
|
||||
const focus = useCallback(() => terminalRef.current?.focus(), []);
|
||||
const blurInput = useCallback(
|
||||
() => terminalRef.current?.textarea?.blur(),
|
||||
[],
|
||||
);
|
||||
const reset = useCallback(() => terminalRef.current?.reset(), []);
|
||||
const clear = useCallback(() => terminalRef.current?.clear(), []);
|
||||
const write = useCallback(
|
||||
(data: Uint8Array) => terminalRef.current?.write(data),
|
||||
[],
|
||||
);
|
||||
const writeLine = useCallback(
|
||||
(line: string) => terminalRef.current?.writeln(line),
|
||||
[],
|
||||
);
|
||||
const dimensions = useCallback(() => {
|
||||
const terminal = terminalRef.current;
|
||||
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
|
||||
}, []);
|
||||
const forgetTitle = useCallback((terminalId: string) => {
|
||||
setSessionTitles((current) => {
|
||||
if (!(terminalId in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const onInput = useCallback(
|
||||
(handler: (data: string) => void) =>
|
||||
terminalRef.current?.onData(handler) ?? { dispose: () => {} },
|
||||
[],
|
||||
);
|
||||
const onResize = useCallback((handler: () => void) => {
|
||||
resizeListenersRef.current.add(handler);
|
||||
return { dispose: () => resizeListenersRef.current.delete(handler) };
|
||||
}, []);
|
||||
const scrollPages = useCallback(
|
||||
(pages: number) => terminalRef.current?.scrollPages(pages),
|
||||
[],
|
||||
);
|
||||
const copy = useCallback(async () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const text = terminal.hasSelection()
|
||||
? terminal.getSelection()
|
||||
: visibleTerminalText(terminal);
|
||||
if (!text) {
|
||||
toast.info("Nothing to copy");
|
||||
return;
|
||||
}
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
terminal.hasSelection() ? "Selection copied" : "Visible screen copied",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const paste = useCallback(async (writeData: (data: string) => void) => {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) writeData(text);
|
||||
terminalRef.current?.focus();
|
||||
} catch (error) {
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const changeFontSize = useCallback((delta: number) => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE;
|
||||
const next = Math.min(
|
||||
MAX_TERMINAL_FONT_SIZE,
|
||||
Math.max(MIN_TERMINAL_FONT_SIZE, current + delta),
|
||||
);
|
||||
if (next === current) return;
|
||||
terminal.options.fontSize = next;
|
||||
setFontSize(next);
|
||||
window.requestAnimationFrame(() => {
|
||||
fitRef.current?.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Wait for the font before creating xterm; this effect owns the instance and its observers.
|
||||
useEffect(() => {
|
||||
if (!hostRef.current || !fontFamily) return;
|
||||
const terminal = new XTerm({
|
||||
cursorBlink: true,
|
||||
convertEol: false,
|
||||
fontFamily,
|
||||
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
|
||||
lineHeight: 1.1,
|
||||
scrollbar: {
|
||||
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
|
||||
},
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: "#0b1117",
|
||||
foreground: "#e5e7eb",
|
||||
cursor: "#6ee7a8",
|
||||
scrollbarSliderBackground: "#52617099",
|
||||
scrollbarSliderHoverBackground: "#6b7c8dcc",
|
||||
scrollbarSliderActiveBackground: "#8294a6",
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.unicode.activeVersion = "11";
|
||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
||||
terminal.loadAddon(new WebLinksAddon());
|
||||
terminal.loadAddon(new ImageAddon({}));
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(hostRef.current);
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
const copiesTerminalSelection =
|
||||
event.type === "keydown" &&
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "KeyC";
|
||||
if (!copiesTerminalSelection) return true;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error("Clipboard access requires HTTPS or localhost");
|
||||
} else {
|
||||
void navigator.clipboard
|
||||
.writeText(terminal.getSelection())
|
||||
.catch((error) =>
|
||||
toast.error("Clipboard access was denied", {
|
||||
description: String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const titleChange = terminal.onTitleChange((title) => {
|
||||
const terminalId = activeTerminalIdRef.current;
|
||||
if (!terminalId) return;
|
||||
const normalized = title.trim();
|
||||
setSessionTitles((current) => {
|
||||
if (current[terminalId] === normalized) return current;
|
||||
if (!normalized) {
|
||||
const next = { ...current };
|
||||
delete next[terminalId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [terminalId]: normalized };
|
||||
});
|
||||
});
|
||||
fitAddon.fit();
|
||||
// Consumers subscribe only after this transition; the port methods stay stable.
|
||||
terminalRef.current = terminal;
|
||||
fitRef.current = fitAddon;
|
||||
setReady(true);
|
||||
|
||||
let resizeFrame = 0;
|
||||
let lastHostWidth = 0;
|
||||
let lastHostHeight = 0;
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.round(entry.contentRect.width);
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (width === lastHostWidth && height === lastHostHeight) return;
|
||||
lastHostWidth = width;
|
||||
lastHostHeight = height;
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
resizeListenersRef.current.forEach((handler) => handler());
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(hostRef.current);
|
||||
|
||||
return () => {
|
||||
titleChange.dispose();
|
||||
resizeObserver.disconnect();
|
||||
resizeListenersRef.current.clear();
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitRef.current = null;
|
||||
setReady(false);
|
||||
};
|
||||
}, [fontFamily, hostRef]);
|
||||
|
||||
const terminal = useMemo<TerminalPort>(
|
||||
() => ({
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
}),
|
||||
[
|
||||
clear,
|
||||
copy,
|
||||
dimensions,
|
||||
forgetTitle,
|
||||
fit,
|
||||
focus,
|
||||
blurInput,
|
||||
onInput,
|
||||
onResize,
|
||||
paste,
|
||||
reset,
|
||||
scrollPages,
|
||||
sendResize,
|
||||
changeFontSize,
|
||||
setActiveTerminalId,
|
||||
write,
|
||||
writeLine,
|
||||
ready,
|
||||
],
|
||||
);
|
||||
|
||||
return { terminal, fontSize, sessionTitles };
|
||||
}
|
||||
+103
-3
@@ -326,7 +326,7 @@
|
||||
|
||||
.terminal-page {
|
||||
display: flex;
|
||||
height: calc(100dvh - 2.5rem);
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
@@ -365,6 +365,9 @@
|
||||
gap: 0.4rem;
|
||||
height: 1.4rem;
|
||||
padding-inline: 0.45rem;
|
||||
border-color: rgb(255 255 255 / 14%);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: #cbd5e1;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -485,7 +488,6 @@
|
||||
|
||||
.terminal-tab time {
|
||||
flex: 0 0 auto;
|
||||
color: #7f8d9a;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@@ -575,6 +577,7 @@
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
order: 2;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -582,6 +585,87 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories-toggle[data-active] {
|
||||
color: #6ee7a8;
|
||||
background: rgb(110 231 168 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories {
|
||||
display: flex;
|
||||
order: 3;
|
||||
min-width: 0;
|
||||
min-height: 2.75rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.45rem max(0.35rem, env(safe-area-inset-bottom));
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
border-top: 1px solid rgb(255 255 255 / 9%);
|
||||
background: #101820;
|
||||
color: #cbd5e1;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.terminal-accessory-group {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessory-group + .terminal-accessory-group {
|
||||
padding-left: 0.35rem;
|
||||
border-left: 1px solid rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
width: auto;
|
||||
min-width: 2.4rem;
|
||||
height: 2rem;
|
||||
padding-inline: 0.55rem;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: rgb(255 255 255 / 4%);
|
||||
color: #cbd5e1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key:hover {
|
||||
background: rgb(255 255 255 / 9%);
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key[data-active] {
|
||||
border-color: rgb(110 231 168 / 48%);
|
||||
background: rgb(110 231 168 / 14%);
|
||||
color: #9af0bf;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--control {
|
||||
min-width: 2.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--wide {
|
||||
min-width: 3.15rem;
|
||||
}
|
||||
|
||||
.terminal-font-controls output {
|
||||
min-width: 1.5rem;
|
||||
color: #91a0ae;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-surface .xterm {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -606,7 +690,7 @@
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.terminal-page {
|
||||
height: calc(100dvh - 2.5rem);
|
||||
height: calc(var(--terminal-visual-viewport-height, 100dvh) - 2.5rem);
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
@@ -623,6 +707,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.terminal-accessories {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key {
|
||||
min-width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.terminal-accessories .terminal-key--icon {
|
||||
width: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Presence badges ──────────────────────────────────────────────────── */
|
||||
|
||||
.presence-dot {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "Wakey JetBrainsMono Nerd Font Mono";
|
||||
src: url("../assets/fonts/jetbrains-mono-nerd/JetBrainsMonoNerdFontMono-Regular.woff2")
|
||||
format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import "./terminalFonts.css";
|
||||
|
||||
const TERMINAL_WEB_FONT = "Wakey JetBrainsMono Nerd Font Mono";
|
||||
const TERMINAL_FALLBACK_FONTS =
|
||||
'"SFMono-Regular", Consolas, "Liberation Mono", monospace';
|
||||
|
||||
let terminalFontsPromise: Promise<string> | undefined;
|
||||
|
||||
/**
|
||||
* Resolves the terminal font stack only after browser glyph metrics are ready.
|
||||
* xterm caches those metrics when it opens, so a late webfont load permanently
|
||||
* misaligns cells for that terminal instance.
|
||||
*/
|
||||
export function loadTerminalFontFamily(): Promise<string> {
|
||||
terminalFontsPromise ??= document.fonts
|
||||
.load(`400 14px "${TERMINAL_WEB_FONT}"`, "\uE0B0")
|
||||
.then((faces) => {
|
||||
if (faces.length === 0) {
|
||||
throw new Error("terminal webfont was not registered");
|
||||
}
|
||||
return `"${TERMINAL_WEB_FONT}", ${TERMINAL_FALLBACK_FONTS}`;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
"Could not load terminal webfont; using system monospace",
|
||||
error,
|
||||
);
|
||||
return TERMINAL_FALLBACK_FONTS;
|
||||
});
|
||||
return terminalFontsPromise;
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"ignoreDeprecations": "5.0",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/vt100 updated: ea740d5ad1...60d0544b14.
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-agent"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
@@ -39,6 +39,6 @@ tracing-subscriber = { version = "0.3", features = [
|
||||
] }
|
||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||
url = "2"
|
||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.1" }
|
||||
vt100 = { package = "wakey-vt100", path = "../vendor/vt100", registry = "gitea", version = "=0.16.2-wakey.2" }
|
||||
wakey = { path = "..", registry = "gitea", version = "0" }
|
||||
wakey-core = { path = "../wakey-core", registry = "gitea", version = "0" }
|
||||
@@ -4,6 +4,11 @@ use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::protocol::{
|
||||
DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||
checked_terminal_session_ttl,
|
||||
};
|
||||
|
||||
pub const DEFAULT_CONFIG_PATH: &str = "/etc/wakey-agent/config.toml";
|
||||
pub const DEFAULT_PID_FILE: &str = "/var/run/wakey-agent.pid";
|
||||
const WAKEY_DHCP_LEASES_ENV: &str = "WAKEY_DHCP_LEASES";
|
||||
@@ -40,13 +45,21 @@ pub struct AgentConfig {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TerminalConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_terminal_shell")]
|
||||
pub shell: PathBuf,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub current_dir: Option<PathBuf>,
|
||||
#[serde(default = "default_terminal_max_sessions")]
|
||||
pub max_sessions: usize,
|
||||
/// Maximum lifetime of a PTY process. Zero disables automatic expiry.
|
||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
||||
pub session_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for TerminalConfig {
|
||||
@@ -54,7 +67,10 @@ impl Default for TerminalConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
shell: default_terminal_shell(),
|
||||
args: Vec::new(),
|
||||
current_dir: None,
|
||||
max_sessions: default_terminal_max_sessions(),
|
||||
session_ttl_seconds: default_terminal_session_ttl_seconds(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +152,17 @@ fn default_terminal_shell() -> PathBuf {
|
||||
}
|
||||
|
||||
const fn default_terminal_max_sessions() -> usize {
|
||||
2
|
||||
DEFAULT_TERMINAL_MAX_SESSIONS
|
||||
}
|
||||
|
||||
const fn default_terminal_session_ttl_seconds() -> u64 {
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
}
|
||||
|
||||
impl TerminalConfig {
|
||||
pub(crate) fn session_ttl(&self) -> Result<Option<std::time::Duration>> {
|
||||
checked_terminal_session_ttl(self.session_ttl_seconds).map_err(anyhow::Error::msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
@@ -161,8 +187,10 @@ pub fn apply_local_path_env_to_command(cmd: &mut std::process::Command, config:
|
||||
pub fn load_config(path: &Path) -> Result<AgentConfig> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read agent config {}", path.display()))?;
|
||||
toml::from_str(&content)
|
||||
.with_context(|| format!("failed to parse agent config {}", path.display()))
|
||||
let config: AgentConfig = toml::from_str(&content)
|
||||
.with_context(|| format!("failed to parse agent config {}", path.display()))?;
|
||||
config.terminal.session_ttl()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn save_config(path: &Path, config: &AgentConfig) -> Result<()> {
|
||||
@@ -249,7 +277,10 @@ mod tests {
|
||||
terminal: TerminalConfig {
|
||||
enabled: true,
|
||||
shell: "/bin/sh".into(),
|
||||
args: vec!["-l".into()],
|
||||
current_dir: Some("/tmp".into()),
|
||||
max_sessions: 2,
|
||||
session_ttl_seconds: 600,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -284,4 +315,64 @@ agent_token = "secret"
|
||||
);
|
||||
assert_eq!(config.terminal, TerminalConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_terminal_config_defaults_optional_command_fields() {
|
||||
let config: AgentConfig = toml::from_str(
|
||||
r#"
|
||||
server_url = "https://example.com"
|
||||
agent_id = "agent-1"
|
||||
agent_token = "secret"
|
||||
|
||||
[terminal]
|
||||
enabled = true
|
||||
shell = "/bin/sh"
|
||||
max_sessions = 2
|
||||
"#,
|
||||
)
|
||||
.expect("existing terminal config should parse");
|
||||
|
||||
assert!(config.terminal.args.is_empty());
|
||||
assert!(config.terminal.current_dir.is_none());
|
||||
assert_eq!(
|
||||
config.terminal.session_ttl_seconds,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_config_rejects_misspelled_fields() {
|
||||
let error = toml::from_str::<AgentConfig>(
|
||||
r#"
|
||||
server_url = "https://example.com"
|
||||
agent_id = "agent-1"
|
||||
agent_token = "secret"
|
||||
|
||||
[terminal]
|
||||
enabled = true
|
||||
max_session = 67
|
||||
"#,
|
||||
)
|
||||
.expect_err("unknown terminal fields must not silently use defaults");
|
||||
|
||||
assert!(error.to_string().contains("max_session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ttl_zero_is_unlimited_and_positive_is_exact() {
|
||||
let mut terminal = TerminalConfig {
|
||||
session_ttl_seconds: 0,
|
||||
..TerminalConfig::default()
|
||||
};
|
||||
assert_eq!(terminal.session_ttl().expect("unlimited TTL"), None);
|
||||
|
||||
terminal.session_ttl_seconds = 67;
|
||||
assert_eq!(
|
||||
terminal.session_ttl().expect("finite TTL"),
|
||||
Some(std::time::Duration::from_secs(67))
|
||||
);
|
||||
|
||||
terminal.session_ttl_seconds = u64::MAX;
|
||||
assert!(terminal.session_ttl().is_err());
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,10 @@ mod tests {
|
||||
terminal: crate::config::TerminalConfig {
|
||||
enabled: true,
|
||||
shell: "/bin/ash".into(),
|
||||
args: vec!["-l".into()],
|
||||
current_dir: Some("/root".into()),
|
||||
max_sessions: 2,
|
||||
session_ttl_seconds: 600,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use macaddr::MacAddr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
use wakey_core::parse::mac;
|
||||
use wakey_core::{
|
||||
Device, DeviceInventory, DhcpLeaseWithState, InterfaceSummary, InventoryQuery,
|
||||
@@ -65,10 +66,59 @@ pub enum AgentCapability {
|
||||
Terminal,
|
||||
}
|
||||
|
||||
pub const DEFAULT_TERMINAL_MAX_SESSIONS: usize = 2;
|
||||
pub const DEFAULT_TERMINAL_SESSION_TTL_SECONDS: u64 = 12 * 60 * 60;
|
||||
|
||||
/// Validates a terminal TTL against the platform's monotonic timer range.
|
||||
/// Zero is the explicit unlimited policy.
|
||||
pub fn checked_terminal_session_ttl(
|
||||
session_ttl_seconds: u64,
|
||||
) -> Result<Option<Duration>, &'static str> {
|
||||
if session_ttl_seconds == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let ttl = Duration::from_secs(session_ttl_seconds);
|
||||
Instant::now()
|
||||
.checked_add(ttl)
|
||||
.ok_or("terminal session TTL is too large for the platform timer")?;
|
||||
Ok(Some(ttl))
|
||||
}
|
||||
|
||||
const fn default_terminal_session_ttl_seconds() -> u64 {
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
}
|
||||
|
||||
/// Optional parameters attached to advertised agent capabilities.
|
||||
///
|
||||
/// Keep this separate from `AgentCapability`: the capability list remains a
|
||||
/// compact, backward-compatible feature check, while this object can grow as
|
||||
/// individual capabilities gain configurable limits or modes.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentCapabilityOptions {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<TerminalCapabilityOptions>,
|
||||
}
|
||||
|
||||
impl AgentCapabilityOptions {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.terminal.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TerminalCapabilityOptions {
|
||||
pub max_sessions: usize,
|
||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
||||
pub session_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentTerminalSession {
|
||||
pub terminal_id: TerminalId,
|
||||
pub created_at_unix: u64,
|
||||
/// The policy captured when this PTY was created. Zero means unlimited.
|
||||
#[serde(default = "default_terminal_session_ttl_seconds")]
|
||||
pub session_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -238,6 +288,8 @@ pub enum ClientMessage {
|
||||
agent_id: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
capabilities: Vec<AgentCapability>,
|
||||
#[serde(default, skip_serializing_if = "AgentCapabilityOptions::is_empty")]
|
||||
capability_options: AgentCapabilityOptions,
|
||||
},
|
||||
Auth {
|
||||
agent_id: String,
|
||||
@@ -384,6 +436,7 @@ mod tests {
|
||||
sessions: vec![AgentTerminalSession {
|
||||
terminal_id: TerminalId::new("term-1").expect("terminal id"),
|
||||
created_at_unix: 42,
|
||||
session_ttl_seconds: 600,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&inventory).expect("serialize terminal inventory");
|
||||
@@ -397,4 +450,53 @@ mod tests {
|
||||
let json = serde_json::to_string(&resume).expect("serialize terminal resume");
|
||||
assert!(json.contains("\"type\":\"resume_terminal\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_serializes_typed_capability_options() {
|
||||
let message = ClientMessage::Hello {
|
||||
agent_id: "router".into(),
|
||||
capabilities: vec![AgentCapability::Terminal],
|
||||
capability_options: AgentCapabilityOptions {
|
||||
terminal: Some(TerminalCapabilityOptions {
|
||||
max_sessions: 3,
|
||||
session_ttl_seconds: 600,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(message).expect("serialize hello");
|
||||
assert_eq!(value["capability_options"]["terminal"]["max_sessions"], 3);
|
||||
assert_eq!(
|
||||
value["capability_options"]["terminal"]["session_ttl_seconds"],
|
||||
600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_ttl_defaults_for_old_messages_and_preserves_explicit_zero() {
|
||||
let old_options: TerminalCapabilityOptions =
|
||||
serde_json::from_value(serde_json::json!({ "max_sessions": 2 }))
|
||||
.expect("deserialize old capability options");
|
||||
assert_eq!(
|
||||
old_options.session_ttl_seconds,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
);
|
||||
|
||||
let old_session: AgentTerminalSession = serde_json::from_value(serde_json::json!({
|
||||
"terminal_id": "term-old",
|
||||
"created_at_unix": 42
|
||||
}))
|
||||
.expect("deserialize old terminal inventory");
|
||||
assert_eq!(
|
||||
old_session.session_ttl_seconds,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS
|
||||
);
|
||||
|
||||
let unlimited: TerminalCapabilityOptions = serde_json::from_value(serde_json::json!({
|
||||
"max_sessions": 2,
|
||||
"session_ttl_seconds": 0
|
||||
}))
|
||||
.expect("deserialize unlimited capability options");
|
||||
assert_eq!(unlimited.session_ttl_seconds, 0);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ use tracing::{debug, error, info, info_span, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::dispatch::{dispatch_command, inventory_for_config};
|
||||
use crate::protocol::{AgentCapability, AgentCommand, ClientMessage, ErrorPayload, ServerMessage};
|
||||
use crate::protocol::{
|
||||
AgentCapability, AgentCapabilityOptions, AgentCommand, ClientMessage, ErrorPayload,
|
||||
ServerMessage, TerminalCapabilityOptions,
|
||||
};
|
||||
use crate::terminal::TerminalManager;
|
||||
|
||||
pub async fn run(config: AgentConfig) -> Result<()> {
|
||||
@@ -82,6 +85,15 @@ async fn run_once(
|
||||
&ClientMessage::Hello {
|
||||
agent_id: config.agent_id.clone(),
|
||||
capabilities: agent_capabilities(config),
|
||||
capability_options: AgentCapabilityOptions {
|
||||
terminal: config
|
||||
.terminal
|
||||
.enabled
|
||||
.then_some(TerminalCapabilityOptions {
|
||||
max_sessions: config.terminal.max_sessions.max(1),
|
||||
session_ttl_seconds: config.terminal.session_ttl_seconds,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
+457
-79
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::Duration;
|
||||
@@ -13,8 +13,9 @@ use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const MAX_TERMINAL_FRAME_BYTES: usize = 64 * 1024;
|
||||
const TERMINAL_SCROLLBACK_ROWS: usize = 5_000;
|
||||
const TERMINAL_SCROLLBACK_ROWS: usize = 1_000;
|
||||
const RELAY_INPUT_QUEUE: usize = 32;
|
||||
const RELAY_OUTPUT_QUEUE: usize = 32;
|
||||
const PTY_EXIT_DRAIN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const PROCESS_SIGNAL_GRACE: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -26,6 +27,94 @@ struct TerminalState {
|
||||
parser: vt100::Parser,
|
||||
}
|
||||
|
||||
/// Buffers ordered terminal output without letting a slow relay grow memory
|
||||
/// without bound. Once a frame is pending, the PTY read branch is paused until
|
||||
/// the relay channel has capacity; control input remains independently active.
|
||||
#[derive(Default)]
|
||||
struct RelayOutputBuffer {
|
||||
sender: Option<mpsc::Sender<Message>>,
|
||||
pending: VecDeque<Message>,
|
||||
snapshot_pending: bool,
|
||||
}
|
||||
|
||||
impl RelayOutputBuffer {
|
||||
fn connect(&mut self, sender: mpsc::Sender<Message>) {
|
||||
self.disconnect();
|
||||
self.sender = Some(sender);
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) {
|
||||
self.sender = None;
|
||||
self.pending.clear();
|
||||
self.snapshot_pending = false;
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.sender.is_some()
|
||||
}
|
||||
|
||||
fn has_pending(&self) -> bool {
|
||||
!self.pending.is_empty()
|
||||
}
|
||||
|
||||
fn pending_sender(&self) -> Option<mpsc::Sender<Message>> {
|
||||
self.has_pending().then(|| self.sender.clone()).flatten()
|
||||
}
|
||||
|
||||
fn queue_live(&mut self, frame: Message) {
|
||||
debug_assert!(
|
||||
self.pending.is_empty(),
|
||||
"PTY reads must pause while relay output is pending"
|
||||
);
|
||||
self.queue_frame(frame);
|
||||
}
|
||||
|
||||
fn queue_frame(&mut self, frame: Message) {
|
||||
let Some(sender) = self.sender.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !self.pending.is_empty() {
|
||||
self.pending.push_back(frame);
|
||||
return;
|
||||
}
|
||||
match sender.try_send(frame) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::error::TrySendError::Full(frame)) => {
|
||||
self.pending.push_back(frame);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => self.disconnect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_snapshot(&mut self, snapshot: Vec<u8>) {
|
||||
if self.sender.is_none() || self.snapshot_pending {
|
||||
return;
|
||||
}
|
||||
for chunk in snapshot.chunks(MAX_TERMINAL_FRAME_BYTES) {
|
||||
self.queue_frame(Message::Binary(chunk.to_vec().into()));
|
||||
if self.sender.is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.snapshot_pending = self.has_pending();
|
||||
}
|
||||
|
||||
fn send_reserved(&mut self, permit: mpsc::OwnedPermit<Message>) {
|
||||
let frame = self
|
||||
.pending
|
||||
.pop_front()
|
||||
.expect("relay permit requires pending output");
|
||||
permit.send(frame);
|
||||
if self.pending.is_empty() {
|
||||
self.snapshot_pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn live_sender(&self) -> Option<mpsc::Sender<Message>> {
|
||||
self.sender.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalState {
|
||||
fn new(rows: u16, cols: u16) -> Self {
|
||||
Self {
|
||||
@@ -52,6 +141,7 @@ struct ActiveTerminal {
|
||||
cancel: Option<oneshot::Sender<()>>,
|
||||
relay_credentials: mpsc::UnboundedSender<String>,
|
||||
created_at_unix: u64,
|
||||
session_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
/// Owns PTY workers independently of any individual control-plane connection.
|
||||
@@ -91,6 +181,7 @@ impl TerminalManager {
|
||||
anyhow::bail!("terminal capability is disabled");
|
||||
}
|
||||
validate_size(rows, cols)?;
|
||||
let session_ttl = config.terminal.session_ttl()?;
|
||||
|
||||
let terminal_key = terminal_id.to_string();
|
||||
let (cancel_tx, cancel_rx) = oneshot::channel();
|
||||
@@ -113,6 +204,7 @@ impl TerminalManager {
|
||||
cancel: Some(cancel_tx),
|
||||
relay_credentials: relay_tx.clone(),
|
||||
created_at_unix,
|
||||
session_ttl_seconds: config.terminal.session_ttl_seconds,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -122,7 +214,16 @@ impl TerminalManager {
|
||||
let active = Arc::downgrade(&self.active);
|
||||
let events = self.events.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = run_terminal(&config, &terminal_id, rows, cols, cancel_rx, relay_rx).await;
|
||||
let result = run_terminal(
|
||||
&config,
|
||||
&terminal_id,
|
||||
rows,
|
||||
cols,
|
||||
session_ttl,
|
||||
cancel_rx,
|
||||
relay_rx,
|
||||
)
|
||||
.await;
|
||||
// Inventory is authoritative. Remove the stopped worker before
|
||||
// notifying the control session, which may immediately resync it.
|
||||
remove_completed(&active, terminal_id.as_str());
|
||||
@@ -176,6 +277,7 @@ impl TerminalManager {
|
||||
.map(|terminal_id| AgentTerminalSession {
|
||||
terminal_id,
|
||||
created_at_unix: active.created_at_unix,
|
||||
session_ttl_seconds: active.session_ttl_seconds,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -210,11 +312,14 @@ async fn run_terminal(
|
||||
terminal_id: &TerminalId,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
session_ttl: Option<Duration>,
|
||||
mut cancel: oneshot::Receiver<()>,
|
||||
mut relay_credentials: mpsc::UnboundedReceiver<String>,
|
||||
) -> Result<()> {
|
||||
let terminal = match wakey::wakey_linux::terminal::TerminalPty::spawn(
|
||||
Path::new(&config.terminal.shell),
|
||||
&config.terminal.args,
|
||||
config.terminal.current_dir.as_deref(),
|
||||
rows,
|
||||
cols,
|
||||
) {
|
||||
@@ -225,24 +330,42 @@ async fn run_terminal(
|
||||
};
|
||||
let (mut reader, mut writer, mut child) = terminal.into_parts();
|
||||
let process_group = child.id();
|
||||
info!(terminal_id = %terminal_id, shell = %config.terminal.shell.display(), "terminal PTY ready");
|
||||
info!(
|
||||
terminal_id = %terminal_id,
|
||||
program = %config.terminal.shell.display(),
|
||||
args = ?config.terminal.args,
|
||||
current_dir = ?config.terminal.current_dir,
|
||||
"terminal PTY ready"
|
||||
);
|
||||
|
||||
let (relay_input_tx, mut relay_input_rx) = mpsc::channel(RELAY_INPUT_QUEUE);
|
||||
let mut relay_output: Option<mpsc::Sender<Message>> = None;
|
||||
let mut relay_output = RelayOutputBuffer::default();
|
||||
let mut relay_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||
let mut relay_generation = 0_u64;
|
||||
let mut terminal_state = TerminalState::new(rows, cols);
|
||||
let mut output = [0_u8; 16 * 1024];
|
||||
let mut requested_close = false;
|
||||
let mut ttl_expired = false;
|
||||
let mut observed_status = None;
|
||||
let mut drain_deadline: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
|
||||
let mut ttl_deadline = session_ttl.map(|ttl| Box::pin(tokio::time::sleep(ttl)));
|
||||
loop {
|
||||
let pending_output_sender = relay_output.pending_sender();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = &mut cancel => {
|
||||
requested_close = true;
|
||||
break;
|
||||
}
|
||||
_ = async {
|
||||
match ttl_deadline.as_mut() {
|
||||
Some(deadline) => deadline.as_mut().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => {
|
||||
ttl_expired = true;
|
||||
break;
|
||||
}
|
||||
status = child.wait(), if observed_status.is_none() => {
|
||||
observed_status = Some(status.context("failed waiting for terminal child")?);
|
||||
drain_deadline = Some(Box::pin(tokio::time::sleep(PTY_EXIT_DRAIN_TIMEOUT)));
|
||||
@@ -270,12 +393,7 @@ async fn run_terminal(
|
||||
terminal_state.resize(rows, cols);
|
||||
}
|
||||
RelayInput::Snapshot { generation } if generation == relay_generation => {
|
||||
if !send_terminal_snapshot(terminal_state.snapshot(), &mut relay_output) {
|
||||
if let Some(task) = relay_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
warn!(terminal_id = %terminal_id, "terminal relay output queue saturated during snapshot");
|
||||
}
|
||||
relay_output.queue_snapshot(terminal_state.snapshot());
|
||||
if let Err(err) = writer.refresh() {
|
||||
warn!(terminal_id = %terminal_id, error = %err, "terminal redraw signal failed");
|
||||
}
|
||||
@@ -285,10 +403,10 @@ async fn run_terminal(
|
||||
break;
|
||||
}
|
||||
RelayInput::Connected { generation, output } if generation == relay_generation => {
|
||||
relay_output = Some(output);
|
||||
relay_output.connect(output);
|
||||
}
|
||||
RelayInput::Disconnected { generation } if generation == relay_generation => {
|
||||
relay_output = None;
|
||||
relay_output.disconnect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -300,8 +418,8 @@ async fn run_terminal(
|
||||
}
|
||||
relay_generation = relay_generation.wrapping_add(1);
|
||||
let generation = relay_generation;
|
||||
let (output_tx, output_rx) = mpsc::channel(32);
|
||||
relay_output = None;
|
||||
let (output_tx, output_rx) = mpsc::channel(RELAY_OUTPUT_QUEUE);
|
||||
relay_output.disconnect();
|
||||
let config = config.clone();
|
||||
let terminal_id = terminal_id.clone();
|
||||
let relay_input_tx = relay_input_tx.clone();
|
||||
@@ -320,20 +438,23 @@ async fn run_terminal(
|
||||
let _ = relay_input_tx.send(RelayInput::Disconnected { generation }).await;
|
||||
}));
|
||||
}
|
||||
read = reader.read(&mut output) => {
|
||||
permit = reserve_relay_output(pending_output_sender), if relay_output.has_pending() => {
|
||||
match permit {
|
||||
Ok(permit) => relay_output.send_reserved(permit),
|
||||
Err(()) => {
|
||||
relay_output.disconnect();
|
||||
if let Some(task) = relay_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
read = reader.read(&mut output), if !relay_output.has_pending() => {
|
||||
match read {
|
||||
Ok(0) => break,
|
||||
Ok(count) => {
|
||||
terminal_state.process(&output[..count]);
|
||||
if !send_terminal_output(
|
||||
Message::Binary(output[..count].to_vec().into()),
|
||||
&mut relay_output,
|
||||
) {
|
||||
if let Some(task) = relay_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
warn!(terminal_id = %terminal_id, "terminal relay output queue saturated");
|
||||
}
|
||||
relay_output.queue_live(Message::Binary(output[..count].to_vec().into()));
|
||||
}
|
||||
// Linux PTY masters commonly report EIO after the slave closes.
|
||||
Err(err) if err.raw_os_error() == Some(5) => break,
|
||||
@@ -347,15 +468,15 @@ async fn run_terminal(
|
||||
Some(status) => status,
|
||||
None => terminate_process_group(&mut child, process_group).await?,
|
||||
};
|
||||
if let Some(output) = relay_output {
|
||||
let control = TerminalControl::Exited {
|
||||
if relay_output.is_connected() {
|
||||
let exit_frame = serde_json::to_string(&TerminalControl::Exited {
|
||||
exit_code: status.code(),
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string(&control) {
|
||||
let _ = output.try_send(Message::Text(text.into()));
|
||||
})
|
||||
.ok()
|
||||
.map(|text| Message::Text(text.into()));
|
||||
flush_relay_output(&mut relay_output, exit_frame, PTY_EXIT_DRAIN_TIMEOUT).await;
|
||||
}
|
||||
}
|
||||
info!(terminal_id = %terminal_id, exit_code = ?status.code(), requested_close, "terminal worker exited");
|
||||
info!(terminal_id = %terminal_id, exit_code = ?status.code(), requested_close, ttl_expired, "terminal worker exited");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -401,7 +522,7 @@ async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
|
||||
let (stream, _) = tokio_tungstenite::connect_async(ws_url.as_str())
|
||||
.await
|
||||
.context("failed to connect terminal relay websocket")?;
|
||||
let (mut sink, mut source) = stream.split();
|
||||
let (mut sink, source) = stream.split();
|
||||
send_json(
|
||||
&mut sink,
|
||||
&TerminalAgentHandshake::Auth {
|
||||
@@ -420,40 +541,61 @@ async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||
|
||||
let mut output = relay.output_rx;
|
||||
relay_loop(source, sink, relay.input, relay.generation, relay.output_rx).await
|
||||
}
|
||||
|
||||
async fn relay_loop<S, St>(
|
||||
mut source: St,
|
||||
mut sink: S,
|
||||
input: mpsc::Sender<RelayInput>,
|
||||
generation: u64,
|
||||
mut output: mpsc::Receiver<Message>,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: futures_util::Sink<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin,
|
||||
St: futures_util::Stream<
|
||||
Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>,
|
||||
> + Unpin,
|
||||
{
|
||||
let mut worker_stopped = false;
|
||||
let mut worker_stop_deadline: Option<tokio::time::Instant> = None;
|
||||
// Reserve at most one control so a full worker queue cannot block output.
|
||||
let mut pending_input = None;
|
||||
let mut close_after_input = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
incoming = source.next() => {
|
||||
_ = async {
|
||||
match worker_stop_deadline {
|
||||
Some(deadline) => tokio::time::sleep_until(deadline).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => break,
|
||||
incoming = source.next(), if !worker_stopped && pending_input.is_none() => {
|
||||
let Some(message) = incoming else { break; };
|
||||
match message.context("terminal relay websocket receive failed")? {
|
||||
Message::Binary(bytes) => {
|
||||
relay.input.send(RelayInput::Binary {
|
||||
generation: relay.generation,
|
||||
pending_input = Some(RelayInput::Binary {
|
||||
generation,
|
||||
bytes: bytes.to_vec(),
|
||||
}).await.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||
});
|
||||
}
|
||||
Message::Text(text) => match serde_json::from_str::<TerminalControl>(&text)
|
||||
.context("invalid terminal control frame")?
|
||||
{
|
||||
TerminalControl::Resize { rows, cols } => {
|
||||
relay.input.send(RelayInput::Resize {
|
||||
generation: relay.generation,
|
||||
pending_input = Some(RelayInput::Resize {
|
||||
generation,
|
||||
rows,
|
||||
cols,
|
||||
}).await
|
||||
.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||
});
|
||||
}
|
||||
TerminalControl::Snapshot => {
|
||||
relay.input.send(RelayInput::Snapshot {
|
||||
generation: relay.generation,
|
||||
}).await.map_err(|_| anyhow::anyhow!("terminal worker stopped"))?;
|
||||
pending_input = Some(RelayInput::Snapshot { generation });
|
||||
}
|
||||
TerminalControl::Close => {
|
||||
let _ = relay.input.send(RelayInput::Close {
|
||||
generation: relay.generation,
|
||||
}).await;
|
||||
break;
|
||||
pending_input = Some(RelayInput::Close { generation });
|
||||
close_after_input = true;
|
||||
}
|
||||
_ => anyhow::bail!("terminal control frame has invalid direction"),
|
||||
},
|
||||
@@ -465,36 +607,82 @@ async fn run_terminal_relay(relay: RelayConnection) -> Result<()> {
|
||||
Message::Frame(_) => {}
|
||||
}
|
||||
}
|
||||
permit = input.reserve(), if !worker_stopped && pending_input.is_some() => {
|
||||
match permit {
|
||||
Ok(permit) => {
|
||||
permit.send(pending_input.take().expect("pending terminal input"));
|
||||
if close_after_input {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
worker_stopped = true;
|
||||
pending_input = None;
|
||||
worker_stop_deadline =
|
||||
Some(tokio::time::Instant::now() + PTY_EXIT_DRAIN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
outgoing = output.recv() => {
|
||||
let Some(message) = outgoing else { break; };
|
||||
if let Some(deadline) = worker_stop_deadline {
|
||||
tokio::time::timeout_at(deadline, sink.send(message))
|
||||
.await
|
||||
.context("terminal relay output drain timed out")?
|
||||
.context("failed to send terminal relay output")?;
|
||||
} else {
|
||||
sink.send(message).await.context("failed to send terminal relay output")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_terminal_output(frame: Message, relay: &mut Option<mpsc::Sender<Message>>) -> bool {
|
||||
if let Some(tx) = relay.as_ref()
|
||||
&& tx.try_send(frame).is_err()
|
||||
{
|
||||
*relay = None;
|
||||
return false;
|
||||
async fn reserve_relay_output(
|
||||
sender: Option<mpsc::Sender<Message>>,
|
||||
) -> Result<mpsc::OwnedPermit<Message>, ()> {
|
||||
match sender {
|
||||
Some(sender) => sender.reserve_owned().await.map_err(|_| ()),
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn send_terminal_snapshot(snapshot: Vec<u8>, relay: &mut Option<mpsc::Sender<Message>>) -> bool {
|
||||
let Some(tx) = relay.as_ref() else {
|
||||
return true;
|
||||
/// Flushes frames the select loop left pending, then sends the exit lifecycle
|
||||
/// frame behind them, bounded by `deadline`: a relay that stays saturated past
|
||||
/// it drops the backlog rather than holding worker shutdown open. Queuing the
|
||||
/// exit frame without this would drop it together with the pending buffer when
|
||||
/// the worker returns.
|
||||
async fn flush_relay_output(
|
||||
relay_output: &mut RelayOutputBuffer,
|
||||
exit_frame: Option<Message>,
|
||||
deadline: Duration,
|
||||
) {
|
||||
let _ = tokio::time::timeout(deadline, async {
|
||||
while relay_output.has_pending() {
|
||||
let sender = relay_output.pending_sender();
|
||||
match reserve_relay_output(sender).await {
|
||||
Ok(permit) => relay_output.send_reserved(permit),
|
||||
Err(()) => {
|
||||
relay_output.disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(frame) = exit_frame else {
|
||||
return;
|
||||
};
|
||||
for chunk in snapshot.chunks(MAX_TERMINAL_FRAME_BYTES) {
|
||||
if tx.try_send(Message::Binary(chunk.to_vec().into())).is_err() {
|
||||
*relay = None;
|
||||
return false;
|
||||
let sender = relay_output
|
||||
.pending_sender()
|
||||
.or_else(|| relay_output.live_sender());
|
||||
match reserve_relay_output(sender).await {
|
||||
Ok(permit) => {
|
||||
permit.send(frame);
|
||||
}
|
||||
Err(()) => relay_output.disconnect(),
|
||||
}
|
||||
true
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
@@ -503,6 +691,7 @@ async fn run_terminal(
|
||||
_terminal_id: &TerminalId,
|
||||
_rows: u16,
|
||||
_cols: u16,
|
||||
_session_ttl: Option<Duration>,
|
||||
_cancel: oneshot::Receiver<()>,
|
||||
_relay_credentials: mpsc::UnboundedReceiver<String>,
|
||||
) -> Result<()> {
|
||||
@@ -599,6 +788,7 @@ mod tests {
|
||||
cancel: Some(cancel),
|
||||
relay_credentials,
|
||||
created_at_unix: 42,
|
||||
session_ttl_seconds: 600,
|
||||
},
|
||||
)]))),
|
||||
max_sessions: 2,
|
||||
@@ -652,6 +842,7 @@ mod tests {
|
||||
cancel: Some(cancel),
|
||||
relay_credentials,
|
||||
created_at_unix: 42,
|
||||
session_ttl_seconds: 600,
|
||||
},
|
||||
)])));
|
||||
let manager = TerminalManager {
|
||||
@@ -680,6 +871,102 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exit_frame_is_delivered_after_saturated_relay_drains() {
|
||||
let (tx, mut rx) = mpsc::channel(RELAY_OUTPUT_QUEUE);
|
||||
let mut relay_output = RelayOutputBuffer::default();
|
||||
relay_output.connect(tx);
|
||||
|
||||
let total = RELAY_OUTPUT_QUEUE * 2;
|
||||
for i in 0..total {
|
||||
relay_output.queue_frame(Message::Text(format!("out-{i}").into()));
|
||||
}
|
||||
assert!(relay_output.has_pending());
|
||||
|
||||
let collected = tokio::spawn(async move {
|
||||
let mut frames = Vec::new();
|
||||
while let Some(frame) = rx.recv().await {
|
||||
frames.push(frame);
|
||||
}
|
||||
frames
|
||||
});
|
||||
|
||||
let exit_frame = serde_json::to_string(&TerminalControl::Exited { exit_code: Some(0) })
|
||||
.expect("exit frame encodes");
|
||||
flush_relay_output(
|
||||
&mut relay_output,
|
||||
Some(Message::Text(exit_frame.clone().into())),
|
||||
PTY_EXIT_DRAIN_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
relay_output.disconnect();
|
||||
|
||||
let mut frames = collected.await.expect("relay reader completes");
|
||||
assert_eq!(frames.len(), total + 1);
|
||||
let Some(Message::Text(last)) = frames.pop() else {
|
||||
panic!("last frame should be the exit control frame");
|
||||
};
|
||||
assert_eq!(last.as_str(), exit_frame);
|
||||
for (i, frame) in frames.iter().enumerate() {
|
||||
let Message::Text(payload) = frame else {
|
||||
panic!("expected text frame at position {i}, got {frame:?}");
|
||||
};
|
||||
assert_eq!(payload.as_str(), format!("out-{i}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_drains_final_output_after_worker_stops_during_concurrent_controls() {
|
||||
let (input, input_rx) = mpsc::channel(RELAY_INPUT_QUEUE);
|
||||
drop(input_rx);
|
||||
let (output_tx, output_rx) = mpsc::channel(RELAY_OUTPUT_QUEUE);
|
||||
output_tx
|
||||
.send(Message::Binary(vec![1, 2, 3].into()))
|
||||
.await
|
||||
.expect("live output queues");
|
||||
let exit_frame = serde_json::to_string(&TerminalControl::Exited { exit_code: Some(0) })
|
||||
.expect("exit frame encodes");
|
||||
output_tx
|
||||
.send(Message::Text(exit_frame.clone().into()))
|
||||
.await
|
||||
.expect("exit output queues");
|
||||
drop(output_tx);
|
||||
|
||||
let source = futures_util::stream::iter([
|
||||
Ok(Message::Binary(vec![4].into())),
|
||||
Ok(Message::Text(
|
||||
serde_json::to_string(&TerminalControl::Resize {
|
||||
rows: 30,
|
||||
cols: 100,
|
||||
})
|
||||
.expect("resize encodes")
|
||||
.into(),
|
||||
)),
|
||||
Ok(Message::Text(
|
||||
serde_json::to_string(&TerminalControl::Snapshot)
|
||||
.expect("snapshot encodes")
|
||||
.into(),
|
||||
)),
|
||||
]);
|
||||
let delivered = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = futures_util::sink::unfold(delivered.clone(), |delivered, frame| async move {
|
||||
delivered.lock().expect("delivered frames").push(frame);
|
||||
Ok::<_, tokio_tungstenite::tungstenite::Error>(delivered)
|
||||
});
|
||||
|
||||
relay_loop(source, Box::pin(sink), input, 7, output_rx)
|
||||
.await
|
||||
.expect("relay drains after worker completion");
|
||||
|
||||
let delivered = delivered.lock().expect("delivered frames");
|
||||
assert_eq!(delivered.len(), 2);
|
||||
assert_eq!(delivered[0].clone().into_data(), vec![1, 2, 3]);
|
||||
let Message::Text(exit) = &delivered[1] else {
|
||||
panic!("exit control should follow final bytes");
|
||||
};
|
||||
assert_eq!(exit.as_str(), exit_frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_rejects_invalid_initial_size_before_spawning() {
|
||||
let mut config = crate::config::DEFAULT_CONFIG.clone();
|
||||
@@ -694,6 +981,34 @@ mod tests {
|
||||
assert!(manager.active.lock().expect("terminal manager").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn local_ttl_terminates_pty_without_control_plane_close() {
|
||||
let mut config = crate::config::DEFAULT_CONFIG.clone();
|
||||
config.terminal.enabled = true;
|
||||
config.terminal.shell = "/bin/sh".into();
|
||||
config.terminal.args = vec!["-c".into(), "sleep 30".into()];
|
||||
let terminal_id = TerminalId::new("ttl-test").expect("terminal id");
|
||||
let (_cancel, cancel_rx) = oneshot::channel();
|
||||
let (_relay, relay_rx) = mpsc::unbounded_channel();
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
run_terminal(
|
||||
&config,
|
||||
&terminal_id,
|
||||
24,
|
||||
80,
|
||||
Some(Duration::from_millis(20)),
|
||||
cancel_rx,
|
||||
relay_rx,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("local TTL should stop the PTY promptly")
|
||||
.expect("TTL cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reconstructs_screen_content_and_cursor() {
|
||||
let mut state = TerminalState::new(24, 80);
|
||||
@@ -829,13 +1144,19 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn snapshot_is_split_at_the_terminal_frame_limit() {
|
||||
let snapshot = vec![7; MAX_TERMINAL_FRAME_BYTES * 2 + 1];
|
||||
let (tx, mut rx) = mpsc::channel(3);
|
||||
let mut relay = Some(tx);
|
||||
|
||||
assert!(send_terminal_snapshot(snapshot.clone(), &mut relay));
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(tx);
|
||||
relay.queue_snapshot(snapshot.clone());
|
||||
|
||||
let mut restored = Vec::new();
|
||||
for _ in 0..3 {
|
||||
if rx.is_empty() {
|
||||
let permit = reserve_relay_output(relay.pending_sender())
|
||||
.await
|
||||
.expect("relay remains connected");
|
||||
relay.send_reserved(permit);
|
||||
}
|
||||
let Message::Binary(chunk) = rx.recv().await.expect("snapshot chunk") else {
|
||||
panic!("snapshot chunks must be binary");
|
||||
};
|
||||
@@ -843,23 +1164,80 @@ mod tests {
|
||||
restored.extend_from_slice(&chunk);
|
||||
}
|
||||
assert_eq!(restored, snapshot);
|
||||
assert!(!relay.has_pending());
|
||||
assert!(relay.is_connected());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saturated_relay_output_stays_attached_without_dropping() {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(tx);
|
||||
relay.queue_live(Message::Binary(vec![1].into()));
|
||||
// This read discovers saturation. The worker retains it, then pauses
|
||||
// further PTY reads until relay capacity becomes available.
|
||||
relay.queue_live(Message::Binary(vec![2].into()));
|
||||
|
||||
assert!(relay.is_connected());
|
||||
assert!(relay.has_pending());
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("original queued frame").into_data(),
|
||||
vec![1]
|
||||
);
|
||||
let permit = reserve_relay_output(relay.pending_sender())
|
||||
.await
|
||||
.expect("relay remains connected");
|
||||
relay.send_reserved(permit);
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("backpressured frame").into_data(),
|
||||
vec![2]
|
||||
);
|
||||
assert!(!relay.has_pending());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_waits_behind_pending_live_output() {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(tx);
|
||||
relay.queue_live(Message::Binary(vec![1].into()));
|
||||
relay.queue_live(Message::Binary(vec![2].into()));
|
||||
relay.queue_snapshot(vec![3]);
|
||||
|
||||
let mut received = Vec::new();
|
||||
for _ in 0..3 {
|
||||
if rx.is_empty() {
|
||||
let permit = reserve_relay_output(relay.pending_sender())
|
||||
.await
|
||||
.expect("relay remains connected");
|
||||
relay.send_reserved(permit);
|
||||
}
|
||||
received.extend(rx.recv().await.expect("ordered relay frame").into_data());
|
||||
}
|
||||
assert_eq!(received, vec![1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturated_relay_output_detaches_without_waiting() {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
tx.try_send(Message::Binary(vec![1].into()))
|
||||
.expect("prefill relay queue");
|
||||
let mut relay = Some(tx);
|
||||
fn replacement_relay_does_not_receive_old_pending_output() {
|
||||
let (old_tx, mut old_rx) = mpsc::channel(1);
|
||||
let (new_tx, mut new_rx) = mpsc::channel(1);
|
||||
let mut relay = RelayOutputBuffer::default();
|
||||
relay.connect(old_tx);
|
||||
relay.queue_live(Message::Binary(vec![1].into()));
|
||||
relay.queue_live(Message::Binary(vec![2].into()));
|
||||
|
||||
relay.connect(new_tx);
|
||||
relay.queue_live(Message::Binary(vec![3].into()));
|
||||
|
||||
assert!(!send_terminal_output(
|
||||
Message::Binary(vec![2].into()),
|
||||
&mut relay,
|
||||
));
|
||||
assert!(relay.is_none());
|
||||
assert_eq!(
|
||||
rx.try_recv().expect("original queued frame").into_data(),
|
||||
old_rx.try_recv().expect("old queued frame").into_data(),
|
||||
vec![1]
|
||||
);
|
||||
assert!(old_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
new_rx.try_recv().expect("new relay frame").into_data(),
|
||||
vec![3]
|
||||
);
|
||||
assert!(!relay.has_pending());
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO audit_events\n (event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,\n event_type, outcome, latency_ms, message, metadata_json)\n VALUES (?1, ?2, ?3, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6ce45cc94cbfdd948aad5fb986f9b5976c79bb63759bc9ecff1a7873e2d70137"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b324587b94ee2e98dc8eff5f6735303a2784b74f49068ce8e946cedcd8d12c4e"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd1666cea8840df04ddcdc8a0c42b9b7771f9bf63c45613c4b6f8b86be479d70"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM audit_events WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ca612fee8c80f815b33dbc088cc7e0a7552f1f4e38390959e14ea5cc765bf2c7"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM alert_transitions WHERE ts_unix < ?1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ce850a32c8702595bbc3207bf86d53209c48be145562192b3a8f84903d39eac1"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "COUNT(*)",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dc7056b7421dcc83648cbd360273ebcce1156ce0f7da2d39f054025ab9b66b6b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO alert_transitions\n (transition_key, transition_id, ts_unix, alert_id, kind, agent_id,\n from_status, to_status, message, metadata_json)\n VALUES (?1, ?2, ?3, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e83a43217a3bfaacd2f5e76df1fd05447558646fb2b47963751a7ab53b3a99b6"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-control-plane"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::Json;
|
||||
@@ -7,12 +8,14 @@ use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Deserialize;
|
||||
use tokio::select;
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
use tokio::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::runtime::AppState;
|
||||
use crate::state::{AlertState, AuditEvent, AuditEventFilter};
|
||||
use crate::state::{AlertState, AlertTransition, AuditEvent, AuditEventFilter};
|
||||
|
||||
struct AlertBuildContext<'a> {
|
||||
now_unix: u64,
|
||||
@@ -26,21 +29,14 @@ struct AlertBuildContext<'a> {
|
||||
enroll_rejected_threshold: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ActiveAlertsQuery {
|
||||
pub lookback_seconds: Option<u64>,
|
||||
pub timeout_threshold: Option<u64>,
|
||||
pub auth_rejected_threshold: Option<u64>,
|
||||
pub enroll_rejected_threshold: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AlertHistoryQuery {
|
||||
pub since_unix: Option<u64>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AlertRuleConfig {
|
||||
pub lookback_seconds: Option<u64>,
|
||||
pub timeout_threshold: Option<u64>,
|
||||
@@ -48,25 +44,255 @@ pub struct AlertRuleConfig {
|
||||
pub enroll_rejected_threshold: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn active_alerts(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ActiveAlertsQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let alerts = evaluate_alerts(
|
||||
&state,
|
||||
AlertRuleConfig {
|
||||
lookback_seconds: query.lookback_seconds,
|
||||
timeout_threshold: query.timeout_threshold,
|
||||
auth_rejected_threshold: query.auth_rejected_threshold,
|
||||
enroll_rejected_threshold: query.enroll_rejected_threshold,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||
warn!(error = %err, "failed to sync alert transitions");
|
||||
impl Default for AlertRuleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
lookback_seconds: Some(900),
|
||||
timeout_threshold: Some(3),
|
||||
auth_rejected_threshold: Some(3),
|
||||
enroll_rejected_threshold: Some(5),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AlertRuleConfig {
|
||||
fn validate(&self) -> Result<(), ApiError> {
|
||||
let defaults = Self::default();
|
||||
let overrides = [
|
||||
(
|
||||
"lookback_seconds",
|
||||
self.lookback_seconds,
|
||||
defaults.lookback_seconds,
|
||||
),
|
||||
(
|
||||
"timeout_threshold",
|
||||
self.timeout_threshold,
|
||||
defaults.timeout_threshold,
|
||||
),
|
||||
(
|
||||
"auth_rejected_threshold",
|
||||
self.auth_rejected_threshold,
|
||||
defaults.auth_rejected_threshold,
|
||||
),
|
||||
(
|
||||
"enroll_rejected_threshold",
|
||||
self.enroll_rejected_threshold,
|
||||
defaults.enroll_rejected_threshold,
|
||||
),
|
||||
];
|
||||
if let Some((field, _, _)) = overrides
|
||||
.into_iter()
|
||||
.find(|(_, requested, default)| requested.is_some() && requested != default)
|
||||
{
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unsupported_alert_override",
|
||||
format!("custom alert {field} is not supported; omit it or use its default value"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AlertsSnapshot {
|
||||
pub ts_unix: u64,
|
||||
pub alerts: Vec<AlertState>,
|
||||
pub recent_transitions: Vec<AlertTransition>,
|
||||
}
|
||||
|
||||
/// Holds the latest alert snapshot computed by the background evaluator and
|
||||
/// broadcasts each new snapshot to connected `alerts/ws` subscribers.
|
||||
#[derive(Clone)]
|
||||
pub struct AlertsHub {
|
||||
tx: broadcast::Sender<String>,
|
||||
status: Arc<RwLock<HubStatus>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HubStatus {
|
||||
latest_payload: Option<String>,
|
||||
latest_alerts: Vec<AlertState>,
|
||||
last_success_unix: Option<u64>,
|
||||
failure: Option<String>,
|
||||
last_health_available: Option<bool>,
|
||||
}
|
||||
|
||||
const ALERT_SNAPSHOT_MAX_AGE_SECS: u64 = 15;
|
||||
|
||||
impl Default for AlertsHub {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AlertsHub {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(16);
|
||||
Self {
|
||||
tx,
|
||||
status: Arc::new(RwLock::new(HubStatus::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<String> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn latest_alerts(&self) -> Vec<AlertState> {
|
||||
self.status.read().await.latest_alerts.clone()
|
||||
}
|
||||
|
||||
async fn mark_unavailable(&self, reason: impl Into<String>) {
|
||||
let mut status = self.status.write().await;
|
||||
if status.failure.is_some() {
|
||||
return;
|
||||
}
|
||||
status.failure = Some(reason.into());
|
||||
let reason = status.failure.as_deref().unwrap_or_default().to_owned();
|
||||
status.last_health_available = Some(false);
|
||||
self.broadcast_health(false, Some(reason));
|
||||
}
|
||||
|
||||
async fn unavailable_reason(&self) -> Option<String> {
|
||||
let status = self.status.read().await;
|
||||
evaluator_unavailable_reason(&status, now_unix())
|
||||
}
|
||||
|
||||
async fn resync_payloads(&self) -> (String, Option<String>) {
|
||||
let status = self.status.read().await;
|
||||
(
|
||||
encode_health(evaluator_unavailable_reason(&status, now_unix())),
|
||||
status.latest_payload.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn broadcast_health_if_changed(&self) {
|
||||
let mut status = self.status.write().await;
|
||||
if status.last_success_unix.is_none() || status.failure.is_some() {
|
||||
return;
|
||||
}
|
||||
let reason = evaluator_unavailable_reason(&status, now_unix());
|
||||
let available = reason.is_none();
|
||||
if status.last_health_available == Some(available) {
|
||||
return;
|
||||
}
|
||||
status.last_health_available = Some(available);
|
||||
self.broadcast_health(available, reason);
|
||||
}
|
||||
|
||||
fn broadcast_health(&self, available: bool, reason: Option<String>) {
|
||||
let _ = self.tx.send(encode_health_message(available, reason));
|
||||
}
|
||||
|
||||
pub async fn publish(&self, snapshot: AlertsSnapshot) {
|
||||
let payload = serde_json::json!({
|
||||
"type": "alerts_snapshot",
|
||||
"ts_unix": snapshot.ts_unix,
|
||||
"alerts": snapshot.alerts,
|
||||
"recent_transitions": snapshot.recent_transitions,
|
||||
});
|
||||
let Ok(encoded) = serde_json::to_string(&payload) else {
|
||||
warn!("failed to encode alerts snapshot payload");
|
||||
return;
|
||||
};
|
||||
let mut status = self.status.write().await;
|
||||
let was_unavailable = evaluator_unavailable_reason(&status, now_unix()).is_some();
|
||||
status.latest_alerts = snapshot.alerts;
|
||||
status.latest_payload = Some(encoded.clone());
|
||||
status.last_success_unix = Some(snapshot.ts_unix);
|
||||
status.failure = None;
|
||||
let available = evaluator_unavailable_reason(&status, now_unix()).is_none();
|
||||
if available {
|
||||
status.last_health_available = Some(true);
|
||||
}
|
||||
if was_unavailable && available {
|
||||
self.broadcast_health(true, None);
|
||||
}
|
||||
let _ = self.tx.send(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluator_unavailable_reason(status: &HubStatus, now: u64) -> Option<String> {
|
||||
if let Some(failure) = &status.failure {
|
||||
return Some(failure.clone());
|
||||
}
|
||||
match status.last_success_unix {
|
||||
Some(ts) if now.saturating_sub(ts) <= ALERT_SNAPSHOT_MAX_AGE_SECS => None,
|
||||
Some(_) => Some("alert evaluator snapshot is stale".into()),
|
||||
None => Some("alert evaluator has not produced a snapshot yet".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_health(reason: Option<String>) -> String {
|
||||
encode_health_message(reason.is_none(), reason)
|
||||
}
|
||||
|
||||
fn encode_health_message(available: bool, reason: Option<String>) -> String {
|
||||
let mut message = serde_json::json!({
|
||||
"type": "alerts_health",
|
||||
"available": available,
|
||||
});
|
||||
if let Some(reason) = reason {
|
||||
message["reason"] = serde_json::Value::String(reason);
|
||||
}
|
||||
message.to_string()
|
||||
}
|
||||
|
||||
/// Background task that owns alert evaluation: on a fixed ticker it recomputes
|
||||
/// the snapshot, persists transitions, and publishes to subscribers so neither
|
||||
/// the GET endpoint nor individual WS clients evaluate alerts themselves.
|
||||
pub async fn run_alert_evaluator(state: AppState) {
|
||||
let config = AlertRuleConfig::default();
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let alerts = match evaluate_alerts(&state, &config).await {
|
||||
Ok(alerts) => alerts,
|
||||
Err(err) => {
|
||||
warn!(code = %err.code, "failed to evaluate alerts in background task");
|
||||
state.alerts.mark_unavailable(err.message.clone()).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||
warn!(error = %err, "failed to sync alert transitions in background task");
|
||||
}
|
||||
|
||||
let recent_transitions = match state.store.list_alert_transitions(None, 20).await {
|
||||
Ok(history) => history,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to load alert transition history for snapshot");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
state
|
||||
.alerts
|
||||
.publish(AlertsSnapshot {
|
||||
ts_unix: now_unix(),
|
||||
alerts,
|
||||
recent_transitions,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn active_alerts(
|
||||
State(state): State<AppState>,
|
||||
Query(config): Query<AlertRuleConfig>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
config.validate()?;
|
||||
if let Some(reason) = state.alerts.unavailable_reason().await {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"alerts_unavailable",
|
||||
reason,
|
||||
));
|
||||
}
|
||||
let alerts = state.alerts.latest_alerts().await;
|
||||
Ok((StatusCode::OK, Json(alerts)))
|
||||
}
|
||||
|
||||
@@ -94,64 +320,62 @@ pub async fn alert_history(
|
||||
pub async fn alerts_stream(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ActiveAlertsQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let config = AlertRuleConfig {
|
||||
lookback_seconds: query.lookback_seconds,
|
||||
timeout_threshold: query.timeout_threshold,
|
||||
auth_rejected_threshold: query.auth_rejected_threshold,
|
||||
enroll_rejected_threshold: query.enroll_rejected_threshold,
|
||||
};
|
||||
ws.on_upgrade(move |socket| alerts_stream_socket(state, socket, config))
|
||||
ws.on_upgrade(move |socket| alerts_stream_socket(state, socket))
|
||||
}
|
||||
|
||||
async fn alerts_stream_socket(state: AppState, mut socket: WebSocket) {
|
||||
let mut rx = state.alerts.subscribe();
|
||||
let mut freshness_tick = tokio::time::interval(Duration::from_secs(1));
|
||||
freshness_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let (health, snapshot) = state.alerts.resync_payloads().await;
|
||||
if socket.send(Message::Text(health.into())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if let Some(encoded) = snapshot
|
||||
&& socket.send(Message::Text(encoded.into())).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
async fn alerts_stream_socket(state: AppState, mut socket: WebSocket, config: AlertRuleConfig) {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let alerts = match evaluate_alerts(&state, config.clone()).await {
|
||||
Ok(alerts) => alerts,
|
||||
Err(err) => {
|
||||
warn!(code = %err.code, "failed to evaluate alerts for stream");
|
||||
continue;
|
||||
select! {
|
||||
incoming = socket.recv() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(_)) => {}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = state.store.sync_alert_transitions(&alerts).await {
|
||||
warn!(error = %err, "failed to sync alert transitions in stream");
|
||||
}
|
||||
|
||||
let history = match state.store.list_alert_transitions(None, 20).await {
|
||||
Ok(h) => h,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to load alert transition history for stream");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"type": "alerts_snapshot",
|
||||
"ts_unix": now_unix(),
|
||||
"alerts": alerts,
|
||||
"recent_transitions": history,
|
||||
});
|
||||
let encoded = match serde_json::to_string(&payload) {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to encode alerts stream payload");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
update = rx.recv() => {
|
||||
match update {
|
||||
Ok(encoded) => {
|
||||
if socket.send(Message::Text(encoded.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
let (health, snapshot) = state.alerts.resync_payloads().await;
|
||||
if socket.send(Message::Text(health.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if let Some(encoded) = snapshot
|
||||
&& socket.send(Message::Text(encoded.into())).await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
_ = freshness_tick.tick() => state.alerts.broadcast_health_if_changed().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn evaluate_alerts(
|
||||
state: &AppState,
|
||||
config: AlertRuleConfig,
|
||||
config: &AlertRuleConfig,
|
||||
) -> Result<Vec<AlertState>, ApiError> {
|
||||
let lookback_seconds = config.lookback_seconds.unwrap_or(900).clamp(60, 86_400);
|
||||
let timeout_threshold = config.timeout_threshold.unwrap_or(3).max(1);
|
||||
@@ -161,7 +385,14 @@ async fn evaluate_alerts(
|
||||
let now = now_unix();
|
||||
let since_unix = now.saturating_sub(lookback_seconds);
|
||||
|
||||
let enrolled_agents = state.store.list_agents().await;
|
||||
let enrolled_agents = state.store.list_agents().await.map_err(|err| {
|
||||
warn!(error = %err, "failed listing agents for alert evaluation");
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"alerts_query_failed",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
let connected_agents = state
|
||||
.sessions
|
||||
.read()
|
||||
@@ -359,9 +590,11 @@ fn now_unix() -> u64 {
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::state::AuditEvent;
|
||||
|
||||
use super::{AlertBuildContext, build_alerts};
|
||||
use super::{AlertBuildContext, AlertRuleConfig, AlertsHub, AlertsSnapshot, build_alerts};
|
||||
|
||||
fn event(agent_id: Option<&str>, event_type: &str, outcome: &str, ts_unix: u64) -> AuditEvent {
|
||||
AuditEvent {
|
||||
@@ -450,4 +683,146 @@ mod tests {
|
||||
.any(|a| a.kind == "enroll_reject_spike" && a.agent_id.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_rule_config_accepts_omitted_and_default_values() {
|
||||
AlertRuleConfig::default().validate().unwrap();
|
||||
AlertRuleConfig {
|
||||
lookback_seconds: None,
|
||||
timeout_threshold: None,
|
||||
auth_rejected_threshold: None,
|
||||
enroll_rejected_threshold: None,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_rule_config_rejects_custom_overrides() {
|
||||
let configs = [
|
||||
AlertRuleConfig {
|
||||
lookback_seconds: Some(300),
|
||||
..Default::default()
|
||||
},
|
||||
AlertRuleConfig {
|
||||
timeout_threshold: Some(4),
|
||||
..Default::default()
|
||||
},
|
||||
AlertRuleConfig {
|
||||
auth_rejected_threshold: Some(4),
|
||||
..Default::default()
|
||||
},
|
||||
AlertRuleConfig {
|
||||
enroll_rejected_threshold: Some(6),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
for config in configs {
|
||||
let error = config.validate().unwrap_err();
|
||||
assert_eq!(error.status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error.code, "unsupported_alert_override");
|
||||
assert!(error.message.contains("not supported"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_reports_initial_failure_and_recovers_on_snapshot() {
|
||||
let hub = AlertsHub::new();
|
||||
assert!(hub.unavailable_reason().await.is_some());
|
||||
let mut updates = hub.subscribe();
|
||||
|
||||
hub.mark_unavailable("store query failed").await;
|
||||
assert_eq!(
|
||||
hub.unavailable_reason().await.as_deref(),
|
||||
Some("store query failed")
|
||||
);
|
||||
let failure: serde_json::Value =
|
||||
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(failure["type"], "alerts_health");
|
||||
assert_eq!(failure["available"], false);
|
||||
assert_eq!(failure["reason"], "store query failed");
|
||||
|
||||
hub.mark_unavailable("same outage, later attempt").await;
|
||||
assert!(
|
||||
updates.try_recv().is_err(),
|
||||
"failure notifications deduplicate"
|
||||
);
|
||||
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: super::now_unix(),
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(hub.unavailable_reason().await, None);
|
||||
let recovery: serde_json::Value =
|
||||
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(recovery["type"], "alerts_health");
|
||||
assert_eq!(recovery["available"], true);
|
||||
assert_eq!(recovery.get("reason"), None);
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(snapshot["type"], "alerts_snapshot");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_rejects_stale_snapshots() {
|
||||
let hub = AlertsHub::new();
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: 0,
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
hub.unavailable_reason().await.as_deref(),
|
||||
Some("alert evaluator snapshot is stale")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_initial_resync_marks_cached_snapshot_stale() {
|
||||
let hub = AlertsHub::new();
|
||||
let (initial_health, initial_snapshot) = hub.resync_payloads().await;
|
||||
let initial_health: serde_json::Value = serde_json::from_str(&initial_health).unwrap();
|
||||
assert_eq!(initial_health["available"], false);
|
||||
assert_eq!(
|
||||
initial_health["reason"],
|
||||
"alert evaluator has not produced a snapshot yet"
|
||||
);
|
||||
assert!(initial_snapshot.is_none());
|
||||
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: 0,
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
let (stale_health, cached_snapshot) = hub.resync_payloads().await;
|
||||
let stale_health: serde_json::Value = serde_json::from_str(&stale_health).unwrap();
|
||||
assert_eq!(stale_health["available"], false);
|
||||
assert_eq!(stale_health["reason"], "alert evaluator snapshot is stale");
|
||||
assert!(cached_snapshot.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn alert_hub_broadcasts_stale_transition_once() {
|
||||
let hub = AlertsHub::new();
|
||||
let mut updates = hub.subscribe();
|
||||
hub.publish(AlertsSnapshot {
|
||||
ts_unix: 0,
|
||||
alerts: Vec::new(),
|
||||
recent_transitions: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
let _snapshot = updates.try_recv().unwrap();
|
||||
|
||||
hub.broadcast_health_if_changed().await;
|
||||
let stale: serde_json::Value = serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
|
||||
assert_eq!(stale["available"], false);
|
||||
assert_eq!(stale["reason"], "alert evaluator snapshot is stale");
|
||||
hub.broadcast_health_if_changed().await;
|
||||
assert!(updates.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use tracing::{info, info_span, warn};
|
||||
use uuid::Uuid;
|
||||
use wakey_agent::protocol::AgentCapability;
|
||||
use wakey_agent::protocol::{AgentCommand, ErrorPayload, RequestId, ServerMessage};
|
||||
use wakey_agent::protocol::{
|
||||
AgentCapability, AgentCapabilityOptions, AgentCommand, ErrorPayload, RequestId, ServerMessage,
|
||||
};
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::runtime::{AgentReply, AppState, SessionEvent};
|
||||
@@ -19,6 +20,7 @@ pub struct AgentStatus {
|
||||
pub connected: bool,
|
||||
pub nickname: Option<String>,
|
||||
pub capabilities: Vec<AgentCapability>,
|
||||
pub capability_options: AgentCapabilityOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -38,7 +40,18 @@ pub struct RelayCommandResponse {
|
||||
}
|
||||
|
||||
pub async fn list_agents(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let enrolled = state.store.list_agents_with_nicknames().await;
|
||||
let enrolled = state
|
||||
.store
|
||||
.list_agents_with_nicknames()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!(error = %err, "failed listing agents");
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"list_agents_failed",
|
||||
err.to_string(),
|
||||
)
|
||||
})?;
|
||||
let sessions = state.sessions.read().await;
|
||||
|
||||
let agents = enrolled
|
||||
@@ -49,6 +62,10 @@ pub async fn list_agents(State(state): State<AppState>) -> Result<impl IntoRespo
|
||||
.get(&agent_id)
|
||||
.map(|session| session.capabilities.clone())
|
||||
.unwrap_or_default(),
|
||||
capability_options: sessions
|
||||
.get(&agent_id)
|
||||
.map(|session| session.capability_options.clone())
|
||||
.unwrap_or_default(),
|
||||
agent_id,
|
||||
nickname,
|
||||
})
|
||||
@@ -114,9 +131,9 @@ pub async fn relay_agent_command(
|
||||
.insert(request_id_string.clone(), pending_tx);
|
||||
|
||||
info!("dispatching command to agent");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -127,10 +144,7 @@ pub async fn relay_agent_command(
|
||||
message: "dispatched command to connected agent".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for command dispatch");
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(err) = tx.send(SessionEvent::Message(ServerMessage::Command {
|
||||
request_id,
|
||||
@@ -138,9 +152,9 @@ pub async fn relay_agent_command(
|
||||
})) {
|
||||
state.pending.lock().await.remove(&request_id_string);
|
||||
warn!(error = %err, "failed sending command to agent session");
|
||||
if let Err(audit_err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -151,10 +165,7 @@ pub async fn relay_agent_command(
|
||||
message: err.to_string(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %audit_err, "failed to append audit event for command send failure");
|
||||
}
|
||||
.await;
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_send_failed",
|
||||
@@ -171,9 +182,9 @@ pub async fn relay_agent_command(
|
||||
let response = match outcome {
|
||||
Ok(Ok(AgentReply::Result(result))) => {
|
||||
info!("agent command completed");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -184,10 +195,7 @@ pub async fn relay_agent_command(
|
||||
message: "agent command completed".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for command success");
|
||||
}
|
||||
.await;
|
||||
RelayCommandResponse {
|
||||
request_id: request_id_string,
|
||||
status: "ok".into(),
|
||||
@@ -197,9 +205,9 @@ pub async fn relay_agent_command(
|
||||
}
|
||||
Ok(Ok(AgentReply::Error(error))) => {
|
||||
warn!(code = %error.code, "agent command returned error");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -210,10 +218,7 @@ pub async fn relay_agent_command(
|
||||
message: error.message.clone(),
|
||||
metadata: serde_json::json!({ "command": command_kind, "code": error.code }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for command error result");
|
||||
}
|
||||
.await;
|
||||
RelayCommandResponse {
|
||||
request_id: request_id_string,
|
||||
status: "error".into(),
|
||||
@@ -223,9 +228,9 @@ pub async fn relay_agent_command(
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
warn!("agent response channel dropped");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -236,10 +241,7 @@ pub async fn relay_agent_command(
|
||||
message: "agent response channel dropped".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for dropped response");
|
||||
}
|
||||
.await;
|
||||
return Err(ApiError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"agent_response_dropped",
|
||||
@@ -252,9 +254,9 @@ pub async fn relay_agent_command(
|
||||
timeout_ms = timeout.as_millis() as u64,
|
||||
"agent command timed out"
|
||||
);
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -265,10 +267,7 @@ pub async fn relay_agent_command(
|
||||
message: "agent command timed out".into(),
|
||||
metadata: serde_json::json!({ "command": command_kind, "timeout_ms": timeout.as_millis() as u64 }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for timeout");
|
||||
}
|
||||
.await;
|
||||
return Err(ApiError::new(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"agent_timeout",
|
||||
|
||||
@@ -74,9 +74,9 @@ pub async fn enroll(
|
||||
match state.store.enroll(&req.enroll_token).await {
|
||||
Ok(issued) => {
|
||||
info!(agent_id = %issued.agent_id, "agent enrollment accepted");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: Some(issued.agent_id.clone()),
|
||||
agent_id: Some(issued.agent_id.clone()),
|
||||
@@ -87,10 +87,7 @@ pub async fn enroll(
|
||||
message: "agent enrollment accepted".into(),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for enroll success");
|
||||
}
|
||||
.await;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(EnrollResponse {
|
||||
@@ -102,9 +99,9 @@ pub async fn enroll(
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "agent enrollment rejected");
|
||||
if let Err(audit_err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -115,10 +112,7 @@ pub async fn enroll(
|
||||
message: err.to_string(),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %audit_err, "failed to append audit event for enroll rejection");
|
||||
}
|
||||
.await;
|
||||
Err(ApiError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"enrollment_rejected",
|
||||
@@ -145,9 +139,9 @@ pub async fn issue_enroll_token(
|
||||
expires_at_unix = issued.expires_at_unix,
|
||||
"issued enroll token"
|
||||
);
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -161,10 +155,7 @@ pub async fn issue_enroll_token(
|
||||
"expires_at_unix": issued.expires_at_unix,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for token issuance");
|
||||
}
|
||||
.await;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(IssueEnrollTokenResponse {
|
||||
@@ -189,9 +180,9 @@ pub async fn list_enroll_tokens(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
match state.store.list_enroll_tokens().await {
|
||||
Ok(tokens) => {
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -204,10 +195,7 @@ pub async fn list_enroll_tokens(
|
||||
"count": tokens.len(),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for token listing");
|
||||
}
|
||||
.await;
|
||||
let body = tokens
|
||||
.into_iter()
|
||||
.map(|t| EnrollTokenStatus {
|
||||
@@ -235,9 +223,9 @@ pub async fn revoke_enroll_token(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
match state.store.revoke_enroll_token(&token).await {
|
||||
Ok(revoked) => {
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
@@ -256,10 +244,7 @@ pub async fn revoke_enroll_token(
|
||||
},
|
||||
metadata: serde_json::json!({ "token": token }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for token revoke");
|
||||
}
|
||||
.await;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(RevokeEnrollTokenResponse { token, revoked }),
|
||||
@@ -289,9 +274,9 @@ pub async fn revoke_agent(
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
@@ -310,10 +295,7 @@ pub async fn revoke_agent(
|
||||
},
|
||||
metadata: serde_json::json!({ "agent_id": agent_id }),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for agent revoke");
|
||||
}
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -349,9 +331,9 @@ pub async fn set_agent_nickname(
|
||||
.await
|
||||
{
|
||||
Ok(updated) => {
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "admin_api".into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.clone()),
|
||||
@@ -373,10 +355,7 @@ pub async fn set_agent_nickname(
|
||||
"nickname": normalized,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for nickname set");
|
||||
}
|
||||
.await;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -4,11 +4,12 @@ use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use futures_util::StreamExt;
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::{AgentCommand, InventoryRequest, WakeRequest};
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::api::commands::relay_agent_command;
|
||||
use crate::api::commands::{RelayCommandResponse, relay_agent_command};
|
||||
use crate::runtime::AppState;
|
||||
|
||||
mod build;
|
||||
@@ -26,6 +27,8 @@ use types::{
|
||||
RefreshFleetDevicesResponse, WakeFleetDeviceRequest, WakeFleetDeviceResponse,
|
||||
};
|
||||
|
||||
const REFRESH_CONCURRENCY: usize = 8;
|
||||
|
||||
pub async fn list_fleet_devices(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListFleetDevicesQuery>,
|
||||
@@ -47,18 +50,17 @@ pub async fn refresh_fleet_devices(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshFleetDevicesRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut agent_ids = if req.agent_ids.is_empty() {
|
||||
let agent_ids = if req.agent_ids.is_empty() {
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.keys().cloned().collect::<Vec<_>>()
|
||||
} else {
|
||||
req.agent_ids.clone()
|
||||
};
|
||||
agent_ids.sort();
|
||||
agent_ids.dedup();
|
||||
let agent_ids = normalize_refresh_agent_ids(agent_ids);
|
||||
|
||||
let mut results = Vec::with_capacity(agent_ids.len());
|
||||
let mut total_accepted = 0usize;
|
||||
for agent_id in agent_ids {
|
||||
let results = futures_util::stream::iter(agent_ids.into_iter().map(|agent_id| {
|
||||
let state = state.clone();
|
||||
async move {
|
||||
let response = relay_agent_command(
|
||||
&state,
|
||||
&agent_id,
|
||||
@@ -73,69 +75,16 @@ pub async fn refresh_fleet_devices(
|
||||
req.timeout_ms,
|
||||
)
|
||||
.await;
|
||||
refresh_fleet_agent_result(&state, &agent_id, response).await
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(REFRESH_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) if response.status == "ok" => {
|
||||
let Some(result) = response.result else {
|
||||
results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some("inventory command returned no result".into()),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
match serde_json::from_value::<wakey_core::DeviceInventory>(result)
|
||||
.map(|i| i.devices)
|
||||
{
|
||||
Ok(devices) => {
|
||||
match state
|
||||
.store
|
||||
.replace_agent_device_snapshot(&agent_id, &devices)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => {
|
||||
total_accepted = total_accepted.saturating_add(accepted);
|
||||
results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "ok".into(),
|
||||
accepted,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(response) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: response
|
||||
.error
|
||||
.map(|error| error.message)
|
||||
.or_else(|| Some("inventory command failed".into())),
|
||||
}),
|
||||
Err(err) => results.push(RefreshFleetAgentResult {
|
||||
agent_id,
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(format!("{}: {}", err.status, err.message)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
let mut results = results;
|
||||
results.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
|
||||
let total_accepted = results.iter().map(|result| result.accepted).sum::<usize>();
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -146,6 +95,76 @@ pub async fn refresh_fleet_devices(
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_refresh_agent_ids(mut agent_ids: Vec<String>) -> Vec<String> {
|
||||
agent_ids.sort();
|
||||
agent_ids.dedup();
|
||||
agent_ids
|
||||
}
|
||||
|
||||
async fn refresh_fleet_agent_result(
|
||||
state: &AppState,
|
||||
agent_id: &str,
|
||||
response: Result<RelayCommandResponse, ApiError>,
|
||||
) -> RefreshFleetAgentResult {
|
||||
match response {
|
||||
Ok(response) if response.status == "ok" => {
|
||||
let Some(result) = response.result else {
|
||||
return RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some("inventory command returned no result".into()),
|
||||
};
|
||||
};
|
||||
match serde_json::from_value::<wakey_core::DeviceInventory>(result)
|
||||
.map(|inventory| inventory.devices)
|
||||
{
|
||||
Ok(devices) => {
|
||||
match state
|
||||
.store
|
||||
.replace_agent_device_snapshot(agent_id, &devices)
|
||||
.await
|
||||
{
|
||||
Ok(accepted) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "ok".into(),
|
||||
accepted,
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(response) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: response
|
||||
.error
|
||||
.map(|error| error.message)
|
||||
.or_else(|| Some("inventory command failed".into())),
|
||||
},
|
||||
Err(err) => RefreshFleetAgentResult {
|
||||
agent_id: agent_id.to_string(),
|
||||
status: "error".into(),
|
||||
accepted: 0,
|
||||
error: Some(format!("{}: {}", err.status, err.message)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wake_fleet_device(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WakeFleetDeviceRequest>,
|
||||
@@ -243,7 +262,7 @@ async fn load_fleet_devices(
|
||||
let sessions = state.sessions.read().await;
|
||||
sessions.keys().cloned().collect::<BTreeSet<_>>()
|
||||
};
|
||||
let agents = state.store.list_agents_with_nicknames().await;
|
||||
let agents = state.store.list_agents_with_nicknames().await?;
|
||||
let agent_status = agents
|
||||
.into_iter()
|
||||
.map(|(agent_id, nickname)| {
|
||||
|
||||
@@ -8,9 +8,23 @@ use super::build::{
|
||||
AgentRuntimeStatus, FleetBuildContext, build_fleet_devices, filter_fleet_devices,
|
||||
known_device_summary,
|
||||
};
|
||||
use super::normalize_refresh_agent_ids;
|
||||
use super::types::ListFleetDevicesQuery;
|
||||
use crate::state::{AgentDeviceRow, AgentDeviceWithChildren, DeviceIdentifier, KnownDevice};
|
||||
|
||||
#[test]
|
||||
fn refresh_agent_selection_keeps_all_agents_beyond_previous_cap() {
|
||||
let ids = (0..140)
|
||||
.map(|index| format!("agent-{index:03}"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let selected = normalize_refresh_agent_ids(ids);
|
||||
|
||||
assert_eq!(selected.len(), 140);
|
||||
assert_eq!(selected.first().map(String::as_str), Some("agent-000"));
|
||||
assert_eq!(selected.last().map(String::as_str), Some("agent-139"));
|
||||
}
|
||||
|
||||
fn context(connected: &[&str]) -> FleetBuildContext {
|
||||
FleetBuildContext {
|
||||
agent_status: connected
|
||||
|
||||
@@ -7,7 +7,7 @@ mod commands;
|
||||
mod control;
|
||||
mod terminals;
|
||||
|
||||
pub use alerts::{active_alerts, alert_history, alerts_stream};
|
||||
pub use alerts::{AlertsHub, active_alerts, alert_history, alerts_stream, run_alert_evaluator};
|
||||
pub use audit::list_audit_events;
|
||||
pub use commands::{list_agents, run_command};
|
||||
pub use control::{
|
||||
@@ -19,7 +19,7 @@ pub use control::{
|
||||
};
|
||||
pub use terminals::{
|
||||
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
|
||||
list_terminals, operator_terminal_ws,
|
||||
list_terminals, operator_terminal_ws, terminal_events_ws,
|
||||
};
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
@@ -5,16 +5,17 @@ use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{info, warn};
|
||||
use wakey_agent::protocol::{
|
||||
AgentCapability, ServerMessage, TerminalAgentHandshake, TerminalControl, TerminalId,
|
||||
TerminalOperatorHandshake,
|
||||
AgentCapability, DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||
ServerMessage, TerminalAgentHandshake, TerminalControl, TerminalId, TerminalOperatorHandshake,
|
||||
};
|
||||
|
||||
use crate::api::ApiError;
|
||||
use crate::runtime::terminals::{
|
||||
TERMINAL_ABSOLUTE_TIMEOUT, TERMINAL_ATTACH_TIMEOUT, TERMINAL_MAX_FRAME_BYTES,
|
||||
TerminalRelayFrame, TerminalSummary,
|
||||
TERMINAL_ATTACH_TIMEOUT, TERMINAL_MAX_FRAME_BYTES, TerminalRelayFrame, TerminalSummary,
|
||||
};
|
||||
use crate::runtime::{AppState, SessionEvent};
|
||||
use crate::state::AuditEventInput;
|
||||
@@ -40,6 +41,7 @@ pub struct TerminalSessionResponse {
|
||||
pub terminal_id: String,
|
||||
pub agent_id: String,
|
||||
pub created_at_unix: u64,
|
||||
pub expires_at_unix: Option<u64>,
|
||||
pub agent_attached: bool,
|
||||
pub operator_attached: bool,
|
||||
pub websocket_url: String,
|
||||
@@ -52,7 +54,7 @@ pub async fn create_terminal(
|
||||
Json(request): Json<CreateTerminalRequest>,
|
||||
) -> Result<(StatusCode, Json<TerminalSessionResponse>), ApiError> {
|
||||
validate_size(request.rows, request.cols)?;
|
||||
let agent_tx = {
|
||||
let (agent_tx, max_sessions, session_ttl_seconds) = {
|
||||
let sessions = state.sessions.read().await;
|
||||
let session = sessions.get(&request.agent_id).ok_or_else(|| {
|
||||
ApiError::new(
|
||||
@@ -68,12 +70,25 @@ pub async fn create_terminal(
|
||||
"agent has not advertised terminal capability",
|
||||
));
|
||||
}
|
||||
session.tx.clone()
|
||||
let max_sessions = session
|
||||
.capability_options
|
||||
.terminal
|
||||
.as_ref()
|
||||
.map(|terminal| terminal.max_sessions)
|
||||
.unwrap_or(DEFAULT_TERMINAL_MAX_SESSIONS)
|
||||
.max(1);
|
||||
let session_ttl_seconds = session
|
||||
.capability_options
|
||||
.terminal
|
||||
.as_ref()
|
||||
.map(|terminal| terminal.session_ttl_seconds)
|
||||
.unwrap_or(DEFAULT_TERMINAL_SESSION_TTL_SECONDS);
|
||||
(session.tx.clone(), max_sessions, session_ttl_seconds)
|
||||
};
|
||||
|
||||
let created = state
|
||||
.terminals
|
||||
.create(request.agent_id.clone())
|
||||
.create_with_limits(request.agent_id.clone(), max_sessions, session_ttl_seconds)
|
||||
.await
|
||||
.map_err(registry_error)?;
|
||||
let terminal_id = TerminalId::new(created.terminal_id.clone()).map_err(|message| {
|
||||
@@ -100,7 +115,12 @@ pub async fn create_terminal(
|
||||
));
|
||||
}
|
||||
|
||||
spawn_absolute_timeout(state.clone(), terminal_id.clone(), request.agent_id.clone());
|
||||
spawn_session_timeout(
|
||||
state.clone(),
|
||||
terminal_id.clone(),
|
||||
request.agent_id.clone(),
|
||||
session_ttl_seconds,
|
||||
);
|
||||
append_terminal_audit(
|
||||
&state,
|
||||
&request.agent_id,
|
||||
@@ -122,6 +142,7 @@ pub async fn create_terminal(
|
||||
terminal_id: terminal_id.to_string(),
|
||||
agent_id: request.agent_id,
|
||||
created_at_unix: created.created_at_unix,
|
||||
expires_at_unix: created.expires_at_unix,
|
||||
agent_attached: false,
|
||||
operator_attached: false,
|
||||
attachment_token: Some(created.attachment_token),
|
||||
@@ -133,7 +154,7 @@ pub async fn get_terminal(
|
||||
State(state): State<AppState>,
|
||||
Path(terminal_id): Path<String>,
|
||||
) -> Result<Json<TerminalSessionResponse>, ApiError> {
|
||||
let (agent_id, created_at_unix, agent_attached, operator_attached) = state
|
||||
let summary = state
|
||||
.terminals
|
||||
.summary(&terminal_id)
|
||||
.await
|
||||
@@ -141,10 +162,11 @@ pub async fn get_terminal(
|
||||
Ok(Json(TerminalSessionResponse {
|
||||
websocket_url: operator_ws_path(&terminal_id),
|
||||
terminal_id,
|
||||
agent_id,
|
||||
created_at_unix,
|
||||
agent_attached,
|
||||
operator_attached,
|
||||
agent_id: summary.agent_id,
|
||||
created_at_unix: summary.created_at_unix,
|
||||
expires_at_unix: summary.expires_at_unix,
|
||||
agent_attached: summary.agent_attached,
|
||||
operator_attached: summary.operator_attached,
|
||||
attachment_token: None,
|
||||
}))
|
||||
}
|
||||
@@ -171,7 +193,7 @@ pub async fn attach_terminal(
|
||||
.issue_attachment_token_for_operator(&terminal_id, &request.operator_id)
|
||||
.await
|
||||
.map_err(registry_error)?;
|
||||
let (agent_id, created_at_unix, agent_attached, operator_attached) = state
|
||||
let summary = state
|
||||
.terminals
|
||||
.summary(&terminal_id)
|
||||
.await
|
||||
@@ -179,10 +201,11 @@ pub async fn attach_terminal(
|
||||
Ok(Json(TerminalSessionResponse {
|
||||
websocket_url: operator_ws_path(&terminal_id),
|
||||
terminal_id,
|
||||
agent_id,
|
||||
created_at_unix,
|
||||
agent_attached,
|
||||
operator_attached,
|
||||
agent_id: summary.agent_id,
|
||||
created_at_unix: summary.created_at_unix,
|
||||
expires_at_unix: summary.expires_at_unix,
|
||||
agent_attached: summary.agent_attached,
|
||||
operator_attached: summary.operator_attached,
|
||||
attachment_token: Some(attachment_token),
|
||||
}))
|
||||
}
|
||||
@@ -222,6 +245,49 @@ pub async fn operator_terminal_ws(
|
||||
.on_upgrade(move |socket| handle_operator_terminal_socket(state, terminal_id, socket))
|
||||
}
|
||||
|
||||
pub async fn terminal_events_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||
ws.on_upgrade(move |socket| handle_terminal_events_socket(state, socket))
|
||||
}
|
||||
|
||||
async fn handle_terminal_events_socket(state: AppState, socket: WebSocket) {
|
||||
let mut events = state.terminals.subscribe();
|
||||
let (mut write, mut read) = socket.split();
|
||||
if write
|
||||
.send(Message::Text(r#"{"type":"sessions_changed"}"#.into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = events.recv() => {
|
||||
match event {
|
||||
Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
if write.send(Message::Text(r#"{"type":"sessions_changed"}"#.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
incoming = read.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
if write.send(Message::Pong(payload)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) => {}
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(Message::Text(_))) | Some(Ok(Message::Binary(_))) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_agent_terminal_socket(state: AppState, terminal_id: String, mut socket: WebSocket) {
|
||||
let auth = match receive_text_handshake(&mut socket).await.and_then(|text| {
|
||||
serde_json::from_str::<TerminalAgentHandshake>(&text).map_err(|_| "invalid handshake")
|
||||
@@ -343,10 +409,10 @@ async fn handle_operator_terminal_socket(
|
||||
warn!(terminal_id, code, "failed to request terminal snapshot");
|
||||
}
|
||||
let summary = state.terminals.summary(&terminal_id).await;
|
||||
if let Some((agent_id, _, _, _)) = &summary {
|
||||
if let Some(summary) = &summary {
|
||||
append_terminal_audit(
|
||||
&state,
|
||||
agent_id,
|
||||
&summary.agent_id,
|
||||
&terminal_id,
|
||||
TerminalAudit {
|
||||
actor_type: "admin_api",
|
||||
@@ -360,7 +426,7 @@ async fn handle_operator_terminal_socket(
|
||||
}
|
||||
|
||||
let (mut write, mut read) = socket.split();
|
||||
if summary.is_some_and(|(_, _, agent_attached, _)| agent_attached) {
|
||||
if summary.is_some_and(|summary| summary.agent_attached) {
|
||||
let ready = serde_json::to_string(&TerminalControl::Ready)
|
||||
.expect("terminal ready control serializes");
|
||||
if send_relay_frame(&mut write, TerminalRelayFrame::Text(ready))
|
||||
@@ -414,6 +480,7 @@ fn terminal_response(summary: TerminalSummary) -> TerminalSessionResponse {
|
||||
terminal_id: summary.terminal_id,
|
||||
agent_id: summary.agent_id,
|
||||
created_at_unix: summary.created_at_unix,
|
||||
expires_at_unix: summary.expires_at_unix,
|
||||
agent_attached: summary.agent_attached,
|
||||
operator_attached: summary.operator_attached,
|
||||
attachment_token: None,
|
||||
@@ -549,9 +616,17 @@ async fn close_registered_terminal(
|
||||
Some(agent_id)
|
||||
}
|
||||
|
||||
fn spawn_absolute_timeout(state: AppState, terminal_id: TerminalId, agent_id: String) {
|
||||
fn spawn_session_timeout(
|
||||
state: AppState,
|
||||
terminal_id: TerminalId,
|
||||
agent_id: String,
|
||||
session_ttl_seconds: u64,
|
||||
) {
|
||||
if session_ttl_seconds == 0 {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(TERMINAL_ABSOLUTE_TIMEOUT).await;
|
||||
tokio::time::sleep(Duration::from_secs(session_ttl_seconds)).await;
|
||||
if close_registered_terminal(
|
||||
&state,
|
||||
terminal_id.as_str(),
|
||||
@@ -618,9 +693,9 @@ async fn append_terminal_audit(
|
||||
terminal_id: &str,
|
||||
audit: TerminalAudit<'_>,
|
||||
) {
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: audit.actor_type.into(),
|
||||
actor_id: None,
|
||||
agent_id: Some(agent_id.to_string()),
|
||||
@@ -631,10 +706,7 @@ async fn append_terminal_audit(
|
||||
message: audit.message.into(),
|
||||
metadata: audit.metadata,
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(terminal_id, event_type = audit.event_type, error = %err, "failed to append terminal audit event");
|
||||
}
|
||||
.await;
|
||||
}
|
||||
|
||||
fn validate_size(rows: u16, cols: u16) -> Result<(), ApiError> {
|
||||
|
||||
@@ -68,14 +68,14 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
.clone()
|
||||
.or(base.state_file)
|
||||
.unwrap_or_else(|| defaults.state_file.clone());
|
||||
let state_file = resolve_path(&data_dir, state_file_raw);
|
||||
let state_file = path_for_rendered_config(&data_dir, state_file_raw);
|
||||
|
||||
let pid_file_raw = args
|
||||
.pid_file
|
||||
.clone()
|
||||
.or(base.pid_file)
|
||||
.unwrap_or_else(|| defaults.pid_file.clone());
|
||||
let pid_file = resolve_path(&data_dir, pid_file_raw);
|
||||
let pid_file = path_for_rendered_config(&data_dir, pid_file_raw);
|
||||
|
||||
let ui_dist_dir = args
|
||||
.ui_dist_dir
|
||||
@@ -147,6 +147,18 @@ pub fn write_init_config(args: &InitConfigArgs) -> Result<Option<PathBuf>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Keep relative paths relative when the rendered config's `data_dir` is also
|
||||
/// relative. The daemon resolves relative paths under `data_dir`; embedding
|
||||
/// that directory into another relative path here would make it get joined a
|
||||
/// second time when the generated config is loaded.
|
||||
fn path_for_rendered_config(data_dir: &std::path::Path, candidate: PathBuf) -> PathBuf {
|
||||
if data_dir.is_relative() && candidate.is_relative() {
|
||||
candidate
|
||||
} else {
|
||||
resolve_path(data_dir, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bootstrap_config_if_missing(args: &ServeArgs) -> Result<bool> {
|
||||
if args.config_file.exists() {
|
||||
return Ok(false);
|
||||
@@ -303,4 +315,51 @@ json_logs = true
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_config_preserves_relative_paths_for_relative_data_dir() {
|
||||
let dir = temp_test_dir("relative-data-dir");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
let base = dir.join("base.toml");
|
||||
let out = dir.join("out.toml");
|
||||
std::fs::write(
|
||||
&base,
|
||||
r#"
|
||||
data_dir = "test-ddir/wakey-control-plane"
|
||||
state_file = "state.sqlite3"
|
||||
pid_file = "wakey-control-plane.pid"
|
||||
"#,
|
||||
)
|
||||
.expect("write base config");
|
||||
|
||||
write_init_config(&InitConfigArgs {
|
||||
config_file: Some(out.clone()),
|
||||
from_config: Some(base),
|
||||
stdout: false,
|
||||
data_dir: None,
|
||||
bind: None,
|
||||
public_url: None,
|
||||
state_file: None,
|
||||
pid_file: None,
|
||||
ui_dist_dir: None,
|
||||
command_timeout_ms: None,
|
||||
enroll_token_ttl_seconds: None,
|
||||
observation_retention_seconds: None,
|
||||
bootstrap_enroll_tokens: Vec::new(),
|
||||
telemetry_otlp_endpoint: None,
|
||||
telemetry_service_name: None,
|
||||
telemetry_json_logs: None,
|
||||
force: false,
|
||||
})
|
||||
.expect("write derived config");
|
||||
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&std::fs::read_to_string(&out).expect("read derived config"))
|
||||
.expect("parse derived config");
|
||||
assert_eq!(parsed["state_file"].as_str(), Some("state.sqlite3"));
|
||||
assert_eq!(parsed["pid_file"].as_str(), Some("wakey-control-plane.pid"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ pub struct DaemonConfig {
|
||||
pub state_file: PathBuf,
|
||||
pub command_timeout: Duration,
|
||||
pub enroll_token_ttl: Duration,
|
||||
#[allow(dead_code)]
|
||||
pub observation_retention: Duration,
|
||||
pub pid_file: PathBuf,
|
||||
pub ui_dist_dir: PathBuf,
|
||||
|
||||
@@ -16,7 +16,7 @@ use tower_http::services::ServeFile;
|
||||
use tracing::info;
|
||||
#[cfg(unix)]
|
||||
use tracing::warn;
|
||||
use wakey_agent::protocol::{AgentCapability, ErrorPayload, ServerMessage};
|
||||
use wakey_agent::protocol::{AgentCapability, AgentCapabilityOptions, ErrorPayload, ServerMessage};
|
||||
|
||||
use crate::api;
|
||||
use crate::config;
|
||||
@@ -43,6 +43,7 @@ pub struct AppState {
|
||||
pub command_timeout: Duration,
|
||||
pub enroll_token_ttl: Duration,
|
||||
pub terminals: terminals::TerminalRegistry,
|
||||
pub alerts: api::AlertsHub,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -50,6 +51,7 @@ pub struct AgentSession {
|
||||
pub connection_id: String,
|
||||
pub tx: mpsc::UnboundedSender<SessionEvent>,
|
||||
pub capabilities: Vec<AgentCapability>,
|
||||
pub capability_options: AgentCapabilityOptions,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub enum SessionEvent {
|
||||
@@ -70,7 +72,7 @@ fn public_api_routes(ui_dist_dir: std::path::PathBuf) -> Router<AppState> {
|
||||
.route("/", get(|| async { Redirect::temporary("/ui/") })) // same as caddyfile
|
||||
.nest_service(
|
||||
"/ui/",
|
||||
get_service(ServeDir::new(ui_dist_dir).not_found_service(ServeFile::new(index_file))),
|
||||
get_service(ServeDir::new(ui_dist_dir).fallback(ServeFile::new(index_file))),
|
||||
)
|
||||
.route("/healthz", get(api::healthz))
|
||||
.route("/api/v1/agents/enroll", post(api::enroll))
|
||||
@@ -146,6 +148,10 @@ fn control_api_routes() -> Router<AppState> {
|
||||
"/api/v1/control/terminals",
|
||||
get(api::list_terminals).post(api::create_terminal),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/terminals/events/ws",
|
||||
get(api::terminal_events_ws),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/terminals/{terminal_id}",
|
||||
get(api::get_terminal).delete(api::close_terminal),
|
||||
@@ -181,8 +187,11 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||
command_timeout: daemon.command_timeout,
|
||||
enroll_token_ttl: daemon.enroll_token_ttl,
|
||||
terminals: terminals::TerminalRegistry::new(),
|
||||
alerts: api::AlertsHub::new(),
|
||||
};
|
||||
|
||||
tokio::spawn(api::run_alert_evaluator(app_state.clone()));
|
||||
|
||||
// Keep route classes explicit so edge policy can map directly:
|
||||
// - public_api_routes: intended internet-facing agent endpoints
|
||||
// - control_api_routes: intended admin-only endpoints behind Access
|
||||
@@ -230,6 +239,18 @@ pub async fn serve(daemon: config::DaemonConfig) -> Result<()> {
|
||||
}
|
||||
Err(err) => warn!(error = %err, "periodic gc failed"),
|
||||
}
|
||||
match app_state.store.gc_retention(daemon.observation_retention).await {
|
||||
Ok(stats) => {
|
||||
if stats.audit_events_removed > 0 || stats.alert_transitions_removed > 0 {
|
||||
info!(
|
||||
audit_events_removed = stats.audit_events_removed,
|
||||
alert_transitions_removed = stats.alert_transitions_removed,
|
||||
"periodic gc pruned rows older than retention window"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(error = %err, "periodic retention gc failed"),
|
||||
}
|
||||
}
|
||||
join = &mut server => {
|
||||
let _ = remove_pid_file(&daemon.pid_file);
|
||||
|
||||
@@ -2,18 +2,17 @@ use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
use uuid::Uuid;
|
||||
use wakey_agent::protocol::{AgentTerminalSession, TerminalId};
|
||||
|
||||
pub const TERMINAL_RELAY_QUEUE: usize = 32;
|
||||
pub const TERMINAL_MAX_FRAME_BYTES: usize = 64 * 1024;
|
||||
pub const TERMINAL_PENDING_AGENT_BYTES: usize = 256 * 1024;
|
||||
pub const TERMINAL_MAX_SESSIONS_PER_AGENT: usize = 2;
|
||||
pub const TERMINAL_ATTACH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub const TERMINAL_ABSOLUTE_TIMEOUT: Duration = Duration::from_secs(12 * 60 * 60);
|
||||
const TERMINAL_TOMBSTONE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
const TERMINAL_MAX_TOMBSTONES: usize = 1024;
|
||||
const TERMINAL_EVENTS_CAPACITY: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TerminalRelayFrame {
|
||||
@@ -26,12 +25,13 @@ pub enum TerminalRelayFrame {
|
||||
pub struct TerminalRegistry {
|
||||
inner: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||
closed: Arc<Mutex<HashMap<String, Instant>>>,
|
||||
events: broadcast::Sender<()>,
|
||||
}
|
||||
|
||||
struct TerminalSession {
|
||||
agent_id: String,
|
||||
created_at_unix: u64,
|
||||
expires_at: Instant,
|
||||
expiry: TerminalExpiry,
|
||||
agent_confirmed: bool,
|
||||
relay_token: Option<String>,
|
||||
attachment_token: Option<String>,
|
||||
@@ -50,6 +50,7 @@ pub struct CreatedTerminal {
|
||||
pub relay_token: String,
|
||||
pub attachment_token: String,
|
||||
pub created_at_unix: u64,
|
||||
pub expires_at_unix: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -57,6 +58,7 @@ pub struct TerminalSummary {
|
||||
pub terminal_id: String,
|
||||
pub agent_id: String,
|
||||
pub created_at_unix: u64,
|
||||
pub expires_at_unix: Option<u64>,
|
||||
pub agent_attached: bool,
|
||||
pub operator_attached: bool,
|
||||
}
|
||||
@@ -69,21 +71,53 @@ impl Default for TerminalRegistry {
|
||||
|
||||
impl TerminalRegistry {
|
||||
pub fn new() -> Self {
|
||||
let (events, _) = broadcast::channel(TERMINAL_EVENTS_CAPACITY);
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||
closed: Arc::new(Mutex::new(HashMap::new())),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, agent_id: String) -> Result<CreatedTerminal, &'static str> {
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<()> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
fn notify_changed(&self) {
|
||||
let _ = self.events.send(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn create(&self, agent_id: String) -> Result<CreatedTerminal, &'static str> {
|
||||
self.create_with_limits(
|
||||
agent_id,
|
||||
wakey_agent::protocol::DEFAULT_TERMINAL_MAX_SESSIONS,
|
||||
wakey_agent::protocol::DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Creates a session using the limit advertised by the connected agent.
|
||||
/// The caller snapshots the limit with the agent connection so UI hints and
|
||||
/// server-side enforcement use the same value.
|
||||
pub async fn create_with_limits(
|
||||
&self,
|
||||
agent_id: String,
|
||||
max_sessions: usize,
|
||||
session_ttl_seconds: u64,
|
||||
) -> Result<CreatedTerminal, &'static str> {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
prune_expired_sessions(&mut sessions);
|
||||
let pruned = prune_expired_sessions(&mut sessions) > 0;
|
||||
if sessions
|
||||
.values()
|
||||
.filter(|session| session.agent_id == agent_id)
|
||||
.count()
|
||||
>= TERMINAL_MAX_SESSIONS_PER_AGENT
|
||||
>= max_sessions.max(1)
|
||||
{
|
||||
drop(sessions);
|
||||
if pruned {
|
||||
self.notify_changed();
|
||||
}
|
||||
return Err("agent_terminal_limit_reached");
|
||||
}
|
||||
|
||||
@@ -94,12 +128,19 @@ impl TerminalRegistry {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let Some(expiry) = expiry_for_created_at(created_at_unix, session_ttl_seconds) else {
|
||||
drop(sessions);
|
||||
if pruned {
|
||||
self.notify_changed();
|
||||
}
|
||||
return Err("terminal_ttl_invalid");
|
||||
};
|
||||
sessions.insert(
|
||||
terminal_id.clone(),
|
||||
TerminalSession {
|
||||
agent_id,
|
||||
created_at_unix,
|
||||
expires_at: Instant::now() + TERMINAL_ABSOLUTE_TIMEOUT,
|
||||
expiry,
|
||||
agent_confirmed: false,
|
||||
relay_token: Some(relay_token.clone()),
|
||||
attachment_token: Some(attachment_token.clone()),
|
||||
@@ -113,17 +154,21 @@ impl TerminalRegistry {
|
||||
operator_detached_at: None,
|
||||
},
|
||||
);
|
||||
drop(sessions);
|
||||
self.notify_changed();
|
||||
|
||||
Ok(CreatedTerminal {
|
||||
terminal_id,
|
||||
relay_token,
|
||||
attachment_token,
|
||||
created_at_unix,
|
||||
expires_at_unix: expiry.unix(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn remove(&self, terminal_id: &str) -> Option<String> {
|
||||
let session = self.inner.lock().await.remove(terminal_id)?;
|
||||
self.notify_changed();
|
||||
self.remember_closed(terminal_id).await;
|
||||
if let Some(tx) = session.agent_tx {
|
||||
let _ =
|
||||
@@ -169,13 +214,16 @@ impl TerminalRegistry {
|
||||
) -> Vec<(TerminalId, String)> {
|
||||
let reported_ids = reported
|
||||
.iter()
|
||||
.filter(|session| expires_at_for_created_at(session.created_at_unix).is_some())
|
||||
.filter(|session| {
|
||||
expiry_for_created_at(session.created_at_unix, session.session_ttl_seconds)
|
||||
.is_some()
|
||||
})
|
||||
.map(|session| session.terminal_id.as_str())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let stale_ids = {
|
||||
let (stale_ids, pruned) = {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
prune_expired_sessions(&mut sessions);
|
||||
sessions
|
||||
let pruned = prune_expired_sessions(&mut sessions) > 0;
|
||||
let stale_ids = sessions
|
||||
.iter()
|
||||
.filter(|(terminal_id, session)| {
|
||||
session.agent_id == agent_id
|
||||
@@ -183,26 +231,33 @@ impl TerminalRegistry {
|
||||
&& !reported_ids.contains(terminal_id.as_str())
|
||||
})
|
||||
.map(|(terminal_id, _)| terminal_id.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.collect::<Vec<_>>();
|
||||
(stale_ids, pruned)
|
||||
};
|
||||
if pruned {
|
||||
self.notify_changed();
|
||||
}
|
||||
for terminal_id in stale_ids {
|
||||
self.remove(&terminal_id).await;
|
||||
}
|
||||
|
||||
let mut credentials = Vec::new();
|
||||
let mut changed = false;
|
||||
let mut sessions = self.inner.lock().await;
|
||||
for reported_session in reported {
|
||||
let Some(expires_at) = expires_at_for_created_at(reported_session.created_at_unix)
|
||||
else {
|
||||
let Some(expiry) = expiry_for_created_at(
|
||||
reported_session.created_at_unix,
|
||||
reported_session.session_ttl_seconds,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let terminal_id = reported_session.terminal_id.as_str().to_string();
|
||||
let session = sessions
|
||||
.entry(terminal_id)
|
||||
.or_insert_with(|| TerminalSession {
|
||||
let session = sessions.entry(terminal_id).or_insert_with(|| {
|
||||
changed = true;
|
||||
TerminalSession {
|
||||
agent_id: agent_id.to_string(),
|
||||
created_at_unix: reported_session.created_at_unix,
|
||||
expires_at,
|
||||
expiry,
|
||||
agent_confirmed: true,
|
||||
relay_token: None,
|
||||
attachment_token: None,
|
||||
@@ -214,6 +269,7 @@ impl TerminalRegistry {
|
||||
operator_id: None,
|
||||
operator_connection_id: None,
|
||||
operator_detached_at: Some(Instant::now()),
|
||||
}
|
||||
});
|
||||
if session.agent_id != agent_id || session.agent_tx.is_some() {
|
||||
continue;
|
||||
@@ -223,14 +279,24 @@ impl TerminalRegistry {
|
||||
session.relay_token = Some(token.clone());
|
||||
credentials.push((reported_session.terminal_id.clone(), token));
|
||||
}
|
||||
drop(sessions);
|
||||
if changed {
|
||||
self.notify_changed();
|
||||
}
|
||||
credentials
|
||||
}
|
||||
|
||||
pub async fn detach_agent(&self, terminal_id: &str) -> Option<String> {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
let session = sessions.get_mut(terminal_id)?;
|
||||
let changed = session.agent_tx.is_some();
|
||||
session.agent_tx = None;
|
||||
Some(session.agent_id.clone())
|
||||
let agent_id = session.agent_id.clone();
|
||||
drop(sessions);
|
||||
if changed {
|
||||
self.notify_changed();
|
||||
}
|
||||
Some(agent_id)
|
||||
}
|
||||
|
||||
/// Issues a token while atomically handing this operator's attachment to
|
||||
@@ -243,17 +309,21 @@ impl TerminalRegistry {
|
||||
) -> Result<String, &'static str> {
|
||||
validate_operator_id(operator_id)?;
|
||||
let mut sessions = self.inner.lock().await;
|
||||
let target = active_session(&mut sessions, terminal_id)?;
|
||||
let target = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
if target.operator_tx.is_some() && target.operator_id.as_deref() != Some(operator_id) {
|
||||
return Err("terminal_operator_already_attached");
|
||||
}
|
||||
|
||||
detach_operator_sessions(&mut sessions, operator_id);
|
||||
let detached = detach_operator_sessions(&mut sessions, operator_id);
|
||||
|
||||
let session = active_session(&mut sessions, terminal_id)?;
|
||||
let session = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
let token = new_token();
|
||||
session.attachment_token = Some(token.clone());
|
||||
session.attachment_operator_id = Some(operator_id.to_string());
|
||||
drop(sessions);
|
||||
if detached {
|
||||
self.notify_changed();
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
@@ -264,7 +334,7 @@ impl TerminalRegistry {
|
||||
relay_token: &str,
|
||||
) -> Result<(mpsc::Receiver<TerminalRelayFrame>, Vec<TerminalRelayFrame>), &'static str> {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
let session = active_session(&mut sessions, terminal_id)?;
|
||||
let session = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
if session.agent_id != agent_id {
|
||||
return Err("terminal_agent_mismatch");
|
||||
}
|
||||
@@ -280,6 +350,8 @@ impl TerminalRegistry {
|
||||
session.agent_tx = Some(tx);
|
||||
let pending = session.pending_agent.drain(..).collect();
|
||||
session.pending_agent_bytes = 0;
|
||||
drop(sessions);
|
||||
self.notify_changed();
|
||||
Ok((rx, pending))
|
||||
}
|
||||
|
||||
@@ -291,7 +363,7 @@ impl TerminalRegistry {
|
||||
) -> Result<mpsc::Receiver<TerminalRelayFrame>, &'static str> {
|
||||
validate_operator_id(operator_id)?;
|
||||
let mut sessions = self.inner.lock().await;
|
||||
let session = active_session(&mut sessions, terminal_id)?;
|
||||
let session = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
if session.operator_tx.is_some() {
|
||||
return Err("terminal_operator_already_attached");
|
||||
}
|
||||
@@ -310,7 +382,7 @@ impl TerminalRegistry {
|
||||
// WebSocket presents that token, it still atomically releases any
|
||||
// older session held by the same browser tab.
|
||||
detach_operator_sessions(&mut sessions, operator_id);
|
||||
let session = active_session(&mut sessions, terminal_id)?;
|
||||
let session = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
session.attachment_token = None;
|
||||
session.attachment_operator_id = None;
|
||||
session.operator_detached_at = None;
|
||||
@@ -318,6 +390,8 @@ impl TerminalRegistry {
|
||||
session.operator_tx = Some(tx);
|
||||
session.operator_id = Some(operator_id.to_string());
|
||||
session.operator_connection_id = Some(attachment_token.to_string());
|
||||
drop(sessions);
|
||||
self.notify_changed();
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
@@ -331,7 +405,7 @@ impl TerminalRegistry {
|
||||
// leave input stranded in the pre-attachment queue.
|
||||
let tx = {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
let session = active_session(&mut sessions, terminal_id)?;
|
||||
let session = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
if let Some(tx) = session.agent_tx.clone() {
|
||||
tx
|
||||
} else {
|
||||
@@ -359,7 +433,7 @@ impl TerminalRegistry {
|
||||
) -> Result<(), &'static str> {
|
||||
let operator_tx = {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
let session = active_session(&mut sessions, terminal_id)?;
|
||||
let session = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
session.operator_tx.clone()
|
||||
};
|
||||
|
||||
@@ -371,13 +445,19 @@ impl TerminalRegistry {
|
||||
// Detach its stale sender; the agent's parsed screen remains
|
||||
// authoritative and will reconstruct the next attachment.
|
||||
let mut sessions = self.inner.lock().await;
|
||||
let session = active_session(&mut sessions, terminal_id)?;
|
||||
let session = active_session(&mut sessions, terminal_id, &self.events)?;
|
||||
let mut changed = false;
|
||||
if session
|
||||
.operator_tx
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.same_channel(&tx))
|
||||
{
|
||||
clear_operator_attachment(session);
|
||||
changed = true;
|
||||
}
|
||||
drop(sessions);
|
||||
if changed {
|
||||
self.notify_changed();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -419,49 +499,62 @@ impl TerminalRegistry {
|
||||
}
|
||||
let detached_at = Instant::now();
|
||||
clear_operator_attachment(session);
|
||||
drop(sessions);
|
||||
self.notify_changed();
|
||||
Some(detached_at)
|
||||
}
|
||||
|
||||
pub async fn summary(&self, terminal_id: &str) -> Option<(String, u64, bool, bool)> {
|
||||
pub async fn summary(&self, terminal_id: &str) -> Option<TerminalSummary> {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
prune_expired_sessions(&mut sessions);
|
||||
sessions.get(terminal_id).map(|session| {
|
||||
(
|
||||
session.agent_id.clone(),
|
||||
session.created_at_unix,
|
||||
session.agent_tx.is_some(),
|
||||
session.operator_tx.is_some(),
|
||||
)
|
||||
})
|
||||
let pruned = prune_expired_sessions(&mut sessions) > 0;
|
||||
let summary = sessions
|
||||
.get(terminal_id)
|
||||
.map(|session| terminal_summary(terminal_id, session));
|
||||
drop(sessions);
|
||||
if pruned {
|
||||
self.notify_changed();
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
pub async fn summaries(&self) -> Vec<TerminalSummary> {
|
||||
let mut sessions = self.inner.lock().await;
|
||||
prune_expired_sessions(&mut sessions);
|
||||
let removed = prune_expired_sessions(&mut sessions);
|
||||
let mut summaries = sessions
|
||||
.iter()
|
||||
.map(|(terminal_id, session)| TerminalSummary {
|
||||
terminal_id: terminal_id.clone(),
|
||||
agent_id: session.agent_id.clone(),
|
||||
created_at_unix: session.created_at_unix,
|
||||
agent_attached: session.agent_tx.is_some(),
|
||||
operator_attached: session.operator_tx.is_some(),
|
||||
})
|
||||
.map(|(terminal_id, session)| terminal_summary(terminal_id, session))
|
||||
.collect::<Vec<_>>();
|
||||
summaries.sort_by_key(|session| std::cmp::Reverse(session.created_at_unix));
|
||||
drop(sessions);
|
||||
if removed > 0 {
|
||||
self.notify_changed();
|
||||
}
|
||||
summaries
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_summary(terminal_id: &str, session: &TerminalSession) -> TerminalSummary {
|
||||
TerminalSummary {
|
||||
terminal_id: terminal_id.to_string(),
|
||||
agent_id: session.agent_id.clone(),
|
||||
created_at_unix: session.created_at_unix,
|
||||
expires_at_unix: session.expiry.unix(),
|
||||
agent_attached: session.agent_tx.is_some(),
|
||||
operator_attached: session.operator_tx.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
fn active_session<'a>(
|
||||
sessions: &'a mut HashMap<String, TerminalSession>,
|
||||
terminal_id: &str,
|
||||
events: &broadcast::Sender<()>,
|
||||
) -> Result<&'a mut TerminalSession, &'static str> {
|
||||
if sessions
|
||||
.get(terminal_id)
|
||||
.is_some_and(|session| session.expires_at <= Instant::now())
|
||||
.is_some_and(|session| session.expiry.is_expired(Instant::now()))
|
||||
{
|
||||
sessions.remove(terminal_id);
|
||||
let _ = events.send(());
|
||||
return Err("terminal_expired");
|
||||
}
|
||||
sessions.get_mut(terminal_id).ok_or("terminal_not_found")
|
||||
@@ -474,12 +567,18 @@ fn validate_operator_id(operator_id: &str) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detach_operator_sessions(sessions: &mut HashMap<String, TerminalSession>, operator_id: &str) {
|
||||
fn detach_operator_sessions(
|
||||
sessions: &mut HashMap<String, TerminalSession>,
|
||||
operator_id: &str,
|
||||
) -> bool {
|
||||
let mut detached = false;
|
||||
for session in sessions.values_mut() {
|
||||
if session.operator_id.as_deref() == Some(operator_id) {
|
||||
clear_operator_attachment(session);
|
||||
detached = true;
|
||||
}
|
||||
}
|
||||
detached
|
||||
}
|
||||
|
||||
fn clear_operator_attachment(session: &mut TerminalSession) {
|
||||
@@ -497,24 +596,52 @@ fn prune_tombstones(closed: &mut HashMap<String, Instant>) {
|
||||
closed.retain(|_, closed_at| closed_at.elapsed() < TERMINAL_TOMBSTONE_TTL);
|
||||
}
|
||||
|
||||
fn prune_expired_sessions(sessions: &mut HashMap<String, TerminalSession>) {
|
||||
fn prune_expired_sessions(sessions: &mut HashMap<String, TerminalSession>) -> usize {
|
||||
let now = Instant::now();
|
||||
sessions.retain(|_, session| session.expires_at > now);
|
||||
let old_len = sessions.len();
|
||||
sessions.retain(|_, session| !session.expiry.is_expired(now));
|
||||
old_len - sessions.len()
|
||||
}
|
||||
|
||||
/// Converts the agent's durable creation timestamp into CC's monotonic
|
||||
/// deadline. Reconciliation must not grant an old PTY a fresh twelve hours.
|
||||
fn expires_at_for_created_at(created_at_unix: u64) -> Option<Instant> {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum TerminalExpiry {
|
||||
Unlimited,
|
||||
Finite { deadline: Instant, unix: u64 },
|
||||
}
|
||||
|
||||
impl TerminalExpiry {
|
||||
fn unix(self) -> Option<u64> {
|
||||
match self {
|
||||
Self::Unlimited => None,
|
||||
Self::Finite { unix, .. } => Some(unix),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(self, now: Instant) -> bool {
|
||||
matches!(self, Self::Finite { deadline, .. } if deadline <= now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the agent's creation-time policy into CC's monotonic deadline.
|
||||
/// `None` rejects expired or unrepresentable sessions; `Unlimited` is the
|
||||
/// explicit zero-TTL policy.
|
||||
fn expiry_for_created_at(created_at_unix: u64, session_ttl_seconds: u64) -> Option<TerminalExpiry> {
|
||||
if session_ttl_seconds == 0 {
|
||||
return Some(TerminalExpiry::Unlimited);
|
||||
}
|
||||
let expires_at_unix = created_at_unix.checked_add(session_ttl_seconds)?;
|
||||
let now_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let age = Duration::from_secs(now_unix.saturating_sub(created_at_unix));
|
||||
if age >= TERMINAL_ABSOLUTE_TIMEOUT {
|
||||
if expires_at_unix <= now_unix {
|
||||
return None;
|
||||
}
|
||||
let remaining = TERMINAL_ABSOLUTE_TIMEOUT - age;
|
||||
Some(Instant::now() + remaining)
|
||||
let remaining = Duration::from_secs(expires_at_unix - now_unix);
|
||||
Some(TerminalExpiry::Finite {
|
||||
deadline: Instant::now().checked_add(remaining)?,
|
||||
unix: expires_at_unix,
|
||||
})
|
||||
}
|
||||
|
||||
fn relay_frame_size(frame: &TerminalRelayFrame) -> usize {
|
||||
@@ -528,6 +655,7 @@ fn relay_frame_size(frame: &TerminalRelayFrame) -> usize {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wakey_agent::protocol::DEFAULT_TERMINAL_SESSION_TTL_SECONDS;
|
||||
|
||||
const OPERATOR_A: &str = "browser-tab-a";
|
||||
const OPERATOR_B: &str = "browser-tab-b";
|
||||
@@ -643,6 +771,73 @@ mod tests {
|
||||
assert_eq!(third.err(), Some("agent_terminal_limit_reached"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn advertised_limit_controls_session_capacity() {
|
||||
let registry = TerminalRegistry::new();
|
||||
for _ in 0..3 {
|
||||
registry
|
||||
.create_with_limits("router".into(), 3, DEFAULT_TERMINAL_SESSION_TTL_SECONDS)
|
||||
.await
|
||||
.expect("within advertised limit");
|
||||
}
|
||||
let fourth = registry
|
||||
.create_with_limits("router".into(), 3, DEFAULT_TERMINAL_SESSION_TTL_SECONDS)
|
||||
.await;
|
||||
assert_eq!(fourth.err(), Some("agent_terminal_limit_reached"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_ttl_is_captured_and_zero_is_unlimited() {
|
||||
let registry = TerminalRegistry::new();
|
||||
let finite = registry
|
||||
.create_with_limits("router".into(), 2, 67)
|
||||
.await
|
||||
.expect("finite session");
|
||||
assert_eq!(
|
||||
finite.expires_at_unix,
|
||||
finite.created_at_unix.checked_add(67)
|
||||
);
|
||||
|
||||
registry.remove(&finite.terminal_id).await;
|
||||
let unlimited = registry
|
||||
.create_with_limits("router".into(), 2, 0)
|
||||
.await
|
||||
.expect("unlimited session");
|
||||
assert_eq!(unlimited.expires_at_unix, None);
|
||||
assert_eq!(
|
||||
registry
|
||||
.summary(&unlimited.terminal_id)
|
||||
.await
|
||||
.expect("unlimited summary")
|
||||
.expires_at_unix,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlimited_inventory_can_adopt_an_old_session() {
|
||||
let registry = TerminalRegistry::new();
|
||||
let terminal_id = TerminalId::new("old-unlimited").expect("terminal id");
|
||||
let reported = AgentTerminalSession {
|
||||
terminal_id: terminal_id.clone(),
|
||||
created_at_unix: 0,
|
||||
session_ttl_seconds: 0,
|
||||
};
|
||||
|
||||
let credentials = registry
|
||||
.reconcile_agent_sessions("router", &[reported])
|
||||
.await;
|
||||
assert_eq!(credentials.len(), 1);
|
||||
assert_eq!(
|
||||
registry
|
||||
.summary(terminal_id.as_str())
|
||||
.await
|
||||
.expect("adopted unlimited session")
|
||||
.expires_at_unix,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_session_is_distinct_from_unknown_session() {
|
||||
let registry = TerminalRegistry::new();
|
||||
@@ -663,6 +858,7 @@ mod tests {
|
||||
let reported = AgentTerminalSession {
|
||||
terminal_id: terminal_id.clone(),
|
||||
created_at_unix,
|
||||
session_ttl_seconds: 0,
|
||||
};
|
||||
|
||||
let credentials = registry
|
||||
@@ -679,9 +875,9 @@ mod tests {
|
||||
.summary(terminal_id.as_str())
|
||||
.await
|
||||
.expect("adopted summary");
|
||||
assert_eq!(summary.0, "router");
|
||||
assert_eq!(summary.1, created_at_unix);
|
||||
assert!(summary.2);
|
||||
assert_eq!(summary.agent_id, "router");
|
||||
assert_eq!(summary.created_at_unix, created_at_unix);
|
||||
assert!(summary.agent_attached);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -695,7 +891,10 @@ mod tests {
|
||||
.await
|
||||
.get_mut(&first.terminal_id)
|
||||
.expect("first session")
|
||||
.expires_at = Instant::now();
|
||||
.expiry = TerminalExpiry::Finite {
|
||||
deadline: Instant::now(),
|
||||
unix: first.created_at_unix,
|
||||
};
|
||||
|
||||
registry
|
||||
.create("router".into())
|
||||
@@ -711,6 +910,7 @@ mod tests {
|
||||
let reported = AgentTerminalSession {
|
||||
terminal_id: terminal_id.clone(),
|
||||
created_at_unix: 0,
|
||||
session_ttl_seconds: DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
|
||||
};
|
||||
|
||||
assert!(
|
||||
@@ -739,7 +939,7 @@ mod tests {
|
||||
.summary(&created.terminal_id)
|
||||
.await
|
||||
.expect("session remains after detach");
|
||||
assert!(!summary.3);
|
||||
assert!(!summary.operator_attached);
|
||||
registry
|
||||
.issue_attachment_token_for_operator(&created.terminal_id, OPERATOR_A)
|
||||
.await
|
||||
@@ -765,7 +965,13 @@ mod tests {
|
||||
.await
|
||||
.expect("handoff token");
|
||||
|
||||
assert!(!registry.summary(&previous.terminal_id).await.unwrap().3);
|
||||
assert!(
|
||||
!registry
|
||||
.summary(&previous.terminal_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.operator_attached
|
||||
);
|
||||
registry
|
||||
.attach_operator(&next.terminal_id, &next_token, OPERATOR_A)
|
||||
.await
|
||||
@@ -800,8 +1006,20 @@ mod tests {
|
||||
.expect_err("occupied target remains protected");
|
||||
|
||||
assert_eq!(error, "terminal_operator_already_attached");
|
||||
assert!(registry.summary(&previous.terminal_id).await.unwrap().3);
|
||||
assert!(registry.summary(&occupied.terminal_id).await.unwrap().3);
|
||||
assert!(
|
||||
registry
|
||||
.summary(&previous.terminal_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.operator_attached
|
||||
);
|
||||
assert!(
|
||||
registry
|
||||
.summary(&occupied.terminal_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.operator_attached
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -829,7 +1047,13 @@ mod tests {
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
assert!(registry.summary(&created.terminal_id).await.unwrap().3);
|
||||
assert!(
|
||||
registry
|
||||
.summary(&created.terminal_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.operator_attached
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -841,4 +1065,127 @@ mod tests {
|
||||
|
||||
assert!(registry.summary(&created.terminal_id).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_events_invalidate_mutations_but_not_reads_or_terminal_bytes() {
|
||||
let registry = TerminalRegistry::new();
|
||||
let mut events = registry.subscribe();
|
||||
let created = registry.create("router".into()).await.expect("create");
|
||||
assert!(events.try_recv().is_ok());
|
||||
|
||||
registry.summaries().await;
|
||||
registry.summary(&created.terminal_id).await;
|
||||
assert!(events.try_recv().is_err(), "ordinary reads emit no event");
|
||||
|
||||
let mut operator = registry
|
||||
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||
.await
|
||||
.expect("attach operator");
|
||||
assert!(events.try_recv().is_ok());
|
||||
registry
|
||||
.relay_from_agent(
|
||||
&created.terminal_id,
|
||||
TerminalRelayFrame::Binary(b"terminal output".to_vec()),
|
||||
)
|
||||
.await
|
||||
.expect("relay terminal bytes");
|
||||
operator.recv().await.expect("terminal bytes delivered");
|
||||
assert!(events.try_recv().is_err(), "terminal bytes emit no event");
|
||||
|
||||
registry
|
||||
.detach_operator(&created.terminal_id, &created.attachment_token)
|
||||
.await
|
||||
.expect("detach operator");
|
||||
assert!(events.try_recv().is_ok());
|
||||
registry.remove(&created.terminal_id).await.expect("remove");
|
||||
assert!(events.try_recv().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lagged_session_receiver_recovers_with_latest_invalidation() {
|
||||
let registry = TerminalRegistry::new();
|
||||
let mut events = registry.subscribe();
|
||||
for _ in 0..(TERMINAL_EVENTS_CAPACITY + 3) {
|
||||
registry
|
||||
.create_with_limits("router".into(), TERMINAL_EVENTS_CAPACITY + 3, 0)
|
||||
.await
|
||||
.expect("create");
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
events.try_recv(),
|
||||
Err(broadcast::error::TryRecvError::Lagged(_))
|
||||
));
|
||||
assert!(events.try_recv().is_ok());
|
||||
assert_eq!(
|
||||
registry.summaries().await.len(),
|
||||
TERMINAL_EVENTS_CAPACITY + 3
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_attach_detach_and_reconciliation_invalidate_catalog_changes() {
|
||||
let registry = TerminalRegistry::new();
|
||||
let mut events = registry.subscribe();
|
||||
let terminal_id = TerminalId::new("reconciled-session").expect("terminal id");
|
||||
let reported = AgentTerminalSession {
|
||||
terminal_id: terminal_id.clone(),
|
||||
created_at_unix: 0,
|
||||
session_ttl_seconds: 0,
|
||||
};
|
||||
|
||||
let credentials = registry
|
||||
.reconcile_agent_sessions("router", std::slice::from_ref(&reported))
|
||||
.await;
|
||||
assert_eq!(credentials.len(), 1);
|
||||
assert!(events.try_recv().is_ok(), "adoption changes the catalog");
|
||||
|
||||
let (_, relay_token) = &credentials[0];
|
||||
registry
|
||||
.attach_agent(terminal_id.as_str(), "router", relay_token)
|
||||
.await
|
||||
.expect("attach agent");
|
||||
assert!(events.try_recv().is_ok());
|
||||
registry
|
||||
.detach_agent(terminal_id.as_str())
|
||||
.await
|
||||
.expect("detach agent");
|
||||
assert!(events.try_recv().is_ok());
|
||||
|
||||
registry.reconcile_agent_sessions("router", &[]).await;
|
||||
assert!(
|
||||
events.try_recv().is_ok(),
|
||||
"reconciliation removes stale session"
|
||||
);
|
||||
assert!(registry.summary(terminal_id.as_str()).await.is_none());
|
||||
assert!(events.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pruning_sibling_keeps_requested_summary_and_notifies_once() {
|
||||
let registry = TerminalRegistry::new();
|
||||
let mut events = registry.subscribe();
|
||||
let expired = registry.create("router".into()).await.expect("expired");
|
||||
let live = registry.create("router".into()).await.expect("live");
|
||||
events.try_recv().expect("expired create event");
|
||||
events.try_recv().expect("live create event");
|
||||
registry
|
||||
.inner
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&expired.terminal_id)
|
||||
.expect("expired session")
|
||||
.expiry = TerminalExpiry::Finite {
|
||||
deadline: Instant::now(),
|
||||
unix: expired.created_at_unix,
|
||||
};
|
||||
|
||||
let summary = registry
|
||||
.summary(&live.terminal_id)
|
||||
.await
|
||||
.expect("live sibling remains available");
|
||||
assert_eq!(summary.terminal_id, live.terminal_id);
|
||||
assert!(matches!(events.try_recv(), Ok(())));
|
||||
assert!(events.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub use store::Store;
|
||||
#[cfg(test)]
|
||||
pub use types::AgentDeviceRow;
|
||||
pub use types::{
|
||||
AgentDeviceWithChildren, AlertState, AuditEvent, AuditEventFilter, AuditEventInput,
|
||||
DeviceIdentifier, DeviceIdentifierInput, KnownDevice, KnownDeviceInput, KnownDeviceSummary,
|
||||
AgentDeviceWithChildren, AlertState, AlertTransition, AuditEvent, AuditEventFilter,
|
||||
AuditEventInput, DeviceIdentifier, DeviceIdentifierInput, KnownDevice, KnownDeviceInput,
|
||||
KnownDeviceSummary,
|
||||
};
|
||||
@@ -10,7 +10,7 @@ use uuid::Uuid;
|
||||
use crate::state::types::{
|
||||
AlertState, AlertTransition, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifier,
|
||||
DeviceIdentifierInput, EnrollTokenInfo, IssuedAgent, IssuedEnrollToken, KnownDevice,
|
||||
KnownDeviceInput, StateStats,
|
||||
KnownDeviceInput, RetentionStats, StateStats,
|
||||
};
|
||||
|
||||
pub struct Store {
|
||||
@@ -103,6 +103,124 @@ mod tests {
|
||||
assert_eq!(exists, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enroll_rejects_negative_expiry() {
|
||||
let ts = TestStore::new().await;
|
||||
sqlx::query!(
|
||||
"INSERT INTO enroll_tokens (token, expires_at_unix) VALUES (?1, ?2)",
|
||||
"enr-negative-expiry-test",
|
||||
-1_i64
|
||||
)
|
||||
.execute(&ts.store().pool)
|
||||
.await
|
||||
.expect("insert should succeed");
|
||||
|
||||
let err = ts
|
||||
.store()
|
||||
.enroll("enr-negative-expiry-test")
|
||||
.await
|
||||
.expect_err("negative expiry should be treated as expired");
|
||||
|
||||
assert!(err.to_string().contains("expired"));
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM enroll_tokens WHERE token = ?1",
|
||||
"enr-negative-expiry-test"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(exists, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gc_retention_prunes_old_audit_and_alert_history() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_secs() as i64;
|
||||
let old_ts = now - 86_400;
|
||||
let old_evt = format!("{:020}:old-evt", old_ts);
|
||||
sqlx::query!(
|
||||
"INSERT INTO audit_events
|
||||
(event_key, event_id, ts_unix, actor_type, actor_id, agent_id, request_id,
|
||||
event_type, outcome, latency_ms, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, 'test', NULL, NULL, NULL, 'test', 'ok', NULL, 'old', '{}')",
|
||||
old_evt,
|
||||
"old-evt",
|
||||
old_ts
|
||||
)
|
||||
.execute(&ts.store().pool)
|
||||
.await
|
||||
.expect("old audit event should insert");
|
||||
|
||||
let old_atr = format!("{:020}:old-atr", old_ts);
|
||||
sqlx::query!(
|
||||
"INSERT INTO alert_transitions
|
||||
(transition_key, transition_id, ts_unix, alert_id, kind, agent_id,
|
||||
from_status, to_status, message, metadata_json)
|
||||
VALUES (?1, ?2, ?3, 'alert-old', 'test', NULL, NULL, 'active', 'old', '{}')",
|
||||
old_atr,
|
||||
"old-atr",
|
||||
old_ts
|
||||
)
|
||||
.execute(&ts.store().pool)
|
||||
.await
|
||||
.expect("old alert transition should insert");
|
||||
|
||||
ts.store()
|
||||
.append_audit_event(crate::state::AuditEventInput {
|
||||
actor_type: "test".into(),
|
||||
actor_id: None,
|
||||
agent_id: None,
|
||||
request_id: None,
|
||||
event_type: "test".into(),
|
||||
outcome: "ok".into(),
|
||||
latency_ms: None,
|
||||
message: "fresh".into(),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
.expect("fresh audit event should insert");
|
||||
|
||||
let stats = ts
|
||||
.store()
|
||||
.gc_retention(Duration::from_secs(3600))
|
||||
.await
|
||||
.expect("retention gc should succeed");
|
||||
|
||||
assert_eq!(stats.audit_events_removed, 1);
|
||||
assert_eq!(stats.alert_transitions_removed, 1);
|
||||
|
||||
let old_audit_gone = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM audit_events WHERE event_id = ?1",
|
||||
"old-evt"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(old_audit_gone, 0);
|
||||
|
||||
let fresh_audit_remains = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM audit_events WHERE message = ?1",
|
||||
"fresh"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(fresh_audit_remains, 1);
|
||||
|
||||
let old_transition_gone = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM alert_transitions WHERE transition_id = ?1",
|
||||
"old-atr"
|
||||
)
|
||||
.fetch_one(&ts.store().pool)
|
||||
.await
|
||||
.expect("read should succeed");
|
||||
assert_eq!(old_transition_gone, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enroll_rejects_expired_token() {
|
||||
let ts = TestStore::new().await;
|
||||
@@ -209,7 +327,11 @@ mod tests {
|
||||
.expect("nickname set should succeed");
|
||||
assert!(updated);
|
||||
|
||||
let listed = ts.store().list_agents_with_nicknames().await;
|
||||
let listed = ts
|
||||
.store()
|
||||
.list_agents_with_nicknames()
|
||||
.await
|
||||
.expect("list agents with nicknames should succeed");
|
||||
assert!(listed.iter().any(|(id, name)| {
|
||||
id == &issued.agent_id && name.as_deref() == Some("kitchen-router")
|
||||
}));
|
||||
@@ -221,7 +343,11 @@ mod tests {
|
||||
.expect("nickname clear should succeed");
|
||||
assert!(cleared);
|
||||
|
||||
let listed = ts.store().list_agents_with_nicknames().await;
|
||||
let listed = ts
|
||||
.store()
|
||||
.list_agents_with_nicknames()
|
||||
.await
|
||||
.expect("list agents with nicknames should succeed");
|
||||
assert!(
|
||||
listed
|
||||
.iter()
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
/// Best-effort audit append: logs the failure with event context instead of
|
||||
/// bubbling it up. Auditing must never fail the operation that triggered it.
|
||||
pub async fn audit(&self, input: AuditEventInput) {
|
||||
let event_type = input.event_type.clone();
|
||||
let outcome = input.outcome.clone();
|
||||
let agent_id = input.agent_id.clone();
|
||||
let request_id = input.request_id.clone();
|
||||
if let Err(err) = self.append_audit_event(input).await {
|
||||
warn!(
|
||||
error = %err,
|
||||
event_type = %event_type,
|
||||
outcome = %outcome,
|
||||
agent_id = ?agent_id,
|
||||
request_id = ?request_id,
|
||||
"failed to append audit event"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> {
|
||||
let event = AuditEvent {
|
||||
event_id: format!("evt-{}", Uuid::new_v4()),
|
||||
|
||||
@@ -154,6 +154,37 @@ impl Store {
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Prunes audit and alert history older than the retention window.
|
||||
///
|
||||
/// Runs from the daemon's periodic maintenance loop; both tables have a
|
||||
/// `ts_unix` index so the deletes stay cheap as history grows.
|
||||
pub async fn gc_retention(&self, older_than: Duration) -> Result<RetentionStats> {
|
||||
let cutoff = i64::try_from(now_unix().saturating_sub(older_than.as_secs().max(1)))
|
||||
.context("retention cutoff does not fit SQLite integer")?;
|
||||
let audit_events_removed =
|
||||
sqlx::query!("DELETE FROM audit_events WHERE ts_unix < ?1", cutoff)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed pruning old audit events")?
|
||||
.rows_affected();
|
||||
let alert_transitions_removed =
|
||||
sqlx::query!("DELETE FROM alert_transitions WHERE ts_unix < ?1", cutoff)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed pruning old alert transitions")?
|
||||
.rows_affected();
|
||||
if audit_events_removed > 0 || alert_transitions_removed > 0 {
|
||||
info!(
|
||||
audit_events_removed,
|
||||
alert_transitions_removed, "pruned state rows older than retention window"
|
||||
);
|
||||
}
|
||||
Ok(RetentionStats {
|
||||
audit_events_removed,
|
||||
alert_transitions_removed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_schema_version(&self) -> Result<()> {
|
||||
match sqlx::query_scalar!("SELECT value FROM meta WHERE key = ?1", SCHEMA_VERSION_KEY)
|
||||
.fetch_optional(&self.pool)
|
||||
|
||||
@@ -17,7 +17,8 @@ impl Store {
|
||||
};
|
||||
|
||||
let now = now_unix();
|
||||
if expires_at_unix as u64 <= now {
|
||||
let now_i64 = i64::try_from(now).context("current time does not fit SQLite integer")?;
|
||||
if expires_at_unix <= now_i64 {
|
||||
sqlx::query!("DELETE FROM enroll_tokens WHERE token = ?1", enroll_token)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
@@ -165,20 +166,16 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_agents(&self) -> Vec<String> {
|
||||
match sqlx::query_scalar!(r#"SELECT agent_id as "agent_id!" FROM agents ORDER BY agent_id"#)
|
||||
pub async fn list_agents(&self) -> Result<Vec<String>> {
|
||||
let ids =
|
||||
sqlx::query_scalar!(r#"SELECT agent_id as "agent_id!" FROM agents ORDER BY agent_id"#)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(out) => out,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agents from state db");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
.context("failed listing agents from state db")?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub async fn list_agents_with_nicknames(&self) -> Vec<(String, Option<String>)> {
|
||||
pub async fn list_agents_with_nicknames(&self) -> Result<Vec<(String, Option<String>)>> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT agents.agent_id as "agent_id!", agent_meta.nickname
|
||||
FROM agents
|
||||
@@ -186,10 +183,10 @@ impl Store {
|
||||
ORDER BY agents.agent_id"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
.context("failed listing agents from state db")?;
|
||||
|
||||
match rows {
|
||||
Ok(rows) => rows
|
||||
let agents = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let nickname = row
|
||||
@@ -198,11 +195,7 @@ impl Store {
|
||||
.filter(|v| !v.is_empty());
|
||||
(row.agent_id, nickname)
|
||||
})
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to list agents from state db");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
.collect();
|
||||
Ok(agents)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ pub struct StateStats {
|
||||
pub expired_enroll_token_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RetentionStats {
|
||||
pub audit_events_removed: u64,
|
||||
pub alert_transitions_removed: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownDevice {
|
||||
pub device_id: String,
|
||||
|
||||
@@ -9,8 +9,9 @@ use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, info_span, warn};
|
||||
use uuid::Uuid;
|
||||
use wakey_agent::protocol::{
|
||||
AgentCapability, AgentTerminalSession, ErrorPayload, RequestId, ServerMessage, TerminalControl,
|
||||
TerminalId,
|
||||
AgentCapability, AgentCapabilityOptions, AgentTerminalSession, DEFAULT_TERMINAL_MAX_SESSIONS,
|
||||
DEFAULT_TERMINAL_SESSION_TTL_SECONDS, ErrorPayload, RequestId, ServerMessage,
|
||||
TerminalCapabilityOptions, TerminalControl, TerminalId, checked_terminal_session_ttl,
|
||||
};
|
||||
use wakey_core::Device;
|
||||
|
||||
@@ -24,6 +25,8 @@ enum IncomingClientMessage {
|
||||
agent_id: String,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<AgentCapability>,
|
||||
#[serde(default)]
|
||||
capability_options: AgentCapabilityOptions,
|
||||
},
|
||||
Auth {
|
||||
agent_id: String,
|
||||
@@ -59,6 +62,7 @@ struct AgentConnectionState {
|
||||
hello_agent_id: Option<String>,
|
||||
hello_at: Option<Instant>,
|
||||
capabilities: Vec<AgentCapability>,
|
||||
capability_options: AgentCapabilityOptions,
|
||||
}
|
||||
|
||||
pub async fn agent_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
||||
@@ -155,9 +159,9 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
||||
sessions.remove(&agent_id);
|
||||
}
|
||||
drop(sessions);
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id),
|
||||
@@ -168,10 +172,7 @@ async fn handle_agent_socket(state: AppState, socket: WebSocket) {
|
||||
message: "agent websocket disconnected".into(),
|
||||
metadata: serde_json::json!({}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for ws disconnect");
|
||||
}
|
||||
.await;
|
||||
}
|
||||
|
||||
writer.abort();
|
||||
@@ -193,6 +194,7 @@ async fn process_agent_text(
|
||||
IncomingClientMessage::Hello {
|
||||
agent_id,
|
||||
capabilities,
|
||||
capability_options,
|
||||
} => {
|
||||
if connection
|
||||
.hello_agent_id
|
||||
@@ -208,6 +210,27 @@ async fn process_agent_text(
|
||||
let connect_to_hello_ms = connected_at.elapsed().as_millis() as u64;
|
||||
info!(agent_id = %agent_id, connect_to_hello_ms, "agent hello received");
|
||||
connection.hello_agent_id = Some(agent_id);
|
||||
let terminal = if capabilities.contains(&AgentCapability::Terminal) {
|
||||
let max_sessions = capability_options
|
||||
.terminal
|
||||
.as_ref()
|
||||
.map(|terminal| terminal.max_sessions)
|
||||
.unwrap_or(DEFAULT_TERMINAL_MAX_SESSIONS)
|
||||
.max(1);
|
||||
let session_ttl_seconds = capability_options
|
||||
.terminal
|
||||
.as_ref()
|
||||
.map(|terminal| terminal.session_ttl_seconds)
|
||||
.unwrap_or(DEFAULT_TERMINAL_SESSION_TTL_SECONDS);
|
||||
checked_terminal_session_ttl(session_ttl_seconds).map_err(anyhow::Error::msg)?;
|
||||
Some(TerminalCapabilityOptions {
|
||||
max_sessions,
|
||||
session_ttl_seconds,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
connection.capability_options = AgentCapabilityOptions { terminal };
|
||||
connection.capabilities = capabilities;
|
||||
}
|
||||
IncomingClientMessage::Auth {
|
||||
@@ -225,9 +248,9 @@ async fn process_agent_text(
|
||||
.await
|
||||
{
|
||||
warn!(agent_id = %agent_id, "agent auth rejected");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id),
|
||||
@@ -241,10 +264,7 @@ async fn process_agent_text(
|
||||
"hello_to_auth_ms": hello_to_auth_ms,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for auth rejection");
|
||||
}
|
||||
.await;
|
||||
anyhow::bail!("agent auth rejected");
|
||||
}
|
||||
state.sessions.write().await.insert(
|
||||
@@ -253,13 +273,14 @@ async fn process_agent_text(
|
||||
connection_id: connection_id.to_string(),
|
||||
tx: tx.clone(),
|
||||
capabilities: connection.capabilities.clone(),
|
||||
capability_options: connection.capability_options.clone(),
|
||||
},
|
||||
);
|
||||
connection.authed_agent_id = Some(agent_id.clone());
|
||||
info!(agent_id = %agent_id, connect_to_auth_ms, hello_to_auth_ms = hello_to_auth_ms.unwrap_or(0), "agent authenticated");
|
||||
if let Err(err) = state
|
||||
state
|
||||
.store
|
||||
.append_audit_event(AuditEventInput {
|
||||
.audit(AuditEventInput {
|
||||
actor_type: "agent".into(),
|
||||
actor_id: Some(agent_id.clone()),
|
||||
agent_id: Some(agent_id),
|
||||
@@ -273,10 +294,7 @@ async fn process_agent_text(
|
||||
"hello_to_auth_ms": hello_to_auth_ms,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "failed to append audit event for auth success");
|
||||
}
|
||||
.await;
|
||||
let _ = tx.send(SessionEvent::Message(ServerMessage::SyncDeviceSnapshot));
|
||||
}
|
||||
IncomingClientMessage::Heartbeat { agent_id } => {
|
||||
@@ -424,10 +442,13 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use wakey_agent::protocol::AgentCapabilityOptions;
|
||||
|
||||
use crate::runtime::AgentSession;
|
||||
|
||||
use super::{AgentConnectionState, is_current_session, validate_auth_identity};
|
||||
use super::{
|
||||
AgentConnectionState, IncomingClientMessage, is_current_session, validate_auth_identity,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn current_session_check_rejects_stale_connection_ids() {
|
||||
@@ -439,6 +460,7 @@ mod tests {
|
||||
connection_id: "conn-new".to_string(),
|
||||
tx,
|
||||
capabilities: Vec::new(),
|
||||
capability_options: AgentCapabilityOptions::default(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -456,4 +478,20 @@ mod tests {
|
||||
assert!(validate_auth_identity(&connection, "agent-a").is_ok());
|
||||
assert!(validate_auth_identity(&connection, "agent-b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_hello_without_capability_options_deserializes() {
|
||||
let message: IncomingClientMessage = serde_json::from_str(
|
||||
r#"{"type":"hello","agent_id":"agent-a","capabilities":["terminal"]}"#,
|
||||
)
|
||||
.expect("deserialize legacy hello");
|
||||
|
||||
let IncomingClientMessage::Hello {
|
||||
capability_options, ..
|
||||
} = message
|
||||
else {
|
||||
panic!("expected hello");
|
||||
};
|
||||
assert_eq!(capability_options, AgentCapabilityOptions::default());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-core"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wakey-linux"
|
||||
version = "0.3.2"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
publish = ["gitea"]
|
||||
|
||||
|
||||
@@ -27,8 +27,18 @@ pub struct TerminalWriter {
|
||||
}
|
||||
|
||||
impl TerminalPty {
|
||||
/// Starts `program` attached to a newly allocated PTY.
|
||||
pub fn spawn(program: &Path, rows: u16, cols: u16) -> Result<Self> {
|
||||
/// Starts a configured program attached to a newly allocated PTY.
|
||||
///
|
||||
/// Wakey retains control of the PTY streams, terminal environment, and
|
||||
/// kill-on-drop behavior; callers may only select arguments and a working
|
||||
/// directory.
|
||||
pub fn spawn(
|
||||
program: &Path,
|
||||
args: &[String],
|
||||
current_dir: Option<&Path>,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
) -> Result<Self> {
|
||||
let (pty, pts) = pty_process::open().context("failed to open PTY")?;
|
||||
pty.resize(Size::new(rows, cols))
|
||||
.context("failed to set initial PTY size")?;
|
||||
@@ -39,10 +49,14 @@ impl TerminalPty {
|
||||
|
||||
// The remote frontend is xterm.js regardless of the daemon's own
|
||||
// environment, so advertise the terminal the child actually receives.
|
||||
let command = Command::new(program)
|
||||
let mut command = Command::new(program)
|
||||
.args(args)
|
||||
.env("TERM", "xterm-256color")
|
||||
.env("COLORTERM", "truecolor")
|
||||
.kill_on_drop(true);
|
||||
if let Some(current_dir) = current_dir {
|
||||
command = command.current_dir(current_dir);
|
||||
}
|
||||
let child = command
|
||||
.spawn(pts)
|
||||
.with_context(|| format!("failed to spawn {} in PTY", program.display()))?;
|
||||
@@ -114,8 +128,10 @@ mod tests {
|
||||
///
|
||||
/// `./scripts/test_remote.ps1 -Package wakey-linux -Filter terminal::tests::pty_round_trip_and_resize -Exact -ShowOutput`
|
||||
#[tokio::test]
|
||||
#[ignore = "live system check, requires PTY and shell"]
|
||||
async fn pty_round_trip_and_resize() {
|
||||
let (mut reader, mut writer, mut child) = TerminalPty::spawn(Path::new("/bin/sh"), 24, 80)
|
||||
let (mut reader, mut writer, mut child) =
|
||||
TerminalPty::spawn(Path::new("/bin/sh"), &[], None, 24, 80)
|
||||
.expect("spawn PTY shell")
|
||||
.into_parts();
|
||||
|
||||
|
||||
Reference in new issue
Block a user