diff --git a/nest/dashboard/static/js/dashboard.js b/nest/dashboard/static/js/dashboard.js
index 9e278b5..84b21d4 100644
--- a/nest/dashboard/static/js/dashboard.js
+++ b/nest/dashboard/static/js/dashboard.js
@@ -26,6 +26,8 @@
page: 'overview',
detailId: null,
detail: null,
+ detailTab: 'overview',
+ terminalId: null,
portEditing: false,
resizeEditing: false,
selected: new Set(),
@@ -45,6 +47,13 @@
menuTrigger: null,
};
+ const sessions = new window.NestSessions({
+ api, getToken: () => state.token,
+ isSelectedVm: (id) => state.page === 'vm-detail' && state.detailId === id,
+ onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
+ esc, formatDate, toast, confirmAction,
+ });
+
function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
@@ -598,9 +607,22 @@
const busy = state.busy.has(vm.id);
const running = runtimeStatus(vm) === 'running';
const actionButton = (action, caption, symbol, primary = false) => ``;
- html('#detail-actions', running
+ html('#detail-actions', (running
? actionButton('restart', 'Restart', 'refresh') + actionButton('stop', 'Stop machine', 'stop')
- : actionButton('start', 'Start machine', 'play', true));
+ : actionButton('start', 'Start machine', 'play', true))
+ + ``);
+ const showingSessions = state.detailTab === 'sessions';
+ $('#vm-overview-panel').hidden = showingSessions;
+ $('#vm-sessions-panel').hidden = !showingSessions;
+ $('#vm-overview-tab').href = vmHref(vm.id);
+ $('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
+ [['#vm-overview-tab', !showingSessions], ['#vm-sessions-tab', showingSessions]].forEach(([selector, active]) => {
+ $(selector).classList.toggle('active', active);
+ if (active) $(selector).setAttribute('aria-current', 'page');
+ else $(selector).removeAttribute('aria-current');
+ });
+ text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : ''}`);
+ sessions.sync(vm, state.terminalId, showingSessions);
html('#detail-config', detailRows([
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
@@ -655,13 +677,14 @@
state.detail = vm;
state.detailId = vm.id;
renderDetail();
- await loadLogs();
+ if (state.detailTab === 'overview') await loadLogs();
} catch (error) {
if (isAborted(error) || request !== state.requests.detail || session !== state.session || state.page !== 'vm-detail') return;
if (state.detail && error.status !== 404) {
text('#detail-error', error.message);
$('#detail-error').hidden = false;
} else {
+ sessions.leave();
state.detail = null;
$('#detail-content').hidden = true;
$('#detail-loading').hidden = false;
@@ -1119,8 +1142,17 @@
if (!state.authenticated) return;
let value = location.hash.slice(1) || 'overview';
let identifier = null;
+ let detailTab = 'overview';
+ let terminalId = null;
if (value.startsWith('vm/')) {
- try { identifier = decodeURIComponent(value.slice(3)); } catch { identifier = null; }
+ const parts = value.split('/');
+ try {
+ identifier = decodeURIComponent(parts[1]);
+ if (parts[2] === 'sessions' && parts.length <= 4) {
+ detailTab = 'sessions';
+ terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
+ } else if (parts.length !== 2) identifier = null;
+ } catch { identifier = null; }
value = identifier ? 'vm-detail' : 'overview';
}
if (!Object.prototype.hasOwnProperty.call(pages, value)) value = 'overview';
@@ -1129,6 +1161,9 @@
state.requests.detail += 1;
state.requests.logs += 1;
state.detailId = identifier;
+ state.detailTab = detailTab;
+ state.terminalId = terminalId;
+ sessions.leave();
state.detail = null;
state.portEditing = false;
state.resizeEditing = false;
@@ -1217,6 +1252,7 @@
}
function logout(message = '') {
+ sessions.leave();
state.session += 1;
state.controllers.forEach((controller) => controller.abort());
state.controllers.clear();
@@ -1285,6 +1321,7 @@
if (action === 'clear-filters') { state.query = ''; state.filter = 'all'; $('#vm-search').value = ''; renderMachines(); }
if (action === 'dismiss-results') $('#bulk-results').hidden = true;
if (action === 'install') installAssets();
+ if (action === 'launch-session' && state.detail) sessions.launch(state.detail);
if (action === 'refresh-logs') loadLogs();
if (action === 'copy-ssh') copyText($('#ssh-command').textContent);
if (action === 'delete-current' && state.detail) machineAction('delete', state.detail.id);
@@ -1388,6 +1425,7 @@
if (target && !target.disabled) handleAction(target);
});
document.addEventListener('keydown', (event) => {
+ if (event.target.closest('.xterm')) return;
const editing = event.target.closest('input, textarea, select, [contenteditable="true"]');
if (state.authenticated && ((event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) || (event.key === '/' && !editing))) {
if ($('#confirm-dialog').open) return;
diff --git a/nest/dashboard/static/js/sessions.js b/nest/dashboard/static/js/sessions.js
new file mode 100644
index 0000000..077e187
--- /dev/null
+++ b/nest/dashboard/static/js/sessions.js
@@ -0,0 +1,363 @@
+(function () {
+ 'use strict';
+
+ const $ = (selector) => document.querySelector(selector);
+ const href = (vmId, sessionId = '') => `#vm/${encodeURIComponent(vmId)}/sessions${sessionId ? `/${encodeURIComponent(sessionId)}` : ''}`;
+ const path = (vmId, suffix = '') => `/vms/${encodeURIComponent(vmId)}/sessions${suffix}`;
+ const bytes = (encoded) => Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
+ const encoded = (data) => btoa(String.fromCharCode(...data));
+
+ window.NestSessions = class {
+ constructor({ api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction }) {
+ Object.assign(this, { api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction });
+ this.vm = null;
+ this.records = [];
+ this.current = null;
+ this.selectedId = null;
+ this.visible = false;
+ this.sequence = 0;
+ this.generation = 0;
+ this.launching = new Set();
+ this.upgrading = new Set();
+ this.offset = 0;
+ this.renderPending = Promise.resolve();
+ $('#sessions-refresh').addEventListener('click', () => this.refresh());
+ $('#session-end').addEventListener('click', () => this.end());
+ $('#session-reconnect').addEventListener('click', () => this.connectSocket());
+ $('#session-install-agent').addEventListener('click', () => this.installAgent());
+ }
+
+ sync(vm, selectedId, visible) {
+ if (this.vm?.id !== vm.id) {
+ this.leave();
+ this.vm = vm;
+ $('#vm-session-count').textContent = '—';
+ }
+ this.vm = vm;
+ if (this.selectedId !== selectedId || this.visible !== visible) {
+ this.detachTerminal();
+ this.showError('');
+ }
+ this.selectedId = selectedId;
+ this.visible = visible;
+ this.updateControls();
+ if (this.pendingError?.vmId === vm.id && visible) {
+ this.showError(this.pendingError.message);
+ $('#terminal-support').open = this.pendingError.setup;
+ this.pendingError = null;
+ }
+ this.renderList();
+ this.openSelected();
+ if (!this.loading && (!this.lastRefresh || Date.now() - this.lastRefresh > 3000)) this.refresh();
+ }
+
+ showError(message) {
+ $('#sessions-error').textContent = message;
+ }
+
+ updateControls() {
+ const running = this.vm && (this.vm.observed_status || this.vm.status) === 'running'
+ && !this.vm.operation && !this.vm.pending_resize;
+ document.querySelectorAll('[data-action="launch-session"]').forEach((button) => {
+ button.disabled = !running || this.launching.has(this.vm?.id);
+ button.querySelector('span').textContent = this.launching.has(this.vm?.id) ? 'Launching…' : 'Launch session';
+ button.title = running ? 'Open an interactive shell inside this microVM' : 'Start this machine before launching a session';
+ });
+ const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped'
+ && !this.vm.operation && !this.vm.pending_resize;
+ $('#session-install-agent').disabled = !stopped || this.upgrading.has(this.vm?.id);
+ $('#session-install-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Install / update terminal support';
+ $('#session-install-hint').textContent = stopped ? 'Ready to update on this stopped machine.' : 'Stop the machine before updating its guest agent.';
+ this.renderStatus();
+ }
+
+ renderList() {
+ const markup = this.records.length ? this.records.map((record) => `
+
+ ${this.esc(record.name)}
+ ${this.esc(this.formatDate(record.created_at))}
${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}
+ `).join('') : `
${this.loading ? 'Loading sessions…' : 'No sessions yet.
Launch a session to get started.'}
`;
+ if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup;
+ }
+
+ async refresh() {
+ if (!this.vm) return;
+ const vmId = this.vm.id;
+ const sequence = ++this.sequence;
+ this.loading = true;
+ $('#sessions-refresh').disabled = true;
+ try {
+ const records = await this.api(path(vmId));
+ if (sequence !== this.sequence || this.vm?.id !== vmId) return;
+ if (!Array.isArray(records)) throw new Error('The session history response was not recognized.');
+ this.records = records;
+ if (this.current) {
+ const latest = records.find((record) => record.id === this.current.id);
+ if (latest && latest.updated_at >= this.current.updated_at) this.current = latest;
+ else if (latest) this.records[this.records.indexOf(latest)] = this.current;
+ }
+ this.lastRefresh = Date.now();
+ $('#vm-session-count').textContent = records.length;
+ this.openSelected();
+ this.renderStatus();
+ } catch (error) {
+ if (sequence === this.sequence && error.name !== 'AbortError') this.showError(error.message);
+ } finally {
+ if (sequence === this.sequence) {
+ this.loading = false;
+ $('#sessions-refresh').disabled = false;
+ this.renderList();
+ }
+ }
+ }
+
+ async launch(vm) {
+ if (!vm || this.launching.has(vm.id)) return;
+ const token = this.getToken();
+ this.launching.add(vm.id);
+ this.showError('');
+ this.updateControls();
+ try {
+ const record = await this.api(path(vm.id), { method: 'POST', body: { rows: 24, cols: 80 } });
+ if (token === this.getToken() && this.isSelectedVm(vm.id)) location.hash = href(vm.id, record.id);
+ } catch (error) {
+ if (token === this.getToken() && error.name !== 'AbortError') {
+ this.toast(error.message, true);
+ if (this.isSelectedVm(vm.id)) {
+ this.pendingError = { vmId: vm.id, message: error.message, setup: error.status === 503 };
+ location.hash = href(vm.id);
+ if (this.visible) this.sync(vm, null, true);
+ }
+ }
+ } finally {
+ this.launching.delete(vm.id);
+ this.lastRefresh = 0;
+ this.updateControls();
+ }
+ }
+
+ async installAgent() {
+ const vm = this.vm;
+ if (!vm || this.upgrading.has(vm.id)) return;
+ this.upgrading.add(vm.id);
+ this.showError('');
+ this.updateControls();
+ try {
+ await this.api(`/vms/${encodeURIComponent(vm.id)}/agent/install`, { method: 'POST' });
+ if (this.vm?.id === vm.id) {
+ this.toast('Terminal support updated. Start this machine, then launch a session.');
+ $('#session-install-hint').textContent = 'Updated. Start the machine to use the new agent.';
+ }
+ } catch (error) {
+ if (this.vm?.id === vm.id && error.name !== 'AbortError') this.showError(error.message);
+ } finally {
+ this.upgrading.delete(vm.id);
+ this.updateControls();
+ }
+ }
+
+ openSelected() {
+ if (!this.visible || !this.selectedId || this.current?.id === this.selectedId) return;
+ const record = this.records.find((item) => item.id === this.selectedId);
+ if (!record) {
+ if (this.lastRefresh) this.showError('This session was not found for the selected machine.');
+ return;
+ }
+ this.showError('');
+ this.detachTerminal();
+ this.current = record;
+ $('#session-blank').hidden = true;
+ $('#session-terminal').hidden = false;
+ this.term = new window.Terminal({
+ cols: record.cols, rows: record.rows, cursorBlink: true, fontSize: 13,
+ fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
+ scrollback: 10000, disableStdin: true,
+ theme: { background: '#25222d', foreground: '#e8e3ef', cursor: '#ede39f', selectionBackground: '#74628780' },
+ });
+ this.fit = new window.FitAddon.FitAddon();
+ this.term.loadAddon(this.fit);
+ this.term.open($('#session-terminal'));
+ this.term.onData((text) => this.input(new TextEncoder().encode(text)));
+ this.term.onBinary((text) => this.input(Uint8Array.from(text, (character) => character.charCodeAt(0))));
+ this.term.onResize(({ rows, cols }) => this.sendSize(rows, cols));
+ this.observer = new ResizeObserver(() => this.fitTerminal());
+ this.observer.observe($('#session-terminal'));
+ this.fitTerminal();
+ this.connectSocket();
+ }
+
+ fitTerminal() {
+ if (this.term && this.visible && $('#session-terminal').clientWidth > 0) {
+ this.fit.fit();
+ }
+ }
+
+ input(data) {
+ if (!this.canType()) return;
+ if (data.length > 65536 || this.socket.bufferedAmount > 65536) {
+ this.showError('Terminal input is busy or the paste exceeds 64 KiB. Please send a smaller chunk.');
+ return;
+ }
+ for (let offset = 0; offset < data.length; offset += 8192) {
+ this.socket.send(JSON.stringify({ type: 'input', data: encoded(data.subarray(offset, offset + 8192)) }));
+ }
+ }
+
+ canType() {
+ return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp;
+ }
+
+ sendSize(rows, cols) {
+ if (this.canType()) this.socket.send(JSON.stringify({ type: 'resize', rows: Math.max(2, Math.min(200, rows)), cols: Math.max(2, Math.min(400, cols)) }));
+ }
+
+ async connectSocket() {
+ if (!this.current || !this.visible || !this.getToken()) return;
+ const generation = this.generation;
+ await this.renderPending;
+ if (generation !== this.generation || !this.current || !this.visible) return;
+ window.clearTimeout(this.reconnectTimer);
+ const previous = this.socket;
+ this.socket = null;
+ previous?.close();
+ this.caughtUp = false;
+ this.replayEnd = null;
+ this.connection = 'Connecting…';
+ const url = new URL(path(this.vm.id, `/${encodeURIComponent(this.current.id)}/terminal`), location.origin);
+ url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const socket = new WebSocket(url);
+ this.socket = socket;
+ let rejected = false;
+ const current = () => generation === this.generation && this.socket === socket;
+ this.renderStatus();
+ socket.onopen = () => {
+ if (current()) socket.send(JSON.stringify({ type: 'auth', token: this.getToken(), offset: this.offset }));
+ };
+ socket.onmessage = (event) => {
+ if (!current()) return;
+ try {
+ const message = JSON.parse(event.data);
+ if (message.type === 'session') {
+ const wasReady = this.caughtUp;
+ this.current = message.session;
+ if (message.offset > this.offset) {
+ this.term.reset();
+ this.offset = message.offset;
+ }
+ if (this.replayEnd == null) this.replayEnd = message.session.output_end;
+ this.caughtUp = this.offset >= this.replayEnd;
+ this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
+ const index = this.records.findIndex((record) => record.id === message.session.id);
+ if (index >= 0) this.records[index] = message.session;
+ this.renderList();
+ this.renderStatus();
+ if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
+ } else if (message.type === 'output') {
+ const data = bytes(message.data);
+ if (message.offset !== this.offset) throw new Error('Terminal output is out of sequence. Reconnect to load history.');
+ this.renderPending = new Promise((resolve) => {
+ this.finishRender = resolve;
+ this.term.write(data, () => {
+ if (generation === this.generation) {
+ this.offset += data.length;
+ const wasReady = this.caughtUp;
+ this.caughtUp = this.offset >= this.replayEnd;
+ this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
+ if (current()) socket.send(JSON.stringify({ type: 'ack', offset: this.offset }));
+ this.renderStatus();
+ if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
+ this.finishRender = null;
+ }
+ resolve();
+ });
+ });
+ } else if (message.type === 'error') {
+ rejected = true;
+ this.showError(message.message);
+ }
+ } catch (error) {
+ rejected = true;
+ this.showError(error.message);
+ socket.close();
+ }
+ };
+ socket.onclose = (event) => {
+ if (!current()) return;
+ this.connection = 'Disconnected';
+ this.caughtUp = false;
+ this.renderStatus();
+ if (event.code === 4401) { this.onExpired(); return; }
+ if (!rejected && ['active', 'starting'].includes(this.current?.status)) {
+ this.reconnectTimer = window.setTimeout(() => this.connectSocket(), 2000);
+ }
+ };
+ }
+
+ renderStatus() {
+ const record = this.current;
+ const active = record?.status === 'active';
+ $('#session-title').textContent = record ? `${record.name} · ${this.vm?.name || this.vm?.id || ''}` : 'Terminal';
+ $('#session-status').textContent = record ? active ? this.connection || 'Connecting…' : `${record.status}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}` : 'Select a session';
+ $('#session-end').disabled = !active || this.ending === record?.id;
+ $('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN;
+ if (this.term) this.term.options.disableStdin = !this.canType();
+ $('#session-meta').textContent = record
+ ? `${record.username || 'Guest'} · Started ${this.formatDate(record.created_at)}${record.ended_at ? ` · Ended ${this.formatDate(record.ended_at)}` : ' · Reopen this session to continue'}${record.truncated ? ' · Showing the latest 8 MiB of output' : ''}${record.error ? ` · ${record.error}` : ''}`
+ : 'Your sessions and terminal output are saved on the host.';
+ }
+
+ async end() {
+ const record = this.current;
+ if (!record || this.ending) return;
+ const confirmed = await this.confirmAction(`End ${record.name}?`, 'Close this shell and its running processes. Its saved terminal output will be available in session history.', 'End session');
+ if (!confirmed) return;
+ this.ending = record.id;
+ this.renderStatus();
+ try {
+ const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' });
+ if (this.current?.id === record.id) this.current = ended;
+ if (this.vm?.id === record.vm_id) await this.refresh();
+ } catch (error) {
+ if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message);
+ } finally {
+ this.ending = null;
+ this.renderStatus();
+ }
+ }
+
+ detachTerminal() {
+ this.generation += 1;
+ window.clearTimeout(this.reconnectTimer);
+ this.observer?.disconnect();
+ this.observer = null;
+ const socket = this.socket;
+ this.socket = null;
+ socket?.close();
+ this.finishRender?.();
+ this.finishRender = null;
+ this.renderPending = Promise.resolve();
+ this.term?.dispose();
+ this.term = null;
+ this.current = null;
+ this.offset = 0;
+ this.caughtUp = false;
+ $('#session-terminal').replaceChildren();
+ $('#session-terminal').hidden = true;
+ $('#session-blank').hidden = false;
+ this.renderStatus();
+ }
+
+ leave() {
+ this.sequence += 1;
+ this.loading = false;
+ this.lastRefresh = 0;
+ this.detachTerminal();
+ this.visible = false;
+ this.vm = null;
+ this.records = [];
+ this.selectedId = null;
+ this.showError('');
+ $('#terminal-support').open = false;
+ }
+ };
+})();
diff --git a/nest/session_routes.py b/nest/session_routes.py
index 35f681d..7ba12ef 100644
--- a/nest/session_routes.py
+++ b/nest/session_routes.py
@@ -122,7 +122,7 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
while True:
record, start, data = await sessions.read(vm_id, session_id, cursor)
if previous != record or start != cursor:
- await websocket.send_json({"type": "session", "session": record, "offset": start})
+ await websocket.send_json({"type": "session", "session": SessionResponse(**record).model_dump(), "offset": start})
previous = record
cursor = start
if data:
diff --git a/nest/session_store.py b/nest/session_store.py
index bd7a1bc..4167dfe 100644
--- a/nest/session_store.py
+++ b/nest/session_store.py
@@ -63,12 +63,15 @@ class SessionStore:
@staticmethod
def _private_file(path: Path) -> int:
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
- metadata = os.fstat(descriptor)
- if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
+ try:
+ metadata = os.fstat(descriptor)
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
+ raise StateError("session history must use private regular files")
+ os.fchmod(descriptor, 0o600)
+ return descriptor
+ except BaseException:
os.close(descriptor)
- raise StateError("session history must use private regular files")
- os.fchmod(descriptor, 0o600)
- return descriptor
+ raise
@contextmanager
def _db(self):
diff --git a/nest/sessions.py b/nest/sessions.py
index d9f84c5..2619e7e 100644
--- a/nest/sessions.py
+++ b/nest/sessions.py
@@ -13,6 +13,17 @@ from uvm.guest_terminal import GuestTerminal, validate_size
from .session_store import SessionStore
+async def _persist(function, *arguments):
+ # Cancellation cannot release the history-owner lock while a worker still
+ # has an outstanding write to that database.
+ task = asyncio.create_task(asyncio.to_thread(function, *arguments))
+ try:
+ return await asyncio.shield(task)
+ except asyncio.CancelledError:
+ await task
+ raise
+
+
@dataclass
class _LiveSession:
vm_id: str
@@ -98,7 +109,7 @@ class SessionManager:
if message["type"] == "exit":
status, exit_code = "closed", message["exit_code"]
break
- await asyncio.to_thread(history.append, session_id, message["data"])
+ await _persist(history.append, session_id, message["data"])
live.changed.set()
except asyncio.CancelledError:
error = "Nest stopped; this shell is no longer connected." if self._closing else "The guest terminal connection was interrupted."
@@ -107,7 +118,7 @@ class SessionManager:
finally:
live.terminal.close()
try:
- await asyncio.to_thread(history.finish, session_id, status, exit_code, error)
+ await _persist(history.finish, session_id, status, exit_code, error)
finally:
live.changed.set()
self._live.pop(session_id, None)
diff --git a/tests/browser_session_server.py b/tests/browser_session_server.py
new file mode 100644
index 0000000..54518bb
--- /dev/null
+++ b/tests/browser_session_server.py
@@ -0,0 +1,82 @@
+"""Isolated browser-test server: all VM and terminal operations are fakes."""
+
+from __future__ import annotations
+
+import argparse
+from dataclasses import replace
+from pathlib import Path
+from types import SimpleNamespace
+
+import uvicorn
+
+from nest.server import create_app
+from tests.test_server import FakeLifecycle, fake_application
+from tests.test_sessions import FakeTerminal
+from uvm.errors import ConflictError, NotFoundError
+from uvm.lifecycle import ListedVm
+
+
+class BrowserTerminal(FakeTerminal):
+ def __init__(self):
+ super().__init__()
+ self.command = bytearray()
+
+ async def write(self, data):
+ await self.events.put({"type": "output", "data": data.replace(b"\r", b"\r\n")})
+ for byte in data:
+ if byte == 13:
+ await self.events.put({"type": "output", "data": b"result:" + bytes(self.command) + b"\r\nguest$ "})
+ self.command.clear()
+ elif byte == 3:
+ self.command.clear()
+ else:
+ self.command.append(byte)
+
+
+class BrowserLifecycle(FakeLifecycle):
+ def __init__(self):
+ super().__init__()
+ self.vm.username = "worker"
+ self.records = {self.vm.id: self.vm, "vm-other": replace(self.vm, id="vm-other", name="second-vm", status="stopped")}
+
+ def get(self, vm_id):
+ try:
+ return self.records[vm_id]
+ except KeyError as error:
+ raise NotFoundError("VM not found") from error
+
+ def inspect(self, identifier):
+ for vm in self.records.values():
+ if identifier in {vm.id, vm.name, vm.guest_ip}:
+ return ListedVm(vm, vm.status, "enforced" if vm.status == "running" else "inactive")
+ raise NotFoundError("VM not found")
+
+ def list_vms(self):
+ return [self.inspect(vm_id) for vm_id in self.records]
+
+ def open_terminal(self, vm_id, **_size):
+ if self.get(vm_id).status != "running":
+ raise ConflictError("Start the machine first")
+ return BrowserTerminal(), "worker"
+
+ def install_guest_agent(self, vm_id):
+ return self.get(vm_id)
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--base", type=Path, required=True)
+ parser.add_argument("--port", type=int, required=True)
+ args = parser.parse_args()
+ application = fake_application(api_token="browser-test-token")
+ application.settings.base = args.base
+ application.lifecycle = BrowserLifecycle()
+ application.state_store = SimpleNamespace(get=application.lifecycle.get)
+ api = create_app(application, host="127.0.0.1")
+ server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
+ api.state.uvicorn_server = server
+ server.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/browser_sessions.cjs b/tests/browser_sessions.cjs
new file mode 100644
index 0000000..d121fe3
--- /dev/null
+++ b/tests/browser_sessions.cjs
@@ -0,0 +1,101 @@
+// Optional integration test: requires Playwright + Chromium and the project's test extra.
+const assert = require('node:assert/strict');
+const { spawn } = require('node:child_process');
+const { mkdtemp, rm } = require('node:fs/promises');
+const os = require('node:os');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+(async () => {
+ const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-browser-'));
+ const port = process.env.NEST_BROWSER_TEST_PORT || '18765';
+ const origin = `http://127.0.0.1:${port}`;
+ const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
+ ['-B', '-m', 'tests.browser_session_server', '--base', temporary, '--port', port],
+ { cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
+ let logs = '';
+ server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
+ server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
+ let browser;
+ let page;
+ try {
+ let ready = false;
+ for (let attempt = 0; attempt < 100; attempt++) {
+ if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
+ try {
+ const response = await fetch(`${origin}/health`);
+ if (response.ok) { ready = true; break; }
+ } catch {}
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ assert.ok(ready, `Fixture server did not start: ${logs}`);
+ browser = await chromium.launch({ headless: true });
+ page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
+ const errors = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
+ const output = (value) => page.waitForFunction((text) => document.querySelector('#session-terminal').textContent.includes(text), value);
+ await page.goto(`${origin}/dashboard#vm/vm-test`);
+ await page.locator('#token-input').fill('browser-test-token');
+ await page.locator('#auth-btn').click();
+ await page.locator('#detail-actions [data-action="launch-session"]').click();
+ await connected();
+ const firstUrl = page.url();
+ assert.match(firstUrl, /#vm\/vm-test\/sessions\/session-/);
+ await page.keyboard.type('echo alpha');
+ await page.keyboard.press('Enter');
+ await output('result:echo alpha');
+
+ await page.reload();
+ await connected();
+ await output('result:echo alpha');
+ assert.equal(await page.locator('.session-item').count(), 1);
+
+ await page.locator('#detail-actions [data-action="launch-session"]').click();
+ await page.waitForURL((url) => url.href !== firstUrl);
+ await connected();
+ assert.equal(await page.locator('.session-item').count(), 2);
+ assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
+ await page.keyboard.type('echo beta');
+ await page.keyboard.press('Enter');
+ await output('result:echo beta');
+ await page.locator('.session-item').filter({ hasText: 'Session 1' }).click();
+ await connected();
+ await output('result:echo alpha');
+ assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
+
+ await page.goto(`${origin}/dashboard#vm/vm-other/sessions`);
+ await page.locator('#vm-sessions-panel').waitFor({ state: 'visible' });
+ await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
+ assert.equal(await page.locator('.session-item').count(), 0);
+ assert.ok(await page.locator('#detail-actions [data-action="launch-session"]').isDisabled());
+ assert.ok(!(await page.locator('#session-terminal').textContent()).includes('alpha'));
+
+ await page.goto(firstUrl);
+ await connected();
+ await output('result:echo alpha');
+ await page.locator('#session-end').click();
+ await page.locator('#confirm-ok').click();
+ await page.waitForFunction(() => document.querySelector('#session-status').textContent.startsWith('closed'));
+ await page.reload();
+ await output('result:echo alpha');
+ assert.ok(await page.locator('#session-end').isDisabled());
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.waitForTimeout(300);
+ const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
+ assert.ok(fits, 'mobile layout overflows the viewport');
+ assert.deepEqual(errors, []);
+ if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
+ console.log('Browser sessions passed: launch, terminal input, reload/reconnect, multiple sessions, VM isolation, closed history, and mobile layout.');
+ } catch (error) {
+ if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
+ console.error(logs);
+ throw error;
+ } finally {
+ await browser?.close();
+ server.kill('SIGTERM');
+ if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
+ await rm(temporary, { recursive: true, force: true });
+ }
+})().catch((error) => { console.error(error); process.exitCode = 1; });
diff --git a/tests/test_guest_agent_images.py b/tests/test_guest_agent_images.py
index 714bf8f..16741d8 100644
--- a/tests/test_guest_agent_images.py
+++ b/tests/test_guest_agent_images.py
@@ -139,8 +139,23 @@ class GuestAgentImageTests(unittest.TestCase):
before = self.snapshot()
self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
self.assertEqual(self.snapshot(), before)
- self.replace_guest_file("/etc/uvm-image", "uvm-ubuntu-jammy-v4\n")
- self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
+ for version in (4, 5):
+ self.replace_guest_file("/etc/uvm-image", f"uvm-ubuntu-jammy-v{version}\n")
+ self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
+
+ def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None:
+ self.make_disk()
+ self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
+ self.runner.run(("debugfs", "-w", "-R", "rm /etc/uvm-agent-files.json", self.disk), capture=True)
+ old_payload = "previous packaged agent\n"
+ self.replace_guest_file("/usr/lib/uvm/agent.py", old_payload)
+ before = self.snapshot()
+ with self.assertRaisesRegex(UvmError, "customized guest agent"):
+ self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
+ self.assertEqual(self.snapshot(), before)
+ with patch("uvm.images._V4_TEMPLATE_AGENT_SHA256", hashlib.sha256(old_payload.encode()).hexdigest()):
+ self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
+ self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
def test_agent_updates_use_recorded_hashes_but_preserve_guest_customizations(self) -> None:
self.make_disk()
diff --git a/tests/test_guest_network.py b/tests/test_guest_network.py
index c669fb9..79dda7f 100644
--- a/tests/test_guest_network.py
+++ b/tests/test_guest_network.py
@@ -108,7 +108,9 @@ class OfflineGuestNetworkTests(unittest.TestCase):
def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None:
for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE),
- ("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE)):
+ ("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE),
+ ("uvm-ubuntu-jammy-v4\n", GUEST_NETWORK_TEMPLATE),
+ ("uvm-ubuntu-jammy-v5\n", GUEST_NETWORK_TEMPLATE)):
with self.subTest(marker=marker):
self.make_disk(marker=marker, profile=profile)
original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
diff --git a/uvm/images.py b/uvm/images.py
index 6eee8bf..7b5d4c1 100644
--- a/uvm/images.py
+++ b/uvm/images.py
@@ -89,6 +89,11 @@ check_python
"""
+# The original v4 template shipped this exact agent without an ownership
+# manifest. Recognize that payload when creating from a cached v4 template.
+_V4_TEMPLATE_AGENT_SHA256 = "dfdd4bd6ee3dc06fbdbda6402cb2c54921cff22190d8da889209559800f8b948"
+
+
def guest_agent_files() -> dict[str, bytes]:
"""The same standalone payload is used by the builder and offline upgrades."""
return {
@@ -283,7 +288,7 @@ class ImageStore:
)
if marker is None or marker.path.read_text(encoding="utf-8") not in {
"uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n",
- "uvm-ubuntu-jammy-v4\n",
+ "uvm-ubuntu-jammy-v4\n", "uvm-ubuntu-jammy-v5\n",
}:
return None
profile = self._read_guest_file(
@@ -374,7 +379,10 @@ class ImageStore:
existing = self._agent_file(disk, path, files / f"existing-{index}")
if existing is not None:
digest = hashlib.sha256(existing.path.read_bytes()).hexdigest()
- if digest not in {hashes[path], old_hashes.get(path)}:
+ allowed = {hashes[path], old_hashes.get(path)}
+ if not old_hashes and path == "/usr/lib/uvm/agent.py":
+ allowed.add(_V4_TEMPLATE_AGENT_SHA256)
+ if digest not in allowed:
raise UvmError(f"refusing to overwrite a customized guest agent file: {path}")
source = files / f"payload-{index}"
source.write_bytes(content)
diff --git a/uvm/install.py b/uvm/install.py
index d2a28d0..beb1cd9 100644
--- a/uvm/install.py
+++ b/uvm/install.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import hashlib
+import json
import os
import shlex
import shutil
@@ -397,12 +399,17 @@ class Installer:
machine_id.symlink_to("../../../etc/machine-id")
for key in (root / "etc/ssh").glob("ssh_host_*_key*"):
key.unlink()
- for guest_path, contents in guest_agent_files().items():
+ agent_payloads = guest_agent_files()
+ for guest_path, contents in agent_payloads.items():
path = root / guest_path.lstrip("/")
path.parent.mkdir(parents=True, exist_ok=True)
path.parent.chmod(0o755)
path.write_bytes(contents)
path.chmod(0o644)
+ manifest = root / "etc/uvm-agent-files.json"
+ manifest.write_text(json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in agent_payloads.items()},
+ sort_keys=True) + "\n", encoding="utf-8")
+ manifest.chmod(0o600)
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service",
"uvm-agent.service"), capture=True)
self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)