operator id
This commit is contained in:
+8
-2
@@ -251,10 +251,16 @@ export function listTerminals(): Promise<TerminalSession[]> {
|
|||||||
return request<TerminalSession[]>("/api/v1/control/terminals");
|
return request<TerminalSession[]>("/api/v1/control/terminals");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachTerminal(terminalId: string): Promise<TerminalSession> {
|
export function attachTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
operatorId: string,
|
||||||
|
): Promise<TerminalSession> {
|
||||||
return request<TerminalSession>(
|
return request<TerminalSession>(
|
||||||
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
|
`/api/v1/control/terminals/${encodeURIComponent(terminalId)}/attach`,
|
||||||
{ method: "POST" },
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ operator_id: operatorId }),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+169
-77
@@ -5,7 +5,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
|||||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { Terminal as XTerm } from "@xterm/xterm";
|
import { Terminal as XTerm } from "@xterm/xterm";
|
||||||
import { Eraser, PlugZap, RotateCcw, Square, Terminal } from "lucide-react";
|
import { Eraser, Plus, RotateCcw, Terminal, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +40,28 @@ type ConnectionState =
|
|||||||
| "exited";
|
| "exited";
|
||||||
|
|
||||||
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
|
||||||
const ATTACH_RETRY_DELAY_MS = 150;
|
const TERMINAL_OPERATOR_KEY = "wakey.terminal-operator-id";
|
||||||
|
|
||||||
|
// polyfill for testing in browsers that don't support crypto.randomUUID()
|
||||||
|
const thing = () =>
|
||||||
|
(String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: string) => {
|
||||||
|
const num = Number(c);
|
||||||
|
return (
|
||||||
|
num ^
|
||||||
|
(window.crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))
|
||||||
|
).toString(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
function terminalOperatorId(): string {
|
||||||
|
// Session storage survives route unmounts but remains scoped to this browser
|
||||||
|
// tab. The ID coordinates attachment ownership; API authentication remains
|
||||||
|
// the security boundary.
|
||||||
|
const remembered = window.sessionStorage.getItem(TERMINAL_OPERATOR_KEY);
|
||||||
|
if (remembered) return remembered;
|
||||||
|
const created = window.crypto.randomUUID?.() ?? thing();
|
||||||
|
window.sessionStorage.setItem(TERMINAL_OPERATOR_KEY, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeTerminalSession(
|
function mergeTerminalSession(
|
||||||
sessions: TerminalSession[],
|
sessions: TerminalSession[],
|
||||||
@@ -49,11 +70,31 @@ function mergeTerminalSession(
|
|||||||
const remaining = sessions.filter(
|
const remaining = sessions.filter(
|
||||||
(item) => item.terminal_id !== next.terminal_id,
|
(item) => item.terminal_id !== next.terminal_id,
|
||||||
);
|
);
|
||||||
return [next, ...remaining].sort(
|
return [...remaining, next].sort(
|
||||||
(left, right) => right.created_at_unix - left.created_at_unix,
|
(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(
|
function restoreCandidates(
|
||||||
sessions: TerminalSession[],
|
sessions: TerminalSession[],
|
||||||
rememberedId: string | null,
|
rememberedId: string | null,
|
||||||
@@ -74,25 +115,6 @@ function restoreCandidates(
|
|||||||
return remembered ? [remembered, ...available] : available;
|
return remembered ? [remembered, ...available] : available;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attachWhenAvailable(
|
|
||||||
terminalId: string,
|
|
||||||
attempts: number,
|
|
||||||
): Promise<TerminalSession> {
|
|
||||||
for (let attempt = 0; ; attempt += 1) {
|
|
||||||
try {
|
|
||||||
return await attachTerminal(terminalId);
|
|
||||||
} catch (error) {
|
|
||||||
const attachmentBusy =
|
|
||||||
error instanceof APIError &&
|
|
||||||
error.code === "terminal_operator_already_attached";
|
|
||||||
if (!attachmentBusy || attempt + 1 >= attempts) throw error;
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
window.setTimeout(resolve, ATTACH_RETRY_DELAY_MS),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function websocketUrl(path: string): string {
|
function websocketUrl(path: string): string {
|
||||||
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
return `${scheme}//${window.location.host}${path}`;
|
return `${scheme}//${window.location.host}${path}`;
|
||||||
@@ -120,6 +142,10 @@ export function TerminalPage({
|
|||||||
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
const [sessions, setSessions] = useState<TerminalSession[]>([]);
|
||||||
const [session, setSession] = useState<TerminalSession | null>(null);
|
const [session, setSession] = useState<TerminalSession | null>(null);
|
||||||
const [connection, setConnection] = useState<ConnectionState>("idle");
|
const [connection, setConnection] = useState<ConnectionState>("idle");
|
||||||
|
const [sessionTitles, setSessionTitles] = useState<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [operatorId] = useState(terminalOperatorId);
|
||||||
|
|
||||||
activeSessionRef.current = session;
|
activeSessionRef.current = session;
|
||||||
selectedAgentIdRef.current = selectedAgentId;
|
selectedAgentIdRef.current = selectedAgentId;
|
||||||
@@ -127,11 +153,14 @@ export function TerminalPage({
|
|||||||
const selectedAgent = agents.find(
|
const selectedAgent = agents.find(
|
||||||
(agent) => agent.agent_id === selectedAgentId,
|
(agent) => agent.agent_id === selectedAgentId,
|
||||||
);
|
);
|
||||||
const canStart =
|
const selectedAgentSessionCount = sessions.filter(
|
||||||
|
(item) => item.agent_id === selectedAgentId,
|
||||||
|
).length;
|
||||||
|
const agentAtSessionLimit = selectedAgentSessionCount >= 2;
|
||||||
|
const canRequestStart =
|
||||||
selectedAgent?.connected &&
|
selectedAgent?.connected &&
|
||||||
selectedAgent.capabilities.includes("terminal") &&
|
selectedAgent.capabilities.includes("terminal") &&
|
||||||
connection !== "connecting" &&
|
connection !== "connecting";
|
||||||
sessions.filter((item) => item.agent_id === selectedAgentId).length < 2;
|
|
||||||
|
|
||||||
const detachTransport = useCallback(() => {
|
const detachTransport = useCallback(() => {
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
@@ -192,6 +221,7 @@ export function TerminalPage({
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "attach",
|
type: "attach",
|
||||||
attachment_token: nextSession.attachment_token,
|
attachment_token: nextSession.attachment_token,
|
||||||
|
operator_id: operatorId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fitRef.current?.fit();
|
fitRef.current?.fit();
|
||||||
@@ -252,7 +282,7 @@ export function TerminalPage({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[detachTransport, sendResize],
|
[detachTransport, operatorId, sendResize],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -314,6 +344,20 @@ export function TerminalPage({
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
const titleChange = terminal.onTitleChange((title) => {
|
||||||
|
const terminalId = activeSessionRef.current?.terminal_id;
|
||||||
|
if (!terminalId) return;
|
||||||
|
const normalized = title.trim();
|
||||||
|
setSessionTitles((current) => {
|
||||||
|
if (current[terminalId] === normalized) return current;
|
||||||
|
if (!normalized) {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[terminalId];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return { ...current, [terminalId]: normalized };
|
||||||
|
});
|
||||||
|
});
|
||||||
fit.fit();
|
fit.fit();
|
||||||
terminalRef.current = terminal;
|
terminalRef.current = terminal;
|
||||||
fitRef.current = fit;
|
fitRef.current = fit;
|
||||||
@@ -322,7 +366,7 @@ export function TerminalPage({
|
|||||||
try {
|
try {
|
||||||
const listed = await listTerminals();
|
const listed = await listTerminals();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSessions(listed);
|
setSessions(orderTerminalSessions(listed));
|
||||||
const rememberedId = window.sessionStorage.getItem(
|
const rememberedId = window.sessionStorage.getItem(
|
||||||
REMEMBERED_TERMINAL_KEY,
|
REMEMBERED_TERMINAL_KEY,
|
||||||
);
|
);
|
||||||
@@ -335,12 +379,9 @@ export function TerminalPage({
|
|||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
setConnection("connecting");
|
setConnection("connecting");
|
||||||
try {
|
try {
|
||||||
// A remembered session may still belong to this page's previous
|
const attached = await attachTerminal(
|
||||||
// WebSocket while its close handshake reaches the control plane.
|
|
||||||
const attempts = candidate.terminal_id === rememberedId ? 8 : 1;
|
|
||||||
const attached = await attachWhenAvailable(
|
|
||||||
candidate.terminal_id,
|
candidate.terminal_id,
|
||||||
attempts,
|
operatorId,
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
terminal.reset();
|
terminal.reset();
|
||||||
@@ -399,7 +440,11 @@ export function TerminalPage({
|
|||||||
const sessionRefresh = window.setInterval(() => {
|
const sessionRefresh = window.setInterval(() => {
|
||||||
void listTerminals()
|
void listTerminals()
|
||||||
.then((listed) => {
|
.then((listed) => {
|
||||||
if (!cancelled) setSessions(listed);
|
if (!cancelled) {
|
||||||
|
setSessions((current) =>
|
||||||
|
reconcileTerminalSessions(current, listed),
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// The attached terminal transport remains authoritative while a
|
// The attached terminal transport remains authoritative while a
|
||||||
@@ -410,6 +455,7 @@ export function TerminalPage({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
input.dispose();
|
input.dispose();
|
||||||
|
titleChange.dispose();
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
window.cancelAnimationFrame(resizeFrame);
|
window.cancelAnimationFrame(resizeFrame);
|
||||||
window.clearInterval(sessionRefresh);
|
window.clearInterval(sessionRefresh);
|
||||||
@@ -422,6 +468,12 @@ export function TerminalPage({
|
|||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
if (!selectedAgentId || !terminalRef.current) return;
|
if (!selectedAgentId || !terminalRef.current) return;
|
||||||
|
if (agentAtSessionLimit) {
|
||||||
|
toast.error("Terminal session limit reached", {
|
||||||
|
description: `${selectedAgent ? displayAgentLabel(selectedAgent) : selectedAgentId} already reached the active session limit. Close one before opening another.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const previousSession = activeSessionRef.current;
|
const previousSession = activeSessionRef.current;
|
||||||
detachTransport();
|
detachTransport();
|
||||||
if (previousSession) {
|
if (previousSession) {
|
||||||
@@ -476,7 +528,10 @@ export function TerminalPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const attached = await attachWhenAvailable(nextSession.terminal_id, 4);
|
const attached = await attachTerminal(
|
||||||
|
nextSession.terminal_id,
|
||||||
|
operatorId,
|
||||||
|
);
|
||||||
connect(attached);
|
connect(attached);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setConnection("idle");
|
setConnection("idle");
|
||||||
@@ -484,7 +539,9 @@ export function TerminalPage({
|
|||||||
description: String(error),
|
description: String(error),
|
||||||
});
|
});
|
||||||
void listTerminals()
|
void listTerminals()
|
||||||
.then(setSessions)
|
.then((listed) =>
|
||||||
|
setSessions((current) => reconcileTerminalSessions(current, listed)),
|
||||||
|
)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,7 +552,7 @@ export function TerminalPage({
|
|||||||
try {
|
try {
|
||||||
terminalRef.current?.reset();
|
terminalRef.current?.reset();
|
||||||
setConnection("connecting");
|
setConnection("connecting");
|
||||||
const attached = await attachWhenAvailable(session.terminal_id, 8);
|
const attached = await attachTerminal(session.terminal_id, operatorId);
|
||||||
connect(attached);
|
connect(attached);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setConnection("disconnected");
|
setConnection("disconnected");
|
||||||
@@ -505,30 +562,42 @@ export function TerminalPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function close() {
|
async function closeSession(closingSession: TerminalSession) {
|
||||||
if (!session) return;
|
const closingId = closingSession.terminal_id;
|
||||||
const closingId = session.terminal_id;
|
const closesActiveSession =
|
||||||
|
closingId === activeSessionRef.current?.terminal_id;
|
||||||
|
const remaining = sessions.filter((item) => item.terminal_id !== closingId);
|
||||||
|
const fallback = remaining.find((item) => !item.operator_attached);
|
||||||
|
|
||||||
|
if (closesActiveSession) {
|
||||||
socketRef.current?.send(JSON.stringify({ type: "close" }));
|
socketRef.current?.send(JSON.stringify({ type: "close" }));
|
||||||
detachTransport();
|
detachTransport();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await closeTerminal(closingId);
|
await closeTerminal(closingId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Terminal cleanup failed", { description: String(error) });
|
toast.error("Terminal cleanup failed", { description: String(error) });
|
||||||
} finally {
|
} finally {
|
||||||
setSession(null);
|
setSessions(remaining);
|
||||||
activeSessionRef.current = null;
|
setSessionTitles((current) => {
|
||||||
setSessions((current) =>
|
const next = { ...current };
|
||||||
current.filter((item) => item.terminal_id !== closingId),
|
delete next[closingId];
|
||||||
);
|
return next;
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
|
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
|
||||||
) {
|
) {
|
||||||
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
|
||||||
}
|
}
|
||||||
|
if (closesActiveSession) {
|
||||||
|
setSession(null);
|
||||||
|
activeSessionRef.current = null;
|
||||||
setConnection("idle");
|
setConnection("idle");
|
||||||
// xterm.clear() deliberately preserves the active cursor line. Closing
|
// xterm.clear() deliberately preserves the active cursor line. Closing
|
||||||
// a session should discard its complete screen and terminal modes.
|
// a session should discard its complete screen and terminal modes.
|
||||||
terminalRef.current?.reset();
|
terminalRef.current?.reset();
|
||||||
|
if (fallback) void activateSession(fallback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,10 +620,6 @@ export function TerminalPage({
|
|||||||
disabled={connection === "connecting"}
|
disabled={connection === "connecting"}
|
||||||
className="w-full min-w-0 sm:w-64"
|
className="w-full min-w-0 sm:w-64"
|
||||||
/>
|
/>
|
||||||
<Button type="button" onClick={start} disabled={!canStart}>
|
|
||||||
<PlugZap className="size-4" aria-hidden />
|
|
||||||
{sessions.length === 0 ? "Connect" : "New session"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -570,8 +635,9 @@ export function TerminalPage({
|
|||||||
|
|
||||||
<div className="terminal-frame">
|
<div className="terminal-frame">
|
||||||
<div className="terminal-framebar">
|
<div className="terminal-framebar">
|
||||||
|
<div className="terminal-tabs">
|
||||||
<div
|
<div
|
||||||
className="terminal-tabs"
|
className="terminal-tab-list"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Terminal sessions"
|
aria-label="Terminal sessions"
|
||||||
>
|
>
|
||||||
@@ -587,20 +653,29 @@ export function TerminalPage({
|
|||||||
);
|
);
|
||||||
const active = item.terminal_id === session?.terminal_id;
|
const active = item.terminal_id === session?.terminal_id;
|
||||||
const locked = item.operator_attached && !active;
|
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 (
|
return (
|
||||||
<button
|
<div
|
||||||
key={item.terminal_id}
|
key={item.terminal_id}
|
||||||
type="button"
|
role="presentation"
|
||||||
role="tab"
|
|
||||||
aria-selected={active}
|
|
||||||
className="terminal-tab"
|
className="terminal-tab"
|
||||||
data-active={active || undefined}
|
data-active={active || undefined}
|
||||||
data-locked={locked || undefined}
|
data-locked={locked || undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
className="terminal-tab-select"
|
||||||
disabled={locked || connection === "connecting"}
|
disabled={locked || connection === "connecting"}
|
||||||
title={
|
title={
|
||||||
locked
|
locked ? "Attached in another browser" : tabLabel
|
||||||
? "Attached in another browser"
|
|
||||||
: `Open ${agent ? displayAgentLabel(agent) : item.agent_id}`
|
|
||||||
}
|
}
|
||||||
onClick={() => void activateSession(item)}
|
onClick={() => void activateSession(item)}
|
||||||
>
|
>
|
||||||
@@ -617,9 +692,7 @@ export function TerminalPage({
|
|||||||
}
|
}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<span>
|
<span className="terminal-tab-label">{tabLabel}</span>
|
||||||
{agent ? displayAgentLabel(agent) : item.agent_id}
|
|
||||||
</span>
|
|
||||||
<time
|
<time
|
||||||
dateTime={new Date(
|
dateTime={new Date(
|
||||||
item.created_at_unix * 1000,
|
item.created_at_unix * 1000,
|
||||||
@@ -628,10 +701,46 @@ export function TerminalPage({
|
|||||||
{terminalCreatedTime(item.created_at_unix)}
|
{terminalCreatedTime(item.created_at_unix)}
|
||||||
</time>
|
</time>
|
||||||
</button>
|
</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>
|
</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">
|
<div className="terminal-frame-actions">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -668,23 +777,6 @@ export function TerminalPage({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Clear terminal</TooltipContent>
|
<TooltipContent>Clear terminal</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{session ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
aria-label="Close terminal session"
|
|
||||||
onClick={close}
|
|
||||||
>
|
|
||||||
<Square className="size-3.5 fill-current" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Close session</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-surface" ref={hostRef} />
|
<div className="terminal-surface" ref={hostRef} />
|
||||||
|
|||||||
+57
-8
@@ -433,23 +433,29 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-list {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-tabs::-webkit-scrollbar {
|
.terminal-tab-list::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-tab {
|
.terminal-tab {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 13rem;
|
max-width: min(20rem, 48vw);
|
||||||
height: 1.85rem;
|
height: 1.85rem;
|
||||||
flex: 0 1 auto;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 0 0.6rem;
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: calc(var(--radius) - 2px);
|
border-radius: calc(var(--radius) - 2px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -457,7 +463,21 @@
|
|||||||
color: #aab7c4;
|
color: #aab7c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-tab > span:nth-child(2) {
|
.terminal-tab-select {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0 0.35rem 0 0.6rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-label {
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -470,16 +490,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.terminal-tab[data-active] {
|
.terminal-tab[data-active] {
|
||||||
|
max-width: min(30rem, 60vw);
|
||||||
border-color: rgb(255 255 255 / 10%);
|
border-color: rgb(255 255 255 / 10%);
|
||||||
background: rgb(255 255 255 / 7%);
|
background: rgb(255 255 255 / 7%);
|
||||||
color: #f1f5f9;
|
color: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-tab[data-locked] {
|
.terminal-tab[data-locked] {
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.58;
|
opacity: 0.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close {
|
||||||
|
display: grid;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: calc(var(--radius) - 3px);
|
||||||
|
background: transparent;
|
||||||
|
color: #7f8d9a;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close:focus-visible,
|
||||||
|
.terminal-tab-close:hover,
|
||||||
|
.terminal-tab[data-active] .terminal-tab-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close:disabled {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-new-tab {
|
||||||
|
width: 1.85rem;
|
||||||
|
height: 1.85rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-tab-dot {
|
.terminal-tab-dot {
|
||||||
width: 0.45rem;
|
width: 0.45rem;
|
||||||
height: 0.45rem;
|
height: 0.45rem;
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ pub enum TerminalAgentHandshake {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum TerminalOperatorHandshake {
|
pub enum TerminalOperatorHandshake {
|
||||||
Attach { attachment_token: String },
|
Attach {
|
||||||
|
attachment_token: String,
|
||||||
|
operator_id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<String> for RequestId {
|
impl TryFrom<String> for RequestId {
|
||||||
@@ -339,6 +342,15 @@ mod tests {
|
|||||||
let snapshot =
|
let snapshot =
|
||||||
serde_json::to_string(&TerminalControl::Snapshot).expect("serialize snapshot");
|
serde_json::to_string(&TerminalControl::Snapshot).expect("serialize snapshot");
|
||||||
assert_eq!(snapshot, r#"{"type":"snapshot"}"#);
|
assert_eq!(snapshot, r#"{"type":"snapshot"}"#);
|
||||||
|
let operator = TerminalOperatorHandshake::Attach {
|
||||||
|
attachment_token: "attach-secret".into(),
|
||||||
|
operator_id: "browser-tab".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&operator).expect("serialize operator handshake");
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
r#"{"type":"attach","attachment_token":"attach-secret","operator_id":"browser-tab"}"#
|
||||||
|
);
|
||||||
|
|
||||||
let inventory = ClientMessage::TerminalSessions {
|
let inventory = ClientMessage::TerminalSessions {
|
||||||
sessions: vec![AgentTerminalSession {
|
sessions: vec![AgentTerminalSession {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ pub struct CreateTerminalRequest {
|
|||||||
pub cols: u16,
|
pub cols: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AttachTerminalRequest {
|
||||||
|
/// Stable for one browser tab, allowing CC to distinguish a stale socket
|
||||||
|
/// owned by this tab from a session open in another browser.
|
||||||
|
pub operator_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct TerminalSessionResponse {
|
pub struct TerminalSessionResponse {
|
||||||
pub terminal_id: String,
|
pub terminal_id: String,
|
||||||
@@ -157,10 +164,11 @@ pub async fn list_terminals(State(state): State<AppState>) -> Json<Vec<TerminalS
|
|||||||
pub async fn attach_terminal(
|
pub async fn attach_terminal(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(terminal_id): Path<String>,
|
Path(terminal_id): Path<String>,
|
||||||
|
Json(request): Json<AttachTerminalRequest>,
|
||||||
) -> Result<Json<TerminalSessionResponse>, ApiError> {
|
) -> Result<Json<TerminalSessionResponse>, ApiError> {
|
||||||
let attachment_token = state
|
let attachment_token = state
|
||||||
.terminals
|
.terminals
|
||||||
.issue_attachment_token(&terminal_id)
|
.issue_attachment_token_for_operator(&terminal_id, &request.operator_id)
|
||||||
.await
|
.await
|
||||||
.map_err(registry_error)?;
|
.map_err(registry_error)?;
|
||||||
let (agent_id, created_at_unix, agent_attached, operator_attached) = state
|
let (agent_id, created_at_unix, agent_attached, operator_attached) = state
|
||||||
@@ -312,10 +320,13 @@ async fn handle_operator_terminal_socket(
|
|||||||
terminal_id: String,
|
terminal_id: String,
|
||||||
mut socket: WebSocket,
|
mut socket: WebSocket,
|
||||||
) {
|
) {
|
||||||
let attachment_token = match receive_text_handshake(&mut socket).await.and_then(|text| {
|
let auth = match receive_text_handshake(&mut socket).await.and_then(|text| {
|
||||||
serde_json::from_str::<TerminalOperatorHandshake>(&text).map_err(|_| "invalid handshake")
|
serde_json::from_str::<TerminalOperatorHandshake>(&text).map_err(|_| "invalid handshake")
|
||||||
}) {
|
}) {
|
||||||
Ok(TerminalOperatorHandshake::Attach { attachment_token }) => attachment_token,
|
Ok(TerminalOperatorHandshake::Attach {
|
||||||
|
attachment_token,
|
||||||
|
operator_id,
|
||||||
|
}) => (attachment_token, operator_id),
|
||||||
Err(code) => {
|
Err(code) => {
|
||||||
close_socket(&mut socket, code).await;
|
close_socket(&mut socket, code).await;
|
||||||
return;
|
return;
|
||||||
@@ -323,7 +334,7 @@ async fn handle_operator_terminal_socket(
|
|||||||
};
|
};
|
||||||
let mut outbound = match state
|
let mut outbound = match state
|
||||||
.terminals
|
.terminals
|
||||||
.attach_operator(&terminal_id, &attachment_token)
|
.attach_operator(&terminal_id, &auth.0, &auth.1)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(attached) => attached,
|
Ok(attached) => attached,
|
||||||
@@ -367,7 +378,7 @@ async fn handle_operator_terminal_socket(
|
|||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
state.terminals.detach_operator(&terminal_id).await;
|
state.terminals.detach_operator(&terminal_id, &auth.0).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +411,7 @@ async fn handle_operator_terminal_socket(
|
|||||||
if explicit_close {
|
if explicit_close {
|
||||||
close_registered_terminal(&state, &terminal_id).await;
|
close_registered_terminal(&state, &terminal_id).await;
|
||||||
} else {
|
} else {
|
||||||
state.terminals.detach_operator(&terminal_id).await;
|
state.terminals.detach_operator(&terminal_id, &auth.0).await;
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
terminal_id,
|
terminal_id,
|
||||||
@@ -610,9 +621,10 @@ fn validate_size(rows: u16, cols: u16) -> Result<(), ApiError> {
|
|||||||
fn registry_error(code: &'static str) -> ApiError {
|
fn registry_error(code: &'static str) -> ApiError {
|
||||||
let status = match code {
|
let status = match code {
|
||||||
"terminal_not_found" => StatusCode::NOT_FOUND,
|
"terminal_not_found" => StatusCode::NOT_FOUND,
|
||||||
"terminal_relay_token_invalid" | "terminal_attachment_token_invalid" => {
|
"terminal_operator_id_invalid" => StatusCode::BAD_REQUEST,
|
||||||
StatusCode::UNAUTHORIZED
|
"terminal_relay_token_invalid"
|
||||||
}
|
| "terminal_attachment_token_invalid"
|
||||||
|
| "terminal_operator_mismatch" => StatusCode::UNAUTHORIZED,
|
||||||
_ => StatusCode::CONFLICT,
|
_ => StatusCode::CONFLICT,
|
||||||
};
|
};
|
||||||
ApiError::new(status, code, code.replace('_', " "))
|
ApiError::new(status, code, code.replace('_', " "))
|
||||||
|
|||||||
@@ -35,10 +35,13 @@ struct TerminalSession {
|
|||||||
agent_confirmed: bool,
|
agent_confirmed: bool,
|
||||||
relay_token: Option<String>,
|
relay_token: Option<String>,
|
||||||
attachment_token: Option<String>,
|
attachment_token: Option<String>,
|
||||||
|
attachment_operator_id: Option<String>,
|
||||||
agent_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
|
agent_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
|
||||||
pending_agent: VecDeque<TerminalRelayFrame>,
|
pending_agent: VecDeque<TerminalRelayFrame>,
|
||||||
pending_agent_bytes: usize,
|
pending_agent_bytes: usize,
|
||||||
operator_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
|
operator_tx: Option<mpsc::Sender<TerminalRelayFrame>>,
|
||||||
|
operator_id: Option<String>,
|
||||||
|
operator_connection_id: Option<String>,
|
||||||
operator_detached_at: Option<Instant>,
|
operator_detached_at: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +102,13 @@ impl TerminalRegistry {
|
|||||||
agent_confirmed: false,
|
agent_confirmed: false,
|
||||||
relay_token: Some(relay_token.clone()),
|
relay_token: Some(relay_token.clone()),
|
||||||
attachment_token: Some(attachment_token.clone()),
|
attachment_token: Some(attachment_token.clone()),
|
||||||
|
attachment_operator_id: None,
|
||||||
agent_tx: None,
|
agent_tx: None,
|
||||||
pending_agent: VecDeque::new(),
|
pending_agent: VecDeque::new(),
|
||||||
pending_agent_bytes: 0,
|
pending_agent_bytes: 0,
|
||||||
operator_tx: None,
|
operator_tx: None,
|
||||||
|
operator_id: None,
|
||||||
|
operator_connection_id: None,
|
||||||
operator_detached_at: None,
|
operator_detached_at: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -193,10 +199,13 @@ impl TerminalRegistry {
|
|||||||
agent_confirmed: true,
|
agent_confirmed: true,
|
||||||
relay_token: None,
|
relay_token: None,
|
||||||
attachment_token: None,
|
attachment_token: None,
|
||||||
|
attachment_operator_id: None,
|
||||||
agent_tx: None,
|
agent_tx: None,
|
||||||
pending_agent: VecDeque::new(),
|
pending_agent: VecDeque::new(),
|
||||||
pending_agent_bytes: 0,
|
pending_agent_bytes: 0,
|
||||||
operator_tx: None,
|
operator_tx: None,
|
||||||
|
operator_id: None,
|
||||||
|
operator_connection_id: None,
|
||||||
operator_detached_at: Some(Instant::now()),
|
operator_detached_at: Some(Instant::now()),
|
||||||
});
|
});
|
||||||
if session.agent_id != agent_id || session.agent_tx.is_some() {
|
if session.agent_id != agent_id || session.agent_tx.is_some() {
|
||||||
@@ -217,14 +226,27 @@ impl TerminalRegistry {
|
|||||||
Some(session.agent_id.clone())
|
Some(session.agent_id.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn issue_attachment_token(&self, terminal_id: &str) -> Result<String, &'static str> {
|
/// Issues a token while atomically handing this operator's attachment to
|
||||||
|
/// the target. A different operator remains protected by the single-viewer
|
||||||
|
/// rule, while a stale socket owned by this operator can be replaced.
|
||||||
|
pub async fn issue_attachment_token_for_operator(
|
||||||
|
&self,
|
||||||
|
terminal_id: &str,
|
||||||
|
operator_id: &str,
|
||||||
|
) -> Result<String, &'static str> {
|
||||||
|
validate_operator_id(operator_id)?;
|
||||||
let mut sessions = self.inner.lock().await;
|
let mut sessions = self.inner.lock().await;
|
||||||
let session = active_session(&mut sessions, terminal_id)?;
|
let target = active_session(&mut sessions, terminal_id)?;
|
||||||
if session.operator_tx.is_some() {
|
if target.operator_tx.is_some() && target.operator_id.as_deref() != Some(operator_id) {
|
||||||
return Err("terminal_operator_already_attached");
|
return Err("terminal_operator_already_attached");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detach_operator_sessions(&mut sessions, operator_id);
|
||||||
|
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
let token = new_token();
|
let token = new_token();
|
||||||
session.attachment_token = Some(token.clone());
|
session.attachment_token = Some(token.clone());
|
||||||
|
session.attachment_operator_id = Some(operator_id.to_string());
|
||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +280,9 @@ impl TerminalRegistry {
|
|||||||
&self,
|
&self,
|
||||||
terminal_id: &str,
|
terminal_id: &str,
|
||||||
attachment_token: &str,
|
attachment_token: &str,
|
||||||
|
operator_id: &str,
|
||||||
) -> Result<mpsc::Receiver<TerminalRelayFrame>, &'static str> {
|
) -> Result<mpsc::Receiver<TerminalRelayFrame>, &'static str> {
|
||||||
|
validate_operator_id(operator_id)?;
|
||||||
let mut sessions = self.inner.lock().await;
|
let mut sessions = self.inner.lock().await;
|
||||||
let session = active_session(&mut sessions, terminal_id)?;
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
if session.operator_tx.is_some() {
|
if session.operator_tx.is_some() {
|
||||||
@@ -267,10 +291,26 @@ impl TerminalRegistry {
|
|||||||
if session.attachment_token.as_deref() != Some(attachment_token) {
|
if session.attachment_token.as_deref() != Some(attachment_token) {
|
||||||
return Err("terminal_attachment_token_invalid");
|
return Err("terminal_attachment_token_invalid");
|
||||||
}
|
}
|
||||||
|
if session
|
||||||
|
.attachment_operator_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|expected| expected != operator_id)
|
||||||
|
{
|
||||||
|
return Err("terminal_operator_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial create tokens are not yet bound to an operator. Once the
|
||||||
|
// WebSocket presents that token, it still atomically releases any
|
||||||
|
// older session held by the same browser tab.
|
||||||
|
detach_operator_sessions(&mut sessions, operator_id);
|
||||||
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
session.attachment_token = None;
|
session.attachment_token = None;
|
||||||
|
session.attachment_operator_id = None;
|
||||||
session.operator_detached_at = None;
|
session.operator_detached_at = None;
|
||||||
let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE);
|
let (tx, rx) = mpsc::channel(TERMINAL_RELAY_QUEUE);
|
||||||
session.operator_tx = Some(tx);
|
session.operator_tx = Some(tx);
|
||||||
|
session.operator_id = Some(operator_id.to_string());
|
||||||
|
session.operator_connection_id = Some(attachment_token.to_string());
|
||||||
Ok(rx)
|
Ok(rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,10 +366,13 @@ impl TerminalRegistry {
|
|||||||
// authoritative and will reconstruct the next attachment.
|
// authoritative and will reconstruct the next attachment.
|
||||||
let mut sessions = self.inner.lock().await;
|
let mut sessions = self.inner.lock().await;
|
||||||
let session = active_session(&mut sessions, terminal_id)?;
|
let session = active_session(&mut sessions, terminal_id)?;
|
||||||
session.operator_tx = None;
|
if session
|
||||||
session
|
.operator_tx
|
||||||
.operator_detached_at
|
.as_ref()
|
||||||
.get_or_insert_with(Instant::now);
|
.is_some_and(|current| current.same_channel(&tx))
|
||||||
|
{
|
||||||
|
clear_operator_attachment(session);
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,12 +403,16 @@ impl TerminalRegistry {
|
|||||||
.ok_or("terminal_not_found")
|
.ok_or("terminal_not_found")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn detach_operator(&self, terminal_id: &str) -> Option<Instant> {
|
/// Detaches only the socket identified by this attachment token. Delayed
|
||||||
|
/// cleanup from an older socket must not clear its replacement.
|
||||||
|
pub async fn detach_operator(&self, terminal_id: &str, connection_id: &str) -> Option<Instant> {
|
||||||
let mut sessions = self.inner.lock().await;
|
let mut sessions = self.inner.lock().await;
|
||||||
let session = sessions.get_mut(terminal_id)?;
|
let session = sessions.get_mut(terminal_id)?;
|
||||||
session.operator_tx = None;
|
if session.operator_connection_id.as_deref() != Some(connection_id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let detached_at = Instant::now();
|
let detached_at = Instant::now();
|
||||||
session.operator_detached_at = Some(detached_at);
|
clear_operator_attachment(session);
|
||||||
Some(detached_at)
|
Some(detached_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +455,28 @@ fn active_session<'a>(
|
|||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_operator_id(operator_id: &str) -> Result<(), &'static str> {
|
||||||
|
if operator_id.is_empty() || operator_id.len() > 128 {
|
||||||
|
return Err("terminal_operator_id_invalid");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detach_operator_sessions(sessions: &mut HashMap<String, TerminalSession>, operator_id: &str) {
|
||||||
|
for session in sessions.values_mut() {
|
||||||
|
if session.operator_id.as_deref() == Some(operator_id) {
|
||||||
|
clear_operator_attachment(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_operator_attachment(session: &mut TerminalSession) {
|
||||||
|
session.operator_tx = None;
|
||||||
|
session.operator_id = None;
|
||||||
|
session.operator_connection_id = None;
|
||||||
|
session.operator_detached_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
fn new_token() -> String {
|
fn new_token() -> String {
|
||||||
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
|
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
|
||||||
}
|
}
|
||||||
@@ -428,6 +497,9 @@ fn relay_frame_size(frame: &TerminalRelayFrame) -> usize {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const OPERATOR_A: &str = "browser-tab-a";
|
||||||
|
const OPERATOR_B: &str = "browser-tab-b";
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn credentials_are_scoped_and_single_use() {
|
async fn credentials_are_scoped_and_single_use() {
|
||||||
let registry = TerminalRegistry::new();
|
let registry = TerminalRegistry::new();
|
||||||
@@ -452,12 +524,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
registry
|
registry
|
||||||
.attach_operator(&created.terminal_id, &created.attachment_token)
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
.await
|
.await
|
||||||
.expect("attach operator");
|
.expect("attach operator");
|
||||||
assert!(
|
assert!(
|
||||||
registry
|
registry
|
||||||
.attach_operator(&created.terminal_id, &created.attachment_token)
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A,)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
@@ -476,7 +548,7 @@ mod tests {
|
|||||||
.expect("ignore detached output");
|
.expect("ignore detached output");
|
||||||
|
|
||||||
let mut outbound = registry
|
let mut outbound = registry
|
||||||
.attach_operator(&created.terminal_id, &created.attachment_token)
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
.await
|
.await
|
||||||
.expect("attach operator");
|
.expect("attach operator");
|
||||||
assert!(outbound.try_recv().is_err());
|
assert!(outbound.try_recv().is_err());
|
||||||
@@ -487,7 +559,7 @@ mod tests {
|
|||||||
let registry = TerminalRegistry::new();
|
let registry = TerminalRegistry::new();
|
||||||
let created = registry.create("router".into()).await.expect("create");
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
let mut outbound = registry
|
let mut outbound = registry
|
||||||
.attach_operator(&created.terminal_id, &created.attachment_token)
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
.await
|
.await
|
||||||
.expect("attach operator");
|
.expect("attach operator");
|
||||||
let frame = TerminalRelayFrame::Binary(b"recent prompt".to_vec());
|
let frame = TerminalRelayFrame::Binary(b"recent prompt".to_vec());
|
||||||
@@ -496,14 +568,16 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("relay output");
|
.expect("relay output");
|
||||||
outbound.recv().await.expect("live output");
|
outbound.recv().await.expect("live output");
|
||||||
registry.detach_operator(&created.terminal_id).await;
|
registry
|
||||||
|
.detach_operator(&created.terminal_id, &created.attachment_token)
|
||||||
|
.await;
|
||||||
|
|
||||||
let token = registry
|
let token = registry
|
||||||
.issue_attachment_token(&created.terminal_id)
|
.issue_attachment_token_for_operator(&created.terminal_id, OPERATOR_A)
|
||||||
.await
|
.await
|
||||||
.expect("reattach token");
|
.expect("reattach token");
|
||||||
let mut remounted = registry
|
let mut remounted = registry
|
||||||
.attach_operator(&created.terminal_id, &token)
|
.attach_operator(&created.terminal_id, &token, OPERATOR_A)
|
||||||
.await
|
.await
|
||||||
.expect("reattach operator");
|
.expect("reattach operator");
|
||||||
|
|
||||||
@@ -582,11 +656,13 @@ mod tests {
|
|||||||
let registry = TerminalRegistry::new();
|
let registry = TerminalRegistry::new();
|
||||||
let created = registry.create("router".into()).await.expect("create");
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
registry
|
registry
|
||||||
.attach_operator(&created.terminal_id, &created.attachment_token)
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
.await
|
.await
|
||||||
.expect("attach operator");
|
.expect("attach operator");
|
||||||
|
|
||||||
registry.detach_operator(&created.terminal_id).await;
|
registry
|
||||||
|
.detach_operator(&created.terminal_id, &created.attachment_token)
|
||||||
|
.await;
|
||||||
|
|
||||||
let summary = registry
|
let summary = registry
|
||||||
.summary(&created.terminal_id)
|
.summary(&created.terminal_id)
|
||||||
@@ -594,11 +670,97 @@ mod tests {
|
|||||||
.expect("session remains after detach");
|
.expect("session remains after detach");
|
||||||
assert!(!summary.3);
|
assert!(!summary.3);
|
||||||
registry
|
registry
|
||||||
.issue_attachment_token(&created.terminal_id)
|
.issue_attachment_token_for_operator(&created.terminal_id, OPERATOR_A)
|
||||||
.await
|
.await
|
||||||
.expect("detached session can be reattached");
|
.expect("detached session can be reattached");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attachment_handoff_releases_previous_operator_atomically() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let previous = registry.create("router".into()).await.expect("previous");
|
||||||
|
let next = registry.create("router".into()).await.expect("next");
|
||||||
|
registry
|
||||||
|
.attach_operator(
|
||||||
|
&previous.terminal_id,
|
||||||
|
&previous.attachment_token,
|
||||||
|
OPERATOR_A,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("attach previous");
|
||||||
|
|
||||||
|
let next_token = registry
|
||||||
|
.issue_attachment_token_for_operator(&next.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("handoff token");
|
||||||
|
|
||||||
|
assert!(!registry.summary(&previous.terminal_id).await.unwrap().3);
|
||||||
|
registry
|
||||||
|
.attach_operator(&next.terminal_id, &next_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach next");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn attachment_handoff_does_not_evict_unrelated_operator() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let previous = registry.create("router".into()).await.expect("previous");
|
||||||
|
let occupied = registry.create("router".into()).await.expect("occupied");
|
||||||
|
registry
|
||||||
|
.attach_operator(
|
||||||
|
&previous.terminal_id,
|
||||||
|
&previous.attachment_token,
|
||||||
|
OPERATOR_A,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("attach previous");
|
||||||
|
registry
|
||||||
|
.attach_operator(
|
||||||
|
&occupied.terminal_id,
|
||||||
|
&occupied.attachment_token,
|
||||||
|
OPERATOR_B,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("attach occupied");
|
||||||
|
|
||||||
|
let error = registry
|
||||||
|
.issue_attachment_token_for_operator(&occupied.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stale_socket_cleanup_does_not_detach_replacement() {
|
||||||
|
let registry = TerminalRegistry::new();
|
||||||
|
let created = registry.create("router".into()).await.expect("create");
|
||||||
|
let old_connection_id = created.attachment_token.clone();
|
||||||
|
let _old_outbound = registry
|
||||||
|
.attach_operator(&created.terminal_id, &created.attachment_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach old socket");
|
||||||
|
|
||||||
|
let replacement_token = registry
|
||||||
|
.issue_attachment_token_for_operator(&created.terminal_id, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("replace own socket");
|
||||||
|
let _replacement_outbound = registry
|
||||||
|
.attach_operator(&created.terminal_id, &replacement_token, OPERATOR_A)
|
||||||
|
.await
|
||||||
|
.expect("attach replacement socket");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.detach_operator(&created.terminal_id, &old_connection_id)
|
||||||
|
.await
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(registry.summary(&created.terminal_id).await.unwrap().3);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reconciliation_does_not_remove_open_request_still_in_flight() {
|
async fn reconciliation_does_not_remove_open_request_still_in_flight() {
|
||||||
let registry = TerminalRegistry::new();
|
let registry = TerminalRegistry::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user