Shared backend and local API#

CLI and HTTP use the same application services over SQLite, OpenSSH and the OS credential vault. The React + TypeScript + Vite frontend replaces the former browser UI and uses /api/v1 throughout. Its packaged build is served locally; Node.js is a development dependency. Desktop presentation remains optional future work: serverctl app and ui-next are not shipped.

Boundaries#

CLI ------------------+
                      +--> ApplicationServices --> existing DB / SSH / Vault
FastAPI /api, /api/v1 -+

LocalRuntime --> loopback FastAPI --> bundled React browser UI

application.py owns host CRUD and config rendering, credential operations, file operations, saved paths, diagnostics, contexts, Skills and imports. Construction and inventory/workspace reads do not acquire the vault or start SSH. Skill readiness reads local frontmatter; it never executes a skill body. HTTP keeps cookie/CSRF/reveal authorization and transfer staging. CLI owns arguments, local prompts, terminal checks and output. CLI never calls HTTP.

Core validation and schema version 7 remain authoritative. Schema checks and migrations, partial read/merge/write updates, and ProxyJump graph validation hold one SQLite write transaction. Alias renames validate the resulting graph before rewriting dependencies. Config rendering serializes its snapshot and atomic replacement with database writers so an older snapshot cannot replace a newer configuration. Rendering is invoked after the mutation commits; it does not support being called inside an uncommitted external DB transaction. Config rendering follows successful host/credential/import mutations, matching legacy behavior; a filesystem failure while rendering does not roll back an already committed database mutation. Saved-path and note edits remain local metadata operations.

Runtime#

serverctl web and its compatible serverctl ui alias use one LocalRuntime. All previous flags, including --credential, --url-file, --status, and --stop, remain available. One managed runtime runs at a time: an exclusive OS lock in the private runtime directory is held for the runtime's lifetime, so even simultaneous launches cannot both serve. The kernel releases the lock if the process dies. The status record is written before the launch URL file, so --status/--stop see a runtime as soon as its URL is usable. The launch URL stays inside the process or an explicitly requested private file; it is never printed. Access logging remains disabled. On macOS and Linux a fixed --port can be reused immediately after --stop.

LocalRuntime.start() starts the backend on a worker thread and returns after Uvicorn startup. A future native shell can use its private launch_url while keeping its GUI event loop on the main thread. stop() or context-manager exit joins the worker and cleans the runtime record and socket. Startup errors and timeouts also clean resources. Browser mode serves in the foreground and opens the browser after readiness. The loopback socket remains bound between port selection and Uvicorn startup.

API v1#

All endpoints require the existing browser session established by consuming the single-use launch URL. Read /api/v1/runtime first for the session CSRF token, version, shell, authentication metadata and capabilities. ui is modern; session.expires_at is the actual UTC expiration and server_time supports a clock-correct countdown. vault.name and vault.status describe the last explicitly observed backend; initially the name can be null and status is unknown. Reading runtime never opens or health-checks the vault. Capabilities describe implemented operations; native-window/dialog capabilities are false.

Resource Routes and operations
Runtime GET /api/v1/runtime
Hosts GET, POST /api/v1/hosts; GET, PATCH, DELETE /api/v1/hosts/{identifier}
Workspace GET /api/v1/hosts/{identifier}/workspace returns host, credential metadata, saved paths and Skills
Route GET /api/v1/hosts/{identifier}/route returns actual managed hops, external boundaries and cached blocked_by
Notes PUT /api/v1/hosts/{identifier}/notes, with mode: append, set, clear
Diagnostics POST /api/v1/hosts/{identifier}/test or /diagnose; optional bounded timeout
Batch checks POST /api/v1/hosts/test with identifiers, optional timeout and concurrency
Files GET /api/v1/hosts/{identifier}/files?path=...&name_filter=...; explicit POST /download, POST /upload
Saved paths GET, POST, PUT /api/v1/hosts/{identifier}/paths; PATCH, DELETE /paths/{path_id}; POST /paths/{path_id}/used
Credentials GET, POST /api/v1/credentials; GET, PATCH, DELETE /credentials/{identifier}
Credential tests POST /api/v1/credentials/{identifier}/test returns NDJSON stages/result; GET /credentials/{identifier}/tests
Credential deletion GET /api/v1/credentials/{identifier}/deletion-preview; POST /credentials/{identifier}/detach-delete; GET /credential-deletions?pending=true; POST /credential-deletions/{operation_id}/retry
Private keys POST /api/v1/credentials/key/inspect; POST /credentials/key/validate
Local metadata POST /api/v1/local/key-browser/scopes; POST /local/key-browser/list; GET /local/ssh-agent
Contexts/tags GET, POST /api/v1/contexts; PATCH, DELETE /api/v1/contexts/{name}
Skills GET, POST /api/v1/skills; GET /skills/discover; POST /skills/{identifier}/refresh; PUT /skills/{identifier}/servers; DELETE /skills/{identifier}
Host Skills PUT /api/v1/hosts/{identifier}/skills
Import POST /api/v1/import/preview, POST /api/v1/import/apply
Reveal authentication POST /api/v1/auth/master/enroll, /master/verify, /passkey/register/options, /passkey/register/verify, /reveal/options, /reveal/verify
Secret display POST /api/v1/credentials/{identifier}/reveal after credential-specific reauthentication

