16 Commits
37 changed files with 2023 additions and 343 deletions

No files matched your search

Generated
+6 -6
View File
@@ -3624,7 +3624,7 @@ dependencies = [
[[package]]
name = "wakey"
version = "0.3.2"
version = "0.3.4"
dependencies = [
"anyhow",
"chrono",
@@ -3645,7 +3645,7 @@ dependencies = [
[[package]]
name = "wakey-agent"
version = "0.3.2"
version = "0.3.4"
dependencies = [
"anyhow",
"clap",
@@ -3670,7 +3670,7 @@ dependencies = [
[[package]]
name = "wakey-control-plane"
version = "0.3.2"
version = "0.3.4"
dependencies = [
"anyhow",
"axum",
@@ -3699,7 +3699,7 @@ dependencies = [
[[package]]
name = "wakey-core"
version = "0.3.2"
version = "0.3.4"
dependencies = [
"macaddr",
"serde",
@@ -3711,7 +3711,7 @@ dependencies = [
[[package]]
name = "wakey-linux"
version = "0.3.2"
version = "0.3.4"
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
View File
@@ -1,6 +1,6 @@
[package]
name = "wakey"
version = "0.3.2"
version = "0.3.4"
edition = "2024"
publish = ["gitea"]
@@ -80,6 +80,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.
+111
View File
@@ -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()
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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.290",
"@xterm/addon-fit": "0.12.0-beta.290",
"@xterm/addon-image": "0.10.0-beta.290",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.290",
"@xterm/addon-unicode11": "0.10.0-beta.290",
"@xterm/addon-web-links": "0.13.0-beta.290",
"@xterm/xterm": "6.1.0-beta.290",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
+61 -36
View File
@@ -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.290
version: 0.3.0-beta.290(@xterm/[email protected])
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
specifier: 0.12.0-beta.290
version: 0.12.0-beta.290(@xterm/[email protected])
'@xterm/addon-image':
specifier: 0.10.0-beta.290
version: 0.10.0-beta.290(@xterm/[email protected])
'@xterm/addon-unicode-graphemes':
specifier: ^0.4.0
version: 0.4.0
specifier: 0.5.0-beta.290
version: 0.5.0-beta.290(@xterm/[email protected])
'@xterm/addon-unicode11':
specifier: ^0.9.0
version: 0.9.0
specifier: 0.10.0-beta.290
version: 0.10.0-beta.290(@xterm/[email protected])
'@xterm/addon-web-links':
specifier: ^0.12.0
version: 0.12.0
specifier: 0.13.0-beta.290
version: 0.13.0-beta.290(@xterm/[email protected])
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
specifier: 6.1.0-beta.290
version: 6.1.0-beta.290
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.290':
resolution: {integrity: sha512-ky44+eNKIxL9clMgrWCBRSRe1Y6Oj+ayNavySfFioScxpuyicu/GYaF7n97eqW3FW0eClElCtsK+wJ6AGqnJmw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.290
'@xterm/[email protected]1.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/[email protected]2.0-beta.290':
resolution: {integrity: sha512-mJ77s82VhARA8XhuWcUnWYKAZkEDzSnCzgBrT9qjHJlHWr+G5duDZcOBnxnVVNtp21LDKaONr3c7IxuLii1XNg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.290
'@xterm/addon-[email protected].0':
resolution: {integrity: sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==}
'@xterm/addon-[email protected]0':
resolution: {integrity: sha512-gzQfH3tACsdf52n0lY3QMwLY7HcII1ce+IvzphWs9UucQIZ3rBKjEkYz4sxBhhEP1Eb6dNn1uerrfoJECmcyGg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.290
'@xterm/addon-unicode[email protected].0':
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
'@xterm/addon-unicode[email protected]0':
resolution: {integrity: sha512-c0hF4HJrbZUlb1cVXNldl6YRf+Udoe3nc80djNnxSzjHgmryT09DhL4U4IDcCqSkzgwMENnN65mexlYKyNXNBw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.290
'@xterm/addon-[email protected].0':
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
'@xterm/addon-[email protected]0':
resolution: {integrity: sha512-tSmvf6hX1tT4RAgqbC8moZc/2oLR3qkaAL0xHaSMPd5bch2wkSvjfTxDfb3cfXJLyKCkWR+uwH54JYNOnCTW5g==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.290
'@xterm/[email protected].0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
'@xterm/[email protected]0':
resolution: {integrity: sha512-p0OL4dPrmETt+6sFUniSjiV2g5nV35ckipVZQv1uuZ8GKtHYvlDTJj4Fr+cHzrsNmDhceaaiIW6fMPynJtCm1w==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.290
'@xterm/[email protected]':
resolution: {integrity: sha512-lbzQN1cx0h9uLUWQM7l6HxjP0qPMoqhc1ahlwmqgbSALpTeOUAQfMhMhHr0TSGRIcv7S4grvhhzQCb6teFrZ7Q==}
[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.290(@xterm/[email protected])':
dependencies:
js-base64: 3.8.1
'@xterm/xterm': 6.1.0-beta.290
'@xterm/[email protected]1.0': {}
'@xterm/[email protected]2.0-beta.290(@xterm/[email protected])':
dependencies:
'@xterm/xterm': 6.1.0-beta.290
'@xterm/addon-[email protected]': {}
'@xterm/addon-[email protected](@xterm/[email protected])':
dependencies:
'@xterm/xterm': 6.1.0-beta.290
'@xterm/addon-unicode[email protected]': {}
'@xterm/addon-unicode[email protected](@xterm/[email protected])':
dependencies:
'@xterm/xterm': 6.1.0-beta.290
'@xterm/addon-[email protected]': {}
'@xterm/addon-[email protected](@xterm/[email protected])':
dependencies:
'@xterm/xterm': 6.1.0-beta.290
'@xterm/[email protected]': {}
'@xterm/[email protected](@xterm/[email protected])':
dependencies:
'@xterm/xterm': 6.1.0-beta.290
'@xterm/[email protected]': {}
[email protected]:
dependencies:
@@ -3403,8 +3430,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
+4
View File
@@ -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;
@@ -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
```
+329 -196
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } 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 { Eraser, Plus, RotateCcw, Terminal, X } from "lucide-react";
import { Eraser, Keyboard, RotateCcw, Terminal } from "lucide-react";
import { toast } from "sonner";
import {
@@ -25,6 +26,24 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
MAX_TERMINAL_FONT_SIZE,
MIN_TERMINAL_FONT_SIZE,
TerminalAccessoryBar,
} from "@/pages/terminal/TerminalAccessoryBar";
import { TerminalSessionTabs } from "@/pages/terminal/TerminalSessionTabs";
import {
NO_TERMINAL_MODIFIERS,
applyTerminalModifiers,
mergeTerminalSession,
orderTerminalSessions,
reconcileTerminalSessions,
restoreCandidates,
visibleTerminalText,
type TerminalConnectionState,
type TerminalModifiers,
} from "@/pages/terminal/sessionUtils";
import { loadTerminalFontFamily } from "@/terminal/terminalFonts";
type Props = {
agents: Agent[];
@@ -32,15 +51,17 @@ type Props = {
onSelectAgent: (agentId: string) => void;
};
type ConnectionState =
| "idle"
| "connecting"
| "ready"
| "disconnected"
| "exited";
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
const DEFAULT_TERMINAL_FONT_SIZE = 14;
const TERMINAL_TOUCH_SLOP_PX = 8;
type TerminalTouch = {
pointerId: number;
startX: number;
startY: number;
moved: boolean;
};
// polyfill for testing in browsers that don't support crypto.randomUUID()
const thing = () =>
@@ -63,75 +84,17 @@ function terminalOperatorId(): string {
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",
});
}
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);
@@ -139,14 +102,36 @@ export function TerminalPage({
const activeSessionRef = useRef<TerminalSession | null>(null);
const selectedAgentIdRef = useRef(selectedAgentId);
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
const modifiersRef = useRef<TerminalModifiers>(NO_TERMINAL_MODIFIERS);
const terminalTouchRef = useRef<TerminalTouch | null>(null);
const terminalInputFocusedBeforeAccessoryRef = useRef(false);
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [session, setSession] = useState<TerminalSession | null>(null);
const [connection, setConnection] = useState<ConnectionState>("idle");
const [connection, setConnection] = useState<TerminalConnectionState>("idle");
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
{},
);
const [terminalFontFamily, setTerminalFontFamily] = useState<string>();
const [terminalFontSize, setTerminalFontSize] = useState(
DEFAULT_TERMINAL_FONT_SIZE,
);
const [nowUnix, setNowUnix] = useState(() => Math.floor(Date.now() / 1000));
const [modifiers, setModifiers] = useState<TerminalModifiers>(
NO_TERMINAL_MODIFIERS,
);
const [accessoriesOpen, setAccessoriesOpen] = useState(
() => window.matchMedia("(pointer: coarse), (max-width: 640px)").matches,
);
const [operatorId] = useState(terminalOperatorId);
useEffect(() => {
const clock = window.setInterval(
() => setNowUnix(Math.floor(Date.now() / 1000)),
30_000,
);
return () => window.clearInterval(clock);
}, []);
activeSessionRef.current = session;
selectedAgentIdRef.current = selectedAgentId;
@@ -156,11 +141,18 @@ 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";
connection !== "connecting",
);
const terminalInputReady = connection === "ready";
const detachTransport = useCallback(() => {
const socket = socketRef.current;
@@ -168,6 +160,101 @@ export function TerminalPage({
socket?.close();
}, []);
const writeTerminalData = useCallback(
(data: string, consumeModifiers = false) => {
const socket = socketRef.current;
if (socket?.readyState !== WebSocket.OPEN) return;
const activeModifiers = modifiersRef.current;
const output = consumeModifiers
? applyTerminalModifiers(data, activeModifiers)
: data;
socket.send(new TextEncoder().encode(output));
if (consumeModifiers && (activeModifiers.ctrl || activeModifiers.meta)) {
modifiersRef.current = NO_TERMINAL_MODIFIERS;
setModifiers(NO_TERMINAL_MODIFIERS);
}
},
[],
);
const toggleModifier = useCallback((modifier: keyof TerminalModifiers) => {
setModifiers((current) => {
const next = { ...current, [modifier]: !current[modifier] };
modifiersRef.current = next;
return next;
});
window.requestAnimationFrame(() => terminalRef.current?.focus());
}, []);
const copyTerminalText = 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;
}
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 pasteTerminalText = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
if (text) writeTerminalData(text);
terminalRef.current?.focus();
} catch (error) {
toast.error("Clipboard access was denied", {
description: String(error),
});
}
}, [writeTerminalData]);
const writeAccessoryKey = useCallback(
(data: string) => {
writeTerminalData(data);
window.requestAnimationFrame(() => terminalRef.current?.focus());
},
[writeTerminalData],
);
const toggleTerminalKeyboard = useCallback(() => {
const terminal = terminalRef.current;
if (!terminal) return;
const blurTerminalInput = () => {
terminal.textarea?.blur();
const activeElement = document.activeElement;
if (
activeElement instanceof HTMLElement &&
hostRef.current?.contains(activeElement)
) {
activeElement.blur();
}
};
const shouldHide =
terminalInputFocusedBeforeAccessoryRef.current ||
Boolean(hostRef.current?.contains(document.activeElement));
terminalInputFocusedBeforeAccessoryRef.current = false;
if (shouldHide) {
blurTerminalInput();
// Base UI may restore the previously focused element as its pointer
// interaction completes. Dismiss the soft keyboard after that cycle too.
window.requestAnimationFrame(blurTerminalInput);
} else {
terminal.focus();
}
}, []);
const sendResize = useCallback(() => {
const terminal = terminalRef.current;
const socket = socketRef.current;
@@ -191,6 +278,26 @@ export function TerminalPage({
);
}, []);
const changeTerminalFontSize = 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;
setTerminalFontSize(next);
window.requestAnimationFrame(() => {
fitRef.current?.fit();
sendResize();
});
},
[sendResize],
);
const connect = useCallback(
(nextSession: TerminalSession) => {
if (!nextSession.attachment_token) {
@@ -286,16 +393,46 @@ export function TerminalPage({
);
useEffect(() => {
if (!hostRef.current) return;
let cancelled = false;
void loadTerminalFontFamily().then((fontFamily) => {
if (!cancelled) setTerminalFontFamily(fontFamily);
});
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);
};
}, []);
useEffect(() => {
if (!hostRef.current || !terminalFontFamily) return;
const terminal = new XTerm({
cursorBlink: true,
convertEol: false,
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
fontSize: 14,
fontFamily: terminalFontFamily,
fontSize: DEFAULT_TERMINAL_FONT_SIZE,
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: {
scrollbar: {
width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16,
},
scrollback: 5000,
@@ -313,6 +450,16 @@ export function TerminalPage({
terminal.unicode.activeVersion = "11";
terminal.loadAddon(new UnicodeGraphemesAddon());
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(
new ImageAddon({
// // Keep image terminals useful on phones without accepting the addon's
// // much larger default decode and retained-canvas memory ceilings.
// pixelLimit: 4 * 1024 * 1024,
// sixelSizeLimit: 8 * 1024 * 1024,
// iipSizeLimit: 8 * 1024 * 1024,
// storageLimit: 32,
}),
);
const fit = new FitAddon();
terminal.loadAddon(new ClipboardAddon());
terminal.loadAddon(fit);
@@ -407,19 +554,7 @@ export function TerminalPage({
}
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));
}
});
const input = terminal.onData((data) => writeTerminalData(data, true));
let resizeFrame = 0;
let lastHostWidth = 0;
let lastHostHeight = 0;
@@ -464,7 +599,13 @@ export function TerminalPage({
terminalRef.current = null;
fitRef.current = null;
};
}, [connect, detachTransport, sendResize]);
}, [
connect,
detachTransport,
sendResize,
terminalFontFamily,
writeTerminalData,
]);
async function start() {
if (!selectedAgentId || !terminalRef.current) return;
@@ -610,7 +751,11 @@ export function TerminalPage({
}
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 />
@@ -643,112 +788,20 @@ 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={session?.terminal_id}
connection={connection}
sessionTitles={sessionTitles}
nowUnix={nowUnix}
canRequestStart={canRequestStart}
agentAtSessionLimit={agentAtSessionLimit}
selectedAgentSessionLimit={selectedAgentSessionLimit}
onActivate={(item) => void activateSession(item)}
onClose={(item) => void closeSession(item)}
onStart={() => void 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
@@ -770,6 +823,27 @@ export function TerminalPage({
Reconnect
</Button>
) : null}
<Tooltip>
<TooltipTrigger>
<Button
type="button"
size="icon"
variant="ghost"
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
@@ -787,7 +861,66 @@ export function TerminalPage({
</Tooltip>
</div>
</div>
<div className="terminal-surface" ref={hostRef} />
{accessoriesOpen ? (
<TerminalAccessoryBar
inputReady={terminalInputReady}
modifiers={modifiers}
fontSize={terminalFontSize}
onRememberInputFocus={() => {
terminalInputFocusedBeforeAccessoryRef.current = Boolean(
hostRef.current?.contains(document.activeElement),
);
}}
onToggleModifier={toggleModifier}
onWriteKey={writeAccessoryKey}
onScrollPages={(pages) => terminalRef.current?.scrollPages(pages)}
onCopy={() => void copyTerminalText()}
onPaste={() => void pasteTerminalText()}
onToggleKeyboard={toggleTerminalKeyboard}
onChangeFontSize={changeTerminalFontSize}
/>
) : null}
<div
className="terminal-surface"
ref={hostRef}
onPointerDown={(event) => {
if (event.pointerType !== "touch") {
terminalRef.current?.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"
) {
terminalRef.current?.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,189 @@
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}
aria-label={`Close ${agentLabel} terminal session`}
title={
locked ? "Attached in another browser" : "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>
);
}
+113
View File
@@ -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");
}
+103 -3
View File
@@ -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 {
+8
View File
@@ -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;
}
+31
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"ignoreDeprecations": "5.0",
"moduleResolution": "Bundler",
+1 -1
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "wakey-agent"
version = "0.3.2"
version = "0.3.4"
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" }
+94 -3
View File
@@ -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());
}
}
+3
View File
@@ -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,
},
};
+102
View File
@@ -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);
}
}
+13 -1
View File
@@ -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?;
+67 -3
View File
@@ -52,6 +52,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 +92,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 +115,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 +125,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 +188,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 +223,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,7 +241,13 @@ 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;
@@ -234,8 +256,10 @@ async fn run_terminal(
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 {
tokio::select! {
biased;
@@ -243,6 +267,15 @@ async fn run_terminal(
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)));
@@ -355,7 +388,7 @@ async fn run_terminal(
let _ = output.try_send(Message::Text(text.into()));
}
}
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(())
}
@@ -503,6 +536,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 +633,7 @@ mod tests {
cancel: Some(cancel),
relay_credentials,
created_at_unix: 42,
session_ttl_seconds: 600,
},
)]))),
max_sessions: 2,
@@ -652,6 +687,7 @@ mod tests {
cancel: Some(cancel),
relay_credentials,
created_at_unix: 42,
session_ttl_seconds: 600,
},
)])));
let manager = TerminalManager {
@@ -694,6 +730,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);
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wakey-control-plane"
version = "0.3.2"
version = "0.3.4"
edition = "2024"
publish = ["gitea"]
+8 -2
View File
@@ -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)]
@@ -49,6 +51,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,
})
+54 -23
View File
@@ -5,16 +5,16 @@ use axum::http::StatusCode;
use axum::response::Response;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::time::Duration;
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 +40,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 +53,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 +69,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 +114,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 +141,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 +153,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 +161,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 +192,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 +200,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),
}))
}
@@ -343,10 +365,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 +382,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 +436,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 +572,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(),
+61 -2
View File
@@ -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);
}
}
+3 -2
View File
@@ -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;
@@ -50,6 +50,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 +71,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))
+193 -44
View File
@@ -9,9 +9,7 @@ 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;
@@ -31,7 +29,7 @@ pub struct TerminalRegistry {
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 +48,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 +56,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,
}
@@ -75,14 +75,32 @@ impl TerminalRegistry {
}
}
pub async fn create(&self, agent_id: String) -> Result<CreatedTerminal, &'static str> {
#[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);
if sessions
.values()
.filter(|session| session.agent_id == agent_id)
.count()
>= TERMINAL_MAX_SESSIONS_PER_AGENT
>= max_sessions.max(1)
{
return Err("agent_terminal_limit_reached");
}
@@ -94,12 +112,14 @@ impl TerminalRegistry {
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let expiry = expiry_for_created_at(created_at_unix, session_ttl_seconds)
.ok_or("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()),
@@ -119,6 +139,7 @@ impl TerminalRegistry {
relay_token,
attachment_token,
created_at_unix,
expires_at_unix: expiry.unix(),
})
}
@@ -169,7 +190,10 @@ 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 = {
@@ -192,8 +216,10 @@ impl TerminalRegistry {
let mut credentials = Vec::new();
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();
@@ -202,7 +228,7 @@ impl TerminalRegistry {
.or_insert_with(|| 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,
@@ -422,17 +448,12 @@ impl TerminalRegistry {
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(),
)
})
sessions
.get(terminal_id)
.map(|session| terminal_summary(terminal_id, session))
}
pub async fn summaries(&self) -> Vec<TerminalSummary> {
@@ -440,26 +461,31 @@ impl TerminalRegistry {
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));
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,
) -> 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);
return Err("terminal_expired");
@@ -499,22 +525,48 @@ fn prune_tombstones(closed: &mut HashMap<String, Instant>) {
fn prune_expired_sessions(sessions: &mut HashMap<String, TerminalSession>) {
let now = Instant::now();
sessions.retain(|_, session| session.expires_at > now);
sessions.retain(|_, session| !session.expiry.is_expired(now));
}
/// 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 +580,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 +696,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 +783,7 @@ mod tests {
let reported = AgentTerminalSession {
terminal_id: terminal_id.clone(),
created_at_unix,
session_ttl_seconds: 0,
};
let credentials = registry
@@ -679,9 +800,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 +816,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 +835,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 +864,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 +890,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 +931,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 +972,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]
+50 -3
View File
@@ -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 {
@@ -193,6 +197,7 @@ async fn process_agent_text(
IncomingClientMessage::Hello {
agent_id,
capabilities,
capability_options,
} => {
if connection
.hello_agent_id
@@ -208,6 +213,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 {
@@ -253,6 +279,7 @@ 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());
@@ -424,10 +451,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 +469,7 @@ mod tests {
connection_id: "conn-new".to_string(),
tx,
capabilities: Vec::new(),
capability_options: AgentCapabilityOptions::default(),
},
);
@@ -456,4 +487,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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wakey-core"
version = "0.3.2"
version = "0.3.4"
edition = "2024"
publish = ["gitea"]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wakey-linux"
version = "0.3.2"
version = "0.3.4"
edition = "2024"
publish = ["gitea"]
+20 -4
View File
@@ -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();