Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
## 1.66.0

### Added

- **See what the Hub is running.** The Hub now describes itself to signed-in
clients, in three places:
- **`omnyshell hub info`** prints it; `--json` gives the raw record for
scripts and bug reports.
- **The dashboard's new Hub tab** (key `5`, or Tab from AI) shows it, with
scrolling and `r` to refresh.
- **`:hub`** prints it inside a session, and `:info` now includes the Hub's
version.
- **What it shows:**
- **Identity:** URL, UID and who you are signed in as.
- **System:** OS, OS version, architecture, CPU count and hostname.
- **OmnyShell:** the Hub's version (compared with this client), its Dart
runtime and the protocol versions it accepts.
- **Activity:** start time and uptime, plus counts of nodes (and how many
are online), connected clients, open sessions and your tunnels.
- **Features:** whether tunneling is enabled and on which ports, and the
Hub's default AI provider.
- **Only signed-in users can see it.** The Hub sends this after login,
never in the greeting it sends to every connection, so someone who can
merely reach the port cannot use it to fingerprint the Hub. API keys are
never included.
- **Older Hubs:** the Hub's version now comes with the login reply. A Hub
older than 1.66.0 doesn't send it, so the client knows straight away that
the Hub can't describe itself and says so, instead of waiting for a reply
that never comes.
- **For embedders:**
- `ClientRuntime` gains `hubInfo()` and `hubVersion`, and the new `HubInfo`
entity is exported from the client barrels.
- The `DashboardBackend` port gains `hubInfo()`, the same way earlier
releases extended it for tunnels, drive and AI. A custom implementation
needs to add it, typically by forwarding to `ClientRuntime.hubInfo()`.

## 1.65.0

