From d782ca7a3f0ce3f3ae238846e9a272017cb5a747 Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sun, 25 Oct 2026 00:22:30 -0300 Subject: [PATCH 1/4] Fix :ide SIGSEGV on first frame under AOT (v1.64.3) renderDiff promoted `previous` through a `reusable` boolean; the Dart 3.13 AOT compiler hoisted `previous._cells` past that guard, reading from null on the first frame. Resolve the previous cells to a nullable local before the loop instead. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++++++++++++++++ .../client/ide/tui/screen_buffer.dart | 16 ++++++++++------ lib/src/version.dart | 2 +- pubspec.yaml | 2 +- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c436e5..c30b847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 1.64.3 + +### Fixed + +- **`omnyshell ide` no longer crashes with a segmentation fault on start.** + - **Before:** the compiled binary died painting the first frame + (`SIGSEGV`, `si_addr=0xf` in `ScreenBuffer.renderDiff`). This was seen on + Linux x64 and reproduced on macOS arm64. + - **Cause:** `renderDiff` checked that `previous` was non-null through a + `reusable` boolean, then read `previous._cells` inside the loop. The Dart + 3.13 AOT compiler moved that read ahead of the check, so on the first + frame, when there is no previous frame, it read from `null`. JIT runs + (`dart test`, `dart run`) were not affected. + - **Now:** the previous frame's cells go into a nullable local once, before + the loop, and every comparison checks that local. + ## 1.64.2 ### Fixed diff --git a/lib/src/application/client/ide/tui/screen_buffer.dart b/lib/src/application/client/ide/tui/screen_buffer.dart index 31809d3..8c83e0c 100644 --- a/lib/src/application/client/ide/tui/screen_buffer.dart +++ b/lib/src/application/client/ide/tui/screen_buffer.dart @@ -99,10 +99,14 @@ class ScreenBuffer { /// The returned string positions the cursor as needed and ends with an SGR /// reset; the caller is responsible for any final cursor placement. String renderDiff(ScreenBuffer? previous) { - final reusable = - previous != null && - previous.width == width && - previous.height == height; + // Resolve the previous frame's cells to a nullable local up front rather + // than promoting `previous` through a `reusable` boolean: the Dart 3.13 + // AOT compiler hoists `previous._cells` out of the loop past that guard, + // dereferencing `null` on the first frame (a SIGSEGV at `si_addr=0xf`). + final prev = + previous != null && previous.width == width && previous.height == height + ? previous._cells + : null; final out = StringBuffer(); Style? current; // the SGR currently active in `out`. @@ -110,7 +114,7 @@ class ScreenBuffer { var x = 0; while (x < width) { final cell = _cells[_index(x, y)]; - final unchanged = reusable && previous._cells[_index(x, y)] == cell; + final unchanged = prev != null && prev[_index(x, y)] == cell; if (unchanged) { x++; continue; @@ -120,7 +124,7 @@ class ScreenBuffer { out.write('\x1b[${y + 1};${x + 1}H'); while (x < width) { final c = _cells[_index(x, y)]; - if (reusable && previous._cells[_index(x, y)] == c) break; + if (prev != null && prev[_index(x, y)] == c) break; if (current != c.style) { out.write(c.style.sgr); current = c.style; diff --git a/lib/src/version.dart b/lib/src/version.dart index b25bee1..640b5fa 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -3,4 +3,4 @@ /// This is the single source of truth for "what build is this": it is rendered /// in the CLI banner and is the default a node reports as its /// [NodeConfig.agentVersion] / [PlatformInfo.agentVersion]. -const String omnyShellVersion = '1.64.2'; +const String omnyShellVersion = '1.64.3'; diff --git a/pubspec.yaml b/pubspec.yaml index 063e8be..3974532 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: >- connect to a Hub by node identity (not host:port); the Hub authenticates, authorizes and brokers encrypted sessions to Nodes over WebSocket-on-TLS. Ships Hub, Node, Client and CLI implementations behind first-class Dart APIs. -version: 1.64.2 +version: 1.64.3 repository: https://github.com/OmnyGrid/omnyshell environment: From 43bded09c8a8a5181286ddf40f386c130e6d35fd Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sun, 25 Oct 2026 00:30:48 -0300 Subject: [PATCH 2/4] Add a node Info tab to the dashboard; nodes report OS version, CPUs, runtime (v1.65.0) An opened node now has Sessions / Info / Credentials tabs (s / i / c; i on the node list opens Info directly). Info shows identity, OS and OS version, arch, CPU count, hostname, agent version vs. this client, Dart runtime, capabilities, labels and the connection. PlatformInfo gains optional osVersion, cpuCount and runtimeVersion, omitted from the JSON when unknown so older nodes and Hubs interoperate. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 +- .../client/dashboard/dashboard_app.dart | 291 +++++++++++++++++- lib/src/application/client/local_command.dart | 12 +- lib/src/domain/entities/platform_info.dart | 24 +- lib/src/domain/entities/platform_info_io.dart | 11 + lib/src/version.dart | 2 +- pubspec.yaml | 2 +- test/unit/dashboard/dashboard_app_test.dart | 116 +++++++ test/unit/domain/domain_types_test.dart | 49 +++ 9 files changed, 514 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30b847..97b8b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,22 @@ -## 1.64.3 +## 1.65.0 + +### Added + +- **The dashboard has a node Info tab.** An opened node now has three tabs, + **Sessions · Info · Credentials**, switched with `s`, `i` and `c`. Press `i` + on the node list to open a node straight onto Info. + - **Shows:** node ID, name, UID and status; OS, OS version, architecture, + CPU count and hostname; the agent's OmnyShell version (compared with this + client) and Dart runtime; shells, features, session limit and direct + endpoint; every label; and the Hub and principal you are connected as. + - **Scrolls** with the arrow keys, Page Up/Down and Home/End, and wraps long + values. `r` re-lists nodes to refresh it. +- **Nodes now report their OS version, CPU count and Dart runtime** (e.g. + `6.1.0-53-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian …`, `8`, + `3.13.0 (stable)`). The fields are optional on the wire: a node or Hub on an + older version leaves them out, and the Info tab shows "not reported". + Clients see them once both the node and the Hub run 1.65.0. The `:info` + local command shows them too. ### Fixed diff --git a/lib/src/application/client/dashboard/dashboard_app.dart b/lib/src/application/client/dashboard/dashboard_app.dart index f9bae8f..5964414 100644 --- a/lib/src/application/client/dashboard/dashboard_app.dart +++ b/lib/src/application/client/dashboard/dashboard_app.dart @@ -9,6 +9,7 @@ import '../../ai/agent_mode.dart'; import '../../ai/ai_config.dart' show AiProviderKind; import '../../ai/ai_config_io.dart' show AiConfigDescription; import '../../../shared/utils/progress_bar.dart' show formatFileDiff; +import '../../../version.dart' show omnyShellVersion; import '../../../protocol/control_message.dart' show DriveCredentialEntry; import '../drive/drive_manager.dart' show SyncOutcome, DriveChanges; import '../drive/mount_store.dart' show MountRecord; @@ -25,11 +26,13 @@ import 'dashboard_backend.dart'; /// Which screen the dashboard is currently showing. `nodes`, `tunnels`, `drive` /// and `ai` are the four top-level tabs; `nodeDetail`/`driveDetail` are -/// sub-screens reached from their tab. +/// sub-screens reached from their tab. Inside a node, `nodeDetail` (sessions), +/// `nodeInfo` and `nodeCredentials` are peer node tabs (see [_nodeTabs]). enum _Screen { login, nodes, nodeDetail, + nodeInfo, nodeCredentials, tunnels, drive, @@ -174,6 +177,9 @@ class DashboardApp { List _sessions = const []; int _sessionSel = 0; + // Node-info screen state: the first visible row of the info list. + int _infoScroll = 0; + // The caller's own git credentials on [_currentNode] (node-credentials screen). List _credentials = const []; int _credSel = 0; @@ -332,6 +338,8 @@ class DashboardApp { await _handleNodesKey(key); case _Screen.nodeDetail: await _handleNodeDetailKey(key); + case _Screen.nodeInfo: + await _handleNodeInfoKey(key); case _Screen.nodeCredentials: await _handleNodeCredentialsKey(key); case _Screen.tunnels: @@ -364,6 +372,7 @@ class DashboardApp { /// The tab currently active (detail sub-screens map to their parent tab). _Screen get _activeTab => switch (_screenId) { _Screen.nodeDetail => _Screen.nodes, + _Screen.nodeInfo => _Screen.nodes, _Screen.nodeCredentials => _Screen.nodes, _Screen.driveDetail => _Screen.drive, final s => s, @@ -697,6 +706,8 @@ class DashboardApp { await _openNode(); case KeyType.char: switch (key.text) { + case 'i': + await _openNode(info: true); case 'r': await _reloadNodes(busy: true); case 'L': @@ -707,15 +718,21 @@ class DashboardApp { } } - Future _openNode() async { + /// Opens the selected node on its Sessions tab, or on its Info tab when + /// [info] is set (which needs no RPC: it renders the listed descriptor). + Future _openNode({bool info = false}) async { if (_nodes.isEmpty) return; _currentNode = _nodes[_nodeSel.clamp(0, _maxIndex(_nodes.length))]; - _screenId = _Screen.nodeDetail; _sessionSel = 0; _sessionScroll = 0; // Drop the previously opened node's sessions so the list starts empty while // this node's sessions load, rather than briefly showing stale rows. _sessions = const []; + if (info) { + _openNodeInfo(); + return; + } + _screenId = _Screen.nodeDetail; await _reloadSessions(busy: true); } @@ -793,6 +810,8 @@ class DashboardApp { await _detachSelected(); case 'k': _confirmKillSelected(); + case 'i': + _openNodeInfo(); case 'c': await _openNodeCredentials(); } @@ -931,6 +950,75 @@ class DashboardApp { ); } + // ---- Node info screen ---------------------------------------------------- + + /// Shows everything known about the current node (identity, OS, agent + /// version, capabilities, labels). Renders the listed descriptor, so it needs + /// no RPC; `r` re-lists nodes to refresh it. + void _openNodeInfo() { + if (_currentNode == null) return; + _screenId = _Screen.nodeInfo; + _infoScroll = 0; + } + + /// Switches to the current node's Sessions tab, loading its sessions (they + /// are not loaded yet when the node was opened straight onto Info). + Future _showNodeSessions() async { + if (_currentNode == null) return; + _screenId = _Screen.nodeDetail; + await _reloadSessions(busy: true); + } + + /// Re-lists nodes and replaces [_currentNode] with its fresh descriptor (so a + /// node that went offline, or upgraded its agent, shows the change). + Future _refreshNodeInfo() async { + final node = _currentNode; + if (node == null) return; + await _reloadNodes(busy: true); + if (_messageIsError) return; // the reload already reported its failure. + final fresh = _nodes.where((n) => n.id == node.id).firstOrNull; + if (fresh == null) { + _setMessage( + '${node.id.value} is no longer listed by the Hub.', + isError: true, + ); + return; + } + _currentNode = fresh; + _setMessage('Node info refreshed.'); + } + + Future _handleNodeInfoKey(KeyEvent key) async { + switch (key.type) { + case KeyType.up: + _infoScroll = (_infoScroll - 1).clamp(0, _infoScroll); + case KeyType.down: + _infoScroll++; // clamped to the content when rendered. + case KeyType.pageUp: + _infoScroll = (_infoScroll - 10).clamp(0, _infoScroll); + case KeyType.pageDown: + _infoScroll += 10; + case KeyType.home: + _infoScroll = 0; + case KeyType.end: + _infoScroll = 1 << 20; + case KeyType.escape: + case KeyType.left: + await _showNodeSessions(); + case KeyType.char: + switch (key.text) { + case 's': + await _showNodeSessions(); + case 'c': + await _openNodeCredentials(); + case 'r': + await _refreshNodeInfo(); + } + default: + break; + } + } + // ---- Node credentials screen --------------------------------------------- /// Opens the caller's git-credential view for the current node. @@ -1000,7 +1088,7 @@ class DashboardApp { _credSel = _maxIndex(_credentials.length); case KeyType.escape: case KeyType.left: - _screenId = _Screen.nodeDetail; + await _showNodeSessions(); case KeyType.char: switch (key.text) { case 'a': @@ -1011,6 +1099,10 @@ class DashboardApp { _confirmRemoveCredential(); case 'r': _refreshCredentials(); + case 's': + await _showNodeSessions(); + case 'i': + _openNodeInfo(); } default: break; @@ -1703,9 +1795,13 @@ class DashboardApp { // The four connected screens (and their detail sub-screens) carry a // top-level tab strip; login does not. - final body = _screenId == _Screen.login + final tabBody = _screenId == _Screen.login ? content : _renderTabBar(s, content); + // Inside a node, a second strip switches between its own tabs. + final body = _nodeTabs.contains(_screenId) + ? _renderNodeTabBar(s, tabBody) + : tabBody; switch (_screenId) { case _Screen.login: @@ -1721,6 +1817,7 @@ class DashboardApp { _renderHints(s, hintRect, const [ ('↑↓', 'move'), ('Enter', 'open'), + ('i', 'info'), ('r', 'refresh'), ('L', 'logout'), ('Tab', 'tab'), @@ -1734,6 +1831,16 @@ class DashboardApp { ('p', 'peek'), ('d', 'detach'), ('k', 'kill'), + ('i', 'info'), + ('c', 'creds'), + ('r', 'refresh'), + ('Esc', 'back'), + ]); + case _Screen.nodeInfo: + _renderNodeInfo(s, body); + _renderHints(s, hintRect, const [ + ('↑↓', 'scroll'), + ('s', 'sessions'), ('c', 'creds'), ('r', 'refresh'), ('Esc', 'back'), @@ -1743,6 +1850,8 @@ class DashboardApp { _renderHints(s, hintRect, const [ ('a', 'add'), ('x', 'remove'), + ('s', 'sessions'), + ('i', 'info'), ('r', 'refresh'), ('Esc', 'back'), ]); @@ -1838,6 +1947,45 @@ class DashboardApp { return body; } + /// The tabs inside an opened node, in strip order, with their hotkeys. + static const _nodeTabs = [ + _Screen.nodeDetail, + _Screen.nodeInfo, + _Screen.nodeCredentials, + ]; + static const _nodeTabLabels = ['s Sessions', 'i Info', 'c Credentials']; + + /// Draws the current node's tab strip (Sessions · Info · Credentials) and + /// returns the remaining body rect below it. + Rect _renderNodeTabBar(ScreenBuffer s, Rect area) { + if (area.isEmpty) return area; + final (barRect, body) = area.splitTop(1); + s.fillRect(barRect.left, barRect.top, barRect.width, 1, ' ', _barStyle); + var x = s.drawText( + barRect.left + 1, + barRect.top, + '${_currentNode?.id.value ?? '?'} ', + _barStyle.copyWith(bold: true), + maxWidth: barRect.width - 1, + ); + for (var i = 0; i < _nodeTabs.length; i++) { + if (x + 1 >= barRect.right) break; + final selected = _nodeTabs[i] == _screenId; + final style = selected + ? _rowSelected + : _barStyle.copyWith(fg: const Color.indexed(250)); + final label = ' ${_nodeTabLabels[i]} '; + x = s.drawText( + x + 1, + barRect.top, + label, + style, + maxWidth: barRect.right - x - 1, + ); + } + return body; + } + /// Renders a modal [_Form] centred over [full]. void _renderForm(ScreenBuffer s, Rect full, _Form form) { final rows = form.fields.length + 4; // title + fields + submit + padding @@ -2301,6 +2449,134 @@ class DashboardApp { ]; } + /// Every row of the node-info tab: a section heading when the value is + /// `null`, else a label/value pair. Fields an older agent or Hub does not + /// report read "not reported" rather than vanishing. + List<(String, String?)> _nodeInfoRows(NodeDescriptor node) { + const notReported = '— (not reported)'; + final p = node.platform; + final caps = node.capabilities; + final labels = node.labels.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + final agent = p.agentVersion == omnyShellVersion + ? '${p.agentVersion} (same as this client)' + : '${p.agentVersion} (this client: $omnyShellVersion)'; + final osVersion = p.osVersion; + return [ + ('Identity', null), + ('Node ID', node.id.value), + ('Name', node.displayName.isEmpty ? '—' : node.displayName), + ('UID', node.uid?.value ?? notReported), + ('Status', node.online ? 'online' : 'offline'), + ('System', null), + ('OS', p.os), + ( + 'OS version', + osVersion == null || osVersion.isEmpty ? notReported : osVersion, + ), + ('Arch', p.arch), + ('CPUs', p.cpuCount?.toString() ?? notReported), + ('Hostname', p.hostname), + ('OmnyShell', null), + ('Version', agent), + ('Dart runtime', p.runtimeVersion ?? notReported), + ('Capabilities', null), + if (caps == null) + ('Advertised', '— (none registered)') + else ...[ + ('Shells', caps.shells.isEmpty ? '—' : caps.shells.join(', ')), + ('Features', caps.features.isEmpty ? '—' : caps.features.join(', ')), + ('Max sessions', '${caps.maxSessions}'), + ('Direct', caps.directEndpoint ?? '— (reached through the Hub)'), + ], + ('Labels', null), + if (labels.isEmpty) + ('', 'none') + else + for (final e in labels) (e.key, e.value), + ('Connection', null), + ('Hub', _backend.connectedHub ?? '?'), + ('Signed in as', _backend.principal?.displayName ?? '?'), + ]; + } + + void _renderNodeInfo(ScreenBuffer s, Rect area) { + if (area.isEmpty) return; + final node = _currentNode; + if (node == null) return; + const labelW = 14; + final valueX = area.left + labelW + 4; + final valueW = (area.right - valueX - 1).clamp(1, area.width); + final labelStyle = Palette.editorBg.copyWith(fg: const Color.indexed(245)); + final valueStyle = Palette.editorBg.copyWith(fg: const Color.indexed(252)); + + // Lay the rows out first (long values wrap onto continuation lines) so the + // scroll offset can be clamped to the real content height. + final lines = <({bool heading, String label, String value, Style style})>[]; + for (final (label, value) in _nodeInfoRows(node)) { + if (value == null) { + if (lines.isNotEmpty) { + lines.add((heading: false, label: '', value: '', style: valueStyle)); + } + lines.add((heading: true, label: label, value: '', style: valueStyle)); + continue; + } + final style = label == 'Status' + ? valueStyle.copyWith( + fg: node.online + ? const Color.indexed(114) + : const Color.indexed(203), + ) + : valueStyle; + final wrapped = _wrap(value, valueW); + for (var i = 0; i < wrapped.length; i++) { + lines.add(( + heading: false, + label: i == 0 ? label : '', + value: wrapped[i], + style: style, + )); + } + } + + final maxScroll = (lines.length - area.height).clamp(0, lines.length); + _infoScroll = _infoScroll.clamp(0, maxScroll); + for (var i = 0; i < area.height; i++) { + final idx = _infoScroll + i; + if (idx >= lines.length) break; + final line = lines[idx]; + final y = area.top + i; + if (line.heading) { + s.drawText( + area.left + 1, + y, + line.label, + _heading, + maxWidth: area.width - 2, + ); + continue; + } + s.drawText(area.left + 3, y, line.label, labelStyle, maxWidth: labelW); + s.drawText(valueX, y, line.value, line.style, maxWidth: valueW); + } + } + + /// Splits [text] into lines of at most [width] characters, breaking at the + /// last space that fits (or mid-word when a word is longer than [width]). + static List _wrap(String text, int width) { + if (width <= 0 || text.length <= width) return [text]; + final out = []; + var rest = text; + while (rest.length > width) { + var cut = rest.lastIndexOf(' ', width); + if (cut <= 0) cut = width; + out.add(rest.substring(0, cut).trimRight()); + rest = rest.substring(cut).trimLeft(); + } + if (rest.isNotEmpty) out.add(rest); + return out; + } + // ---- Tunnels / Drive / AI rendering -------------------------------------- void _renderNodeCredentials(ScreenBuffer s, Rect area) { @@ -2577,6 +2853,11 @@ class DashboardApp { _Screen.nodes => who, _Screen.nodeDetail => '${_currentNode?.id.value ?? '?'} · ${_sessions.length} session(s)', + _Screen.nodeInfo => + '${_currentNode?.id.value ?? '?'} · ' + '${_currentNode?.platform.os ?? '?'}/' + '${_currentNode?.platform.arch ?? '?'} · ' + 'agent ${_currentNode?.platform.agentVersion ?? '?'}', _Screen.nodeCredentials => 'Git credentials on ${_currentNode?.id.value ?? '?'} · ' '${_credentials.length} entr${_credentials.length == 1 ? 'y' : 'ies'}', diff --git a/lib/src/application/client/local_command.dart b/lib/src/application/client/local_command.dart index ed8be42..c3d2dec 100644 --- a/lib/src/application/client/local_command.dart +++ b/lib/src/application/client/local_command.dart @@ -361,10 +361,14 @@ class _InfoCommand extends LocalCommand { final name = node.displayName; c.writeLine('Node: ${node.id.value}${name.isEmpty ? '' : ' ($name)'}'); if (node.uid != null) c.writeLine('UID: ${node.uid!.value}'); - c.writeLine('OS: ${node.platform.os}'); - c.writeLine('Arch: ${node.platform.arch}'); - c.writeLine('Hostname: ${node.platform.hostname}'); - c.writeLine('Agent: ${node.platform.agentVersion}'); + final p = node.platform; + c.writeLine('OS: ${p.os}'); + if (p.osVersion != null) c.writeLine('OS Version: ${p.osVersion}'); + c.writeLine('Arch: ${p.arch}'); + if (p.cpuCount != null) c.writeLine('CPUs: ${p.cpuCount}'); + c.writeLine('Hostname: ${p.hostname}'); + c.writeLine('Agent: ${p.agentVersion}'); + if (p.runtimeVersion != null) c.writeLine('Runtime: ${p.runtimeVersion}'); final client = c.client; if (client != null) c.writeLine('Hub: ${client.config.hubUri}'); if (node.labels.isNotEmpty) { diff --git a/lib/src/domain/entities/platform_info.dart b/lib/src/domain/entities/platform_info.dart index 0caf8af..146cbac 100644 --- a/lib/src/domain/entities/platform_info.dart +++ b/lib/src/domain/entities/platform_info.dart @@ -19,20 +19,39 @@ class PlatformInfo { /// The node's hostname. final String hostname; + /// The operating system's version/build string (e.g. the kernel release and + /// build on Linux), or `null` when the node did not report one (older + /// agents). + final String? osVersion; + + /// The number of logical processors on the node, or `null` when not reported. + final int? cpuCount; + + /// The Dart runtime the agent runs on (e.g. `3.13.0 (stable)`), or `null` + /// when not reported. + final String? runtimeVersion; + /// Creates platform info. const PlatformInfo({ required this.os, required this.arch, required this.agentVersion, required this.hostname, + this.osVersion, + this.cpuCount, + this.runtimeVersion, }); - /// Serializes to a JSON map. + /// Serializes to a JSON map. The optional fields are omitted when unknown, so + /// the wire shape stays identical for agents that do not report them. Map toJson() => { 'os': os, 'arch': arch, 'agentVersion': agentVersion, 'hostname': hostname, + if (osVersion != null) 'osVersion': osVersion, + if (cpuCount != null) 'cpuCount': cpuCount, + if (runtimeVersion != null) 'runtimeVersion': runtimeVersion, }; /// Decodes from a JSON map. @@ -41,5 +60,8 @@ class PlatformInfo { arch: Json.requireString(json, 'arch'), agentVersion: Json.optString(json, 'agentVersion') ?? 'unknown', hostname: Json.optString(json, 'hostname') ?? 'unknown', + osVersion: Json.optString(json, 'osVersion'), + cpuCount: Json.optInt(json, 'cpuCount'), + runtimeVersion: Json.optString(json, 'runtimeVersion'), ); } diff --git a/lib/src/domain/entities/platform_info_io.dart b/lib/src/domain/entities/platform_info_io.dart index b6979ca..35aa7c1 100644 --- a/lib/src/domain/entities/platform_info_io.dart +++ b/lib/src/domain/entities/platform_info_io.dart @@ -15,8 +15,19 @@ extension PlatformInfoLocal on PlatformInfo { arch: _architecture(), agentVersion: agentVersion, hostname: Platform.localHostname, + osVersion: Platform.operatingSystemVersion.trim(), + cpuCount: Platform.numberOfProcessors, + runtimeVersion: runtimeVersion(Platform.version), ); + /// The Dart version from a `Platform.version` string, without the trailing + /// build date and target (`3.13.0 (stable) (Wed …) on "linux_x64"` → + /// `3.13.0 (stable)`). + static String runtimeVersion(String version) { + final match = RegExp(r'^\S+(?: \([a-z]+\))?').firstMatch(version.trim()); + return match?.group(0) ?? version.trim(); + } + static String _architecture() { final version = Platform.version; final match = RegExp(r'"([^"]+)"').firstMatch(version); diff --git a/lib/src/version.dart b/lib/src/version.dart index 640b5fa..1c9fff9 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -3,4 +3,4 @@ /// This is the single source of truth for "what build is this": it is rendered /// in the CLI banner and is the default a node reports as its /// [NodeConfig.agentVersion] / [PlatformInfo.agentVersion]. -const String omnyShellVersion = '1.64.3'; +const String omnyShellVersion = '1.65.0'; diff --git a/pubspec.yaml b/pubspec.yaml index 3974532..5b3c3cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: >- connect to a Hub by node identity (not host:port); the Hub authenticates, authorizes and brokers encrypted sessions to Nodes over WebSocket-on-TLS. Ships Hub, Node, Client and CLI implementations behind first-class Dart APIs. -version: 1.64.3 +version: 1.65.0 repository: https://github.com/OmnyGrid/omnyshell environment: diff --git a/test/unit/dashboard/dashboard_app_test.dart b/test/unit/dashboard/dashboard_app_test.dart index cc1fa4a..e0de708 100644 --- a/test/unit/dashboard/dashboard_app_test.dart +++ b/test/unit/dashboard/dashboard_app_test.dart @@ -20,12 +20,14 @@ import 'package:omnyshell/src/protocol/control_message.dart' show DriveCredentialEntry; import 'package:omnyshell/src/domain/auth/principal.dart'; import 'package:omnyshell/src/domain/entities/detached_session_info.dart'; +import 'package:omnyshell/src/domain/entities/node_capabilities.dart'; import 'package:omnyshell/src/domain/entities/node_descriptor.dart'; import 'package:omnyshell/src/domain/entities/platform_info.dart'; import 'package:omnyshell/src/domain/entities/session.dart'; import 'package:omnyshell/src/domain/entities/tunnel_info.dart'; import 'package:omnyshell/src/domain/value_objects/node_id.dart'; import 'package:omnyshell/src/domain/value_objects/principal_id.dart'; +import 'package:omnyshell/src/version.dart'; import 'package:test/test.dart'; /// A scriptable [TerminalDriver] backed by a broadcast input stream (so the app @@ -517,6 +519,8 @@ const ctrlQ = [17]; const tab = [9]; const down = [27, 91, 66]; const left = [27, 91, 68]; +const home = [27, 91, 72]; +const end = [27, 91, 70]; DashboardApp _app(FakeTerminal term, FakeDashboardBackend backend) => DashboardApp( @@ -1144,6 +1148,118 @@ void main() { }, ); + group('node info tab', () { + NodeDescriptor detailedNode() => NodeDescriptor( + id: NodeId('web-01'), + displayName: 'Web server', + online: true, + platform: const PlatformInfo( + os: 'linux', + arch: 'x64', + agentVersion: omnyShellVersion, + hostname: 'menuici-sites', + osVersion: + '6.1.0-53-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.187-1 ' + '(2026-09-07)', + cpuCount: 8, + runtimeVersion: '3.13.0 (stable)', + ), + labels: const {'region': 'eu', 'env': 'prod'}, + capabilities: const NodeCapabilities( + shells: ['bash', 'sh'], + features: ['exec', 'shell'], + maxSessions: 50, + ), + ); + + test('i on the node list opens Info with every node field', () async { + final term = FakeTerminal(cols: 80, rows: 40); + final backend = connectedBackend()..nodes = [detailedNode()]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); // open web-01 straight onto Info + await pump(); + + final text = frameText(term.lastFrame); + expect(text, contains('s Sessions')); + expect(text, contains('i Info')); + expect(text, contains('c Credentials')); + expect(text, contains('Web server')); + expect(text, contains('6.1.0-53-cloud-amd64 #1 SMP PREEMPT_DYNAMIC')); + // The long OS version wraps onto a continuation line at 80 columns + // rather than being clipped. + expect(text, contains('(2026-09-07)')); + expect(text, isNot(contains('PREEMPT_DYNAMIC Debian 6.1.187-1 (2026'))); + expect(text, contains('menuici-sites')); + expect(text, contains('$omnyShellVersion (same as this client)')); + expect(text, contains('3.13.0 (stable)')); + expect(text, contains('bash, sh')); + expect(text, contains('— (reached through the Hub)')); + expect(text, contains('region')); + expect(text, contains('wss://h')); + expect(RegExp(r'CPUs\s+8').hasMatch(text), isTrue); + // Info renders the listed descriptor: no session RPC until asked. + expect(backend.calls.where((c) => c.startsWith('listSessions')), isEmpty); + + term.send('s'.codeUnits); // -> Sessions tab loads the sessions + await pump(); + expect(backend.calls, contains('listSessions:web-01')); + + term.send(ctrlQ); + await running; + }); + + test('an older agent shows its missing fields as not reported', () async { + final term = FakeTerminal(cols: 100, rows: 40); + final backend = connectedBackend(); // node('web-01'): agent 1.0.0 + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send(enter); // open node (Sessions) + await pump(); + term.send('i'.codeUnits); // -> Info tab + await pump(); + + final text = frameText(term.lastFrame); + expect(text, contains('1.0.0 (this client: $omnyShellVersion)')); + expect(RegExp(r'OS version\s+— \(not reported\)').hasMatch(text), isTrue); + expect(text, contains('— (none registered)')); + + term.send(ctrlQ); + await running; + }); + + test('scrolls on a short terminal and r refreshes the node', () async { + final term = FakeTerminal(cols: 100, rows: 14); + final backend = connectedBackend()..nodes = [detailedNode()]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); + await pump(); + expect(frameText(term.lastFrame), isNot(contains('Signed in as'))); + + term.send(end); + await pump(); + expect(frameText(term.lastFrame), contains('Signed in as')); + + backend.nodes = [detailedNode().copyWith(online: false)]; + term.send('r'.codeUnits); + await pump(); + term.send(home); + await pump(); + final text = frameText(term.lastFrame); + expect(text, contains('offline')); + + term.send(ctrlQ); + await running; + }); + }); + test('tunnel close asks to confirm then dispatches closeTunnel', () async { final term = FakeTerminal(); final backend = connectedBackend()..tunnels = [tunnelInfo('t1')]; diff --git a/test/unit/domain/domain_types_test.dart b/test/unit/domain/domain_types_test.dart index 8bbf55c..1b28ee7 100644 --- a/test/unit/domain/domain_types_test.dart +++ b/test/unit/domain/domain_types_test.dart @@ -2,6 +2,7 @@ library; import 'package:omnyshell/omnyshell.dart'; +import 'package:omnyshell/src/domain/entities/platform_info_io.dart'; import 'package:omnyshell/src/shared/utils/bytes.dart'; import 'package:test/test.dart'; @@ -233,4 +234,52 @@ void main() { expect(const TransportException('down'), isA()); }); }); + + group('PlatformInfo', () { + test('round-trips the optional OS version, CPU count and runtime', () { + const info = PlatformInfo( + os: 'linux', + arch: 'x64', + agentVersion: '1.64.3', + hostname: 'h', + osVersion: '6.1.0-53-cloud-amd64 #1 SMP', + cpuCount: 8, + runtimeVersion: '3.13.0 (stable)', + ); + final back = PlatformInfo.fromJson(info.toJson()); + expect(back.osVersion, '6.1.0-53-cloud-amd64 #1 SMP'); + expect(back.cpuCount, 8); + expect(back.runtimeVersion, '3.13.0 (stable)'); + }); + + test('omits unknown optional fields and decodes an older agent', () { + const old = PlatformInfo( + os: 'linux', + arch: 'x64', + agentVersion: '1.0.0', + hostname: 'h', + ); + expect(old.toJson().keys, ['os', 'arch', 'agentVersion', 'hostname']); + final back = PlatformInfo.fromJson(old.toJson()); + expect(back.osVersion, isNull); + expect(back.cpuCount, isNull); + expect(back.runtimeVersion, isNull); + }); + + test('local() reports the runtime without its build date and target', () { + expect( + PlatformInfoLocal.runtimeVersion( + '3.13.0 (stable) (Wed Aug 5 00:28:05 2026 -0700) on "linux_x64"', + ), + '3.13.0 (stable)', + ); + expect( + PlatformInfoLocal.runtimeVersion('3.14.0-12.0.dev (dev) on "x"'), + '3.14.0-12.0.dev (dev)', + ); + final local = PlatformInfoLocal.local(agentVersion: 'v'); + expect(local.cpuCount, greaterThan(0)); + expect(local.osVersion, isNotEmpty); + }); + }); } From e29e6013e37977430512bca21f14c4646d92bf0f Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sun, 25 Oct 2026 00:36:51 -0300 Subject: [PATCH 3/4] Deliver a bare Esc in :ide and the dashboard; add AOT and node-tab tests A lone ESC byte stayed buffered in KeyDecoder until another key arrived, so a bare Esc did nothing in :ide or the dashboard until the next keypress. Both apps now flush it after KeyDecoder.escapeTimeout (50 ms) when nothing follows; a sequence split across reads still decodes. Tests: an integration test AOT-compiles a renderDiff driver (it crashes on the 1.64.2 code), node-tab navigation and Esc, a node dropping out on refresh, mid-word wrapping, split arrow keys, and :info's new fields. Co-Authored-By: Claude Opus 5.5 --- .../client/dashboard/dashboard_app.dart | 54 ++++--- lib/src/application/client/ide/ide_app.dart | 39 +++-- .../client/ide/tui/key_decoder.dart | 20 ++- test/integration/aot_render_test.dart | 50 +++++++ test/support/aot/render_frames.dart | 24 ++++ test/unit/client/local_command_test.dart | 34 ++++- test/unit/dashboard/dashboard_app_test.dart | 134 +++++++++++++++++- test/unit/ide/ide_app_test.dart | 25 ++-- 8 files changed, 339 insertions(+), 41 deletions(-) create mode 100644 test/integration/aot_render_test.dart create mode 100644 test/support/aot/render_frames.dart diff --git a/lib/src/application/client/dashboard/dashboard_app.dart b/lib/src/application/client/dashboard/dashboard_app.dart index 5964414..d1fc39f 100644 --- a/lib/src/application/client/dashboard/dashboard_app.dart +++ b/lib/src/application/client/dashboard/dashboard_app.dart @@ -147,6 +147,10 @@ class DashboardApp { StreamSubscription>? _inputSub; StreamSubscription? _resizeSub; + /// Delivers a buffered lone `ESC` as Escape when no more input follows it + /// (see [KeyDecoder.hasPendingEscape]). + Timer? _escTimer; + _Screen _screenId = _Screen.login; ScreenBuffer? _screen; @@ -233,28 +237,44 @@ class DashboardApp { void _subscribe() { final decoder = KeyDecoder(); - _inputSub = _terminal.input.listen( + late final StreamSubscription> sub; + + // Runs [keys] with input paused so key handling stays serialised. Only + // resumes if [sub] is still the live subscription — a resume/peek handoff + // cancels and re-creates it mid-chunk. + Future dispatch(List keys) async { + sub.pause(); + for (final key in keys) { + // Never let an unexpected error from a key handler crash or freeze the + // TUI: surface it in the status bar and keep the loop alive. + try { + await _handleKey(key); + } on Object catch (e) { + _setError('Unexpected error', e); + } + if (_done.isCompleted) break; + } + if (!_done.isCompleted) _render(); + if (!_done.isCompleted && identical(_inputSub, sub)) sub.resume(); + } + + sub = _terminal.input.listen( (bytes) async { - final sub = _inputSub; - sub?.pause(); - for (final key in decoder.decode(bytes)) { - // Never let an unexpected error from a key handler crash or freeze the - // TUI: surface it in the status bar and keep the loop alive. - try { - await _handleKey(key); - } on Object catch (e) { - _setError('Unexpected error', e); - } - if (_done.isCompleted) break; + _escTimer?.cancel(); + await dispatch(decoder.decode(bytes)); + // A bare Escape keypress is a lone ESC byte the decoder cannot yet tell + // from the start of a sequence; deliver it once nothing follows. + if (decoder.hasPendingEscape && identical(_inputSub, sub)) { + _escTimer = Timer(KeyDecoder.escapeTimeout, () { + if (_done.isCompleted || !identical(_inputSub, sub)) return; + if (decoder.hasPendingEscape) unawaited(dispatch(decoder.flush())); + }); } - if (!_done.isCompleted) _render(); - // Only resume if this is still the live subscription — a resume/peek - // handoff cancels and re-creates it mid-chunk. - if (!_done.isCompleted && identical(_inputSub, sub)) sub?.resume(); }, onError: (_) => _finish(), onDone: _finish, ); + _inputSub = sub; _resizeSub = _terminal.resizeEvents.listen((_) { _terminal.invalidate(); if (!_done.isCompleted) _render(); @@ -262,6 +282,8 @@ class DashboardApp { } Future _unsubscribe() async { + _escTimer?.cancel(); + _escTimer = null; await _inputSub?.cancel(); _inputSub = null; await _resizeSub?.cancel(); diff --git a/lib/src/application/client/ide/ide_app.dart b/lib/src/application/client/ide/ide_app.dart index f5efd76..c7ab29d 100644 --- a/lib/src/application/client/ide/ide_app.dart +++ b/lib/src/application/client/ide/ide_app.dart @@ -199,6 +199,7 @@ class IdeApp { final decoder = KeyDecoder(); StreamSubscription>? inputSub; StreamSubscription? resizeSub; + Timer? escTimer; try { // Load the tree root and git state before the first paint. _repo = await GitRepo.discover( @@ -212,21 +213,34 @@ class IdeApp { // Input is handled asynchronously (file ops await the workspace), so the // subscription is paused while a chunk is processed and resumed after, to // serialise key handling and keep ordering deterministic. + Future dispatch(List keys) async { + inputSub!.pause(); + for (final key in keys) { + // Never let an unexpected error from a key handler crash or freeze + // the TUI: surface it in the status bar and keep the loop alive. + try { + await _handleKey(key); + } on Object catch (e) { + _setMessage('Unexpected error: $e', isError: true); + } + if (_done.isCompleted) break; + } + if (!_done.isCompleted) _render(); + if (!_done.isCompleted) inputSub!.resume(); + } + inputSub = _terminal.input.listen( (bytes) async { - inputSub!.pause(); - for (final key in decoder.decode(bytes)) { - // Never let an unexpected error from a key handler crash or freeze - // the TUI: surface it in the status bar and keep the loop alive. - try { - await _handleKey(key); - } on Object catch (e) { - _setMessage('Unexpected error: $e', isError: true); - } - if (_done.isCompleted) break; + escTimer?.cancel(); + await dispatch(decoder.decode(bytes)); + // A bare Escape keypress is a lone ESC byte the decoder cannot yet + // tell from the start of a sequence; deliver it once nothing follows. + if (decoder.hasPendingEscape && !_done.isCompleted) { + escTimer = Timer(KeyDecoder.escapeTimeout, () { + if (_done.isCompleted || !decoder.hasPendingEscape) return; + unawaited(dispatch(decoder.flush())); + }); } - if (!_done.isCompleted) _render(); - if (!_done.isCompleted) inputSub!.resume(); }, onError: (_) => _finish(), onDone: _finish, @@ -237,6 +251,7 @@ class IdeApp { }); await _done.future; } finally { + escTimer?.cancel(); await inputSub?.cancel(); await resizeSub?.cancel(); await _terminalPanel?.dispose(); diff --git a/lib/src/application/client/ide/tui/key_decoder.dart b/lib/src/application/client/ide/tui/key_decoder.dart index e59425d..e0b831e 100644 --- a/lib/src/application/client/ide/tui/key_decoder.dart +++ b/lib/src/application/client/ide/tui/key_decoder.dart @@ -12,6 +12,18 @@ import 'key.dart'; class KeyDecoder { final List _pending = []; + /// How long a caller should wait, with [hasPendingEscape] set, before calling + /// [flush] to deliver the buffered `ESC` as an Escape key. A terminal writes + /// an escape sequence in one go, so its bytes arrive well within this; a bare + /// Escape keypress is a lone byte that nothing follows. + static const escapeTimeout = Duration(milliseconds: 50); + + /// Whether the only buffered input is a lone `ESC`: either a bare Escape + /// keypress or the start of a sequence whose rest has not arrived yet. The + /// caller resolves it by calling [flush] after [escapeTimeout] (if no more + /// input arrived meanwhile). + bool get hasPendingEscape => _pending.length == 1 && _pending.first == 0x1b; + /// Decodes [bytes], returning the keys that completed. Incomplete trailing /// sequences are retained for the next call. List decode(List bytes) { @@ -26,7 +38,8 @@ class KeyDecoder { } /// Flushes any buffered bytes as a best-effort final decode (e.g. a lone - /// trailing `ESC`). Call when input ends. + /// trailing `ESC`). Call when input ends, or when [hasPendingEscape] has + /// stayed set for [escapeTimeout]. List flush() { final events = []; if (_pending.length == 1 && _pending.first == 0x1b) { @@ -93,8 +106,9 @@ class KeyDecoder { int _decodeEscape(List events) { if (_pending.length == 1) { // Could be a lone Escape or the start of a sequence. Wait briefly: the - // streaming caller delivers the rest in the same or next chunk. Treat a - // solitary ESC that never grows as Escape via [flush]. + // streaming caller delivers the rest in the same or next chunk. A + // solitary ESC that never grows becomes Escape via [flush], which the + // caller runs after [escapeTimeout] (see [hasPendingEscape]). return 0; } final second = _pending[1]; diff --git a/test/integration/aot_render_test.dart b/test/integration/aot_render_test.dart new file mode 100644 index 0000000..cc6803f --- /dev/null +++ b/test/integration/aot_render_test.dart @@ -0,0 +1,50 @@ +@TestOn('vm') +@Timeout(Duration(minutes: 3)) +library; + +import 'dart:io'; + +import 'package:test/test.dart'; + +/// Compiles test/support/aot/render_frames.dart ahead-of-time and runs it. +/// +/// Regression test for the 1.64.2 `:ide` crash: `ScreenBuffer.renderDiff` +/// promoted its nullable `previous` through a `reusable` boolean, and the AOT +/// compiler moved the `previous._cells` read ahead of that guard, so the very +/// first frame dereferenced `null` (`SIGSEGV`, `si_addr=0xf`). Shipped binaries +/// are AOT-compiled, but `dart test` runs under the JIT, where the code was +/// correct — only a real `dart compile exe` reproduces it. +void main() { + late Directory tmp; + + setUp(() => tmp = Directory.systemTemp.createTempSync('omnyshell-aot')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test( + 'renderDiff survives AOT compilation (first frame, diff, resize)', + () async { + final exe = + '${tmp.path}/render_frames${Platform.isWindows ? '.exe' : ''}'; + final compiled = await Process.run(Platform.resolvedExecutable, [ + 'compile', + 'exe', + 'test/support/aot/render_frames.dart', + '-o', + exe, + ]); + expect( + compiled.exitCode, + 0, + reason: '${compiled.stdout}${compiled.stderr}', + ); + + final run = await Process.run(exe, const []); + expect( + run.exitCode, + 0, + reason: 'the AOT binary crashed:\n${run.stdout}${run.stderr}', + ); + expect(run.stdout as String, startsWith('rendered ')); + }, + ); +} diff --git a/test/support/aot/render_frames.dart b/test/support/aot/render_frames.dart new file mode 100644 index 0000000..dc2fa56 --- /dev/null +++ b/test/support/aot/render_frames.dart @@ -0,0 +1,24 @@ +// An entry point compiled ahead-of-time by +// test/integration/aot_render_test.dart. It drives [ScreenBuffer.renderDiff] +// the way `:ide` and the dashboard do (a first frame with no previous one, +// then diffs, then a resize) so AOT-only miscompilations surface as a crash. +// `dart test` runs under the JIT, which never reproduced them. +import 'package:omnyshell/src/application/client/ide/tui/screen_buffer.dart'; +import 'package:omnyshell/src/application/client/ide/tui/style.dart'; + +void main(List args) { + // Sizes come from argv so the compiler cannot fold them to constants. + final width = 80 + args.length; + final height = 24 + args.length; + var bytes = 0; + ScreenBuffer? front; + for (var i = 0; i < 20; i++) { + // Resize halfway through: a size change must repaint everything. + final w = i < 10 ? width : width + 20; + final frame = ScreenBuffer(w, height) + ..drawText(0, i % height, 'frame $i', const Style(bold: true)); + bytes += frame.renderDiff(front).length; + front = frame; + } + print('rendered $bytes'); +} diff --git a/test/unit/client/local_command_test.dart b/test/unit/client/local_command_test.dart index 6cf2541..f3a939c 100644 --- a/test/unit/client/local_command_test.dart +++ b/test/unit/client/local_command_test.dart @@ -406,7 +406,39 @@ void main() { expect(out, contains('UID: nod_abc123')); expect(out, contains('Labels: env=prod')); expect(out, contains('Session Duration: 01:02:03')); - }); + // An older agent reports no OS version / CPUs / runtime: no empty lines. + expect(out.any((l) => l.startsWith('OS Version:')), isFalse); + expect(out.any((l) => l.startsWith('CPUs:')), isFalse); + expect(out.any((l) => l.startsWith('Runtime:')), isFalse); + }); + + test( + ':info shows the OS version, CPUs and runtime when reported', + () async { + final node = NodeDescriptor( + id: NodeId('n1'), + displayName: 'n1', + platform: const PlatformInfo( + os: 'linux', + arch: 'x64', + agentVersion: '1.65.0', + hostname: 'host', + osVersion: '6.1.0-53-cloud-amd64 #1 SMP', + cpuCount: 8, + runtimeVersion: '3.13.0 (stable)', + ), + online: true, + ); + final out = await run(':info', node: node); + final os = out.indexOf('OS: linux'); + expect(os, greaterThanOrEqualTo(0)); + // Each sits next to the field it qualifies. + expect(out[os + 1], 'OS Version: 6.1.0-53-cloud-amd64 #1 SMP'); + expect(out[os + 2], 'Arch: x64'); + expect(out[os + 3], 'CPUs: 8'); + expect(out, contains('Runtime: 3.13.0 (stable)')); + }, + ); }); group('argument validation (no network)', () { diff --git a/test/unit/dashboard/dashboard_app_test.dart b/test/unit/dashboard/dashboard_app_test.dart index e0de708..61d989d 100644 --- a/test/unit/dashboard/dashboard_app_test.dart +++ b/test/unit/dashboard/dashboard_app_test.dart @@ -15,6 +15,7 @@ import 'package:omnyshell/src/application/client/drive/drive_manager.dart' import 'package:omnyshell/src/application/client/drive/mount_store.dart' show MountRecord; import 'package:omnyshell/src/application/client/ide/tui/screen_buffer.dart'; +import 'package:omnyshell/src/application/client/ide/tui/key_decoder.dart'; import 'package:omnyshell/src/application/client/ide/tui/terminal_driver.dart'; import 'package:omnyshell/src/protocol/control_message.dart' show DriveCredentialEntry; @@ -417,6 +418,14 @@ Future pump([int times = 8]) async { } } +/// Presses a bare Escape the way a real terminal sends it — a lone ESC byte +/// with nothing after it — and waits for the app to time it out into Escape. +Future pressEscape(FakeTerminal term) async { + term.send(const [27]); + await Future.delayed(KeyDecoder.escapeTimeout * 2); + await pump(); +} + NodeDescriptor node( String id, { bool online = true, @@ -1250,10 +1259,133 @@ void main() { backend.nodes = [detailedNode().copyWith(online: false)]; term.send('r'.codeUnits); await pump(); + // Checked before the next key, which clears transient messages. + expect(frameText(term.lastFrame), contains('Node info refreshed.')); term.send(home); await pump(); + expect(frameText(term.lastFrame), contains('offline')); + + term.send(ctrlQ); + await running; + }); + + test('r reports a node the Hub no longer lists', () async { + final term = FakeTerminal(cols: 100, rows: 40); + final backend = connectedBackend()..nodes = [detailedNode()]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); + await pump(); + + backend.nodes = const []; + term.send('r'.codeUnits); + await pump(); final text = frameText(term.lastFrame); - expect(text, contains('offline')); + expect(text, contains('web-01 is no longer listed by the Hub.')); + // The last-known descriptor stays on screen. + expect(text, contains('menuici-sites')); + + term.send(ctrlQ); + await running; + }); + + test( + 's / i / c move between the node tabs; Esc returns to Sessions', + () async { + final term = FakeTerminal(cols: 100, rows: 40); + final backend = connectedBackend()..nodes = [detailedNode()]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); // Info, sessions never loaded + await pump(); + + term.send('c'.codeUnits); // Info -> Credentials + await pump(); + expect(backend.calls, contains('listGitCredentials:web-01')); + expect( + frameText(term.lastFrame), + contains('CREDENTIAL (yours, masked)'), + ); + + term.send('i'.codeUnits); // Credentials -> Info + await pump(); + expect(frameText(term.lastFrame), contains('OS version')); + + term.send('c'.codeUnits); + await pump(); + // Esc on Credentials lands on Sessions, and loads them: the node was + // opened straight onto Info, so they were never fetched. + expect( + backend.calls.where((c) => c.startsWith('listSessions')), + isEmpty, + ); + await pressEscape(term); + expect(backend.calls, contains('listSessions:web-01')); + expect(frameText(term.lastFrame), contains('No sessions on this node')); + + term.send('i'.codeUnits); // Sessions -> Info + await pump(); + await pressEscape(term); // Esc on Info -> Sessions + expect(frameText(term.lastFrame), contains('No sessions on this node')); + + term.send('c'.codeUnits); + await pump(); + term.send('s'.codeUnits); // Credentials -> Sessions + await pump(); + expect(frameText(term.lastFrame), contains('No sessions on this node')); + + term.send(ctrlQ); + await running; + }, + ); + + test('an arrow key split across reads is not taken for Escape', () async { + final term = FakeTerminal(cols: 100, rows: 40); + final backend = connectedBackend()..nodes = [detailedNode()]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); + await pump(); + + // ESC, then the rest of Down (`[B`) in the next read, inside the timeout. + term.send(const [27]); + await pump(); + term.send(const [91, 66]); + await Future.delayed(KeyDecoder.escapeTimeout * 2); + await pump(); + // Still on Info: an Escape would have gone back to Sessions. + expect(frameText(term.lastFrame), contains('OS version')); + expect(backend.calls.where((c) => c.startsWith('listSessions')), isEmpty); + + term.send(ctrlQ); + await running; + }); + + test('a value longer than the column breaks mid-word', () async { + final term = FakeTerminal(cols: 40, rows: 40); + final backend = connectedBackend() + ..nodes = [ + detailedNode().copyWith(labels: {'k': 'x' * 30}), + ]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); + await pump(); + + // The value column is 40 - 18 - 1 = 21 wide: 21 x's, then the other 9. + final rows = frameText(term.lastFrame).split('\n'); + final first = rows.indexWhere((r) => r.contains('x' * 21)); + expect(first, greaterThanOrEqualTo(0)); + expect(rows[first], isNot(contains('x' * 22))); + expect(rows[first + 1].trim(), 'x' * 9); term.send(ctrlQ); await running; diff --git a/test/unit/ide/ide_app_test.dart b/test/unit/ide/ide_app_test.dart index 4c61a7e..264ae5e 100644 --- a/test/unit/ide/ide_app_test.dart +++ b/test/unit/ide/ide_app_test.dart @@ -5,6 +5,7 @@ import 'package:omnyshell/src/application/ai/providers/ai_provider.dart'; import 'package:omnyshell/src/application/client/ide/agent/agent_backend.dart'; import 'package:omnyshell/src/application/client/ide/ide_app.dart'; import 'package:omnyshell/src/application/client/ide/terminal/command_runner.dart'; +import 'package:omnyshell/src/application/client/ide/tui/key_decoder.dart'; import 'package:omnyshell/src/application/client/ide/tui/screen_buffer.dart'; import 'package:omnyshell/src/application/client/ide/tui/terminal_driver.dart'; import 'package:omnyshell/src/application/client/ide/workspace/local_workspace.dart'; @@ -179,6 +180,14 @@ Future pump([int times = 4]) async { } } +/// Presses a bare Escape the way a real terminal sends it — a lone ESC byte +/// with nothing after it — and waits for the app to time it out into Escape. +Future pressEscape(FakeTerminal term) async { + term.send(const [0x1b]); + await Future.delayed(KeyDecoder.escapeTimeout * 2); + await pump(); +} + void main() { late Directory tmp; @@ -479,12 +488,12 @@ void main() { await pump(); term.send([0x32]); // '2' await pump(); - // Esc cancels. The decoder buffers a lone ESC until the next byte arrives - // (it can't tell a bare Esc from an escape sequence), so a second ESC byte - // flushes the first as an Escape key. + // Esc cancels. The decoder can't tell a bare Esc from the start of an + // escape sequence, so the app delivers a lone ESC once nothing follows it. term.send([0x1b]); await pump(); - term.send([0x1b]); + expect(frameText(term.lastFrame), contains('Go to line')); // still waiting + await Future.delayed(KeyDecoder.escapeTimeout * 2); await pump(); final text = frameText(term.lastFrame); expect(text, isNot(contains('Go to line'))); @@ -658,10 +667,10 @@ void main() { await pump(); expect(frameText(term.lastFrame), contains(r'$ x')); - // Esc hands focus back to the editor; the panel stays visible. Send a second - // byte to flush the decoder's buffered lone ESC. - term.send([0x1b]); - await pump(); + // A bare Esc (a lone ESC byte, nothing after it) hands focus back to the + // editor once the decoder times it out; the panel stays visible. + await pressEscape(term); + expect(frameText(term.lastFrame), contains(r'$ x')); // nothing typed term.send([0x59]); // 'Y' -> now edits the file await pump(); final text = frameText(term.lastFrame); From 60ce9870022710b3d9972478bf19eb45b64bc39a Mon Sep 17 00:00:00 2001 From: "Graciliano M. P." Date: Sun, 25 Oct 2026 01:02:30 -0300 Subject: [PATCH 4/4] Fix review findings: terminal-escape hardening, node tab behaviour - ScreenBuffer paints control characters as U+FFFD and :info strips them, so node-reported text (hostname, labels, OS version) cannot inject terminal escape sequences. - Dashboard: a background credentials load that settles after the user left the tab or node is dropped instead of landing on another screen; Esc on Info/Credentials leaves the node with no RPC; returning to Sessions only fetches when never loaded. - Info rows are typed (no colouring by label text); label keys go whole into the wrapping column; wrapping measures runes, not UTF-16 units. - The Sessions tab shows a one-line summary instead of duplicating Info. - The lone-ESC timeout lives in one LoneEscapeTimer used by both apps. - Reference the upstream VM bug (dart-lang/sdk#63413, backport request dart-lang/sdk#64723) in the renderDiff comment and CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 43 ++- .../client/dashboard/dashboard_app.dart | 258 ++++++++++-------- lib/src/application/client/ide/ide_app.dart | 18 +- .../client/ide/tui/key_decoder.dart | 46 +++- .../client/ide/tui/screen_buffer.dart | 32 ++- lib/src/application/client/local_command.dart | 38 ++- test/unit/client/local_command_test.dart | 23 ++ test/unit/dashboard/dashboard_app_test.dart | 182 +++++++++--- test/unit/ide/key_decoder_test.dart | 43 +++ test/unit/ide/screen_buffer_test.dart | 23 ++ 10 files changed, 505 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97b8b4f..26820cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ endpoint; every label; and the Hub and principal you are connected as. - **Scrolls** with the arrow keys, Page Up/Down and Home/End, and wraps long values. `r` re-lists nodes to refresh it. + - **Esc** (or Left) on any of the three tabs goes back to the node list. + Sessions load the first time you open that tab, and `r` refreshes them. + - **The Sessions tab** shows a one-line node summary above the table, + leaving more room for sessions. The full detail is on Info. - **Nodes now report their OS version, CPU count and Dart runtime** (e.g. `6.1.0-53-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian …`, `8`, `3.13.0 (stable)`). The fields are optional on the wire: a node or Hub on an @@ -24,13 +28,40 @@ - **Before:** the compiled binary died painting the first frame (`SIGSEGV`, `si_addr=0xf` in `ScreenBuffer.renderDiff`). This was seen on Linux x64 and reproduced on macOS arm64. - - **Cause:** `renderDiff` checked that `previous` was non-null through a - `reusable` boolean, then read `previous._cells` inside the loop. The Dart - 3.13 AOT compiler moved that read ahead of the check, so on the first - frame, when there is no previous frame, it read from `null`. JIT runs - (`dart test`, `dart run`) were not affected. + - **Cause:** a Dart VM AOT compiler bug, + [dart-lang/sdk#63413](https://github.com/dart-lang/sdk/issues/63413). + `renderDiff` checked that `previous` was non-null through a `reusable` + boolean, then read `previous._cells` inside the loop. Dart's AOT compiler, + up to 3.13.7, moves that read ahead of the check, so on the first frame, + when there is no previous frame, it read from `null`. JIT runs (`dart test`, + `dart run`) were not affected. It is fixed in Dart 3.14, and a 3.13 + backport is requested in + [dart-lang/sdk#64723](https://github.com/dart-lang/sdk/issues/64723). - **Now:** the previous frame's cells go into a nullable local once, before - the loop, and every comparison checks that local. + the loop, and every comparison checks that local. An integration test + builds a small AOT binary of the renderer, so this class of crash fails CI. +- **A bare Esc now works in `:ide` and the dashboard.** + - **Before:** pressing Esc on its own did nothing until another key was + pressed. Closing a dialog or going back took Esc plus a second key. + - **Cause:** a lone ESC byte could also be the start of an arrow-key or + other escape sequence, so the key decoder kept it buffered, waiting for + more bytes, and nothing ever released it. + - **Now:** if nothing follows the ESC within 50 ms, it is delivered as + Escape. Terminals send a whole escape sequence at once, so arrow keys and + other sequences still decode, even when split across two reads. + +### Security + +- **Text from a node can no longer send escape sequences to your terminal.** + - **Before:** values a node reports, such as its hostname or labels, were + written verbatim by `:info`, the dashboard and `:ide`. A hostile node + could embed ESC or BEL sequences that the user's terminal would act on: + clear the screen, change the window title, write the clipboard, or spoof + text. + - **Now:** the full-screen renderer paints any control character as `�`, + and `:info` drops them. This covers every string drawn by `:ide` and the + dashboard, including file names and the new OS version and runtime + fields. ## 1.64.2 diff --git a/lib/src/application/client/dashboard/dashboard_app.dart b/lib/src/application/client/dashboard/dashboard_app.dart index d1fc39f..62892ea 100644 --- a/lib/src/application/client/dashboard/dashboard_app.dart +++ b/lib/src/application/client/dashboard/dashboard_app.dart @@ -108,6 +108,11 @@ class _Pager { int scroll = 0; } +/// One row of a node's Info tab: a section heading (just [label]), or a +/// [label]/[value] pair whose value may carry its own [color] (the node's +/// online/offline state). +typedef _InfoRow = ({bool heading, String label, String value, Color? color}); + /// A full-screen TUI over the OmnyShell CLI: log in to a Hub, then work across /// four top-level tabs — Nodes (browse a node's info and sessions, and /// resume / peek / detach / terminate them), Tunnels (open / list / close), @@ -147,9 +152,8 @@ class DashboardApp { StreamSubscription>? _inputSub; StreamSubscription? _resizeSub; - /// Delivers a buffered lone `ESC` as Escape when no more input follows it - /// (see [KeyDecoder.hasPendingEscape]). - Timer? _escTimer; + /// Delivers a bare Escape keypress for the live input subscription. + LoneEscapeTimer? _escape; _Screen _screenId = _Screen.login; ScreenBuffer? _screen; @@ -181,12 +185,19 @@ class DashboardApp { List _sessions = const []; int _sessionSel = 0; + /// Whether [_sessions] holds [_currentNode]'s sessions — false when the node + /// was opened straight onto its Info tab, so Sessions knows to fetch them. + bool _sessionsLoaded = false; + // Node-info screen state: the first visible row of the info list. int _infoScroll = 0; // The caller's own git credentials on [_currentNode] (node-credentials screen). List _credentials = const []; int _credSel = 0; + + /// Counts credential loads so a late result from a superseded one is dropped. + int _credLoad = 0; int _credScroll = 0; int _sessionScroll = 0; @@ -258,23 +269,22 @@ class DashboardApp { if (!_done.isCompleted && identical(_inputSub, sub)) sub.resume(); } + final escape = LoneEscapeTimer(decoder, (keys) { + if (!_done.isCompleted && identical(_inputSub, sub)) { + unawaited(dispatch(keys)); + } + }); sub = _terminal.input.listen( (bytes) async { - _escTimer?.cancel(); + escape.cancel(); await dispatch(decoder.decode(bytes)); - // A bare Escape keypress is a lone ESC byte the decoder cannot yet tell - // from the start of a sequence; deliver it once nothing follows. - if (decoder.hasPendingEscape && identical(_inputSub, sub)) { - _escTimer = Timer(KeyDecoder.escapeTimeout, () { - if (_done.isCompleted || !identical(_inputSub, sub)) return; - if (decoder.hasPendingEscape) unawaited(dispatch(decoder.flush())); - }); - } + escape.arm(); }, onError: (_) => _finish(), onDone: _finish, ); _inputSub = sub; + _escape = escape; _resizeSub = _terminal.resizeEvents.listen((_) { _terminal.invalidate(); if (!_done.isCompleted) _render(); @@ -282,8 +292,8 @@ class DashboardApp { } Future _unsubscribe() async { - _escTimer?.cancel(); - _escTimer = null; + _escape?.cancel(); + _escape = null; await _inputSub?.cancel(); _inputSub = null; await _resizeSub?.cancel(); @@ -750,6 +760,7 @@ class DashboardApp { // Drop the previously opened node's sessions so the list starts empty while // this node's sessions load, rather than briefly showing stale rows. _sessions = const []; + _sessionsLoaded = false; if (info) { _openNodeInfo(); return; @@ -854,6 +865,7 @@ class DashboardApp { _loading = true; try { _sessions = await _backend.listSessions(node.id.value); + _sessionsLoaded = true; _applySessionOrder(); } on Object catch (e) { // Keep the last-known sessions on a refresh failure (soft-fail). @@ -983,12 +995,13 @@ class DashboardApp { _infoScroll = 0; } - /// Switches to the current node's Sessions tab, loading its sessions (they - /// are not loaded yet when the node was opened straight onto Info). + /// Switches to the current node's Sessions tab, fetching its sessions only + /// if they were never loaded (the node was opened straight onto Info); `r` + /// there refreshes them. Future _showNodeSessions() async { if (_currentNode == null) return; _screenId = _Screen.nodeDetail; - await _reloadSessions(busy: true); + if (!_sessionsLoaded) await _reloadSessions(busy: true); } /// Re-lists nodes and replaces [_currentNode] with its fresh descriptor (so a @@ -1026,7 +1039,7 @@ class DashboardApp { _infoScroll = 1 << 20; case KeyType.escape: case KeyType.left: - await _showNodeSessions(); + _screenId = _Screen.nodes; // leave the node, as from its other tabs. case KeyType.char: switch (key.text) { case 's': @@ -1068,9 +1081,21 @@ class DashboardApp { Future _reloadCredentials() async { final node = _currentNode; if (node == null) return; - _loading = true; + final load = ++_credLoad; + // The load runs in the background ([_refreshCredentials]): by the time it + // settles the user may have switched tab or node, or started a newer load. + // A stale result must not land on another node or overwrite that screen's + // status, so it is dropped. (Progress shows as the "Loading credentials…" + // status, not the shared [_loading] flag, which a load outliving its + // screen would leave set on another one.) + bool stale() => + load != _credLoad || + !identical(_currentNode, node) || + _screenId != _Screen.nodeCredentials; try { - _credentials = await _backend.listGitCredentials(node.id.value); + final credentials = await _backend.listGitCredentials(node.id.value); + if (stale()) return; + _credentials = credentials; if (_credSel > _maxIndex(_credentials.length)) { _credSel = _maxIndex(_credentials.length); } @@ -1084,13 +1109,12 @@ class DashboardApp { : '$n git credential${n == 1 ? '' : 's'} on ${node.id.value}.', ); } on Object catch (e) { + if (stale()) return; if (_credentials.isEmpty) { _setError('Failed to list credentials', e); } else { _setError('Showing last results — refresh failed', e); } - } finally { - _loading = false; } } @@ -1110,7 +1134,7 @@ class DashboardApp { _credSel = _maxIndex(_credentials.length); case KeyType.escape: case KeyType.left: - await _showNodeSessions(); + _screenId = _Screen.nodes; // leave the node, as from its other tabs. case KeyType.char: switch (key.text) { case 'a': @@ -2312,33 +2336,17 @@ class DashboardApp { if (area.isEmpty) return; final node = _currentNode; if (node == null) return; - final infoLines = _nodeInfoLines(node); - final infoH = (infoLines.length + 1).clamp( - 0, - (area.height - 2).clamp(0, area.height), - ); - final (infoRect, rest) = area.splitTop(infoH); - - _bar( - s, - _row(infoRect, 0), - ' Node ${node.id.value}${_loading ? ' (refreshing…)' : ''}', - _barStyle.copyWith(bold: true), - ); - final labelStyle = Palette.panelBg.copyWith(fg: const Color.indexed(245)); - final valueStyle = Palette.panelBg.copyWith(fg: const Color.indexed(252)); - for (var i = 0; i < infoLines.length; i++) { - final y = infoRect.top + 1 + i; - if (y >= infoRect.bottom) break; - final (label, value) = infoLines[i]; - s.fillRect(infoRect.left, y, infoRect.width, 1, ' ', Palette.panelBg); - s.drawText(infoRect.left + 2, y, label, labelStyle, maxWidth: 14); + // A one-line summary; the Info tab (`i`) has the full detail. + final (summaryRow, rest) = area.splitTop(area.height >= 3 ? 1 : 0); + if (!summaryRow.isEmpty) { + final y = summaryRow.top; + s.fillRect(summaryRow.left, y, summaryRow.width, 1, ' ', Palette.panelBg); s.drawText( - infoRect.left + 16, + summaryRow.left + 2, y, - value, - valueStyle, - maxWidth: (infoRect.width - 18).clamp(1, infoRect.width), + '${_nodeSummary(node)}${_loading ? ' (refreshing…)' : ''}', + Palette.panelBg.copyWith(fg: const Color.indexed(252)), + maxWidth: (summaryRow.width - 3).clamp(1, summaryRow.width), ); } @@ -2452,30 +2460,26 @@ class DashboardApp { return s.drawText(x, y, text, style, maxWidth: right - x); } - List<(String, String)> _nodeInfoLines(NodeDescriptor node) { - final caps = node.capabilities; - return [ - ('Name', node.displayName.isEmpty ? '—' : node.displayName), - ('Status', node.online ? 'online' : 'offline'), - ('Platform', '${node.platform.os}/${node.platform.arch}'), - ('Hostname', node.platform.hostname), - ('Agent', node.platform.agentVersion), - if (node.uid != null) ('UID', node.uid!.value), - if (node.labels.isNotEmpty) - ( - 'Labels', - node.labels.entries.map((e) => '${e.key}=${e.value}').join(' '), - ), - if (caps != null) ('Shells', caps.shells.join(', ')), - if (caps != null) ('Features', caps.features.join(', ')), - ]; + /// The one-line node summary above the Sessions table; the Info tab + /// ([_nodeInfoRows]) has the full detail. + String _nodeSummary(NodeDescriptor node) { + final p = node.platform; + final name = node.displayName.isEmpty || node.displayName == node.id.value + ? '' + : '${node.displayName} · '; + return '$name${node.online ? 'online' : 'offline'} · ' + '${p.os}/${p.arch} · ${p.hostname} · agent ${p.agentVersion}'; } - /// Every row of the node-info tab: a section heading when the value is - /// `null`, else a label/value pair. Fields an older agent or Hub does not + /// Every row of the node Info tab. Fields an older agent or Hub does not /// report read "not reported" rather than vanishing. - List<(String, String?)> _nodeInfoRows(NodeDescriptor node) { + List<_InfoRow> _nodeInfoRows(NodeDescriptor node) { const notReported = '— (not reported)'; + _InfoRow heading(String title) => + (heading: true, label: title, value: '', color: null); + _InfoRow row(String label, String value, {Color? color}) => + (heading: false, label: label, value: value, color: color); + final p = node.platform; final caps = node.capabilities; final labels = node.labels.entries.toList() @@ -2485,40 +2489,49 @@ class DashboardApp { : '${p.agentVersion} (this client: $omnyShellVersion)'; final osVersion = p.osVersion; return [ - ('Identity', null), - ('Node ID', node.id.value), - ('Name', node.displayName.isEmpty ? '—' : node.displayName), - ('UID', node.uid?.value ?? notReported), - ('Status', node.online ? 'online' : 'offline'), - ('System', null), - ('OS', p.os), - ( + heading('Identity'), + row('Node ID', node.id.value), + row('Name', node.displayName.isEmpty ? '—' : node.displayName), + row('UID', node.uid?.value ?? notReported), + row( + 'Status', + node.online ? 'online' : 'offline', + color: node.online + ? const Color.indexed(114) + : const Color.indexed(203), + ), + heading('System'), + row('OS', p.os), + row( 'OS version', osVersion == null || osVersion.isEmpty ? notReported : osVersion, ), - ('Arch', p.arch), - ('CPUs', p.cpuCount?.toString() ?? notReported), - ('Hostname', p.hostname), - ('OmnyShell', null), - ('Version', agent), - ('Dart runtime', p.runtimeVersion ?? notReported), - ('Capabilities', null), + row('Arch', p.arch), + row('CPUs', p.cpuCount?.toString() ?? notReported), + row('Hostname', p.hostname), + heading('OmnyShell'), + row('Version', agent), + row('Dart runtime', p.runtimeVersion ?? notReported), + heading('Capabilities'), if (caps == null) - ('Advertised', '— (none registered)') + row('Advertised', '— (none registered)') else ...[ - ('Shells', caps.shells.isEmpty ? '—' : caps.shells.join(', ')), - ('Features', caps.features.isEmpty ? '—' : caps.features.join(', ')), - ('Max sessions', '${caps.maxSessions}'), - ('Direct', caps.directEndpoint ?? '— (reached through the Hub)'), + row('Shells', caps.shells.isEmpty ? '—' : caps.shells.join(', ')), + row('Features', caps.features.isEmpty ? '—' : caps.features.join(', ')), + row('Max sessions', '${caps.maxSessions}'), + row('Direct', caps.directEndpoint ?? '— (reached through the Hub)'), ], - ('Labels', null), + // Label keys are operator-chosen and often long (`kubernetes.io/arch`), + // so each label goes whole into the wrapping value column, not the + // fixed-width label column. + heading('Labels'), if (labels.isEmpty) - ('', 'none') + row('', 'none') else - for (final e in labels) (e.key, e.value), - ('Connection', null), - ('Hub', _backend.connectedHub ?? '?'), - ('Signed in as', _backend.principal?.displayName ?? '?'), + for (final e in labels) row('', '${e.key} = ${e.value}'), + heading('Connection'), + row('Hub', _backend.connectedHub ?? '?'), + row('Signed in as', _backend.principal?.displayName ?? '?'), ]; } @@ -2534,29 +2547,23 @@ class DashboardApp { // Lay the rows out first (long values wrap onto continuation lines) so the // scroll offset can be clamped to the real content height. - final lines = <({bool heading, String label, String value, Style style})>[]; - for (final (label, value) in _nodeInfoRows(node)) { - if (value == null) { + final lines = <_InfoRow>[]; + for (final row in _nodeInfoRows(node)) { + if (row.heading) { + // A blank line before every section but the first. if (lines.isNotEmpty) { - lines.add((heading: false, label: '', value: '', style: valueStyle)); + lines.add((heading: false, label: '', value: '', color: null)); } - lines.add((heading: true, label: label, value: '', style: valueStyle)); + lines.add(row); continue; } - final style = label == 'Status' - ? valueStyle.copyWith( - fg: node.online - ? const Color.indexed(114) - : const Color.indexed(203), - ) - : valueStyle; - final wrapped = _wrap(value, valueW); + final wrapped = _wrap(row.value, valueW); for (var i = 0; i < wrapped.length; i++) { lines.add(( heading: false, - label: i == 0 ? label : '', + label: i == 0 ? row.label : '', value: wrapped[i], - style: style, + color: row.color, )); } } @@ -2578,24 +2585,39 @@ class DashboardApp { ); continue; } + final color = line.color; s.drawText(area.left + 3, y, line.label, labelStyle, maxWidth: labelW); - s.drawText(valueX, y, line.value, line.style, maxWidth: valueW); + s.drawText( + valueX, + y, + line.value, + color == null ? valueStyle : valueStyle.copyWith(fg: color), + maxWidth: valueW, + ); } } - /// Splits [text] into lines of at most [width] characters, breaking at the - /// last space that fits (or mid-word when a word is longer than [width]). + /// Splits [text] into lines of at most [width] columns, breaking at the last + /// space that fits (or mid-word when a word is longer than [width]). + /// + /// Measures and cuts in runes, as [ScreenBuffer.drawText] paints one rune per + /// column, so text outside the BMP (emoji) neither wraps early nor splits a + /// surrogate pair. static List _wrap(String text, int width) { - if (width <= 0 || text.length <= width) return [text]; + var rest = text.runes.toList(); + if (width <= 0 || rest.length <= width) return [text]; + const space = 0x20; final out = []; - var rest = text; while (rest.length > width) { - var cut = rest.lastIndexOf(' ', width); + var cut = rest.lastIndexOf(space, width); if (cut <= 0) cut = width; - out.add(rest.substring(0, cut).trimRight()); - rest = rest.substring(cut).trimLeft(); + out.add(String.fromCharCodes(rest.sublist(0, cut)).trimRight()); + rest = rest.sublist(cut); + while (rest.isNotEmpty && rest.first == space) { + rest = rest.sublist(1); + } } - if (rest.isNotEmpty) out.add(rest); + if (rest.isNotEmpty) out.add(String.fromCharCodes(rest)); return out; } diff --git a/lib/src/application/client/ide/ide_app.dart b/lib/src/application/client/ide/ide_app.dart index c7ab29d..73349d1 100644 --- a/lib/src/application/client/ide/ide_app.dart +++ b/lib/src/application/client/ide/ide_app.dart @@ -199,7 +199,7 @@ class IdeApp { final decoder = KeyDecoder(); StreamSubscription>? inputSub; StreamSubscription? resizeSub; - Timer? escTimer; + LoneEscapeTimer? escape; try { // Load the tree root and git state before the first paint. _repo = await GitRepo.discover( @@ -229,18 +229,14 @@ class IdeApp { if (!_done.isCompleted) inputSub!.resume(); } + final escapeTimer = escape = LoneEscapeTimer(decoder, (keys) { + if (!_done.isCompleted) unawaited(dispatch(keys)); + }); inputSub = _terminal.input.listen( (bytes) async { - escTimer?.cancel(); + escapeTimer.cancel(); await dispatch(decoder.decode(bytes)); - // A bare Escape keypress is a lone ESC byte the decoder cannot yet - // tell from the start of a sequence; deliver it once nothing follows. - if (decoder.hasPendingEscape && !_done.isCompleted) { - escTimer = Timer(KeyDecoder.escapeTimeout, () { - if (_done.isCompleted || !decoder.hasPendingEscape) return; - unawaited(dispatch(decoder.flush())); - }); - } + if (!_done.isCompleted) escapeTimer.arm(); }, onError: (_) => _finish(), onDone: _finish, @@ -251,7 +247,7 @@ class IdeApp { }); await _done.future; } finally { - escTimer?.cancel(); + escape?.cancel(); await inputSub?.cancel(); await resizeSub?.cancel(); await _terminalPanel?.dispose(); diff --git a/lib/src/application/client/ide/tui/key_decoder.dart b/lib/src/application/client/ide/tui/key_decoder.dart index e0b831e..ea47d91 100644 --- a/lib/src/application/client/ide/tui/key_decoder.dart +++ b/lib/src/application/client/ide/tui/key_decoder.dart @@ -1,7 +1,39 @@ +import 'dart:async'; import 'dart:convert'; import 'key.dart'; +/// Delivers a [KeyDecoder]'s buffered lone `ESC` as an Escape key once +/// [KeyDecoder.escapeTimeout] passes with no more input: a bare Escape keypress, +/// which the decoder alone cannot tell from the start of an escape sequence. +/// +/// An input loop calls [cancel] when a chunk arrives and [arm] once it has +/// handled the chunk's keys; if nothing else arrives in time, [onEscape] +/// receives the flushed Escape from a timer. +class LoneEscapeTimer { + LoneEscapeTimer(this._decoder, this._onEscape); + + final KeyDecoder _decoder; + final void Function(List keys) _onEscape; + Timer? _timer; + + /// Starts the timeout if the decoder is holding a lone `ESC`. + void arm() { + cancel(); + if (!_decoder.hasPendingEscape) return; + _timer = Timer(KeyDecoder.escapeTimeout, () { + _timer = null; + if (_decoder.hasPendingEscape) _onEscape(_decoder.flush()); + }); + } + + /// Stops a pending timeout (more input arrived, or the loop is ending). + void cancel() { + _timer?.cancel(); + _timer = null; + } +} + /// Turns raw terminal input bytes (read in raw mode) into [KeyEvent]s. /// /// The decoder is streaming: feed it each chunk from stdin with [decode] and it @@ -12,16 +44,16 @@ import 'key.dart'; class KeyDecoder { final List _pending = []; - /// How long a caller should wait, with [hasPendingEscape] set, before calling - /// [flush] to deliver the buffered `ESC` as an Escape key. A terminal writes - /// an escape sequence in one go, so its bytes arrive well within this; a bare - /// Escape keypress is a lone byte that nothing follows. + /// How long to wait, with [hasPendingEscape] set, before calling [flush] to + /// deliver the buffered `ESC` as an Escape key (see [LoneEscapeTimer]). A + /// terminal writes an escape sequence in one go, so its bytes arrive well + /// within this; a bare Escape keypress is a lone byte that nothing follows. static const escapeTimeout = Duration(milliseconds: 50); /// Whether the only buffered input is a lone `ESC`: either a bare Escape - /// keypress or the start of a sequence whose rest has not arrived yet. The - /// caller resolves it by calling [flush] after [escapeTimeout] (if no more - /// input arrived meanwhile). + /// keypress or the start of a sequence whose rest has not arrived yet. + /// [LoneEscapeTimer] resolves it by calling [flush] after [escapeTimeout] + /// if no more input arrived meanwhile. bool get hasPendingEscape => _pending.length == 1 && _pending.first == 0x1b; /// Decodes [bytes], returning the keys that completed. Incomplete trailing diff --git a/lib/src/application/client/ide/tui/screen_buffer.dart b/lib/src/application/client/ide/tui/screen_buffer.dart index 8c83e0c..a397bff 100644 --- a/lib/src/application/client/ide/tui/screen_buffer.dart +++ b/lib/src/application/client/ide/tui/screen_buffer.dart @@ -57,14 +57,25 @@ class ScreenBuffer { /// Paints a single character at (`x`,`y`); out-of-bounds writes are ignored. /// A multi-rune [char] is truncated to its first rune; an empty string paints - /// a space. + /// a space. A control character paints as `�` (see [drawText]). void setCell(int x, int y, String char, Style style) { if (x < 0 || x >= width || y < 0 || y >= height) return; final runes = char.runes; - final c = runes.isEmpty ? ' ' : String.fromCharCode(runes.first); + final c = runes.isEmpty ? ' ' : _printable(runes.first); _cells[_index(x, y)] = Cell(c, style); } + /// The cell text for [rune]: a TAB becomes a space and any other control + /// character (C0, DEL, C1) becomes `�`. Cells are written to the terminal + /// verbatim, so a raw ESC or BEL from displayed text — a hostname or file + /// name reported by a remote node — would otherwise run as a terminal escape + /// sequence (clear the screen, set the title, write the clipboard). + static String _printable(int rune) { + if (rune == 0x09) return ' '; + if (rune < 0x20 || (rune >= 0x7f && rune <= 0x9f)) return '\u{FFFD}'; + return String.fromCharCode(rune); + } + /// Fills [rect]'s area (clipped to the buffer) with [char] in [style]. void fillRect(int left, int top, int w, int h, String char, Style style) { for (var y = top; y < top + h; y++) { @@ -75,8 +86,10 @@ class ScreenBuffer { } /// Draws [text] starting at (`x`,`y`), one cell per rune, clipped to the row - /// and to [maxWidth] columns (when given). Tabs expand to a single space. - /// Returns the column just past the last painted cell. + /// and to [maxWidth] columns (when given). Tabs expand to a single space and + /// other control characters paint as `�`, so text can never inject a + /// terminal escape sequence. Returns the column just past the last painted + /// cell. int drawText(int x, int y, String text, Style style, {int? maxWidth}) { if (y < 0 || y >= height) return x; final limit = maxWidth == null ? width : (x + maxWidth).clamp(0, width); @@ -84,8 +97,7 @@ class ScreenBuffer { for (final rune in text.runes) { if (col >= limit || col >= width) break; if (col >= 0) { - final ch = rune == 0x09 ? ' ' : String.fromCharCode(rune); - _cells[_index(col, y)] = Cell(ch, style); + _cells[_index(col, y)] = Cell(_printable(rune), style); } col++; } @@ -100,9 +112,11 @@ class ScreenBuffer { /// reset; the caller is responsible for any final cursor placement. String renderDiff(ScreenBuffer? previous) { // Resolve the previous frame's cells to a nullable local up front rather - // than promoting `previous` through a `reusable` boolean: the Dart 3.13 - // AOT compiler hoists `previous._cells` out of the loop past that guard, - // dereferencing `null` on the first frame (a SIGSEGV at `si_addr=0xf`). + // than promoting `previous` through a `reusable` boolean: the Dart AOT + // compiler (≤ 3.13) hoists `previous._cells` out of the loop past that + // guard, dereferencing `null` on the first frame (a SIGSEGV at + // `si_addr=0xf`). VM bug dart-lang/sdk#63413, fixed in 3.14; stable + // cherry-pick requested in dart-lang/sdk#64723. final prev = previous != null && previous.width == width && previous.height == height ? previous._cells diff --git a/lib/src/application/client/local_command.dart b/lib/src/application/client/local_command.dart index c3d2dec..cbd9212 100644 --- a/lib/src/application/client/local_command.dart +++ b/lib/src/application/client/local_command.dart @@ -357,38 +357,48 @@ class _InfoCommand extends LocalCommand { String get description => 'Show node and session info'; @override Future run(LocalCommandContext c, List args) async { + // Most of these values are reported by the remote node, and the lines go + // to the user's terminal verbatim: drop control characters so a hostile + // node cannot smuggle in an escape sequence. + void line(String text) => c.writeLine(_stripControlChars(text)); + final node = c.node; final name = node.displayName; - c.writeLine('Node: ${node.id.value}${name.isEmpty ? '' : ' ($name)'}'); - if (node.uid != null) c.writeLine('UID: ${node.uid!.value}'); + line('Node: ${node.id.value}${name.isEmpty ? '' : ' ($name)'}'); + if (node.uid != null) line('UID: ${node.uid!.value}'); final p = node.platform; - c.writeLine('OS: ${p.os}'); - if (p.osVersion != null) c.writeLine('OS Version: ${p.osVersion}'); - c.writeLine('Arch: ${p.arch}'); - if (p.cpuCount != null) c.writeLine('CPUs: ${p.cpuCount}'); - c.writeLine('Hostname: ${p.hostname}'); - c.writeLine('Agent: ${p.agentVersion}'); - if (p.runtimeVersion != null) c.writeLine('Runtime: ${p.runtimeVersion}'); + line('OS: ${p.os}'); + if (p.osVersion != null) line('OS Version: ${p.osVersion}'); + line('Arch: ${p.arch}'); + if (p.cpuCount != null) line('CPUs: ${p.cpuCount}'); + line('Hostname: ${p.hostname}'); + line('Agent: ${p.agentVersion}'); + if (p.runtimeVersion != null) line('Runtime: ${p.runtimeVersion}'); final client = c.client; - if (client != null) c.writeLine('Hub: ${client.config.hubUri}'); + if (client != null) line('Hub: ${client.config.hubUri}'); if (node.labels.isNotEmpty) { - c.writeLine( + line( 'Labels: ' '${node.labels.entries.map((e) => '${e.key}=${e.value}').join(', ')}', ); } final s = c.session; if (s != null) { - c.writeLine('Shell: ${_shellFamilyLabel(s.shellFamily)}'); - c.writeLine('Session: ${s.id?.value ?? '(pending)'} (${s.mode.name})'); + line('Shell: ${_shellFamilyLabel(s.shellFamily)}'); + line('Session: ${s.id?.value ?? '(pending)'} (${s.mode.name})'); } - c.writeLine( + line( 'Session Duration: ' '${_formatDuration(c.clock.now().difference(c.startedAt))}', ); } } +/// [text] with every control character (C0 including TAB, DEL, C1) removed, +/// for single-line output of remote-supplied strings to a terminal. +String _stripControlChars(String text) => + text.replaceAll(RegExp(r'[\x00-\x1F\x7F-\x9F]'), ''); + /// A human-readable label for the remote shell's command-language family. String _shellFamilyLabel(ShellFamily family) => switch (family) { ShellFamily.posix => 'POSIX (sh/bash)', diff --git a/test/unit/client/local_command_test.dart b/test/unit/client/local_command_test.dart index f3a939c..ff4dfb2 100644 --- a/test/unit/client/local_command_test.dart +++ b/test/unit/client/local_command_test.dart @@ -439,6 +439,29 @@ void main() { expect(out, contains('Runtime: 3.13.0 (stable)')); }, ); + + test(':info drops control characters from node-reported values', () async { + final node = NodeDescriptor( + id: NodeId('n1'), + displayName: 'n1', + platform: const PlatformInfo( + os: 'linux', + arch: 'x64', + agentVersion: '1.65.0', + hostname: 'evil\x1b]52;c;cHdu\x07host', + osVersion: '6.1\x1b[2J\x9b0m', + ), + online: true, + labels: const {'k': 'v\x1b[31m'}, + ); + final out = await run(':info', node: node); + for (final line in out) { + expect(line, isNot(matches(RegExp(r'[\x00-\x1f\x7f-\x9f]')))); + } + expect(out, contains('Hostname: evil]52;c;cHduhost')); + expect(out, contains('OS Version: 6.1[2J0m')); + expect(out, contains('Labels: k=v[31m')); + }); }); group('argument validation (no network)', () { diff --git a/test/unit/dashboard/dashboard_app_test.dart b/test/unit/dashboard/dashboard_app_test.dart index 61d989d..4d148eb 100644 --- a/test/unit/dashboard/dashboard_app_test.dart +++ b/test/unit/dashboard/dashboard_app_test.dart @@ -15,6 +15,7 @@ import 'package:omnyshell/src/application/client/drive/drive_manager.dart' import 'package:omnyshell/src/application/client/drive/mount_store.dart' show MountRecord; import 'package:omnyshell/src/application/client/ide/tui/screen_buffer.dart'; +import 'package:omnyshell/src/application/client/ide/tui/style.dart'; import 'package:omnyshell/src/application/client/ide/tui/key_decoder.dart'; import 'package:omnyshell/src/application/client/ide/tui/terminal_driver.dart'; import 'package:omnyshell/src/protocol/control_message.dart' @@ -327,9 +328,13 @@ class FakeDashboardBackend implements DashboardBackend { /// When true, [listGitCredentials] throws (to exercise TUI error handling). bool failCredentials = false; + /// When set, [listGitCredentials] waits for it (a slow node). + Future? credentialsGate; + @override Future> listGitCredentials(String nodeId) async { calls.add('listGitCredentials:$nodeId'); + await credentialsGate; if (failCredentials) throw StateError('boom: node unreachable'); return gitCredentials; } @@ -602,9 +607,10 @@ void main() { expect(backend.calls, contains('listSessions:web-01')); final text = frameText(term.lastFrame); - expect(text, contains('Node web-01')); - expect(text, contains('linux/x64')); - expect(text, contains('env=prod')); + // The node's tab strip, with a one-line summary above the sessions (the + // full detail, labels included, is on the Info tab). + expect(text, contains('web-01 s Sessions')); + expect(text, contains('online · linux/x64 · host · agent 1.0.0')); expect(text, contains('a1b2c3d4')); expect(text, contains('e5f6a7b8')); // Age is 1h given the injected clock and createdAt. @@ -691,7 +697,7 @@ void main() { await pump(); term.send(enter); // open node await pump(); - expect(frameText(term.lastFrame), contains('Node web-01')); + expect(frameText(term.lastFrame), contains('web-01 s Sessions')); term.send(left); // back to nodes await pump(); @@ -1115,10 +1121,10 @@ void main() { // The error is surfaced on screen rather than freezing on "Loading…". expect(frameText(term.lastFrame).toLowerCase(), contains('failed')); - // Still interactive: Left/Esc returns to the node-detail screen. + // Still interactive: Left/Esc leaves the node for the node list. term.send(left); await pump(); - expect(frameText(term.lastFrame), contains('Node web-01')); + expect(frameText(term.lastFrame), contains('1 node(s), 1 online')); term.send(ctrlQ); await running; @@ -1291,58 +1297,162 @@ void main() { await running; }); - test( - 's / i / c move between the node tabs; Esc returns to Sessions', - () async { + test('s / i / c move between the node tabs; sessions load once', () async { + final term = FakeTerminal(cols: 100, rows: 40); + final backend = connectedBackend()..nodes = [detailedNode()]; + int sessionLoads() => + backend.calls.where((c) => c == 'listSessions:web-01').length; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); // Info, sessions never loaded + await pump(); + + term.send('c'.codeUnits); // Info -> Credentials + await pump(); + expect(backend.calls, contains('listGitCredentials:web-01')); + expect(frameText(term.lastFrame), contains('CREDENTIAL (yours, masked)')); + + term.send('i'.codeUnits); // Credentials -> Info + await pump(); + expect(frameText(term.lastFrame), contains('OS version')); + expect(sessionLoads(), 0); + + // Sessions fetches them the first time: the node was opened onto Info. + term.send('s'.codeUnits); + await pump(); + expect(sessionLoads(), 1); + expect(frameText(term.lastFrame), contains('No sessions on this node')); + + // Coming back to Sessions does not re-fetch; `r` does. + term.send('i'.codeUnits); + await pump(); + term.send('c'.codeUnits); + await pump(); + term.send('s'.codeUnits); + await pump(); + expect(sessionLoads(), 1); + term.send('r'.codeUnits); + await pump(); + expect(sessionLoads(), 2); + + term.send(ctrlQ); + await running; + }); + + for (final tab in ['i', 'c']) { + test('Esc on the ${tab == 'i' ? 'Info' : 'Credentials'} tab leaves the ' + 'node with no RPC', () async { final term = FakeTerminal(cols: 100, rows: 40); final backend = connectedBackend()..nodes = [detailedNode()]; final running = _app(term, backend).run(); await pump(); term.send(enter); // connect await pump(); - term.send('i'.codeUnits); // Info, sessions never loaded + term.send('i'.codeUnits); // open straight onto Info await pump(); + if (tab == 'c') { + term.send('c'.codeUnits); + await pump(); + } + final callsBefore = backend.calls.length; - term.send('c'.codeUnits); // Info -> Credentials - await pump(); - expect(backend.calls, contains('listGitCredentials:web-01')); + await pressEscape(term); + expect(frameText(term.lastFrame), contains('1 node(s), 1 online')); expect( - frameText(term.lastFrame), - contains('CREDENTIAL (yours, masked)'), + backend.calls.skip(callsBefore), + isNot(contains(startsWith('listSessions'))), ); - term.send('i'.codeUnits); // Credentials -> Info - await pump(); - expect(frameText(term.lastFrame), contains('OS version')); + term.send(ctrlQ); + await running; + }); + } - term.send('c'.codeUnits); + test( + 'a credentials load that settles after leaving the tab is dropped', + () async { + final term = FakeTerminal(cols: 100, rows: 40); + final gate = Completer(); + final backend = connectedBackend() + ..nodes = [detailedNode()] + ..credentialsGate = gate.future + ..gitCredentials = const [ + DriveCredentialEntry( + host: 'github.com', + description: 'GitPat(***)', + ), + ]; + final running = _app(term, backend).run(); await pump(); - // Esc on Credentials lands on Sessions, and loads them: the node was - // opened straight onto Info, so they were never fetched. - expect( - backend.calls.where((c) => c.startsWith('listSessions')), - isEmpty, - ); - await pressEscape(term); - expect(backend.calls, contains('listSessions:web-01')); - expect(frameText(term.lastFrame), contains('No sessions on this node')); - - term.send('i'.codeUnits); // Sessions -> Info + term.send(enter); // connect await pump(); - await pressEscape(term); // Esc on Info -> Sessions - expect(frameText(term.lastFrame), contains('No sessions on this node')); - - term.send('c'.codeUnits); + term.send('i'.codeUnits); await pump(); - term.send('s'.codeUnits); // Credentials -> Sessions + term.send('c'.codeUnits); // starts the (gated) background load + await pump(); + term.send('i'.codeUnits); // leave for Info before it settles await pump(); - expect(frameText(term.lastFrame), contains('No sessions on this node')); + + gate.complete(); + await pump(); + final text = frameText(term.lastFrame); + // Info's screen is untouched: no credentials summary over its status. + expect(text, isNot(contains('git credential'))); + expect(text, contains('OS version')); term.send(ctrlQ); await running; }, ); + test('long label keys and emoji wrap whole, by column', () async { + final term = FakeTerminal(cols: 60, rows: 40); + final backend = connectedBackend() + ..nodes = [ + detailedNode().copyWith( + labels: { + 'kubernetes.io/hostname': 'a', + 'kubernetes.io/arch': 'b', + 'Status': 'maintenance', + 'emoji': '🚀' * 50, + }, + ), + ]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send('i'.codeUnits); + await pump(); + term.send(end); + await pump(); + + final frame = term.lastFrame!; + final text = frameText(frame); + // Both keys are told apart: shown whole in the value column. + expect(text, contains('kubernetes.io/hostname = a')); + expect(text, contains('kubernetes.io/arch = b')); + // The value column is 60 - 18 - 1 = 41 columns and each rune takes one: + // the line breaks at the space after "emoji =", then 41 rockets, then the + // last 9 (cutting by UTF-16 units would fit only 20 and split a pair). + final rows = text.split('\n'); + final first = rows.indexWhere((r) => r.trimRight().endsWith('emoji =')); + expect(first, greaterThanOrEqualTo(0)); + expect(rows[first + 1].trim(), '🚀' * 41); + expect(rows[first + 2].trim(), '🚀' * 9); + expect(text, isNot(contains('\u{FFFD}'))); // no split surrogate pair + + // A label keyed "Status" is not coloured like the node's status. + final y = rows.indexWhere((r) => r.contains('Status = maintenance')); + final x = rows[y].indexOf('maintenance'); + expect(frame.cellAt(x, y).style.fg, const Color.indexed(252)); + + term.send(ctrlQ); + await running; + }); + test('an arrow key split across reads is not taken for Escape', () async { final term = FakeTerminal(cols: 100, rows: 40); final backend = connectedBackend()..nodes = [detailedNode()]; diff --git a/test/unit/ide/key_decoder_test.dart b/test/unit/ide/key_decoder_test.dart index d13e200..5735db2 100644 --- a/test/unit/ide/key_decoder_test.dart +++ b/test/unit/ide/key_decoder_test.dart @@ -87,4 +87,47 @@ void main() { expect(d.decode([bytes[1]]), [KeyEvent.char('é'.runes.first)]); }); }); + + group('LoneEscapeTimer', () { + late List delivered; + late LoneEscapeTimer timer; + setUp(() { + delivered = []; + timer = LoneEscapeTimer(d, delivered.addAll); + }); + tearDown(() => timer.cancel()); + + Future waitOut() => + Future.delayed(KeyDecoder.escapeTimeout * 2); + + test('delivers a lone ESC as Escape once nothing follows it', () async { + expect(d.decode([0x1b]), isEmpty); + expect(d.hasPendingEscape, isTrue); + timer.arm(); + expect(delivered, isEmpty); // not before the timeout + await waitOut(); + expect(delivered, [const KeyEvent(KeyType.escape)]); + expect(d.hasPendingEscape, isFalse); + }); + + test( + 'more input before the timeout completes the sequence instead', + () async { + d.decode([0x1b]); + timer.arm(); + timer.cancel(); // the next chunk arrived + expect(d.decode([0x5b, 0x41]), [const KeyEvent(KeyType.up)]); + timer.arm(); // nothing pending now + await waitOut(); + expect(delivered, isEmpty); + }, + ); + + test('arm does nothing when no lone ESC is buffered', () async { + d.decode('ab'.codeUnits); + timer.arm(); + await waitOut(); + expect(delivered, isEmpty); + }); + }); } diff --git a/test/unit/ide/screen_buffer_test.dart b/test/unit/ide/screen_buffer_test.dart index 2051343..f3d102b 100644 --- a/test/unit/ide/screen_buffer_test.dart +++ b/test/unit/ide/screen_buffer_test.dart @@ -30,6 +30,29 @@ void main() { expect(buf.cellAt(2, 2).char, '#'); expect(buf.cellAt(3, 3).char, ' '); }); + + test('control characters paint as U+FFFD, never reaching the output', () { + // e.g. a hostile node's hostname: clear screen, set title, ring bell. + const hostile = '\x1b[2J\x1b]0;pwn\x07\x9b31m\x7f'; + final buf = ScreenBuffer(30, 1) + ..drawText(0, 0, hostile, Style.none) + ..setCell(29, 0, '\x1b', Style.none); + final out = buf.renderDiff(null); + // Strip the renderer's own cursor/SGR sequences, then nothing from the + // text may remain that a terminal would act on. + final painted = out + .replaceAll(RegExp(r'\x1b\[\d+;\d+H'), '') + .replaceAll(RegExp(r'\x1b\[[\d;]*m'), ''); + expect(painted, isNot(matches(RegExp(r'[\x00-\x1f\x7f-\x9f]')))); + expect(buf.cellAt(0, 0).char, '\u{FFFD}'); // the ESC + expect(buf.cellAt(1, 0).char, '['); // the rest is inert text + expect(buf.cellAt(29, 0).char, '\u{FFFD}'); + }); + + test('a TAB still paints as a space', () { + final buf = ScreenBuffer(3, 1)..drawText(0, 0, 'a\tb', Style.none); + expect([for (var x = 0; x < 3; x++) buf.cellAt(x, 0).char].join(), 'a b'); + }); }); group('renderDiff', () {