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
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/014-register-ssh-host"
"feature_directory": "specs/015-registry-v2"
}
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# remo Development Guidelines

Auto-generated from all feature plans. Last updated: 2026-07-13
Auto-generated from all feature plans. Last updated: 2026-07-25

## Constitution

Expand All @@ -10,10 +10,11 @@ See `.specify/memory/constitution.md` for project principles and non-negotiable
- Ansible 2.14+ / YAML + `ansible.builtin`, `community.general` (existing), Incus CLI (local) (002-incus-container-support)
- N/A (Incus storage pools already configured by 001-bootstrap-incus-host) (002-incus-container-support)
- Python 3.11+ + Click (CLI framework), InquirerPy (interactive picker), boto3 (AWS, optional), hcloud (Hetzner, optional) (003-python-cli-rewrite)
- Flat file (`~/.config/remo/known_hosts`, colon-delimited) (003-python-cli-rewrite)
- Versioned JSON registry (`~/.config/remo/registry.json`, format v2 — named fields per type, no positional overloading; single accessor `core/registry.py` owns parse/serialize/validate/lock/migrate for CLI, providers, and the web service). Legacy `~/.config/remo/known_hosts` (colon-delimited) is read-only migration input, lazily migrated to v2 on first CLI read and renamed to `known_hosts.v1.bak`. (003-python-cli-rewrite; superseded by 015-registry-v2)
- Cross-provider snapshot model (`models/snapshot.py`) + shared helpers in `core/snapshot.py` (name generator, validator, table formatter, destroy-time cleanup hook). No new runtime deps. (005-provider-snapshots)
- FastAPI/Uvicorn + WebSockets (backend, optional `web` extra), TypeScript/Vite/React + ghostty-web (frontend), Bash (`remo-host` host command templated by Ansible) (010-web-session-interface)
- Stdlib `urllib.request` CLI setup client + token-gated `/api/v1/setup/*` FastAPI surface; service state in flat files under the writable `REMO_HOME` volume (`web-identity/` keypair + service known_hosts, `~/.config/remo/web-service.json` saved credentials) (011-web-adopt)
- Stdlib `urllib.request` CLI setup client + token-gated `/api/v1/setup/*` FastAPI surface; service state in flat files under the writable `REMO_HOME` volume (`web-identity/` keypair + service known_hosts, `~/.config/remo/web-service.json` saved credentials, `cache_version: 2`) (011-web-adopt; payload versioning updated by 015-registry-v2)
- `core/registry.py`: stdlib `json` (format), `fcntl` (advisory locking via a `registry.lock` sidecar), `os.replace` (atomic writes). No new runtime deps. Setup API mirror payload moved to v2 (`contracts/mirror-payload-v2.md`) with a `payload_versions` capability handshake; an upgraded service still accepts v1 payloads. (015-registry-v2)

- Ansible 2.14+ / YAML + `ansible.builtin`, `community.general` (for zypper module) (001-bootstrap-incus-host)

Expand Down Expand Up @@ -43,7 +44,8 @@ src/remo_cli/ # Python CLI package (src layout, hatchling build)
│ ├── config.py # REMO_HOME, paths, read-only registry accessor
│ ├── output.py # Colored output, confirm()
│ ├── validation.py # Name, port, region, tool validation
│ ├── known_hosts.py # Flat-file host registry
│ ├── registry.py # Registry v2 accessor: parse/serialize/validate/lock/migrate (registry.json + legacy known_hosts)
│ ├── known_hosts.py # Thin delegates onto registry.py (public API unchanged: get/save/remove/clear_known_hosts*)
│ ├── ssh.py # build_ssh_base_cmd(), SSH options, terminal reset, timezone
│ ├── remo_host_client.py # Versioned remo-host protocol client (shared by CLI + web)
│ ├── web_adopt.py # Workstation-side adoption/push engine (stdlib HTTP, keyscan trust verify, authorized_keys mgmt, --via tunnel)
Expand Down Expand Up @@ -188,6 +190,7 @@ Provider SDKs (boto3, hcloud) are lazy-imported with clear error messages if mis
- Ansible 2.14+ / YAML: Follow standard conventions plus Constitution principles