Host identifiers accept IDs or aliases. Host-list query searches alias, hostname, username, tags and notes; tag is a case-insensitive exact tag filter. Lists preserve the core's ordering and return arrays. Creates return 201. Host and credential updates are PATCH only — partial updates, with no PUT full-replacement form. Omit fields to retain them, send credential_id: null to detach a credential, and send an empty tags/jumps array to clear that list. geo accepts a manual {country_code, city?} override or null to clear it. Other host fields reject explicit null. Contexts are addressed by name in the path: percent-encode it as one segment (Team A/B → Team%20A%2FB); PATCH accepts new_name and/or server_ids, and DELETE takes no body. Bulk context server_ids are database IDs. Credential creation is discriminated by kind and rejects fields belonging to another credential kind.

Host responses include nullable, saved facts, host_key, geo, last_resolved_ip, and last_success_at, plus local address_scope, proxy_jump_details and jump_for_count. Only explicit tests resolve DNS and inspect local known-host keys; successful diagnosis stores sanitized system facts. verified requires a negotiated SHA256 key matching a saved ordinary host key; missing evidence remains unknown. No key is accepted by HTTP. Failure retains prior system facts and last-success history. Hostname/port changes clear derived observations, and late old-endpoint results are ignored. Country/city is currently manual only; no online lookup occurs.

Route expansion follows OpenSSH: only the first explicit jump inherits its own route, because later jumps receive a command-line -J override. External aliases remain marked boundaries; their external configuration is not inferred. Batch checks resolve every selection before remote work, deduplicate aliases and IDs, accept 1–64 hosts and 1–4 workers, and share four slots across batches in the service instance. Results preserve selection order; per-host failures and deletion while queued do not abort other results.

Every write/action requires X-CSRF-Token. Downloads remain POST with a JSON path, returning binary content and a filename header; uploads use multipart file, path, and optional overwrite (default false). Both share the legacy 100 MiB limit and private temporary staging cleanup. Download size is verified in the SFTP namespace before transfer and checked again afterward. Nonregular files and symlinks whose target size cannot be verified are refused by web download; CLI get remains available without the web limit.

Default upload returns 409 with error_code: exists and sanitized existing metadata if the target exists. A new upload goes to a random temporary file in the same parent, then an SFTP hardlink atomically publishes it without replacing a concurrently created target. Failure attempts to remove only the temporary file. Servers without hardlink support return unsupported (501), with no direct-put fallback. Explicit overwrite=true permits direct put. CLI put keeps its existing overwrite behavior. Web file paths reject dot/parent segments and trailing directory paths; local staging filenames are fixed. Versioned transfer results omit the internal staging path. File browsing is read-only but does initiate an explicit SFTP operation.

Directory responses keep SFTP display fields and numeric uid/gid. A bounded optional Python3 probe enriches visible entries with owner/group names, precise UTC modified_at and bounded link_target, only when SFTP type/size/IDs match. Missing shell/Python leaves enrichment nullable and numeric ownership intact; the display date is never used to guess UTC time. Metadata stdout/stderr are limited while reading to 8 MiB/64 KiB. Filtering and directory-first/name sorting precede the 5,000-entry response cap; total counts parsed entries, matching_total counts matches, and truncated describes the filtered result.

All API responses are no-store. Output DTOs whitelist fields, and ordinary credential/workspace responses contain no secret value. Reveal grants remain credential-specific, expire after 30 seconds and are consumed once across both API versions. IDs and labels resolve to the same grant identity. Session expiry, Host/Origin checks, CSP and OpenSSH host-key verification remain enforced. Validation errors never echo supplied input, including passwords.

Credential verification and deletion#

Schema 7 preserves previous host observations and adds credential revisions, nullable cached key metadata, independent credential-test history, and a durable credential-deletion journal. Inventory reads use saved metadata only; they do not parse key files, query the agent, or open the vault. At most 50 test results are retained per credential. last_test applies to its current revision; replacing a secret or changing metadata invalidates that current result without erasing prior test history.

POST /credentials/{id}/test takes {host_id, timeout?} and tests the selected credential against any saved host, temporarily overriding the target binding. It does not change that binding or host health records, and executes no remote command. NDJSON emits sanitized stage events followed by a result event. Password tests permit only password authentication; private-key tests permit only the selected public key; agent tests use agent identities without private key/default-identity fallback. Normal host connections continue to follow the managed OpenSSH configuration. A server that accepts none authentication returns credential-not-used, not a verified credential. Disconnecting the stream cancels the probe. Errors and progress contain no raw SSH/vault output.