### Added
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,8 +596,13 @@ omnyshell exec worker-prod-01 "uname -a"
omnyshell exec worker-prod-01 "make build" --cwd /srv/app # set the working dir
omnyshell nodes list
omnyshell whoami
omnyshell hub info # the Hub's version, OS, uptime and activity
omnyshell hub info --json # the same, for scripts and bug reports
```

`hub info` (also the dashboard's **Hub** tab, key `5`, and the `:hub` local
command) needs a Hub on 1.66.0 or later; an older Hub is reported as such.

Or pass credentials explicitly (and target another Hub) on any single command:

```sh
Expand Down Expand Up @@ -1078,7 +1083,7 @@ omnyshell local --pty-backend none # pipe shell with env-var geometry
It is the full experience minus the network: a managed line editor, history
(under `~/.omnyshell/history/`, keyed `local@<host>`), TAB completion, the
prompt with cwd/git/privilege, and the AI agent (`:ai`). The Hub-only local
commands (`:ping`, `:latency`, `:tunnel`, `:detach`, `:tree`) are not shown,
commands (`:hub`, `:ping`, `:latency`, `:tunnel`, `:detach`, `:tree`) are not shown,
since there is nothing remote to reach. `:ai` works with just an API key (an
`ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/`GEMINI_API_KEY` env var or
`~/.omnyshell/ai.yaml`) and runs its commands in the live local shell.
Expand All @@ -1090,16 +1095,19 @@ commands and are never sent to the remote shell:

```text
:help :info :node :host :os :arch :session :capabilities :clear
:latency :ping [count] :whoami :tree :download :upload :tunnel :drive
:ide :ai :detach :exit
:hub :latency :ping [count] :whoami :tree :download :upload :tunnel
:drive :ide :ai :detach :exit
```

`:hub` describes the Hub you are connected through (version, OS, uptime,
activity); `:info` includes the Hub's version.

`:ping` accepts an optional count (e.g. `:ping 3`) and prints each round-trip
plus a `min · avg · max` summary. `:clear` clears the screen and scrollback.

The same `:command` system powers `omnyshell local`, where the Hub-only commands
(`:latency`, `:ping`, `:tunnel`, `:detach`, `:tree`, `:download`, `:upload`,
`:drive`) are hidden since there is nothing remote to reach. `:ide` works in both
(`:hub`, `:latency`, `:ping`, `:tunnel`, `:detach`, `:tree`, `:download`,
`:upload`, `:drive`) are hidden since there is nothing remote to reach. `:ide` works in both
modes (it edits the local filesystem).

### The `l` shortcut
Expand Down
55 changes: 55 additions & 0 deletions bin/omnyshell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import 'package:omnyshell/omnyshell_client.dart';
import 'package:omnyshell/omnyshell_hub.dart';
import 'package:omnyshell/omnyshell_node.dart';
import 'package:omnyshell/src/application/client/drive/workspace_layout.dart';
import 'package:omnyshell/src/application/client/hub_info_view.dart';
import 'package:omnyshell/src/application/client/tunnel_http_options.dart';
import 'package:omnyshell/src/application/update/installation.dart';
import 'package:omnyshell/src/application/update/latest_version.dart';
Expand Down Expand Up @@ -1233,6 +1234,9 @@ class _CliDashboardBackend implements DashboardBackend {
@override
Future<List<NodeDescriptor>> listNodes() => _requireClient.listNodes();

@override
Future<HubInfo> hubInfo() => _requireClient.hubInfo();

@override
Future<List<DetachedSessionInfo>> listSessions(String nodeId) =>
_requireClient.listSessions(nodeId: nodeId);
Expand Down Expand Up @@ -1863,6 +1867,7 @@ class CertGenCommand extends Command<void> {
class HubCommand extends Command<void> {
HubCommand() {
addSubcommand(HubStartCommand());
addSubcommand(HubInfoCommand());
}

@override
Expand All @@ -1872,6 +1877,56 @@ class HubCommand extends Command<void> {
String get description => 'Run and manage a Hub.';
}

/// `omnyshell hub info` — describe the Hub you are connected to.
class HubInfoCommand extends Command<void> {
HubInfoCommand() {
_addConnectionOptions(argParser);
argParser.addFlag(
'json',
negatable: false,
help: 'Print the Hub description as JSON.',
);
}

@override
String get name => 'info';

@override
String get description =>
'Show the connected Hub: version, OS, uptime and activity.';

@override
String? get usageFooter => _usageExamples([
'omnyshell hub info',
'omnyshell hub info --hub wss://hub.example.com:8443 --json',
]);

@override
Future<void> run() async {
final client = await _connectClient(argResults!);
try {
final HubInfo info;
try {
info = await client.hubInfo();
} on HubTooOldException catch (e) {
throw _CliError(e.message);
}
if (argResults!['json'] as bool) {
stdout.writeln(const JsonEncoder.withIndent(' ').convert(info));
return;
}
final sections = hubInfoSections(
info,
hubUrl: client.config.hubUri.toString(),
principal: client.principal?.displayName,
);
hubInfoLines(sections).forEach(stdout.writeln);
} finally {
await client.close();
}
}
}

class HubStartCommand extends Command<void> {
HubStartCommand() {
_addHubOptions(argParser);
Expand Down
3 changes: 2 additions & 1 deletion doc/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ provide end-to-end backpressure.
| Type | Direction | Purpose |
| --- | --- | --- |
| `hello` | both | Handshake; the Hub includes a single-use challenge nonce |
| `auth.request` / `auth.ok` / `auth.fail` | both | Authentication |
| `auth.request` / `auth.ok` / `auth.fail` | both | Authentication (`auth.ok` carries the Hub's `hubVersion` from 1.66.0) |
| `node.register` / `node.registered` | node ⇄ hub | Register identity & platform |
| `node.capabilities` | node → hub | Advertise capabilities |
| `node.heartbeat` / `node.heartbeat.ack` | node ⇄ hub | Liveness (monotonic `seq`) |
| `ping` / `pong` | both | RTT + half-open detection |
| `node.list.request` / `node.list.response` | client ⇄ hub | Discovery |
| `hub.info.request` / `hub.info.response` | client ⇄ hub | Hub self-description: version, platform, uptime, activity (1.66.0+; authenticated only) |
| `session.open` / `session.opened` / `session.rejected` | client ⇄ hub | Open a session |
| `node.session.open` / `.opened` / `.rejected` | hub ⇄ node | Node-side session |
| `channel.resize` / `channel.signal` / `channel.eof` | client → node | Session control |
Expand Down
1 change: 1 addition & 0 deletions lib/omnyshell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export 'src/domain/value_objects/session_id.dart';

// Domain entities.
export 'src/domain/entities/detached_session_info.dart';
export 'src/domain/entities/hub_info.dart';
export 'src/domain/entities/node_capabilities.dart';
export 'src/domain/entities/node_descriptor.dart';
export 'src/domain/entities/platform_info.dart';
Expand Down
1 change: 1 addition & 0 deletions lib/omnyshell_client_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export 'src/domain/auth/principal.dart';

// Domain entities surfaced by the client API.
export 'src/domain/entities/detached_session_info.dart';
export 'src/domain/entities/hub_info.dart';
export 'src/domain/entities/node_capabilities.dart';
export 'src/domain/entities/node_descriptor.dart';
export 'src/domain/entities/platform_info.dart';
Expand Down
39 changes: 39 additions & 0 deletions lib/src/application/client/client_runtime.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import '../../domain/auth/principal.dart';
import '../../domain/backend/pty_spec.dart';
import '../../domain/backend/shell_family.dart';
import '../../domain/entities/detached_session_info.dart';
import '../../domain/entities/hub_info.dart';
import '../../domain/entities/node_descriptor.dart';
import '../../domain/entities/session.dart';
import '../../domain/entities/tunnel_info.dart';
Expand Down Expand Up @@ -300,6 +301,7 @@ class ClientRuntime {
ChannelMultiplexer? _mux;
StreamSubscription? _controlSub;
Principal? _principal;
String? _hubVersion;
Completer<void>? _auth;

final Queue<Completer<List<NodeDescriptor>>> _pendingNodeLists = Queue();
Expand All @@ -321,6 +323,7 @@ class ClientRuntime {
final Map<String, Completer<List<TunnelInfo>>> _pendingTunnelLists = {};
final Map<String, Completer<TunnelCloseResult>> _pendingTunnelCloses = {};
final Map<String, Completer<HubAiConfig>> _pendingAiConfigs = {};
final Map<String, Completer<HubInfo>> _pendingHubInfos = {};
final Map<String, Completer<HttpProxyResponse>> _pendingHttpProxies = {};

/// Live tunnel bridges for `@local` tunnels exposing this client's machine,
Expand All @@ -333,6 +336,11 @@ class ClientRuntime {
/// The authenticated principal (available after [connect]).
Principal? get principal => _principal;

/// The OmnyShell build the Hub runs, from its login reply; `null` before
/// [connect], or when the Hub is older than 1.66.0 (which does not report
/// it, and cannot answer [hubInfo]).
String? get hubVersion => _hubVersion;

/// Whether the client is connected and authenticated.
bool get isConnected => _principal != null && (_connection?.isOpen ?? false);

Expand Down Expand Up @@ -370,6 +378,7 @@ class ClientRuntime {
displayName: ok.displayName,
roles: ok.roles.toSet(),
);
_hubVersion = ok.hubVersion;
if (_auth != null && !_auth!.isCompleted) _auth!.complete();
case final AuthFail fail:
if (_auth != null && !_auth!.isCompleted) {
Expand Down Expand Up @@ -467,6 +476,8 @@ class ClientRuntime {
language: resp.language,
),
);
case final HubInfoResponse resp:
_pendingHubInfos.remove(resp.requestId)?.complete(resp.info);
case final HttpProxyResponse resp:
_pendingHttpProxies.remove(resp.requestId)?.complete(resp);
case final NodeTunnelConnect connect:
Expand Down Expand Up @@ -629,6 +640,26 @@ class ClientRuntime {
return _rpcTimeout(completer.future, () => _pendingAiConfigs.remove(id));
}

/// Asks the Hub to describe itself: version, platform, uptime and activity.
///
/// Completes with [HubTooOldException] without a round trip when the Hub is
/// older than 1.66.0 — it would silently drop the request. Every failure
/// arrives through the returned future, never as a synchronous throw.
Future<HubInfo> hubInfo() async {
_ensureConnected();
if (_hubVersion == null) {
throw const HubTooOldException(
'The Hub is older than 1.66.0 and cannot describe itself; '
'upgrade it to see hub info.',
);
}
final id = newId();
final completer = Completer<HubInfo>();
_pendingHubInfos[id] = completer;
_connection!.send(ControlFrame(HubInfoRequest(requestId: id)));
return _rpcTimeout(completer.future, () => _pendingHubInfos.remove(id));
}

/// Asks the Hub to perform an outbound AI HTTPS request on the caller's behalf
/// — used by the browser client, which cannot reach provider APIs directly
/// (CORS). The Hub enforces an https + host allowlist and, when
Expand Down Expand Up @@ -914,6 +945,7 @@ class ClientRuntime {

void _onDisconnected() {
_principal = null;
_hubVersion = null;
if (_auth != null && !_auth!.isCompleted) {
_auth!.completeError(const TransportException('Disconnected from Hub'));
}
Expand Down Expand Up @@ -985,6 +1017,12 @@ class ClientRuntime {
}
}
_pendingAiConfigs.clear();
for (final completer in _pendingHubInfos.values) {
if (!completer.isCompleted) {
completer.completeError(const TransportException('Disconnected'));
}
}
_pendingHubInfos.clear();
for (final completer in _pendingHttpProxies.values) {
if (!completer.isCompleted) {
completer.completeError(const TransportException('Disconnected'));
Expand Down Expand Up @@ -1139,5 +1177,6 @@ class ClientRuntime {
_connection = null;
_mux = null;
_principal = null;
_hubVersion = null;
}
}
Loading
Loading