## Recent Changes
- 015-registry-v2: Replaced the colon-delimited `known_hosts` registry with a versioned JSON `registry.json` (format v2, named per-type fields, no positional overloading) via a single new accessor `core/registry.py` (parse/serialize/validate/advisory-lock/migrate) shared by the CLI, providers, and web service; lazy, lossless, idempotent CLI migration (`known_hosts` → `known_hosts.v1.bak`); `core/known_hosts.py` slimmed to thin delegates; the setup API's adoption mirror moved to payload v2 with a `payload_versions` capability handshake (an upgraded service still accepts v1 payloads); push delta-cache bumped to `cache_version: 2`.
- 011-web-adopt: Added CLI-to-web adoption — unconfigured boot with service-scoped ed25519 identity, token-gated `/api/v1/setup/*` (REMO_WEB_API_TOKEN, fail-closed), `remo web adopt`/`remo web push` (registry mirror + workstation-verified host keys + idempotent `remo-web@<id>` authorized_keys entries), AwaitingAdoption SPA page.
- 010-web-session-interface: Added remo-web Docker service (FastAPI + React/ghostty-web) brokering browser terminal sessions across all Remo-managed instances via a new remo-host SSH command; web extra + remo web {serve,check} CLI group.
- 005-provider-snapshots: Added cross-provider snapshot CLI (`remo <P> snapshot {create,list,restore,delete}`) + destroy-time cleanup hook across Incus / Proxmox / AWS / Hetzner.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ remo completion zsh >> ~/.zshrc
remo completion fish > ~/.config/fish/completions/remo.fish
```

After re-loading your shell, `remo proxmox info --name <TAB>` will suggest registered container names from your `known_hosts` registry.
After re-loading your shell, `remo proxmox info --name <TAB>` will suggest registered container names from your `registry.json` registry.

---

Expand Down Expand Up @@ -454,7 +454,7 @@ rm -rf ~/.config/remo

| Path | Contents |
|------|----------|
| `~/.config/remo/` | Runtime state: `known_hosts` (environment registry) |
| `~/.config/remo/` | Runtime state: `registry.json` (environment registry, format v2); `known_hosts.v1.bak` (pre-upgrade backup, if you upgraded from an older remo) |

**Note:** Uninstalling remo does not destroy any cloud resources (EC2 instances, Hetzner VMs, Incus or Proxmox containers). Run `remo <platform> destroy` first if you want to tear those down.

Expand Down
8 changes: 5 additions & 3 deletions docs/web-session-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ second launcher; it reuses the same host-side scripts (`project-launch`) that th
## Architecture

```text
~/.config/remo/known_hosts (read-only mount)
~/.config/remo/registry.json (read-only mount; legacy known_hosts also read in place)
remo-web (FastAPI + Uvicorn)
Expand Down Expand Up @@ -268,7 +268,9 @@ bind-mount deployment to this release changes nothing (FR-005/SC-005).
Everything the adopted service owns lives in one place: `<REMO_HOME>/web-identity/` — `id_ed25519` +
`id_ed25519.pub` (the service keypair, generated once via `ssh-keygen` and **never** silently
regenerated while the files exist), `known_hosts` (service-managed instance host keys), and
`state.json` (the deployment id). The registry stays at its usual path (`~/.config/remo/known_hosts`).
`state.json` (the deployment id). The registry stays at its usual path (`~/.config/remo/registry.json`,
format v2 — a legacy `known_hosts` file is still read in place if present, e.g. right after an
in-place service upgrade, before the next adoption push replaces it).
From these artifacts the service derives one of four states:

| State | Derivation | `remo web check` | `GET /api/v1/ready` | Browser |
Expand Down Expand Up @@ -344,7 +346,7 @@ only its named state volume.

| Mount | Purpose | Why read-only |
|---|---|---|
| `${HOME}/.config/remo:/home/remo/.config/remo:ro` | The Remo **registry** (`known_hosts`) — provider type, instance name, address, SSH user, access mode, region. | This is metadata, **not authentication material** (see below). The service never needs to write it; FR-004 requires hot-reload without a container restart, not mutation. |
| `${HOME}/.config/remo:/home/remo/.config/remo:ro` | The Remo **registry** (`registry.json`, format v2 — or legacy `known_hosts`, read in place) — provider type, instance name, address, SSH user, access, and per-type fields (e.g. AWS instance id/region). | This is metadata, **not authentication material** (see below). The service never needs to write it; FR-004 requires hot-reload without a container restart, not mutation. |
| `${HOME}/.ssh/id_ed25519:/home/remo/.ssh/id_ed25519:ro` (+ `config`, `known_hosts`) | The **SSH identity** that actually authenticates to every instance. | The service only ever needs to *use* this key, never modify it; read-only limits blast radius if the container is compromised. |
| `${HOME}/.aws:/home/remo/.aws:ro` (optional, commented out by default) | AWS credentials/profile for any registered instance using the SSM access mode. | Same reasoning — read-only, and only mounted at all if you actually have SSM-routed instances. |

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ web = [
]

[project.scripts]
remo-cli = "remo_cli.cli.main:cli"
remo = "remo_cli.cli.main:cli"
remo-cli = "remo_cli.cli.main:main"
remo = "remo_cli.cli.main:main"

[tool.hatch.build.targets.wheel]
packages = ["src/remo_cli"]
Expand Down
36 changes: 36 additions & 0 deletions specs/015-registry-v2/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Specification Quality Checklist: Versioned Structured Host Registry (Registry v2)

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-07-25
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

