Files
excloud-skills/skills/excloud-cli/SKILL.md
lolwierd 17cb564448 init
2026-04-24 13:46:01 +05:30

311 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: excloud-cli
description: Drive Excloud resources (compute, networking, security groups, volumes, snapshots, public IPs, IAM, billing, Kubernetes) through the `exc` CLI. Use when a user asks to plan or execute `exc` commands - creating / inspecting / updating / deleting VMs, running commands on them via `exec` / `scp` / `console`, managing security groups and public IPs, or pulling Kubernetes kubeconfigs - with safety guardrails and auth checks.
---
# Excloud CLI
This skill is a _starting guide_, not a spec. The `exc` CLI is generated from a live OpenAPI surface, so commands and flags change. **Whenever a command or flag in this file disagrees with `exc <command> --help`, trust the CLI.** Re-read the relevant `--help` before shaping a real command, and prefer discovering the surface interactively over memorising it from here.
```
exc --help
exc <group> --help
exc <group> <subcommand> --help
```
Everything below has been observed working at some point; the model should still verify before running anything destructive.
---
## Workflow principles
- Prefer `exc` for all Excloud actions unless the user explicitly asks for direct API / SDK use.
- Confirm before anything destructive (see Safety).
- If authentication is missing or expired, tell the user to run `exc login` and stop — do not invent tokens.
- When flag names or behaviours look odd, run `exc <...> --help` rather than guessing. Generated CLIs evolve between releases.
- Read `list` / `get` output shapes carefully before trying to parse them; there is no universal `-o json` flag today (see Output formats).
## Authentication
The CLI reads credentials in this precedence order:
1. `EXCLOUD_ACCESS_TOKEN` or `ACCESS_TOKEN` env var.
2. `EXCLOUD_ID_TOKEN` or `ID_TOKEN` env var.
3. `~/.exc/config` (JSON) written by `exc login` — contains the default account, default org, default zone, and per-account `id_token` / `access_token` material.
If none of those are present or valid, commands that need a token (`exec`, `scp`, `console`, `k8s cluster kubeconfig get/merge`) fail with `not authenticated; run \`exc login\``. `exc login` opens a browser flow and serves a callback on `http://localhost:7899/callback`.
`exc me`, `exc org list`, `exc account list`, `exc config list` are useful "where am I?" probes after login.
## Safety guardrails
Require explicit user confirmation before running any of these:
- `exc compute terminate` (especially with `--delete_root_volume`).
- `exc compute volume delete`, `exc compute snapshot delete`, `exc compute key delete`.
- `exc compute publicip release`, `exc compute publicip disassociate`.
- `exc securitygroup delete`, `exc securitygroup rule delete`, `exc securitygroup binding delete`.
- `exc k8s cluster delete`, `exc k8s cluster worker delete`.
- `exc account revoke`, `exc serviceaccount delete`, `exc apikey delete`, `exc policy delete`, `exc policy binding delete`.
For shell commands delivered through `exc compute exec` or an `exec` script file, refuse or confirm explicitly before running anything like `shutdown`, `reboot`, `rm -rf`, `mkfs`, `dd`, `wipefs`, rewrites of `/etc/fstab`, bootloader edits, or `systemctl stop ssh*` (the last one will make the VM unreachable over SSH — see Interactive access).
## Discoverability and authoritative lookups
The skill does _not_ hard-code IDs, instance type names, image IDs, subnet IDs, security group IDs, or zone IDs. Those change per account and over time. Before any `create` / `rule create` / `binding create` call, confirm the IDs with the relevant `list` command:
- `exc compute instancetype list` — CPU / memory / disk for each advertised type. Pick the smallest type whose CPU/MEMORY columns cover the workload; default to the cheapest advertised micro for scratch work and step up for real workloads.
- `exc compute instancetype capacity --instance_type <type>` — per-zone availability probe (`available=true|false`). Unknown types return `false` gracefully rather than 404, so `true` is the only reliable signal.
- `exc compute image list` — authoritative image catalog. Image IDs vary per org; do not hard-code them.
- `exc compute subnet list` + `exc compute subnet get --id <id>` — check `DISABLE_IPV4_PUBLIC_IP`: subnets with this set cannot take `--allocate_public_ipv4=true` at create time.
- `exc securitygroup list` + `exc securitygroup rule list --security_group_id <id>` + `exc securitygroup binding list --security_group_id <id>` (or `--interface_id <id>`) — confirm what a SG allows and where it's bound before relying on it.
- `exc compute publicip list` / `exc compute key list` / `exc compute volume list` / `exc compute snapshot list` — authoritative inventories for each resource type.
If `--help` on the installed CLI shows commands or flags not documented here, prefer `--help`.
## Common VM lifecycle
### Create
Required flags for `exc compute create`:
- `--name <dns-compatible-name>` (lowercase, `[a-z0-9][a-z0-9-]*[a-z0-9]`).
- `--subnet_id <id>` (zone of the subnet must match your default zone).
- `--allocate_public_ipv4=true|false` — the flag must be explicit.
- `--image_id <id>`
- `--instance_type <type>`
- `--root_volume_size_gib <n>`
Useful optional flags (verify via `--help`):
- `--security_group_ids <id1,id2>` — attach one or more SGs to the primary interface at create time. **If you omit this, the VM may come up with no SG attached** — set at least one.
- `--ssh_pubkey "<key or key name>"` — inline SSH public key string _or_ the `name` of a key managed via `exc compute key`.
- `--public_ipv4_reservation_id <id>` — attach an existing reserved public IPv4 instead of allocating a new ephemeral one.
- `--root_password <pw>` — for console / emergency access only; SSH keys are strongly preferred.
- `--root_volume_id <id>` **or** `--root_volume_source_snapshot_id <id>` (mutually exclusive) — reuse an existing volume or clone from a snapshot for the root disk.
- `--root_volume_baseline_iops <n>` / `--root_volume_baseline_throughput_mbps <n>` — provisioned performance for EBS-backed roots.
- `--user_data <inline>` or `--user-data-file <path>` — first-boot script. See User data below.
Do not pass flags the help output does not list; deprecated flags (e.g. `--root_volume_perf_tier`) are removed or hidden and will error or be ignored.
`create` prints a one-row table with at minimum `ID`, `NAME`, `STATE` (usually `STARTING` or `CREATING`), `ZONE`, `SUBNET`, `ROOT_VOLUME_ID`, `PUBLIC_IPV4`, `INTERFACE_IPV4`, `INTERFACE_IPV6`. Note that this row does **not** include `INTERFACE_ID`; fetch that later with `exc compute get --id <vm_id>`.
### Wait for RUNNING (no native `--wait`)
The CLI does not provide a wait primitive. Poll `compute get` and key off the `STATE` column:
```bash
until [ "$(exc compute get --id <vm_id> | awk 'NR==2 {for (i=1;i<=NF;i++) if ($i ~ /^(CREATING|STARTING|RUNNING|STOPPING|STOPPED|RESTARTING|TERMINATING|TERMINATED)$/) print $i}')" = "RUNNING" ]; do sleep 3; done
```
(Using column-name matching rather than a fixed index because the header ordering in `compute get` has shifted between releases; trust the header row rather than a hard-coded `$4`.)
Typical progression for a fresh VM: `CREATING` → `STARTING` → `RUNNING` in roughly half a minute, plus another 1520 seconds before cloud-init finishes and SSH answers. After RUNNING, wait a bit before the first `exc compute exec` or SSH connection will be reliable.
### Inspect and control
- `exc compute list` — hides `TERMINATED` VMs by default. Use this for "what is alive now".
- `exc compute instances list` — rich-metadata variant that shows **all** states unless filtered; add `--states running,stopped`, `--created_after <rfc3339>`, `--created_before <rfc3339>` as appropriate.
- `exc compute get --id <vm_id>` — single VM detail. Shows `INTERFACE_ID` (needed for publicip / SG binding ops) but not `ROOT_VOLUME_ID`.
- `exc compute rename --vm_id <id> --name <new_name>`
- `exc compute resize --vm_id <id> --instance_type <type>` — generally requires the VM to be STOPPED first.
- `exc compute start --vm_id <id>`
- `exc compute stop --vm_id <id> [--reserve_public_ipv4]` — pass `--reserve_public_ipv4` to keep the ephemeral public IPv4 across the stop.
- `exc compute restart --vm_id <id>` — a full API-level restart; useful to recover a VM whose SSH stack you broke from `exec`.
- `exc compute terminate --vm_id <id> [--delete_root_volume]` — without `--delete_root_volume` the root volume is kept and can be reused via `create --root_volume_id <id>`.
### Delete protection
Three commands can change the `delete_protection` flag; all return the updated VM as JSON:
- `exc compute protect --vm-id <id>` — enable protection.
- `exc compute unprotect --vm-id <id>` — disable protection.
- `exc compute rename --vm_id <id> --name <name> [--delete_protection=true|false]` — rename the VM and, if `--delete_protection` is passed, set protection in the same call. Omitting the flag on `rename` leaves the protection flag untouched, so a bare rename will not accidentally clear it.
While protection is enabled, `exc compute terminate` returns `VM delete protection is enabled. Disable delete protection before terminating this instance.` (exit 1). Run `unprotect` first, then retry `terminate`.
### Termination clean-up
After terminate with `--delete_root_volume`, confirm both with:
```bash
exc compute get --id <vm_id> # STATE should become TERMINATED in a few seconds
exc compute volume list # the root volume should disappear / move to DELETING
```
## User data
- `--user-data-file <path>` wins over `--user_data <inline>` if both are set (the inline one is ignored with a warning).
- The CLI is permissive — it only warns when content looks neither like a shell script nor a cloud-init document. Accepted heuristics:
- Shebang start: `#!/bin/bash`, `#!/usr/bin/env bash`, `#!/bin/sh`.
- First non-empty line begins with `#cloud-` (e.g. `#cloud-config`, `#cloud-boothook`).
- Prefer real `#!/bin/bash` scripts or `#cloud-config` YAML; other content will run but triggers the warning.
## Interactive access: `connect`, `exec`, `scp`, `console`
`exc compute connect` is the low-level session primitive; `exec`, `scp` and `console` all build on it.
- `exc compute connect --vm_id <id> [--user ubuntu] [--return_private_key]` — returns a short-lived session ID and, when `--return_private_key` is set, a base64-encoded PEM authorised for the VM.
- `exc compute exec --vm-id <id> (--command "<cmd>" | --script-file <path>) [--user ubuntu] [--timeout <seconds>]`
- `--command` and `--script-file` are mutually exclusive; exactly one is required.
- `--script-file` is **interpreted as bash on the VM** (piped into `bash -s`). It is not a plain upload — plain-text files that contain non-command lines will fail with `command not found`. For transferring files verbatim, use `scp`.
- `--timeout` has a sensible default (tens of seconds) and a hard backend cap (check `--help`). A timed-out command prints `command timed out` and returns exit 124.
- Remote exit codes propagate: `exit 42` on the VM → local exit 42, with `Process exited with status 42` on stderr.
- On success and failure alike the command emits `warning: host key not verified` on stderr — that is expected (the CLI trusts the instance-connect key without pinning). Redirect stderr when scripting.
- SSH targets are tried in order: public IPv4 → any interface private IPv4 → any interface IPv6. If all SSH targets fail, `exec` automatically falls back to the WebSocket console transport. The fallback uses a unique marker to capture the remote exit code. Whether the WS transport succeeds depends on the compute service — if it rejects the session (`unknown session`) or times out (`Timeout connecting to the instance`), `exec` will fail with a 255 exit. In that case, confirm the VM is actually reachable via its public IPv4 (security group / sshd status) rather than relying on WS.
- `exc compute scp --vm-id <id> --src <src> --dst <dst> [--user ubuntu] [--recursive] [--download] [--timeout <seconds>]`
- Default direction is **upload** (local → VM). Pass `--download` to pull files from the VM to local.
- `--recursive` is required for directory transfers in either direction.
- Symlinks are **rejected** — an encountered symlink fails the whole transfer with `symlink entries are not supported: <path>` (exit 1). Dereference or archive them locally (e.g. `tar -czhf ...`) before calling `scp`.
- `scp` does **not** fall back to the WebSocket transport when SSH is unreachable; it errors out. Use `scp` only on VMs whose SSH is reachable.
- If the destination requires elevation, upload to a writable path (e.g. `/tmp/...`) and move with `sudo` via `exc compute exec`.
- `exc compute console --vm-id <id> [--user ubuntu] [--timeout <seconds>] [--ssh | --ws]`
- Opens an **interactive** shell on the VM. By default it tries SSH first, then falls back to the WebSocket console.
- `--ssh` forces SSH only, `--ws` forces WebSocket only.
- Requires a real TTY — piping input or running inside a non-interactive shell will fail with `failed to set terminal to raw mode: inappropriate ioctl for device`. For scripted one-shots use `exec`; for interactive work suggest the user run `exc compute console` directly.
### Troubleshooting SSH / exec failures
1. Does the VM have a reachable address? `exc compute get --id <vm_id>` — check `PUBLIC_IPV4`, `INTERFACE_IPV4`, `INTERFACE_IPV6`.
2. Is a security group bound and does it permit SSH?
- `exc securitygroup binding list --interface_id <if_id>`
- `exc securitygroup binding create --interface_id <if_id> --security_group_id <sg_id>`
- `exc securitygroup rule list --security_group_id <sg_id>`
3. Is there an ingress rule for port 22 from your source IP? If not, create one:
- `exc securitygroup rule create --security_group_id <sg_id> --is_ingress=true --protocol TCPv4 --port_range 22 --cidr "<your_ip>/32"`
4. Is there an egress rule for the VM to reach the internet? Most setups want a broad egress rule:
- `exc securitygroup rule create --security_group_id <sg_id> --is_ingress=false --protocol IPv4 --port_range ANY --cidr 0.0.0.0/0`
5. If `exec` says `connection refused` on port 22, sshd is likely not running. `exc compute restart --vm_id <id>` brings it back (the API-level restart does not need SSH).
## Serial console logs
`exc compute seriallogs --id <vm_id> [--boot_id <id>] [--offset <n> --direction older|newer] [--limit <n>] [-f]`
- Omitting `--boot_id` returns the latest boot.
- `--offset` and `--direction` must be set together; the valid directions are `older` and `newer`.
- `--limit` must be positive when set; typical default is ~200 and the backend has a hard cap.
- `-f / --follow` polls for newer lines every couple of seconds — not a native stream.
- Lines are prefixed with `[<rfc3339 timestamp> offset=<n>]`. Look for `Cloud-init ... finished`, `Reached target ... cloud-init.target`, and the login banner (`Ubuntu X.Y.Z ip-a-b-c-d ttyS0`) to confirm a clean boot.
## Networking
### Subnets
- `exc compute subnet list` — the `DISABLE_IPV4_PUBLIC_IP` column is the gate on whether `--allocate_public_ipv4=true` is legal.
- `exc compute subnet get --id <id>`
### Public IPv4
- `exc compute publicip list` / `exc compute publicip get --id <reservation_id>`
- `exc compute publicip reserve --name <name> [--interface_id <if_id>]` — if `--interface_id` is passed the new reservation is also attached in one step.
- `exc compute publicip associate --interface_id <if_id> --reservation_id <id>`
- `exc compute publicip disassociate --reservation_id <id>`
- `exc compute publicip rename --reservation_id <id> --name <new_name>`
- `exc compute publicip release --reservation_id <id>` (destructive).
### Local IP check
`exc compute localip --ip <addr>` asks the service whether a given IP falls inside Excloud's local ranges. It returns `{ip, is_local}` and is a backend-defined membership probe — not a "what is my public IP" helper (observed returning `is_local=true` for some clearly non-Excloud addresses, so do not use it as a precise classifier). To learn the caller's public IP, use an external service (e.g. `curl -s https://api.ipify.org`).
## Security groups
- `exc securitygroup create --name <name> [--description "..."]`
- `exc securitygroup list`
- `exc securitygroup get --id <sg_id>` (note: the flag here is `--id`, not `--security_group_id`).
- `exc securitygroup delete --security_group_id <sg_id>`
### Rules
- `exc securitygroup rule create --security_group_id <id> --is_ingress=true|false --protocol <proto> --port_range <range> --cidr <cidr> [--description "..."]`
- `--is_ingress` is **required**. Pass `=true` for ingress, `=false` for egress. Omitting it errors with `required flag(s) "is_ingress" not set`.
- `--protocol` takes Excloud family strings such as `TCPv4`, `UDPv4`, `ICMPv4`, `IPv4` — verify current valid values via a successful `rule list` if unsure.
- `--port_range` accepts single ports (`22`), ranges (`80-443`), or `ANY`.
- Rules are not updatable — to change one, `rule delete` and `rule create` again.
- `exc securitygroup rule list --security_group_id <id>`
- `exc securitygroup rule delete --security_group_rule_id <id>` (destructive).
### Bindings
- `exc securitygroup binding create --interface_id <if_id> --security_group_id <sg_id>`
- `exc securitygroup binding list (--interface_id <id> | --security_group_id <id>)` — at least one filter is required.
- `exc securitygroup binding delete --interface_id <if_id> --security_group_id <sg_id>`
## Volumes and snapshots
- `exc compute volume list` / `exc compute volume get --id <id>`
- `exc compute volume create --name <name> --size_gib <n> [--source_snapshot_id <id>] [--baseline_iops <n>] [--baseline_throughput_mbps <n>]` — zone is injected from config; there is no `--zone_id` flag.
- `exc compute volume rename --volume_id <id> --name <new_name>`
- `exc compute volume resize --volume_id <id> --new_size_gib <n> [--baseline_iops <n>] [--baseline_throughput_mbps <n>]`
- `exc compute volume delete --volume_id <id>` (destructive).
- `exc compute snapshot list` / `exc compute snapshot create --volume_id <id>` / `exc compute snapshot delete --snapshot_id <id>`
## SSH key catalog
- `exc compute key list` / `exc compute key get --id <id>`
- `exc compute key create --name <name> (--ssh-public-key "<pub>" | --ssh-public-key-path <file>)`
- `exc compute key delete --id <id>`
- The key `name` can be passed to `compute create --ssh_pubkey` in place of a raw public key string.
## Kubernetes
- `exc k8s health`
- `exc k8s cluster list`
- `exc k8s cluster create --control_plane_image_id <id> --control_plane_instance_type <type> --subnet_id <id> --root_volume_size_gib <n> [--allocate_public_ipv4] [--security_group_ids <id1,id2>] [--ssh_pubkey "<pubkey>"] [-o <path>]`
- The response contains the admin kubeconfig inline. Passing `-o <path>` writes it to disk (mode 0600, creating parent dirs) and strips it from stdout — strongly preferred.
- `exc k8s cluster delete --cluster_id <id>` (destructive).
- `exc k8s cluster worker list --cluster_id <id>`
- `exc k8s cluster worker create --cluster_id <id> --worker_image_id <id> --worker_instance_type <type> --subnet_id <id> --root_volume_size_gib <n> [--allocate_public_ipv4] [--security_group_ids <ids>] [--ssh_pubkey "<pubkey>"]`
- `exc k8s cluster worker delete --cluster_id <id> --worker_id <id>` (destructive).
- `exc k8s cluster kubeconfig get --cluster_id <id> [-o <path>]` — fetches the current kubeconfig and prints to stdout (or writes to `-o` with mode 0600). Returns a clear 404 if the cluster id is unknown.
- `exc k8s cluster kubeconfig merge --cluster_id <id> [--kubeconfig <path>] [--backup=true|false]` — merges into `~/.kube/config` (or `--kubeconfig`) using `kubectl config view --merge --flatten --raw`. Requires `kubectl` on PATH. `--backup` defaults to `true` and writes `<path>.bak`, `<path>.bak1`, ... before overwriting.
- `exc k8s bootstrap controlplane get --vm_id <id> --x-exc-imds-token <token>` — operator bootstrap path; the IMDS token must come from inside the VM's IMDS agent, not be invented.
## IAM, billing, quota
- `exc org list`
- `exc account list` / `exc account invite --email <email>` / `exc account revoke --email <email>` (the revoke flag is `--email`, not an invite id).
- `exc serviceaccount list` / `exc serviceaccount delete --name <name>`
- `exc apikey list` / `exc apikey create` (prints the new key once — capture it immediately) / `exc apikey delete --hash <hash>`
- `exc policy list` / `exc policy delete --id <policy_id>`
- `exc policy binding list (--account_id <id> | --service_account_id <id>)` — at least one filter required; neither errors with `either account_id or service_account_id must be provided`.
- `exc policy binding delete --policy_id <id> (--account_id <id> | --service_account_id <id>)`
- `exc billing get` / `exc quota`
## Config and misc
- `exc me` / `exc version` / `exc completion <bash|zsh|fish|powershell>`
- `exc config list` — shows the current default account / org / zone and configured accounts.
- `exc config set [-a|--account <account_id>] [-o|--org <org_id>]` — no `--zone` here; default zone is set at login time.
## Output formats
Every command either prints a column table (or TSV) or prints JSON — no command should print raw Go-struct dumps anymore. Both shapes are machine-parseable; pick your tool accordingly.
- **Column tables / TSV** (awk / `cut` / `awk -F\t` friendly): `compute list`, `compute instances list`, `compute get`, `compute create`, `compute terminate` (TSV `vm_id\tstate`), `compute instancetype list` / `capacity`, `compute image list`, `compute subnet list`, `compute volume list`, `compute volume get`, `compute snapshot list`, `compute publicip list`, `compute key list`, `securitygroup list` / `rule list` / `binding list`, `org list`, `account list`, `apikey list`, `policy list`, `config list`, `compute seriallogs`.
- **JSON** (pipe through `jq`): `me`, `quota`, `billing get`, `compute health` (`{"raw":"OK"}`), `k8s health`, `compute subnet get`, `compute publicip get`, `compute key get`, `securitygroup get`, `compute metrics`, `compute connect`, `serviceaccount list`, `compute protect`, `compute unprotect`, `compute rename`, `k8s cluster kubeconfig get` (raw kubeconfig YAML, not JSON-wrapped), and the inline `kubeconfig` field inside the JSON response from `k8s cluster create` when `-o` is not set.
Before scripting heavy logic against a command, run it once and check the shape. The split between "table" and "JSON" is not always guessable — lists tend to be tables, getters tend to be JSON, but verify.
## Metrics
`exc compute metrics --vm_id <id> --start <rfc3339> --end <rfc3339> [--family <family>]`
- Only `cpu` is currently supported. Omitting `--family` defaults to CPU. Any other family (`memory`, `network`, `diskio`, ...) returns `Requested metrics family is not supported for this endpoint.` with exit 1. Re-check `--help` and the above claim if the backend later adds families.
- Output is JSON: `{"series":[{"family":"cpu","period_seconds":5,"points":[{"timestamp":"...","average":<n>,"max":<n>,"min":<n>}, ...],"unit":"Percent"}]}`. Parse with `jq` (e.g. `jq '.series[0].points[-1].average'`).
## Error messages to recognise
- `not authenticated; run \`exc login\`` — no valid token in env or `~/.exc/config`.
- `required flag(s) "<name>" not set` — cobra-level enforcement. Read `--help` again.
- `Could not parse your request!! Are you sure you passed the correct flags?` — generic backend 400. Typically means an unknown ID, a value of the wrong type, or a server-side required field that the CLI accepted as empty. Verify every ID against a `list` before retrying.
- `Oops could not find the <Resource> you specified, maybe try checking if the <resource> exists?` — backend 404-ish. Trust the hint.
- `Oops the IP provided is invalid` — syntactic IP validation on `compute localip`.
- `Something went wrong on our end!!` — backend 500. Observed on `compute connect` for a non-existent VM. Verify the VM exists via `compute get`; do not retry blindly.
- `VM delete protection is enabled. Disable delete protection before terminating this instance.` — run `exc compute unprotect --vm-id <id>` first, then retry `terminate`.
- `At least one field must be provided: name or delete_protection.` — you hit `compute rename` / `compute update` with neither flag set. Pass `--name <name>` and/or use `protect` / `unprotect` instead of `rename --delete_protection=...` for protection changes.
- `command timed out` (exit 124) — `exec --timeout` elapsed. Raise the timeout, or launch the work in the background on the VM (`nohup`, systemd unit) and poll with subsequent `exec` calls.
- `invalid --direction "<x>": must be one of older or newer` / `use --offset and --direction together` / `--limit must be greater than 0` — `seriallogs` argument validation.
- `either account_id or service_account_id must be provided` — `policy binding list` needs at least one filter.
- `symlink entries are not supported: <path>` (exit 1) — `scp --recursive` refuses trees containing symlinks; archive or dereference locally first.
- `unknown session` / `Timeout connecting to the instance!` from `exec` WS fallback — the server-side console rejected the session. SSH is the only reliable path right now; tell the user to ensure the VM has a reachable SSH address and permissive SG rather than relying on WS fallback.