Skip to content

Commit eac4287

Browse files
TeoSlayerteovl
andauthored
feat(pilotctl): one-command install + canonical catalogue.json + live smoke test (#235)
Closes the new-user gap: instead of "find a bundle tarball, untar it, pass the path to install", a user runs: pilotctl appstore catalogue pilotctl appstore install io.pilot.wallet and the tool fetches the bundle from the URL pinned in the catalogue, sha-checks it, extracts it, and hands it to the existing install path (which validates the manifest + verifies the embedded ed25519 signature). The supervisor's rescan picks it up within 2 s. ## What ships - catalogue/catalogue.json — the canonical list of installable apps. Schema-versioned (catalogue.version=1). Pilotctl refuses unknown versions so a future migration can't silently misbehave on old pilotctl binaries. Each entry pins bundle_url + bundle_sha256. - catalogue/README.md — schema + how-to-publish-a-new-version doc. - cmd/pilotctl/appstore_catalogue.go — loads catalogue from $PILOT_APPSTORE_CATALOG_URL (override), default the raw GitHub URL for catalogue/catalogue.json on main. Supports file://, https://, and http:// on loopback only (refuses plaintext install artifacts from anywhere else). - cmd/pilotctl/appstore.go — install accepts a catalogue ID alongside the existing bundle-dir path; catalogue / catalog subcommand dispatches to the new lister. ## Chain of trust User trusts pilotctl (release-pipeline-signed binary). pilotctl fetches catalogue from a URL hardcoded in the binary (auditable in the public repo). Future: signed catalogue verified against appstore.EmbeddedCatalogPubkey. Catalogue pins each bundle's sha256 — a compromised CDN can't substitute different bytes (mismatch errors out). Bundle's manifest has an ed25519 signature against an embedded publisher pubkey; supervisor verifies at install + every rescan. Defence-in-depth: tarball-unpack refuses any entry with "../" path traversal, mirroring the supervisor's manifest.binary.path guard. ## Live smoke test scripts/smoke-test-appstore.sh runs the entire flow end-to-end against HEAD: builds pilotctl + daemon + wallet, gen-key, sign, stage a catalogue pointing at the local tarball, start the daemon (which loads the app-store unconditionally per #PR_1), list + install by ID, daemon spawns it, run a 50 USDC payment between two wallet processes, verify replay-guard, uninstall, verify clean. Eight steps, each with explicit assertions. scripts/smoke-pay-driver — the wallet-side IPC client. Notably does NOT import the wallet package; talks to any installed app over raw ipc.Call with map[string]any payloads. Proves the appstore stays dynamic: a third-party tool needs no compile-time knowledge of the app to drive its exposed methods. Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
1 parent f076b77 commit eac4287

7 files changed

Lines changed: 771 additions & 6 deletions

File tree

catalogue/README.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Pilot app store catalogue
2+
3+
This directory holds `catalogue.json` — the canonical list of apps installable via
4+
`pilotctl appstore install <app-id>`.
5+
6+
## Schema
7+
8+
```json
9+
{
10+
"version": 1,
11+
"updated_at": "<RFC3339 timestamp>",
12+
"apps": [
13+
{
14+
"id": "<reverse-DNS app id, must match manifest.id>",
15+
"version": "<semver>",
16+
"description": "<one-line, shown in `pilotctl appstore catalogue`>",
17+
"bundle_url": "https://<host>/<path>.tar.gz",
18+
"bundle_sha256": "<hex sha256 of the tarball>"
19+
}
20+
]
21+
}
22+
```
23+
24+
The schema is intentionally flat — `pilotctl` decodes it directly into
25+
`catalogueEntry` in `cmd/pilotctl/appstore_catalogue.go`. Any field added
26+
here must also land there.
27+
28+
## Where pilotctl loads it from
29+
30+
By default, `pilotctl appstore catalogue` and `pilotctl appstore install
31+
<id>` fetch this file from the URL hardcoded in `appstore_catalogue.go`
32+
(`defaultCatalogueURL`, pointing at this file's raw URL on `main`). Override
33+
with `PILOT_APPSTORE_CATALOG_URL` for local dev or for staging a release:
34+
35+
```bash
36+
# point at a local file while staging a release
37+
export PILOT_APPSTORE_CATALOG_URL=file:///path/to/staging/catalogue.json
38+
pilotctl appstore catalogue
39+
```
40+
41+
`http://` is rejected unless the host is loopback (no plaintext install
42+
from a remote — operators relying on a catalogue do so over `https` only).
43+
44+
## Publishing a new app version
45+
46+
1. Bump the version in the app's `manifest.json`. Re-sign:
47+
```bash
48+
pilotctl appstore sign --key /secure/path/publisher.key path/to/manifest.json
49+
```
50+
2. Build the bundle dir (`manifest.json` + `bin/<name>`) and tar it:
51+
```bash
52+
tar czf io.pilot.wallet-X.Y.Z.tar.gz manifest.json bin/wallet
53+
```
54+
3. Compute the sha256:
55+
```bash
56+
shasum -a 256 io.pilot.wallet-X.Y.Z.tar.gz
57+
```
58+
4. Upload the tarball as a release artifact (`gh release upload` or
59+
equivalent — GitHub releases, Cloudflare R2, anywhere reachable over
60+
HTTPS).
61+
5. Update this `catalogue.json` with the new `version`, `bundle_url`, and
62+
`bundle_sha256`. Commit. The change goes live the moment the commit
63+
lands on `main` and the raw URL serves the new bytes — no daemon
64+
restart, no pilotctl release.
65+
66+
## Trust model
67+
68+
| Layer | Trust anchor | Verifies |
69+
|---|---|---|
70+
| User trusts pilotctl | Project release pipeline (signed pilotctl binary) | The catalogue URL is correct |
71+
| pilotctl trusts the catalogue | Future: signed against `EmbeddedCatalogPubkey`; today: the raw URL itself | App IDs map to specific bundle URLs + SHAs |
72+
| pilotctl trusts the bundle | Embedded `bundle_sha256` matches downloaded bytes | A CDN substitute is rejected |
73+
| Daemon trusts the manifest | Embedded ed25519 publisher pubkey verifies the signature | The bundle's manifest hasn't been tampered with |
74+
75+
Every layer is checked at install time, and the manifest signature is
76+
re-verified at every supervisor rescan (every 2 s).

catalogue/catalogue.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": 1,
3+
"updated_at": "2026-06-08T10:25:00Z",
4+
"apps": [
5+
{
6+
"id": "io.pilot.wallet",
7+
"version": "0.3.0",
8+
"description": "On-overlay USDC payments — ed25519 + EIP-3009 receipts.",
9+
"bundle_url": "https://github.com/TeoSlayer/pilotprotocol/releases/download/wallet-v0.3.0/io.pilot.wallet-0.3.0.tar.gz",
10+
"bundle_sha256": "REPLACE_AT_RELEASE_TIME"
11+
}
12+
]
13+
}

cmd/pilotctl/appstore.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ func cmdAppStore(args []string) {
6767
cmdAppStoreGenKey(args[1:])
6868
case "sign":
6969
cmdAppStoreSign(args[1:])
70+
case "catalogue", "catalog":
71+
cmdAppStoreCatalogue(args[1:])
7072
case "restart":
7173
cmdAppStoreRestart(args[1:])
7274
case "caps":
@@ -77,7 +79,7 @@ func cmdAppStore(args []string) {
7779
appStoreHelp()
7880
default:
7981
fatalHint("invalid_argument",
80-
"available: list, status, audit, uninstall, verify, install, gen-key, sign, restart, caps, actions, call",
82+
"available: list, status, audit, uninstall, verify, install, gen-key, sign, catalogue, restart, caps, actions, call",
8183
"unknown appstore subcommand: %s", args[0])
8284
}
8385
}
@@ -99,8 +101,10 @@ Usage:
99101
duration: Go syntax (e.g. 10m, 1h, 24h)
100102
pilotctl appstore uninstall <id> --yes remove an installed app from the install root
101103
pilotctl appstore verify <bundle-dir> sha256-check a pre-install bundle against its manifest
102-
pilotctl appstore install <bundle-dir> [--force]
103-
stage + atomically place a verified bundle into the install root
104+
pilotctl appstore catalogue list apps available for one-command install
105+
pilotctl appstore install <app-id-or-dir> [--force]
106+
install by catalogue ID (fetches + verifies + extracts)
107+
OR by local bundle directory (offline / dev path)
104108
pilotctl appstore gen-key <key-file> generate a fresh ed25519 publisher keypair; prints the public side
105109
pilotctl appstore sign --key <key-file> <manifest>
106110
sign (or re-sign) a manifest's store.signature so the supervisor accepts it
@@ -967,10 +971,10 @@ type installReport struct {
967971
func cmdAppStoreInstall(args []string) {
968972
if len(args) < 1 {
969973
fatalHint("invalid_argument",
970-
"usage: pilotctl appstore install <bundle-dir> [--force]",
971-
"missing bundle dir")
974+
"usage: pilotctl appstore install <app-id-or-dir> [--force]",
975+
"missing app id or bundle dir")
972976
}
973-
bundleDir := args[0]
977+
target := args[0]
974978
force := false
975979
for i := 1; i < len(args); i++ {
976980
switch args[i] {
@@ -983,6 +987,17 @@ func cmdAppStoreInstall(args []string) {
983987
}
984988
}
985989

990+
// Resolve `target` to a local bundle dir. If it's a catalogue ID
991+
// (e.g. "io.pilot.wallet"), fetch + verify the tarball and unpack
992+
// into a tempdir. If it's already a directory, use it as-is. The
993+
// rest of this function operates only on the directory.
994+
bundleDir, err := resolveInstallTarget(target)
995+
if err != nil {
996+
fatalHint("invalid_argument",
997+
"the argument must be either a catalogue ID (`pilotctl appstore catalogue` to list) or a path to a bundle dir containing manifest.json",
998+
"%v", err)
999+
}
1000+
9861001
// 1. Validate the bundle — same shape as verify. Reusing the
9871002
// surface manifest.Parse + sha256File makes the trust check
9881003
// identical to what the supervisor runs at every spawn.

0 commit comments

Comments
 (0)