diff --git a/catalogue/README.md b/catalogue/README.md index d9e22c4f..75481398 100644 --- a/catalogue/README.md +++ b/catalogue/README.md @@ -47,7 +47,10 @@ ignores the v2 fields. **Always set `"version": 2` when using any v2 field.** "license": "", "metadata_url": "https:///apps//metadata.json", - "metadata_sha256": "" + "metadata_sha256": "", + + "renamed_to": ", optional", + "hidden": false } ] } @@ -58,6 +61,26 @@ fields stay required. `pilotctl` decodes the index directly into `catalogueEntry` in `cmd/pilotctl/appstore_catalogue.go` — any field added here must also land there. +### Renaming an app (`renamed_to` + `hidden`) + +Both are optional v2 fields (**keep `"version": 2`**). To rename an app id +`old → new` without breaking existing installs, do NOT delete the old entry — +the daemon supervisor pins each installed app's publisher key from its +catalogue entry and **fail-closes (stops) an installed app whose id has no +pin**. Instead, replace the old entry's body with a **tombstone**: keep `id` +and `publisher` (so existing installs keep their pin and keep running), set +`"renamed_to": ""`, and set `"hidden": true`. Drop `bundle_url` / +`bundles` / `metadata_url` (the tombstone is not installable) and delete the old +`apps//` detail dir. Then add the full new entry under the new id and +re-sign. + +A bundles-aware `pilotctl` then, for the old id: omits it from `catalogue`, +and on `install`/`view`/`call` prints a deprecation warning and routes to +`renamed_to`. `hidden` alone (without `renamed_to`) just omits an entry from the +listing while keeping it resolvable. Older clients ignore both fields (they see +a normal, pin-only entry). One hop only — a `renamed_to` that points at another +tombstone is a bug and is not chased. + `bundles` is the per-platform map keyed by `"os/arch"`. It is an **optional v2 field — keep `"version": 2`, do NOT bump to 3.** `loadCatalogue` fail-closes on any version other than 1 or 2, so a version-3 catalogue is rejected wholesale by diff --git a/catalogue/apps/io.pilot.smolmachines/metadata.json b/catalogue/apps/io.pilot.smolmachines/metadata.json deleted file mode 100644 index 522394e1..00000000 --- a/catalogue/apps/io.pilot.smolmachines/metadata.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "schema_version": 1, - "id": "io.pilot.smolmachines", - "display_name": "Smol Machines", - "tagline": "Fast, hardware-isolated microVMs on demand", - "description_md": "Smol Machines — the app-store front door for the smolmachines VM engine. It lets an agent spin up fast, hardware-isolated Linux microVMs on demand (sub-second boot, real hypervisor isolation — not shared-kernel containers), then run workloads in a disposable sandbox. Free to use. Portable .smolmachine artifacts run identically on macOS and Linux, locally or in the cloud.\n\nUse it to:\n- Run untrusted or AI-generated code safely, with networking off by default\n- Give an agent a real Linux shell — a stateful, isolated execution backend\n- Automate headless browsers (GPU-accelerated) for scraping, screenshots, and web tasks\n- Run GPU/compute jobs via Vulkan with container-like speed\n- Spin up disposable dev sandboxes — a clean VM per task, torn down after\n- Keep persistent dev VMs — installed packages survive restarts\n- Run CI-style jobs — build, test, lint in clean environments\n- Fan out parallel ephemeral workers thanks to sub-second boot\n- Analyze malware / suspicious files in a throwaway environment\n- Build once, run anywhere — same artifact local, cloud, or self-hosted\n\nDiscover the live method surface at runtime with smolmachines.help, which lists each method's parameters and latency class.", - "vendor": { - "name": "smol machines", - "url": "https://smolmachines.com", - "publisher_pubkey": "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI=" - }, - "homepage": "https://smolmachines.com", - "source_url": "https://github.com/smol-machines/smolvm", - "license": "Apache-2.0", - "categories": [ - "dev", - "virtualization", - "security" - ], - "keywords": [ - "microvm", - "sandbox", - "vm", - "isolation", - "gpu", - "ci" - ], - "size": { - "bundle_bytes": 5346146, - "installed_bytes": 9601119 - }, - "compat": { - "min_pilot_version": "1.0.0", - "runtimes": [ - "go" - ] - }, - "methods": [ - { - "name": "smolmachines.exec", - "summary": "Run any smolvm subcommand in a fast, hardware-isolated Linux microVM. Payload is {\"args\":[...]} — the verbatim smolvm argv. Command surface: `machine run` (ephemeral VM, one-off command), `machine create|start|exec|stop|delete|shell|status|ls|cp|update|monitor|prune` (persistent VMs; `exec` persists filesystem changes), `pack create|run` (portable .smolmachine artifacts), `serve` (HTTP API), `config`. Key flags: `--net` (networking is OFF by default), `--image `, `-v HOST:GUEST`, `-p HOST:GUEST`, `--gpu`, `--ssh-agent`, `--secret-env GUEST=HOST`. Example args: [\"machine\",\"run\",\"--net\",\"--image\",\"alpine\",\"--\",\"sh\",\"-c\",\"echo hi\"]. Not supported over IPC: interactive sessions (-it / `machine shell`) and long-running `serve`." - }, - { - "name": "smolmachines.help", - "summary": "Discovery: every method with params, kind, and latency class." - } - ], - "changelog": [ - { - "version": "1.2.0", - "notes": [ - "Released v1.2.0" - ] - } - ], - "links": [ - { - "label": "Source", - "url": "https://github.com/smol-machines/smolvm" - }, - { - "label": "Website", - "url": "https://smolmachines.com" - } - ] -} diff --git a/catalogue/catalogue.json b/catalogue/catalogue.json index af06bedb..3d366499 100644 --- a/catalogue/catalogue.json +++ b/catalogue/catalogue.json @@ -82,34 +82,10 @@ }, { "id": "io.pilot.smolmachines", - "version": "1.2.0", - "description": "Smol Machines — spin up fast, hardware-isolated Linux microVMs on demand (sub-second boot, real hypervisor isolation) to safely run untrusted code, GPU tasks, or headless browser automation in a disposable sandbox.", - "display_name": "Smol Machines", - "vendor": "smol machines", - "license": "Apache-2.0", - "source_url": "https://github.com/smol-machines/smolvm", - "bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-linux-amd64.tar.gz", - "bundle_sha256": "44141c68524081f4ef6b439f4bb18e949b91900db1d93bf88c6178aaaac917e6", - "bundles": { - "darwin/arm64": { - "bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-darwin-arm64.tar.gz", - "bundle_sha256": "b6e6b1604b96939966e4fbe9daaa7f1a994e073e168a1be69c6be55498d3d62c" - }, - "darwin/amd64": { - "bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-darwin-amd64.tar.gz", - "bundle_sha256": "06835bbdfabe684bc302c75dbafcc27e820c462bde0bedfc7b78d6e2593443e8" - }, - "linux/arm64": { - "bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-linux-arm64.tar.gz", - "bundle_sha256": "1df2c5d0ff9b5ad1db4773278da4f52e60cd77fb9169955c5e81f316da610d3c" - }, - "linux/amd64": { - "bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-linux-amd64.tar.gz", - "bundle_sha256": "44141c68524081f4ef6b439f4bb18e949b91900db1d93bf88c6178aaaac917e6" - } - }, - "metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.smolmachines/metadata.json", - "metadata_sha256": "f43493f690786b8adbe1ac1072bbec0b0d04e05d9a247e7b14e5d36c4e397a3b", + "renamed_to": "io.pilot.smol", + "hidden": true, + "display_name": "Smol Machines (renamed → io.pilot.smol)", + "description": "Renamed to io.pilot.smol. Install io.pilot.smol — it is the same app plus a cloud plane. This tombstone entry is retained only to keep already-installed copies running and to redirect the old id; it is not listed and is not installable.", "publisher": "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI=" }, { diff --git a/catalogue/catalogue.json.sig b/catalogue/catalogue.json.sig index be456b58..5257fd58 100644 --- a/catalogue/catalogue.json.sig +++ b/catalogue/catalogue.json.sig @@ -1 +1 @@ -zjuh59YUSuwetWmEVs3K/0A3v8gsQCRN2NjDW98UoSU8UCj2AGHPWOZg30ncUayLLDove9N7URsQmMqPSF3BDg== +LVW3kLb7ZwK0qfjEY8HWp2oT6uDFkGW5eS8BqbHpgt3x1SeOZD4cXLrz5giWCGHuqhKBV0sfhQ3NdsLF22zYDQ== diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 296d4edc..b467fc37 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -2152,6 +2152,20 @@ func cmdAppStoreCall(args []string) { sockPath := filepath.Join(appStoreRoot(), appID, "app.sock") if _, err := os.Stat(sockPath); err != nil { + // No local socket for this id. It may be a rename tombstone — an id that + // was renamed and never installed here. Consult the catalogue (best-effort, + // offline-tolerant) and, if so, warn with a switch hint before failing. + // We do NOT silently retarget to the new app's socket: the method + // namespace changes with the rename (e.g. smolmachines.* → smol.*), so a + // verbatim old method would only 404 there. An existing old-id install + // keeps its own socket at this path, so this branch never fires for it. + if c, lerr := loadCatalogue(); lerr == nil { + if canonicalID, _, renamed := resolveRenamed(c, appID); renamed { + fatalHint("invalid_argument", + fmt.Sprintf("install %q (`pilotctl appstore install %s`) and call its methods — see `pilotctl appstore view %s`", canonicalID, canonicalID, canonicalID), + "app %q was renamed to %q", appID, canonicalID) + } + } fatalHint("io_error", "is the daemon running and has it supervised this app yet?", "socket %s not present: %v", sockPath, err) diff --git a/cmd/pilotctl/appstore_catalogue.go b/cmd/pilotctl/appstore_catalogue.go index 174a5f87..c612f32a 100644 --- a/cmd/pilotctl/appstore_catalogue.go +++ b/cmd/pilotctl/appstore_catalogue.go @@ -118,6 +118,22 @@ type catalogueEntry struct { // manifest. MetadataURL string `json:"metadata_url,omitempty"` MetadataSHA string `json:"metadata_sha256,omitempty"` + + // --- rename / deprecation redirect --- + // RenamedTo, when non-empty, marks this entry as a tombstone: the app was + // renamed to the given catalogue id. The entry is kept (not deleted) only to + // preserve the publisher pin for already-installed copies — the daemon + // supervisor fail-closes an installed app whose id has no catalogue pin — and + // to redirect id resolution. `install`/`view`/`call` of this id warn and route + // to RenamedTo; it is omitted from the `catalogue` listing (see Hidden). + // Older clients that don't know this field simply ignore it (omitempty keeps + // v1/v2 wire-compat). At most one hop is followed — a chain is a catalogue bug. + RenamedTo string `json:"renamed_to,omitempty"` + + // Hidden, when true, omits this entry from the `catalogue` listing. It is + // still resolvable by id (for redirect and back-compat). A RenamedTo tombstone + // is treated as hidden regardless of this flag. + Hidden bool `json:"hidden,omitempty"` } // bundleVariant is one platform's downloadable tarball + its pinned sha256. @@ -307,15 +323,25 @@ func cmdAppStoreCatalogue(_ []string) { "check $PILOT_APPSTORE_CATALOG_URL (currently: "+catalogueURL()+")", "%v", err) } + // Tombstones (renamed_to) and explicitly hidden entries are not offered in + // the listing — they exist only to preserve publisher pins and to redirect + // old ids. They remain resolvable by `view`/`install`/`call`. + visible := make([]catalogueEntry, 0, len(c.Apps)) + for _, e := range c.Apps { + if e.Hidden || e.RenamedTo != "" { + continue + } + visible = append(visible, e) + } if jsonOutput { - _ = json.NewEncoder(os.Stdout).Encode(c.Apps) + _ = json.NewEncoder(os.Stdout).Encode(visible) return } - if len(c.Apps) == 0 { + if len(visible) == 0 { fmt.Println("catalogue is empty") return } - for _, e := range c.Apps { + for _, e := range visible { fmt.Printf("%-40s %s\n", e.ID, e.Description) } fmt.Println("\nRun 'pilotctl appstore view ' for full details.") @@ -338,12 +364,43 @@ const ( installSourceLocal ) +// findEntry returns a pointer to the catalogue entry with the given id, or nil. +// The pointer aliases c.Apps' backing array, so it stays valid for reads as long +// as c does. +func (c *catalogue) findEntry(id string) *catalogueEntry { + for i := range c.Apps { + if c.Apps[i].ID == id { + return &c.Apps[i] + } + } + return nil +} + +// resolveRenamed follows a rename tombstone. Given a requested id, it returns the +// canonical id and its entry (or nil if the canonical id is absent), plus whether +// a rename was followed. When the id is not a tombstone it returns (id, its own +// entry-or-nil, false). On a followed rename it prints a one-time deprecation +// warning to stderr. Only one hop is followed — a tombstone that points at another +// tombstone is a catalogue bug and is not chased, avoiding any loop. +func resolveRenamed(c *catalogue, id string) (canonicalID string, entry *catalogueEntry, renamed bool) { + e := c.findEntry(id) + if e == nil || e.RenamedTo == "" { + return id, e, false + } + canonical := e.RenamedTo + fmt.Fprintf(os.Stderr, + "warn: app %q has been renamed to %q — use %q instead. The old id is deprecated: it no longer receives updates and will be removed.\n", + id, canonical, canonical) + return canonical, c.findEntry(canonical), true +} + // resolveInstallTarget turns the user's `target` arg into a local // bundle directory the existing install code can consume, plus a // source tag indicating which trust regime the bundle came from. If // `target` matches a catalogue ID, the catalogue entry is fetched, -// verified, and unpacked. Otherwise `target` is treated as a local -// path and the caller is expected to apply sideload policy. +// verified, and unpacked. A `target` that matches a rename tombstone +// warns and installs the canonical app instead. Otherwise `target` is +// treated as a local path and the caller applies sideload policy. func resolveInstallTarget(target string) (string, installSource, error) { c, err := loadCatalogue() if err != nil { @@ -353,11 +410,15 @@ func resolveInstallTarget(target string) (string, installSource, error) { // the user knows their URL or env override might be the issue. fmt.Fprintf(os.Stderr, "warn: catalogue lookup failed (%v); proceeding with local-path interpretation\n", err) } else { - for _, e := range c.Apps { - if target == e.ID { - dir, err := fetchAndUnpackBundle(e) - return dir, installSourceCatalogue, err - } + // resolveRenamed also handles the plain (non-renamed) match: it + // returns the exact entry when `target` isn't a tombstone. + canonicalID, entry, renamed := resolveRenamed(c, target) + if renamed && entry == nil { + return "", installSourceLocal, fmt.Errorf("app %q was renamed to %q, but %q is missing from the catalogue", target, canonicalID, canonicalID) + } + if entry != nil { + dir, err := fetchAndUnpackBundle(*entry) + return dir, installSourceCatalogue, err } } info, err := os.Stat(target) diff --git a/cmd/pilotctl/appstore_rename_test.go b/cmd/pilotctl/appstore_rename_test.go new file mode 100644 index 00000000..db83ffa8 --- /dev/null +++ b/cmd/pilotctl/appstore_rename_test.go @@ -0,0 +1,68 @@ +package main + +import "testing" + +// A rename tombstone (renamed_to set) resolves to the canonical entry, and a +// plain id resolves to itself. This is the shared resolver behind the +// install/view/call redirect. +func TestResolveRenamed(t *testing.T) { + c := &catalogue{Apps: []catalogueEntry{ + {ID: "io.pilot.smolmachines", RenamedTo: "io.pilot.smol", Hidden: true, Publisher: "ed25519:old"}, + {ID: "io.pilot.smol", Version: "1.2.0", Publisher: "ed25519:new"}, + {ID: "io.pilot.other", Version: "1.0.0"}, + }} + + // Tombstone → canonical entry. + canonical, entry, renamed := resolveRenamed(c, "io.pilot.smolmachines") + if !renamed { + t.Fatalf("expected renamed=true for the tombstone id") + } + if canonical != "io.pilot.smol" { + t.Fatalf("canonical = %q, want io.pilot.smol", canonical) + } + if entry == nil || entry.ID != "io.pilot.smol" { + t.Fatalf("entry = %+v, want the io.pilot.smol entry", entry) + } + + // Plain id → itself, not flagged renamed. + id, e2, r2 := resolveRenamed(c, "io.pilot.smol") + if r2 || id != "io.pilot.smol" || e2 == nil || e2.ID != "io.pilot.smol" { + t.Fatalf("plain resolve got (%q, %+v, %v), want (io.pilot.smol, io.pilot.smol-entry, false)", id, e2, r2) + } + + // Unknown id → itself, nil entry, not renamed. + id3, e3, r3 := resolveRenamed(c, "io.pilot.missing") + if r3 || e3 != nil || id3 != "io.pilot.missing" { + t.Fatalf("unknown resolve got (%q, %+v, %v), want (io.pilot.missing, nil, false)", id3, e3, r3) + } +} + +// A tombstone that points at a missing canonical id still reports the rename, +// with a nil entry — callers surface a clear error rather than chasing it. +func TestResolveRenamed_CanonicalMissing(t *testing.T) { + c := &catalogue{Apps: []catalogueEntry{ + {ID: "io.pilot.old", RenamedTo: "io.pilot.gone"}, + }} + canonical, entry, renamed := resolveRenamed(c, "io.pilot.old") + if !renamed || canonical != "io.pilot.gone" || entry != nil { + t.Fatalf("got (%q, %+v, %v), want (io.pilot.gone, nil, true)", canonical, entry, renamed) + } +} + +// Only one hop is followed: a tombstone pointing at another tombstone is a +// catalogue bug and must not loop. resolveRenamed returns the (still-tombstone) +// second entry without recursing. +func TestResolveRenamed_OneHopOnly(t *testing.T) { + c := &catalogue{Apps: []catalogueEntry{ + {ID: "a", RenamedTo: "b"}, + {ID: "b", RenamedTo: "c"}, + {ID: "c", Version: "1.0.0"}, + }} + canonical, entry, renamed := resolveRenamed(c, "a") + if !renamed || canonical != "b" { + t.Fatalf("canonical = %q (renamed=%v), want b/true — one hop only", canonical, renamed) + } + if entry == nil || entry.ID != "b" { + t.Fatalf("entry = %+v, want the (tombstone) b entry, not a chased c", entry) + } +} diff --git a/cmd/pilotctl/appstore_view.go b/cmd/pilotctl/appstore_view.go index afd96b74..c8ba3430 100644 --- a/cmd/pilotctl/appstore_view.go +++ b/cmd/pilotctl/appstore_view.go @@ -157,12 +157,10 @@ func cmdAppStoreView(args []string) { var entry *catalogueEntry var meta *appMetadata if c, err := loadCatalogue(); err == nil { - for i := range c.Apps { - if c.Apps[i].ID == appID { - entry = &c.Apps[i] - break - } - } + // Follow a rename tombstone so `view ` resolves to (and displays) + // the renamed app, with a deprecation warning. Local install facts above + // stay keyed to the requested id so an existing old-id install still shows. + _, entry, _ = resolveRenamed(c, appID) if entry != nil { if m, err := loadAppMetadata(*entry); err != nil { fmt.Fprintf(os.Stderr, "warn: could not load detail metadata: %v\n", err)