- The spec deliberately names two existing internal artifacts — the legacy file's location/format and the in-memory host model (KnownHost) — because they are the scope boundary the user's feature description fixed ("only serialization and parsing move"). These are constraints on WHAT changes, not prescriptions of HOW; no new technology, library, or code structure is prescribed.
- All ambiguities were resolved with documented defaults in the Assumptions section (migration timing, backup handling, downgrade posture, supported upgrade-skew direction, file naming deferred to design). No open clarifications remain.
- Validation performed 2026-07-25: all items pass on first iteration.
77 changes: 77 additions & 0 deletions specs/015-registry-v2/contracts/mirror-payload-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Contract: Adoption Mirror Payload v2 & Version Negotiation

Governs the workstation→service registry mirror (push) after Registry v2. Existing endpoint semantics (pairing-code gating, dormant-404, validate-all-before-write, atomic ordered apply) are unchanged; this contract changes the payload schema and adds version negotiation.

## 1. `GET /api/v1/setup/status` — capability advertisement

Response gains one field:

```json
{
"state": "unconfigured | adopted | mount_configured | broken",
"deployment_id": "…",
"public_key_available": true,
"registry_instances": 5,
"payload_versions": [1, 2]
}
```

- `payload_versions`: payload versions this service accepts on `PUT /setup/registry`.
- **Workstation rule (FR-021)**: read `payload_versions` before pushing; absence of the field means `[1]` (pre-v2 service). If the workstation's payload version (2) is not listed, ABORT before any instance processing or PUT, with: *"this remo-web deployment only accepts registry payload v1 — upgrade the remo-web container image, then re-run the push."* No mutation of any kind (instances are not keyscanned/authorized either — fail truly fast).

## 2. `PUT /api/v1/setup/registry` — payload schemas

### v2 payload (new canonical; what an upgraded CLI sends)

```json
{
"version": 2,
"registry": [ { …hostEntry per registry-file-v2.md… } ],
"host_keys": { "<name>": ["<known_hosts line>", …] }
}
```

- `registry` entries use the exact `hostEntry` schema from [registry-file-v2.md](registry-file-v2.md) — no overloaded fields on the wire (FR-020).
- `host_keys` semantics unchanged: keys must reference registry entries; entries with `access: "ssm"` MUST NOT have host keys (both ends enforce, as today).
- Validation is all-or-nothing before any write, as today. The colon/newline field checks that existed to protect the legacy line format are replaced by the v2 validation rules (V2–V6); the newline/control-character ban stays.

### v1 payload (accepted for backward compatibility, FR-022)

```json
{ "version": 1, "registry": [{ "type": "...", "name": "...", "host": "...", "user": "...", "instance_id": "", "access_mode": "", "region": "" }], "host_keys": { … } }
```

- An upgraded service MUST continue accepting v1 payloads from a not-yet-upgraded workstation, mapping entries through the same legacy→v2 mapper used by file migration (data-model §4), then storing v2.
- SSM classification of v1 entries follows the legacy implicit rule, exactly as the old service computed it.

### Unknown versions

- `version` not in `payload_versions` → `400` with `{"error": {"code": "unsupported_payload_version", "supported": [1, 2], …}}`. No partial application; the service's existing mirror stays intact and served (FR-021).

## 3. Apply sequence (service side)

Ordered, each step atomic (`os.replace`), crash-convergent (a crash mid-sequence leaves a readable superset; re-push converges):

1. Write service trust file `web-identity/known_hosts` (unchanged from today).
2. Write `registry.json` (v2) via `core.registry.replace_registry`.
3. Remove any legacy `known_hosts` mirror file left from a pre-upgrade push (service-owned replaceable state — not user data; removal keeps the service out of the both-files-present state permanently).

The service stores v2 regardless of received payload version. Empty registry still requires `?allow_empty=true`.

## 4. Compatibility matrix (FR-021/FR-022, SC-006)

| Workstation | Service | Outcome |
|-------------|---------|---------|
| v2 (new) | new (`payload_versions: [1,2]`) | v2 push, normal flow |
| v2 (new) | old (no `payload_versions`) | abort before any mutation, remediation names the service as the side to upgrade |
| v1 (old) | new | v1 payload accepted, mapped, stored as v2; old CLI needs no changes |
| v1 (old) | old | unchanged legacy behavior (out of this feature's scope) |
| any | any, mount_configured | 409 `mount_configured`, unchanged from today |

## 5. Push delta cache (workstation side)

`~/.config/remo/web-service.json`:

- Adds `"cache_version": 2`; loaders treat any other/missing value as an empty cache (one-time full re-verification push after upgrade — FR-026, clarification #4).
- `fingerprint` = SHA-256 over the canonical sorted-key JSON of the entry's v2 `hostEntry` object (replaces the legacy 7-field digest).
- Unchanged: keyed by `deployment_id`, non-secret, 0600, atomic writes, cached `host_keys` re-sent on every push because the PUT is wholesale.
Loading
Loading