Changelog

Every release of the Servonaut CLI, straight from GitHub.

Atom feed

v2.23.4 #

Added

Undo for a completed remediation — when a finding's remediation is revertible, its detail view now shows an "Undo remediation…" button that reverses it.

  • Runs the same server-signed preview → typed-confirm → execute → poll flow as remediations: nothing changes until you confirm the exact reversal command.
  • Reuses the typed-RUN confirmation gate; a dry-run option previews the reversal without touching anything.
  • The button appears only when the server reports a finding's remediation as revertible, so it never renders an action that can't run.

Upgrade: pipx upgrade servonaut (or pip install -U servonaut).

View on GitHub

v2.23.3 #

Added

unblock_ip remediation executor — the inverse of block_ip, so an IP ban applied by the proactive-remediation flow can be reverted.

  • Same AWS-vs-on-box split as block_ip, dispatched on the ban method: AWS methods (WAF / security group / NACL) go through the IP-ban service, which re-resolves the rule from the IP; on-box methods (nftables / ufw / firewalld) remove the box firewall rule over SSH and verify the IP is no longer in the ruleset.
  • Idempotent: unbanning an IP that isn't banned succeeds (the goal state already holds) rather than erroring, so repeating the operation or reverting an expired ban is safe.
  • On-box argv is built locally from the validated IP; dry-run reports what would change and mutates nothing. The remediation verb allowlist stays closed — unknown verbs are refused.

Upgrade: pipx upgrade servonaut (or pip install -U servonaut).

View on GitHub

v2.23.2 #

Performance

Settings opens ~8× faster — roughly 4 seconds down to about half a second.

Opening Settings built and mounted all 24 panels (~1100 widgets) up front, then immediately hid 23 of them. Because each mount re-applies the stylesheet against a growing widget tree, the cost was closer to (number of mounts × tree size) than to 24 × one panel — so you paid for every panel to render just to look at one.

Panels are now built the first time you select them, and cached from then on:

before after
Open Settings ~3996 ms ~524 ms
First visit to a panel 335–647 ms
Revisit 98–242 ms

The cost is moved rather than erased: you pay for a panel the first time you open it, and never for the panels you don't. Every panel stays reachable and searchable before it's built, per-panel Save and the unsaved-changes guard are unaffected.

Upgrade: pipx upgrade servonaut (or pip install -U servonaut).

View on GitHub

v2.23.1 #

Fixed

Background fleet memory auto-scan now actually runs.

With auto-scan enabled, server memories could stay stale indefinitely — the scan never ran a single cycle. The loop slept the full configured interval (24h by default) before its first scan, and the last-run marker lived only in memory, resetting on every launch. Since the app rarely stays open for a whole interval, each restart restarted the clock and the first cycle never arrived.

  • Persistent schedule — the last-run timestamp is now stored in ~/.servonaut/memory_scan_state.json (atomic write, fails soft), so the schedule survives restarts.
  • Catch-up on launch — after a short startup grace, if the interval has already elapsed since the last run, the scan runs immediately instead of waiting out another full interval. A cycle skipped because the instance list hasn't loaded retries shortly after rather than burning a full interval.
  • Live status updates — a background cycle now updates the Fleet Memory table in real time (per-instance progress + repopulate) and refreshes the instance-list memory column, without interrupting you with a dialog. The Fleet Memory status line shows a real "next in ~Xh" countdown that persists across restarts.

Upgrade: pipx upgrade servonaut (or pip install -U servonaut). Shortly after your next launch, any stale servers get a catch-up memory scan automatically.

View on GitHub

v2.23.0 — v2.23.0 — Proactive Detection & Remediation #

What's Changed

Full Changelog: https://github.com/zb-ss/servonaut/compare/v2.22.0...v2.23.0

View on GitHub

v2.20.2 #

What's Changed

Full Changelog: https://github.com/zb-ss/servonaut/compare/v2.20.1...v2.20.2

View on GitHub

v2.19.7 #

What's Changed

Full Changelog: https://github.com/zb-ss/servonaut/compare/v2.19.6...v2.19.7

View on GitHub

v2.19.6 #

What's Changed