Key inspection accepts {key_path} and returns key_metadata plus an opaque key_revision. Metadata contains format, algorithm, SHA256 public fingerprint, bits, encryption status and a platform-specific permissions assessment. An encrypted key inspected without its passphrase has nullable algorithm, fingerprint and bits, with status: passphrase_required. Unsupported formats or algorithms return explicit errors; unavailable permission evidence is unknown, never an assumed safe mode. Validation accepts passphrase, or credential_id with use_stored_passphrase: true, and loads OpenSSH/PEM key bytes in process memory using cryptography. It does not put the passphrase in argv, environment variables, logs, temporary files or an AskPass script.

Pass the returned expected_key_revision when saving a changed key. The token is scoped to the browser session, canonical path and file snapshot, expires after two minutes, and cannot be reused for a different file or session. Save revalidates the file snapshot before database commit; a changed file aborts the mutation and restores any prior vault value. expected_updated_at provides credential edit concurrency checks. Omitted, null or empty-string secret fields preserve existing values; whitespace is preserved literally. Only clear_passphrase: true clears a saved passphrase, and replacement plus clear in the same request is rejected. Private key contents are never returned.

The Web key picker is a local path browser, not a native desktop dialog. Create a session scope with {root_path?} (default ~/.ssh), then list one directory with {scope_id, path?, offset?}. Scopes expire after five minutes. Listings return path/name/type only, skip symlinks and junctions, and reject paths outside the authorized canonical root. Identity checks prevent directory replacement from widening the scope. Session expiration removes all scopes and key revisions. Agent inspection calls only local ssh-add -l -E sha256, with a bounded timeout and identity count; no private key is loaded or returned.

Per-credential OS locks serialize vault changes, reveal, tests, host binding changes and deletion across threads and processes. Ordinary DELETE still rejects any referenced credential. Explicit detach/delete first reads a deletion preview, then posts {operation_id, expected_updated_at, expected_host_ids}. In one database transaction it checks the snapshot, detaches the complete saved reference set, removes the credential and records cleanup work. A stale preview returns conflict before committing. Deletion revokes that credential's reveal grants in every live browser session.

After commit, password/passphrase vault slots and managed-config cleanup are retried from the journal. Success returns 200 with status: complete; a cleanup failure returns 202 with committed: true, cleanup_pending: true and remaining slots. It does not undo the detached host state or report that secrets were removed. Pending-operation list/retry survive runtime restarts; reuse of the same operation ID is idempotent for the same deletion. Default DELETE retains the existing credential response fields plus operation/cleanup metadata. Neither path deletes the user's private-key file.

Expected v1 errors use {"ok": false, "error": "Type", "message": "..."}. Status codes are 400 for core validation/authentication errors, 401 for a missing/expired session, 403 for Origin/CSRF failures, 404 for missing resources, 409 for conflicts, 422 for typed request validation, and 502 for SSH failures. V1 file failures also provide error_code: permission denied maps to 403, missing file/directory to 404, existing target to 409, oversize to 413, and unsupported safe publication to 501; other transport/file errors use 502. Legacy transfers keep 400 for oversize, 409 for upload conflicts and 502 for file-operation failures. WebAuthn ceremony documents retain their algorithm-specific JSON shape.

Offline contract and verification#

Production /docs, /redoc and /openapi.json remain disabled. Generate the versioned schema without reading real user state or acquiring the vault:

.venv/bin/python scripts/export-openapi.py --output contracts/api-v1.openapi.json
.venv/bin/python scripts/export-openapi.py --check contracts/api-v1.openapi.json
.venv/bin/python -m pytest tests/

The artifact exposes only v1 routes and their referenced DTOs. Its security requirements specify cookie and CSRF together for mutations. CI checks drift in the existing Python OS matrix, installing with -c contracts/constraints.txt so the FastAPI/pydantic versions that shape the JSON are pinned. To upgrade them, raise the pins, regenerate the artifact and review its diff. The frontend's generated TypeScript DTOs come from this artifact; npm run check-types --prefix frontend checks drift without opening production schema routes. npm run build --prefix frontend writes the bundled assets, and check-build verifies reproducibility, manifest integrity and compressed-size budgets.

Regression coverage verifies CLI/legacy/v1 inventory parity, config side effects, offline workspace reads, authentication/CSRF on every v1 operation, secret-free validation/output, shared reveal grants, transfer limits/cleanup and real loopback background-server startup/shutdown. It uses disposable data, runtime, SSH config paths and a memory vault. Additional disposable loopback SSH/SFTP fixtures cover direct, multi-hop and external-hop connections, auth failures, large directories and transfer conflicts; they never use saved real hosts or the developer's credential vault.

Edit this page on GitHub