Two independent hosted-AI chat fixes (PR #49):

Configurable max_tool_rounds for hosted AI

  • New config key chat_max_tool_rounds (Optional[int], None = use the server default), sent as max_tool_rounds on the chat request and clamped server-side. Lets you raise the agentic tool-loop cap for long investigations.
  • Deliberately a new key rather than reusing chat_max_tool_iterations (which governs only the local/user-keyed provider loop and is persisted as 10 in existing configs).
  • Omitted from the request body when unset, so servers that don't support it ignore the key harmlessly.

Dangerous-tool confirm modal no longer silently denies

  • Pressing Enter with an empty or wrong value on the typed-confirm "RUN" modal previously dismissed it as a denial with no feedback. Now the only ways to deny are Escape or Cancel.
  • An exact RUN (tolerant of surrounding whitespace, still case-sensitive) confirms; any other submitted value keeps the modal open with an inline hint.

Full Changelog: https://github.com/zb-ss/servonaut/compare/v2.19.5...v2.19.6

View on GitHub

v2.17.3 #

What's Changed

Full Changelog: https://github.com/zb-ss/servonaut/compare/v2.17.2...v2.17.3

View on GitHub

v2.16.1 #

Fixes

Config Sync — correct passphrase prompt on restore / new machine (#31)

Restoring a synced config snapshot on a brand-new machine no longer mis-prompts you to set a new passphrase. The prompt now correctly shows "Enter Sync Passphrase" for restores (and any device that already has a passphrase), reserving the type-it-twice "Set Sync Passphrase" flow for the genuine first push to an account with zero snapshots.

Previously the prompt keyed off the local probe file, so a fresh machine (no probe) was asked to create a new passphrase — which could never decrypt an existing snapshot, surfacing as a confusing "wrong passphrase / encryption error" even with the correct passphrase.

Crypto is unchanged; cross-machine decryption was always correct given the right passphrase.

View on GitHub

v2.15.0 — v2.15.0 — chat catalog consumer + dangerous-floor escalation (PR5') #

Chat catalog consumer + dangerous-floor escalation — PR5'

Final CLI-side PR of the dynamic-tool-catalog migration. Pairs with the web-side PR1' / PR3' / PR4' work shipped to staging earlier today (2026-05-26 UTC). This release flips capabilities.supports_dynamic_catalog: true on the connect handshake — the signal the hosted backend gates the CHAT_CATALOG_EXPANSION_ENABLED flag-flip on.

What's new

57 new chat-routable tool dispatches. services/ai_tool_bridge.py::_LOCAL_TOOL_HANDLERS now routes every non-relay tool in the server-side catalog to its handler in ServonautTools. Once the staging flag flips, the hosted AI chat panel can invoke any of the following without tool_unavailable errors:

  • AWS describe (6): aws_list_regions, aws_list_amis, aws_list_instance_types, aws_list_key_pairs, aws_list_subnets, aws_list_security_groups
  • AWS lifecycle standard (3): aws_start_instance, aws_stop_instance, aws_reboot_instance
  • AWS lifecycle dangerous (2): aws_run_instances, aws_terminate_instance (MCP-relay surface only — chat surface gates these per the server tier filter + ApprovalService UX deferred to follow-up)
  • S3 read (2): s3_list_buckets, s3_list_objects
  • S3 mutations dangerous (8): s3_create_bucket, s3_delete_bucket, s3_upload_object, s3_delete_object, s3_copy_object, s3_move_object, s3_generate_presigned_url, s3_download_object
  • AWS observability + IP ban (7): cloudwatch_list_log_groups, cloudwatch_get_log_events, cloudwatch_top_ips, cloudtrail_lookup_events, ip_ban_list_configs, ip_ban_list_banned, ip_ban_set
  • Log fetch (1): get_logs
  • Hetzner read + power (8): hetzner_list_servers, hetzner_list_server_types, hetzner_list_ssh_keys, hetzner_power_on, hetzner_power_off, hetzner_shutdown, hetzner_reboot, hetzner_create_ssh_key
  • Hetzner lifecycle dangerous (3): hetzner_create_server, hetzner_delete_server, hetzner_delete_ssh_key
  • OVH read + lifecycle (11): ovh_monitoring, ovh_list_ips, ovh_firewall_rules, ovh_ssh_keys, ovh_snapshots, ovh_dns_records, ovh_billing, ovh_invoices, ovh_start_instance, ovh_stop_instance, ovh_reboot_instance
  • OVH lifecycle dangerous (2): ovh_create_instance, ovh_delete_instance
  • Memory (4): get_server_memory, list_server_memories, build_server_memory, refresh_server_memory

Dangerous-floor escalation is now ACTIVE

The 18-pattern regex floor shipped dormant in v2.14.0 (PR2'). v2.15.0 wires it into the dispatch path:

  • Every tool_call arriving via ai_tool_bridge.handle_tool_call passes through _FloorDangerousMixin._floor_dangerous(name, server_tier) before guard enforcement.
  • If the tool name matches one of the destructive-pattern regexes AND the server-asserted tier is below dangerous, the call is escalated to dangerous tier and the mismatch is audit-logged to ~/.servonaut/mcp_audit.jsonl with reason dangerous_floor_escalation.
  • This is defense-in-depth — a buggy server catalog or wire-shape regression can no longer silently downgrade a destructive operation. The CLI refuses to take the server's word on what counts as dangerous.

This also caught one legitimate pre-existing miss: run_command was historically classified standard for chat-surface dispatch despite being arbitrary shell exec. v2.15.0 escalates it to dangerous automatically. The associated audit-row test was updated to reflect the corrected tier.

Capability bit

capabilities.supports_dynamic_catalog: true on the connect handshake. Hosted backends gate their dynamic-catalog activation on this bit — older CLIs (v2.14.x and earlier) keep receiving the original 9-tool chat surface; v2.15.0+ CLIs receive the expanded catalog.

tool_catalog SSE event handler

services/ai_providers/servonaut_provider.py now handles the tool_catalog SSE event emitted by /api/ai/chat/stream. Audit-only in v2.15.0 — the handler logs receipt to mcp_audit.jsonl with reason tool_catalog_received plus catalog_version, surface, and tool_count. The static _LOCAL_TOOL_HANDLERS map remains source of truth for dispatch; the audit row proves the wire works for a future PR to consume the catalog dynamically without changing v2.15.0 behaviour.

Install / upgrade

pipx install servonaut --force
# or
pipx upgrade servonaut

Full diff

v2.14.0...v2.15.0

What's next

The dynamic-tool-catalog migration is feature-complete on both sides. Staging-flip protocol:

  1. v2.15.0 deployed to PyPI (this release)
  2. Operator runs pipx upgrade servonaut on the staging-connected CLI
  3. v2.15.0 CLI heartbeat carries capabilities.supports_dynamic_catalog: true
  4. Operator flips CHAT_CATALOG_EXPANSION_ENABLED=true on the staging web env var (no redeploy needed, ~10s container restart)
  5. End-to-end test: ask the hosted AI chat "list my EC2 regions" — the 40 newly-chat-exposed tools dispatch through this CLI's ai_tool_bridge to the local mcp/tools.py handlers and return real responses

Once staging E2E is verified, production flag-flip happens on the user's schedule.

View on GitHub

v2.14.0 — v2.14.0 — dynamic-catalog CLI consumer (PR2') #

Dynamic-catalog CLI consumer — PR2'

This release is the CLI half of the dynamic-tool-catalog migration. The server side (PR1' on the web backend) shipped to staging on 2026-05-26 00:23 UTC; this release wires the CLI to emit the new connect/heartbeat fields and adds defense-in-depth for any future server-emitted catalog.

No user-visible behaviour change in this release — all wire changes are additive and the server doesn't yet act on the new fields. Defense-in-depth is dormant until v2.15.x (PR5'). The catalog-drift CI gate is the immediate value: any future CLI tool addition now fails CI until the server-side catalog accepts it.

What's new (wire-format v1.0)

The CLI now emits two enriched messages to /api/cli/heartbeat:

// First message on every (re)connect
{
  "type": "cli.handshake",
  "version": "2.14.0",
  "cli_release_channel": "stable",        // or "beta" / "dev"
  "providers_configured": ["aws", "hetzner", "ovh"],
  "capabilities": {"supports_dynamic_catalog": false}
}

// Every 30s afterwards
{"type": "cli.heartbeat", "providers_configured": ["aws", "hetzner"]}
  • providers_configured — array of bare provider names ("aws", "hetzner", "ovh"). Re-resolved on every heartbeat from the running ServonautApp's service state, so mid-session credential changes propagate within ~30s without forcing a reconnect.
  • cli_release_channel — read from the SERVONAUT_RELEASE_CHANNEL env var. Defaults to "stable", falls back to "dev" for editable / pipx-install-from-source builds. Lets server-side telemetry segment usage by release channel.
  • versionservonaut.__version__. Lets the server gate on minimum-CLI-version requirements without a forced upgrade.
  • capabilities.supports_dynamic_catalog — hardcoded false for v2.14.x. Flips to true in v2.15.x (PR5') when the CLI starts honouring a server-emitted tool_catalog SSE event.

Old CLIs without these fields stay fully functional — server-side resolution defaults to "advertise everything for this user," matching pre-PR1' behaviour. Additive contract, no breakage.

Defense-in-depth: dangerous-tool pattern floor

New module servonaut.services.dangerous_tool_floor ships an 18-regex floor that forces the dangerous guard tier for any tool whose name matches a known-destructive pattern:

  • AWS lifecycle: aws_run_instances, aws_terminate_instance
  • S3 mutations + presigned URL: s3_create_bucket, s3_delete_*, s3_upload_object, s3_copy_object, s3_move_object, s3_generate_presigned_url
  • Hetzner / OVH create + delete (prefix-pattern — auto-catches future tools)
  • Cross-provider destructive: deploy, provision, security_scan, run_command, transfer_file, ip_ban_set

When the CLI starts consuming server-emitted catalogs in v2.15.x, any tool whose server-claimed tier is below dangerous AND whose name matches this floor will be escalated to dangerous and the mismatch audit-logged to ~/.servonaut/mcp_audit.jsonl with reason dangerous_floor_escalation. Belt-and-suspenders posture: server catalog can be wrong; CLI refuses to take its word on destructive ops.

Catalog-drift CI gate

tests/test_catalog_drift.py is the new forcing function. Every CI run asserts:

set(server_catalog_v1.json["tools"]) == set(tool_schemas.TOOL_SCHEMAS) - CATALOG_EXCLUDED_CLI_ONLY

where CATALOG_EXCLUDED_CLI_ONLY is the 7-tool set of CLI-local-only meta tools that intentionally don't appear in the hosted-MCP catalog (whoami, relay_status, relay_reconnect, api_request, mcp_tool_call, check_status, get_server_info). Anyone adding a new tool to the CLI must either land it in the server-side catalog in the same PR (via the web-side resolver update) or extend CATALOG_EXCLUDED_CLI_ONLY with a justification — drift fails CI loudly.

Install / upgrade

pipx install servonaut --force
# or
pipx upgrade servonaut

Full diff

v2.13.0...v2.14.0

What's next

PR3' (web-side providers_configured consumer) kicks off on the web backend. Once it deploys to staging, hosted MCP starts filtering its catalog by which providers the user has configured on at least one connected CLI. PR4' (chat-surface unification, brings the same dynamic catalog to the hosted AI chat panel) and PR5' (CLI consumes server-emitted catalog + escalation wired through _floor_dangerous) follow.

View on GitHub

v2.13.0 — v2.13.0 — AWS provider, S3 object storage, 21 new MCP tools #

AWS provider, end-to-end

Servonaut now manages AWS EC2 as a first-class provider alongside Hetzner and OVH — full lifecycle in the TUI and full coverage on the MCP server.

TUI

  • AWS sidebar section with ⚙ Manage (per-instance start / stop / reboot / terminate), 📊 CloudWatch, 🔒 IP Ban Manager, and 🔍 CloudTrail — the AWS-specific log + security tools previously lived under their own "Logs & Security" section; they're now grouped where they belong.
  • EC2 launch wizard — full create-instance flow with region / AMI / instance type / keypair / subnet / security-group selection, AMI search with debounce, and Name tag pre-fill.
  • S3 / Object Storage screens for AWS, Hetzner Object Storage, and OVH Object Storage — bucket list, object browser, upload / download / copy / move / delete, presigned-URL generation, breadcrumb navigation. All three providers share the same screen (ObjectStorageScreen(provider=...)).
  • Provider setup wizards now own the S3 form — Hetzner setup adds a Step 4 (Object Storage), OVH setup adds a Step 7. Each setup screen also rebuilds the S3 service on save so the sidebar Object Storage button appears immediately without a restart.
  • Region dropdowns replace free-form text inputs on all three S3 forms. AWS = 21 regions, Hetzner = 3, OVH = 9. Default values selected so the form is never invalid.
  • Docked Save Settings button at the bottom of the Settings screen — no more hunting for Ctrl+S after scrolling through twelve sections.

MCP server (+21 tools)

11 AWS EC2 tools + 10 S3 tools (provider-parameterised across aws / hetzner / ovh):

Tier Tools
readonly aws_list_regions, aws_list_amis, aws_list_instance_types, aws_list_key_pairs, aws_list_subnets, aws_list_security_groups, s3_list_buckets, s3_list_objects
standard aws_start_instance, aws_stop_instance, aws_reboot_instance, s3_download_object
dangerous aws_terminate_instance, aws_run_instances, s3_create_bucket, s3_delete_bucket, s3_upload_object, s3_delete_object, s3_copy_object, s3_move_object, s3_generate_presigned_url

All mutating tools go through the existing audit trail (~/.servonaut/aws_audit.jsonl for AWS lifecycle, ~/.servonaut/mcp_audit.jsonl for the MCP layer). s3_generate_presigned_url is classified dangerous despite being a read — the returned URL is a bearer credential and is masked in the audit row (only the JSON-RPC return value to the agent contains the URL itself).

Sidebar conditional visibility

OVH and Hetzner sections were previously hidden entirely when the compute service wasn't configured — which broke the new "S3-only" use case (configure object-storage credentials without setting up the provider's compute API). Now: section visible when either compute OR S3 is configured, with the compute-only buttons hiding when only S3 is set up. AWS section is always visible per the original design; only its Object Storage button is gated on credential availability.

Fix: config-sync data loss

A separate bug found while testing this PR — Hetzner api_token and OVH application_key / application_secret / consumer_key were being silently wiped on config-sync pull because the preservation loop didn't recurse into nested dataclasses at depth ≥2 and the copy at the top was shallow. Both fixed with deep-copy + materialising depth-aware preservation. Regression tests added.

Under the hood

  • New services/object_storage_service.py — single ObjectStorageService class parameterised by provider; SSRF-guarded endpoint validation (https-only, no @-userinfo, no path/query/fragment, alternate IPv4 encodings rejected via socket.inet_aton), path-traversal validation against (cwd, ~, ~/Downloads).
  • New services/object_storage_factory.py — single build_object_storage_services(config) shared between the TUI app and the headless MCP server so credential-handling logic never drifts between the two surfaces.
  • New services/aws_audit.py — JSONL audit trail with O_NOFOLLOW | O_CREAT | O_APPEND mode 0o600 — symlink-resistant + private by default.
  • New services/object_storage_regions.py — single source of truth for the dropdown options + defaults.
  • config.json is now written atomically with mode 0o600 + O_NOFOLLOW; a leftover symlink at the target is detected and refused rather than silently followed.

Install / upgrade

pipx install servonaut --force
# or for an existing install
pipx upgrade servonaut

Full diff

v2.12.0...v2.13.0

View on GitHub

v2.12.0 — v2.12.0 — Bitwarden Password Manager SSH integration #

Bitwarden Password Manager SSH integration

Servonaut now resolves SSH keys from your Bitwarden Password Manager vault through a server-side ref system. Personal and team-scoped refs are supported; local `~/.ssh` remains a fallback.

New CLI commands

```bash servonaut ssh # connect via BW-resolved key (or local fallback) servonaut servers verify # probe the BW key + report verify status to the audit log ```

The resolution chain walks personal → team → local in that order. BW key bodies live in a 0600 ephemeral tmpfile under `~/.servonaut/tmp/` that's wiped + unlinked on exit; a 24-hour sweeper handles crash-recovery on next startup.

New TUI surfaces (v2 default)

  • "SSH" column on the instance table — at-a-glance verify-status badge. Renders "—" when no ref is stored or when `ssh_verified_at` is NULL per the server contract.
  • R. Manage SSH Ref action — add / edit / delete the per-instance BW item ref. Modal lets you set `item_id`, optional `collection_id`, optional `vault_url`.
  • V. Verify SSH action — runs the local probe and POSTs the result. Updates the row in place.
  • Settings → Bitwarden SSH Vault section — configure your personal BW vault wiring (vault URL, default collection).
  • Both R and V are top-level shortcuts on InstanceListScreen — no drilling into the actions menu required.

Discoverability fixes

  • The actions menu now has three entry points: press Enter, press O (Open), or double-click a row. The footer always shows `o Actions` so users can discover the menu.
  • DataTable was previously consuming Enter without surfacing it — this is fixed via the new `on_data_table_row_selected` handler.

Mercure relay listener

  • Subscribes to both `/cli/{uid}/commands` (legacy) and `/cli/{uid}/ai-tool-calls` (new, PR #74 server-side) on one SSE connection.
  • Bounded LRU dedup (256 entries, 5-min TTL) keyed by `tool_call_id` so the dual-publish transition window doesn't cause double-execution.

Wire contract guards (regression-tested)

  • `ssh_credential_ref` is the body field — never `ref`. Test asserts `"ref" not in body`.
  • `ssh_verified_at` is NULL whenever status ≠ `verified` — enforced client-side too, even if the server somehow returns a timestamp on a non-verified status.
  • Provider whitelist `aws|ovh|hetzner` + instance_id regex `[A-Za-z0-9_\-]{1,64}` validated client-side so malformed inputs fail locally instead of round-tripping.

Test count

3985 passing, 5 skipped (+327 new tests covering all surfaces).

Coordination

Backend epic landed in prod earlier this week. CLI side is feature-complete. See #27 for the full PR.

View on GitHub

v2.11.0 #

Complete team management UX + Shared Configs v1. Coordinated CLI half of servonaut.dev PR #66 (prod-deployed earlier today).

Highlights

Team Management Screen — wrapped sections in curved-border cards (matches Settings/Manage UX), moved inline forms in-context inside their parent section, fixed forms expanding to fill the viewport, added Cancel button on every form.

Members surface

  • Role Select widget (Viewer / Member / Admin); admin hidden for non-owners
  • Status column with countdown ("Invited (expires in 5d)")
  • Resend Invite action (new endpoint)
  • Copy Accept URL action (fallback when email fails)
  • Change Role action (PUT route, no /role suffix)
  • 402/422 error branching with billing-aware copy (owner vs member)
  • Hide Create Team when caller already owns a team

Shared Servers

  • Instance-picker form replaces the old blind "share first instance" placeholder
  • Optional Username + Port inputs (prefilled from custom servers)
  • Fix wire-format mismatch: hostname not host, {\"data\":[]} envelope
  • Share All button with dedupe + continue-on-failure

Shared Configs v1 (new)

  • Push your sanitised local config (connection profiles, scan rules, custom servers) to share with the team
  • Pull team's baseline with diff preview before applying
  • Strips local filesystem paths (ssh_key, bastion_key) with warning
  • Versioned (server keeps 100 per team)

Seat-cap display

  • "Seats: X / Y" line in team header (X = accepted + pending, matches server-side accounting)
  • Special copy when no active Teams subscription

Under the hood

  • APIClient.put() (was missing — completes the post/patch/put trio)
  • TeamService.update_role route fix (was silently 404ing)
  • Rename user_idmember_id (TeamMember.id, not User.id; diverges for pending invites)
  • New: resend_invite, list_team_configs, get_latest_team_config, push_team_config
  • relay_listener now skips non-CommandType events at DEBUG (was logging ERROR for every AI tool call mirrored on the relay channel)
  • chat_panel defensive args parsing for AI tool calls + DEBUG dump for wire-shape divergence

Tests

110 team-bucket tests passing (added 56 new); 37 relay tests passing (added 2 new). Full suite: 3666 passed, 5 skipped, 0 failed.

Future work (planned)

Two planning notes saved for follow-up releases:

  • Connect-to-Shared-Server — SSH "Connect" button on Shared Servers table (depends on team-secrets key resolution)
  • Shared Configs v2 — three waves: Trust+Safety (auto-snapshot before Apply, restore historical), Granular Control (per-section selective import, merge/overlay modes), Two-Way Visibility (Mercure push notifications, admin drift dashboard)
View on GitHub

v2.9.3 #

What's Changed

Full Changelog: https://github.com/zb-ss/servonaut/compare/v2.9.2...v2.9.3

View on GitHub

v2.9.2 #

Auth refresh-race + relay 401 split-brain fix (re-ship of v2.9.1)

v2.9.2 contains the same code fix as v2.9.1 (which failed to publish — its publish.yml was missing the optional provider SDKs needed by the test gate).

What's fixed

Two compounding bugs were logging users out repeatedly — on CLI restart and mid-session — even when the refresh_token was still valid:

  1. Refresh race in AuthService.refresh_token. Concurrent 401-retries presented the same refresh_token to /api/oauth/refresh. First rotated it; second hit a revoked token and got 400 invalid_grant. Blunt non-200 handler killed the session. Fix: per-process asyncio.Lock + disk re-read inside the lock to dedup concurrent refreshes, plus _classify_refresh_failure so only 400 invalid_grant / 401 / 403 marks the session dead (429 / 5xx / network errors stay transient).
  2. Local 1h clock killed the UI. AuthToken.is_authenticated gated on time.time() >= expires_at → CLI flagged "not connected" after 1h without ever firing the call that would have triggered refresh. Relaxed: server is the source of truth via the 401-retry path.
  3. Relay split-brain. RelayListener owns its own httpx.AsyncClient and bypassed APIClient's refresh-on-401, so the heartbeat fired session_expired instantly on a stale access_token while the login screen correctly reported "logged in". New _authed_request helper threads through heartbeat / mercure-token / command-result and refreshes-and-retries once before declaring the session expired.

Workflow fix (the reason v2.9.2 exists)

publish.yml now installs [test,hetzner,ovh,mcp] to match ci.yml. Without this, the optional-SDK-using tests block every release-triggered publish.

Coordination

Backend agreed to add server-side row locking on the refresh-token row + a short idempotency window in TokenService::refreshToken as belt-and-braces (covers cross-process and shared-auth.json-over-Dropbox cases).

Tests

7 new auth tests + 2 new relay tests. Full suite 2913 passing.

View on GitHub

v2.9.1 #

Fix: OAuth refresh-race and relay 401 split-brain

Two compounding bugs were logging users out repeatedly — on CLI restart and mid-session — even when the refresh_token was still valid for 29 more days.

What was broken

  1. Refresh race. Concurrent 401-retries (chat stream + status poll, parallel memory-sync batches, etc.) both presented the same refresh_token to /api/oauth/refresh. First call rotated and revoked it; second call hit a revoked token and got 400 invalid_grant. The blunt non-200 handler treated that as a dead session and kicked the user out.
  2. Local 1h clock killed the UI. AuthToken.is_authenticated gated on time.time() >= expires_at, so once the access_token's 1h TTL elapsed the CLI showed "not connected" without ever firing the call that would have triggered refresh.
  3. Relay split-brain on restart. RelayListener owns its own httpx.AsyncClient (the SSE subscription has to hold it open), so it bypassed APIClient's refresh-on-401. On a fresh CLI start with a locally-stale access_token, the heartbeat hit 401 and fired session_expired immediately — while the login screen, reading the relaxed is_authenticated, correctly reported "logged in". Hence the mismatched indicators.

What changed

  • AuthService.refresh_token: per-process asyncio.Lock + disk re-read inside the lock to dedup concurrent refreshes (adopts a newer disk token without hitting the network). New _classify_refresh_failure splits responses three ways:
    • 200 → success, persist, clear sticky revoked flag.
    • 401/403/400 invalid_grant → set _refresh_grant_revoked, UI asks user to re-authenticate.
    • 429 / 5xx / 400 with other codes / network error → transient, keep credentials, next 401-retry will try again.
  • AuthToken.is_authenticated no longer gates on local expiry — server is the source of truth via the 401-retry path. Stale access_tokens get healed transparently.
  • RelayListener got a refresh_callback parameter and _authed_request helper. Heartbeat / mercure-token / command-result POSTs now refresh-and-retry on 401 before declaring session_expired.
  • validate_token only deletes auth.json when the revoked flag is set — transient failures no longer nuke the file.

Tests

  • 7 new auth tests: lock/dedup path, concurrent refresh serialisation, invalid_grant flagging, 429/5xx/network-error transient handling, success-clears-flag.
  • 2 new relay tests: heartbeat 401 + successful refresh → no session_expired; heartbeat 401 + failed refresh → session_expired still fires.
  • Full suite: 2913 passing.

Coordination

Server-side fix coordinated with servonaut-web-backend — they'll follow up with SELECT ... FOR UPDATE row locking on the refresh-token row + a short idempotency window in TokenService::refreshToken as belt-and-braces. Not load-bearing now that the client serialises, but covers the cross-process and shared-auth.json-over-Dropbox cases.

View on GitHub

v2.9.0 — v2.9.0 — Per-provider managers + full MCP lifecycle parity #

Highlights

Per-provider manager screens. New ⚙ Manage entries under both Hetzner and OVH in the sidebar — each opens a provider-scoped table with a state-aware action toolbar:

  • Hetzner → ⚙ Manage — Create / Power on / Shutdown / Power off / Reboot / Delete. Start enables only on stopped servers, Shutdown / Power off / Reboot only on running ones.
  • OVH → ⚙ Manage — Create / Start / Stop / Reboot / Delete, routed automatically across Cloud / VPS / dedicated bare-metal per row's provider_type.

The unified InstanceListScreen stays unchanged as the search-and-SSH fast path.

MCP tool lifecycle parity. 10 new tools so AI agents have the same surface as the UI:

Provider New tools
Hetzner power_on, power_off, shutdown, reboot (standard tier), delete_ssh_key (dangerous)
OVH create_instance, delete_instance (dangerous), start_instance, stop_instance, reboot_instance (standard)

Power management at the standard guard tier; create/delete at dangerous. Every mutating tool's description carries an explicit "Confirm with the user before calling" cue, plus a top-level confirmation-protocol prompt in the MCP server's instructions block (summarise → state args → wait for affirmative reply).

Total tool count: 39 (up from 24 in the README's prior count — hetzner_* was missing from the docs entirely).

Hetzner SSH keys screen. New Hetzner → 🔑 SSH Keys surface with full CRUD against the Hetzner Cloud project's key registry — the same registry the create-server wizard injects from. Replaces the prior CLI-hint placeholder.

OVH SSH keys screen migrated. Was managing /me/sshKey (account-level, used by VPS / dedicated). Now manages /cloud/project/{id}/sshkey — the registry the cloud-create wizard reads from. Adding a key actually shows up in the wizard now.

OVH cloud-create wizard fixes.

  • Region-first picker. Region is a Select populated from GET /cloud/project/{id}/region; on change, flavors and images reload filtered. Picking a flavor in the wrong region is impossible by construction (was the source of Flavor X could not be found errors).
  • Datacentre labels. Region dropdown reads GRA11 — Gravelines, France instead of bare codes. Stable mapping for ~13 known prefixes; unknown codes fall back gracefully.
  • Pricing surfaced. _extract_ovh_price helper parses all three OVH price-block shapes (text / value / legacy priceInUcents); flavor table grows Hourly / Monthly columns and the confirm modal includes the cost line.
  • Hide dead regions. Regions with zero deployable flavors are filtered out of the picker; available: false flavors are hidden from the table within otherwise-active regions.

Notable bug fixes

  • fix(relay) — login wrote auth tokens but never relay.{base_url, mercure_url}, so freshly-logged-in users tripped a spurious "MCP relay URLs not configured" toast. New RelayManager.ensure_configured() derives URLs from the API base (prod splits api/apex; staging single-host) and persists.
  • fix(screens) — Textual 8.x rejects push_screen_wait outside a worker context. Audited every callsite; six screens were latently broken and would've crashed the moment a user clicked a destructive action button. Mechanical fix across hetzner_create, ovh_cloud_create, ovh_resize, ovh_snapshots, ovh_firewall, ovh_ip_management.
  • fix(custom-servers) — Save / Cancel row was clipped on small terminals (sat at the end of #add_form, off-screen on a 24-row terminal). Now docked to the screen bottom, mirroring the wizard pattern.
  • fix(ovh-storage)OVHStorageScreen._get_project_ids reached for a non-existent config_manager.config attribute, always returned [] regardless of what was configured. Bug was unique to this one screen — verified by grep across the codebase.
  • fix(hetzner-create) — wizard relied on the service-side fallback to default_hetzner_ssh_key; if that wasn't set the footgun guard fired with a generic message. Now pre-selects the configured default in the keys table + adds an actionable submit-time check pointing at the right Settings field.
  • fix(ovh-ip) — global .hidden { display: none } rule was missing; several screens (instance_list memory banner, ovh_ip_management forms, ovh_firewall) toggled classes="hidden" to no effect. Fixed at the root.

UX polish

  • Sidebar relay indicator moved from the dock-bottom row to the brand block (under "Server Manager"), aligned to the same left margin.
  • Curved-border style language applied uniformly to every OVH-screen DataTable + the new manager surfaces. Action toolbars match the Snapshot Manager visual rhythm.
  • OVH IP Management restructured — each form now in its own card-styled Container so show/hide is atomic; removes the gaps the user reported between sections.

Tests

~150 new tests landed across the release covering: new service methods (HetznerService.{power_on,power_off,shutdown,reboot,delete_ssh_key,list_locations,list_images}, OVHCloudService.list_regions, flavor available + price field shapes), 15 region-label parametrised cases, all 11 new MCP tools (dispatch / guard rejection / validation / api-error wrapping), Hetzner setup screen dropdown seeding/refresh/saved-deprecated value preservation, relay URL derivation and ensure_configured.

Upgrade

pipx install --force servonaut
# or
pip install --upgrade servonaut

Full diff: https://github.com/zb-ss/servonaut/compare/v2.7.0...v2.9.0

View on GitHub

v2.5.0 #

Multi-provider expansion + the foundation for the Servonaut Cloud account and MCP relay. No breaking API changes.

Highlights

OVHcloud is the second first-class provider

Dedicated servers, VPS, and Public Cloud all managed from the same instance list as AWS EC2. Guided setup via servonaut --setup-ovh, and a sidebar section for DNS zones, IP management, snapshots, block storage, billing, SSH keys, and Public Cloud instance creation. 206 new tests covering the full OVH surface.

Servonaut Cloud account

Sign in from the TUI's Account screen to enable:

  • Config sync — client-encrypted snapshots of your config.json pushed to / pulled from servonaut.dev. Passphrase never leaves your client.
  • MCP relay (foundation) — Servonaut now establishes a Mercure SSE connection to servonaut.dev on login. The backend sees the CLI as online and can dispatch MCP tool calls through the relay channel; the agent-facing dispatcher and hosted tool surface ship in an upcoming release.

MCP relay infrastructure

The TUI opens an in-process relay listener for the lifetime of the TUI window. For always-on reachability, run servonaut connect --bg — the CLI and TUI cooperate over ~/.servonaut/relay.lock so they can't both listen at once.

New servonaut connect subcommand family:

  • --bg / --stop / --status — background listener + backend-view status (flags divergence when heartbeats aren't landing).
  • --reconnect — heal a stale SSE socket (stop + start).
  • --force-bg — SIGUSR1 a TUI's in-process listener to hand over to a detached bg listener.

MCP tool additions

New tools registered on the local MCP server:

  • Session: whoami (no token leak), api_request (authenticated REST proxy with 401-refresh, 1 MiB cap, 30/min rate limit, header allowlist).
  • Relay: relay_status (backend view), relay_reconnect (heal stale connection, idempotent on healthy), mcp_tool_call (JSON-RPC 2.0 wrapper for hosted MCP — used by future hosted tools).
  • OVH (read-only introspection): ovh_ssh_keys, ovh_invoices, ovh_firewall_rules. Additional OVH tools exist but are being refined and will be featured in a later release.

The built-in TUI chat now delegates to the same ServonautTools implementation as the MCP server — single source of truth in src/servonaut/mcp/tool_schemas.py. Fixes a bug where the chat couldn't see OVH or custom servers.

Security

  • ~/.servonaut/auth.json chmod'd to 0600 with atomic writes. Older world-readable files are silently fixed on next load.
  • Relay lifecycle log (~/.servonaut/logs/relay.log) redacts authorization / token / secret / api_key values and detects Bearer / JWT content on unrecognised keys.

Smaller wins

  • Per-host extra_ssh_options for legacy boxes (HostKeyAlgorithms=+ssh-rsa, custom keepalives).
  • Login modal widened and the verification URL wrapped in a real OSC 8 hyperlink so clicks open the full URL regardless of wrapping.
  • Unreleased plan features (Premium AI / GCP / Azure / Teams) filtered from the login screen until they ship on the CLI.

Upgrade notes

  • No config migration required — all new keys are optional with defaults.
  • Users who previously ran servonaut connect --bg manually can keep doing so; the TUI will detect the existing listener and report external listener (PID N) instead of starting a duplicate.
  • Free-tier users see an "Upgrade to connect" indicator in the sidebar after logging in — no Mercure fetch is attempted, so there's no network cost to seeing the indicator.

Under the hood

  • 1599 tests (up from 718 at v2.4.13). New suites: test_relay_lock, test_relay_manager, test_relay_log, test_mcp_whoami, test_mcp_api_request, test_mcp_relay_reconnect, test_mcp_relay_status_and_tool_call, test_main_relay, plus expansions to test_auth_service and test_chat_tools.
  • Tool registration centralised: adding a new tool = one entry in tool_schemas.py + one method on ServonautTools. Chat and MCP surfaces pick it up automatically.

Full changelog: https://github.com/zb-ss/servonaut/compare/v2.4.13...v2.5.0

View on GitHub

v2.3.5 #

What's Changed

Bug Fixes

  • Fix keystroke skipping — SSH subprocesses no longer steal terminal stdin (stdin=DEVNULL)
  • Preserve search filter on background instance refresh
  • Dynamic version display on main menu (from package metadata)
  • Fix duplicate "Analyzing..." text in AI analyzer
  • Fix DataTable rendering in CloudWatch/CloudTrail browsers (Select widgets were consuming all vertical space)
  • Fix IP ban form visibility in Settings (switched to programmatic display toggling)

Features & Improvements

  • CloudWatch Logs Browser — Select dropdowns for region/log group/time range, auto-discovery of log groups, pagination (100/page), fetch all events for accurate IP counting
  • CloudTrail Event Browser — Select dropdowns for region and time range, pagination (100/page), fetch all events (up to 10k)
  • IP Ban Manager — Side-by-side layout for banned IPs and audit log, ban count column from audit history, copy selected IP (y) or use it in input (Enter)
  • Settings IP Ban Config — AWS resource discovery (WAF IP sets, Security Groups, NACLs), Select dropdowns for region/method, better placeholders and examples
  • AI Analyzer — Auto-copy mouse selection to clipboard
  • Textual 8.x upgrade — Updated from 0.40.0 to 8.0.2

Full Changelog

https://github.com/zb-ss/servonaut/compare/v2.3.0...v2.3.5

View on GitHub

v2.1.0 #

What's New

Consolidated Config Directory (~/.ec2-ssh/)

All runtime files are now organized under a single ~/.ec2-ssh/ directory:

File Purpose
~/.ec2-ssh/config.json Main configuration
~/.ec2-ssh/cache.json Cached instance list
~/.ec2-ssh/keywords.json Keyword scan results
~/.ec2-ssh/command_history.json Saved commands & history
~/.ec2-ssh/logs/ Application logs & wrapper scripts

Automatic migration: If you have existing files at the old locations (~/.ec2_ssh_config.json, etc.), they are moved automatically on first startup. No manual action needed.

Saved Commands & Persistent History

  • Persistent command history — commands you run in the overlay are saved across sessions. Reopen the overlay and press Up/Down to recall previous commands.
  • Save favorites (Ctrl+S) — save frequently-used commands with a custom name
  • Command picker (Ctrl+R) — search and select from saved commands and recent history with real-time filtering
  • Per-instance + global history — Up/Down arrows recall commands for the current server; the picker shows commands across all servers
  • Keybinding hints bar — the command overlay now shows available shortcuts at a glance

Scan Progress Indicator

  • Per-server progress feedback during keyword scans with instance names shown as they complete

Keyboard Shortcuts (Command Overlay)

Key Action
Ctrl+R Open command picker (saved + recent)
Ctrl+S Save current command to favorites
/ Navigate command history
Ctrl+C Stop running command
Escape Close overlay

Full Changelog

https://github.com/zb-ss/ec2-ssh/compare/v2.0.0...v2.1.0

View on GitHub

v2.0.0 #

EC2 Connect v2.0.0

Complete rewrite as a modern Terminal User Interface (TUI) powered by Textual.

Features

  • Interactive TUI with mouse and keyboard support
  • List and search EC2 instances across all AWS regions
  • SSH into instances — launches in new terminal window with auto-detected emulator
  • Run remote commands via overlay panel with real-time streaming output
  • Browse remote file systems — interactive file tree navigation
  • SCP file transfer — upload/download files and directories
  • Keyword-based server scanning — search file contents across instances
  • Bastion host / jump server support via ProxyJump or ProxyCommand
  • SSH key management with auto-discovery and per-instance configuration
  • Instance caching with stale-while-revalidate for fast startup
  • Fully configurable via ~/.ec2_ssh_config.json

Install

pipx install ec2-tui

Or use the one-liner installers:

# Linux / macOS
curl -sSL https://raw.githubusercontent.com/zb-ss/ec2-ssh/master/install.sh | bash

# Windows (PowerShell)
irm https://raw.githubusercontent.com/zb-ss/ec2-ssh/master/install.ps1 | iex

Requirements

  • Python 3.8+
  • AWS CLI configured with ec2:DescribeInstances and ec2:DescribeRegions permissions
  • SSH client
View on GitHub