From 32ab230ad50ff219d0d641e68b2ea4059844e03a Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Tue, 9 Jun 2026 05:13:38 +0300 Subject: [PATCH] feat(examples): add hello-world reference app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smallest end-to-end Pilot app, sideload-safe by design. Single IPC method, ~150 lines of Go, three-command build → install → call. Read alongside the per-file comments — they're written as the reference for the supervisor lifecycle contract (the six standard --flags), the trust-regime split (catalogue vs sideload), and the manifest schema. Layout: examples/hello-world/ cmd/hello/main.go single binary, one IPC method (hello.echo) manifest.json sideload-safe grants (audit.log + fs $APP/) Makefile bundle / install-local / uninstall targets README.md build steps, supervisor contract, trust regimes The committed manifest.json has sha256: "REPLACE_WITH_BUILD_OUTPUT" as a deliberate placeholder. `make bundle` copies the manifest into bundle/ and pins the binary's real sha256 there, so re-builds never rewrite the committed file. bundle/ and bin/ are gitignored. `make install-local` uses the sideload path from pilot-protocol/app-store#15 (the --local flag landed in web4/pilotctl PR #240). Without that pair this example would have to be a publisher-signed catalogue entry just to be installable, which is the opposite of "smallest possible app." Companion to the README that walks new developers from this app through how an app gets supervised, what an IPC dispatcher looks like, and where the wallet sits on the same scaffolding. --- examples/README.md | 13 +++ examples/hello-world/.gitignore | 2 + examples/hello-world/Makefile | 34 ++++++ examples/hello-world/README.md | 105 +++++++++++++++++ examples/hello-world/cmd/hello/main.go | 151 +++++++++++++++++++++++++ examples/hello-world/manifest.json | 32 ++++++ 6 files changed, 337 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/hello-world/.gitignore create mode 100644 examples/hello-world/Makefile create mode 100644 examples/hello-world/README.md create mode 100644 examples/hello-world/cmd/hello/main.go create mode 100644 examples/hello-world/manifest.json diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..951e1aa --- /dev/null +++ b/examples/README.md @@ -0,0 +1,13 @@ +# app-store examples + +Reference Pilot apps. Read these to learn the supervisor lifecycle +contract, the manifest schema, and the trust regimes. + +| Example | What it shows | +|---|---| +| [`hello-world/`](hello-world/) | The smallest possible app: one IPC method, sideload-safe manifest, three-command build → install → call. Start here. | + +For a production-scale example see the **wallet** +([`pilot-protocol/wallet`](https://github.com/pilot-protocol/wallet)): +multichain EVM payments, spend caps from manifest grants, hook +participation in `send-message` primitives, settler integration. diff --git a/examples/hello-world/.gitignore b/examples/hello-world/.gitignore new file mode 100644 index 0000000..b8a679c --- /dev/null +++ b/examples/hello-world/.gitignore @@ -0,0 +1,2 @@ +bundle/ +bin/ diff --git a/examples/hello-world/Makefile b/examples/hello-world/Makefile new file mode 100644 index 0000000..d0c4eaa --- /dev/null +++ b/examples/hello-world/Makefile @@ -0,0 +1,34 @@ +.PHONY: bundle clean install-local uninstall + +# bundle assembles a ready-to-install bundle under ./bundle/. The +# committed manifest.json is the template; the build output (with +# binary.sha256 pinned to the freshly-built binary) lives under +# bundle/ so re-runs never rewrite the committed file. +BUNDLE_DIR := bundle + +bundle: $(BUNDLE_DIR)/bin/hello $(BUNDLE_DIR)/manifest.json + +$(BUNDLE_DIR)/bin/hello: cmd/hello/main.go + @mkdir -p $(BUNDLE_DIR)/bin + go build -o $(BUNDLE_DIR)/bin/hello ./cmd/hello + +# Copy the template manifest into the bundle, then pin the binary's +# actual sha256. python3 is on every dev box this project supports; +# no jq dependency. +$(BUNDLE_DIR)/manifest.json: manifest.json $(BUNDLE_DIR)/bin/hello + @cp manifest.json $(BUNDLE_DIR)/manifest.json + @SHA=$$(shasum -a 256 $(BUNDLE_DIR)/bin/hello | awk '{print $$1}'); \ + python3 -c "import json,sys;p=json.load(open('$(BUNDLE_DIR)/manifest.json'));p['binary']['sha256']='$$SHA';json.dump(p,open('$(BUNDLE_DIR)/manifest.json','w'),indent=2)"; \ + echo "bundled at $(BUNDLE_DIR)/ (binary sha256: $$SHA)" + +# install-local sideloads the bundle into the local daemon's install +# root. Requires pilotctl on $PATH. The --local flag is required +# because the manifest is not catalogue-signed. +install-local: bundle + pilotctl appstore install $(CURDIR)/$(BUNDLE_DIR) --local + +uninstall: + pilotctl appstore uninstall io.example.hello --yes + +clean: + rm -rf $(BUNDLE_DIR) diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md new file mode 100644 index 0000000..3cb7f20 --- /dev/null +++ b/examples/hello-world/README.md @@ -0,0 +1,105 @@ +# hello-world + +The smallest end-to-end Pilot app — single IPC method, sideload-safe +manifest, build → install → call in three commands. Read alongside +[`cmd/hello/main.go`](cmd/hello/main.go) and +[`manifest.json`](manifest.json); together they're the reference for +what an app needs to do to be supervisable by a Pilot daemon. + +## Try it + +```sh +# from this directory: +make bundle # builds ./bundle/bin/hello and ./bundle/manifest.json with the binary sha pinned +make install-local # pilotctl appstore install ./bundle --local + +# wait ~30s for the supervisor to spawn it (or check progress): +pilotctl appstore list # expect: io.example.hello … state: ready [sideloaded] + +# call the one IPC method this app exposes: +pilotctl appstore call io.example.hello hello.echo '{"message":"hi"}' +# → {"echo":"hi","sideloaded":true} +``` + +The committed `manifest.json` is the template; `make bundle` copies +it into `bundle/manifest.json` and pins the binary's sha256 there, +so re-running the build never rewrites the committed file. + +## What the manifest declares + +| Field | Why it matters | +|---|---| +| `id` | Reverse-DNS unique identifier. Reuses are install conflicts. | +| `binary.path` + `binary.sha256` | Supervisor sha-verifies the binary at every spawn; mismatch → refuse. | +| `exposes` | Method names the daemon will route to this app. Must match what `cmd/hello/main.go` registers on its dispatcher. | +| `grants` | The *only* source of authority. The runtime brokers every privileged op against this list. | +| `store.publisher` / `store.signature` | Catalogue-signed apps must verify here. Sideloaded apps skip this check (see below). | + +The grants in this example are deliberately minimal — `fs.read`/`fs.write` +limited to `$APP/data.db` and `audit.log` for forensics. This is +exactly the surface a sideloaded app is permitted; the manifest will +install without modification under `pilotctl appstore install . --local`. + +## Catalogue vs sideload + +Two trust regimes exist for installed apps. They affect what grants +your manifest is allowed to declare and how the supervisor verifies +its provenance: + +| | Catalogue | Sideloaded (`--local`) | +|---|---|---| +| Install command | `pilotctl appstore install ` | `pilotctl appstore install --local` | +| Provenance check | `store.signature` must verify against `store.publisher` | None (publisher key is honour-system) | +| Grants allowed | Whatever the publisher signed for | `audit.log`, `fs.read $APP/*`, `fs.write $APP/*` only | +| `extends` / `dynamic_extends` hooks | Yes | No | +| Cross-app `ipc.call` | Yes | No | +| `net.dial` / `key.sign` | Yes | No | +| Sentinel on disk | (none) | `.sideloaded` (mode `0o400`) in install dir | + +If your manifest declares a grant outside the sideload allow-list, +`pilotctl appstore install --local` refuses up-front with a message +naming the offending cap. Strip it and re-run, or take the manifest +through publishing (signed catalogue entry) if the cap is necessary. + +The sideload regime is a **manifest gate**. It guarantees no +sideloaded app's *declared* surface escapes the allow-list. It does +NOT prevent a hostile binary from ignoring its declarations at the +syscall level — OS-level sandbox work is a follow-up +(landlock/seccomp on Linux, sandbox-exec on macOS). Only install +paths from sources you'd trust on the host shell. + +## The supervisor lifecycle contract + +The daemon's supervisor passes every app the same six flags at spawn +time. `cmd/hello/main.go` shows the minimal handling — declare them +even if your app ignores most: + +| Flag | Purpose | +|---|---| +| `--addr` | The pilot address the daemon assigned this app. | +| `--db` | Default path for app-local sqlite (`$APP/data.db`). | +| `--socket` | Unix socket the app must listen on. Supervisor watches for this file to mark the app `ready`. | +| `--identity` | Per-app ed25519 keypair (`$APP/identity.json`). Auto-created on first start by apps that use it. | +| `--manifest` | Path to the pinned manifest. Used by spend-cap-aware apps to activate their declared `key.sign`-cap limits. | +| `--cap-state` | JSONL spend-log path for rolling-window cap state. | + +Beyond these, you may add your own flags. `os.Getenv("PILOT_SIDELOAD")` +is set to `"1"` for sideloaded apps — surface it in your replies the +way `echoResp.Sideloaded` does, or use it to refuse high-privilege +operations even when your manifest authorises them. + +## Releasing as a catalogue app + +For internal testing the sideload path is fine. To publish via the +public catalogue: + +1. Generate a publisher keypair: `pilotctl appstore gen-key publisher.key`. +2. Sign the manifest: `pilotctl appstore sign --key publisher.key manifest.json`. +3. Build platform tarballs (linux/amd64, linux/arm64, darwin/amd64, + darwin/arm64), pin per-arch `binary.sha256` in their respective + manifests, host the tarballs at stable URLs. +4. Open a PR to add the app to `web4/catalogue/catalogue.json`. + +The wallet (`io.pilot.wallet`) is the working example — see its +manifest in `pilot-protocol/wallet` for what a published catalogue +app looks like at production scale. diff --git a/examples/hello-world/cmd/hello/main.go b/examples/hello-world/cmd/hello/main.go new file mode 100644 index 0000000..ddb3eb8 --- /dev/null +++ b/examples/hello-world/cmd/hello/main.go @@ -0,0 +1,151 @@ +// Hello-world Pilot app. +// +// Demonstrates the smallest possible app that the daemon's supervisor +// can spawn and route IPC calls into. Sideload-safe by design: it +// declares only audit.log + fs.read/fs.write under $APP, so a user +// can install it via `pilotctl appstore install ./examples/hello-world --local` +// without tripping any sideload-policy refusal. +// +// Read alongside ../manifest.json — the manifest is the only thing that +// authorises this binary to do anything privileged. Every flag below is +// part of the standard lifecycle contract the supervisor passes to every +// app at spawn time: +// +// --addr, --db, --socket, --identity, --manifest, --cap-state +// +// An app may add its own flags on top, but these six are guaranteed by +// the supervisor and should not error if unrecognised by future tooling. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "os" + "os/signal" + "syscall" + + "github.com/pilot-protocol/app-store/pkg/ipc" +) + +const ( + // methodEcho is the only IPC entrypoint this app exposes. The + // manifest's "exposes" array must mirror this — see manifest.json. + methodEcho = "hello.echo" + + // envSideloaded is the supervisor's hint that the app was installed + // via `--local` rather than from the signed catalogue. Cap-aware + // apps can use this to refuse high-privilege operations even when + // their own manifest authorises them — defence in depth on top of + // the supervisor's manifest gate. + envSideloaded = "PILOT_SIDELOAD" +) + +type echoReq struct { + Message string `json:"message"` +} + +type echoResp struct { + Echo string `json:"echo"` + Sideloaded bool `json:"sideloaded"` +} + +func main() { + fs := flag.NewFlagSet("hello", flag.ExitOnError) + var ( + // Pilot address the daemon assigned this app — opaque to the app + // itself in the hello-world case, but real apps use it for + // identity in peer-facing messages. + _ = fs.String("addr", "", "pilot address (e.g. 0:0001.HHHH.LLLL)") + _ = fs.String("db", "", "sqlite path (unused by hello-world; declared for lifecycle parity)") + sockPath = fs.String("socket", "", "unix socket to listen on; supervisor sets this") + _ = fs.String("identity", "", "ed25519 identity file (unused by hello-world)") + _ = fs.String("manifest", "", "path to manifest.json (unused by hello-world)") + _ = fs.String("cap-state", "", "spend-cap state log (unused by hello-world)") + ) + if err := fs.Parse(os.Args[1:]); err != nil { + log.Fatalf("flag parse: %v", err) + } + if *sockPath == "" { + log.Fatalf("supervisor did not pass --socket; refusing to start") + } + + sideloaded := os.Getenv(envSideloaded) == "1" + logger := log.New(os.Stderr, "hello-world: ", log.LstdFlags|log.Lmicroseconds) + logger.Printf("starting (sideloaded=%v) listening on %s", sideloaded, *sockPath) + + // Unix-domain socket sat exactly where the supervisor told us to + // put it. The supervisor watches for this file's appearance to mark + // the app "ready"; if we listen anywhere else, the supervisor will + // time out and the app stays "stopped" from its perspective. + if err := os.Remove(*sockPath); err != nil && !os.IsNotExist(err) { + logger.Fatalf("remove stale socket: %v", err) + } + ln, err := net.Listen("unix", *sockPath) + if err != nil { + logger.Fatalf("listen: %v", err) + } + defer ln.Close() + + d := ipc.NewDispatcher() + d.Register(methodEcho, echoHandler(sideloaded)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Clean shutdown on SIGTERM: the supervisor sends SIGTERM to the + // whole process group when uninstalling, restarting, or stopping + // the daemon. Ignoring it would let the supervisor wait the full + // grace period before SIGKILLing — slower restarts. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-sigCh + logger.Printf("shutdown signal received") + cancel() + _ = ln.Close() + }() + + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + logger.Printf("accept: %v", err) + continue + } + // One Serve loop per connection, on its own goroutine. The + // daemon may open multiple connections in parallel — this is + // the standard concurrency model for every app. + go func(c net.Conn) { + defer c.Close() + if err := ipc.Serve(ctx, c, d); err != nil { + logger.Printf("serve: %v", err) + } + }(conn) + } +} + +// echoHandler is the entire business logic of this app: take a +// message, return it back. The sideloaded flag is surfaced in the +// reply so callers can confirm at runtime which trust regime the +// supervisor put the app in. +func echoHandler(sideloaded bool) ipc.Handler { + return func(_ context.Context, req *ipc.Envelope) (json.RawMessage, error) { + var args echoReq + if len(req.Payload) > 0 { + if err := json.Unmarshal(req.Payload, &args); err != nil { + return nil, fmt.Errorf("decode echo args: %w", err) + } + } + resp := echoResp{Echo: args.Message, Sideloaded: sideloaded} + body, err := json.Marshal(resp) + if err != nil { + return nil, fmt.Errorf("marshal echo resp: %w", err) + } + return body, nil + } +} diff --git a/examples/hello-world/manifest.json b/examples/hello-world/manifest.json new file mode 100644 index 0000000..18b0103 --- /dev/null +++ b/examples/hello-world/manifest.json @@ -0,0 +1,32 @@ +{ + "id": "io.example.hello", + "app_version": "0.1.0", + "manifest_version": 1, + "binary": { + "runtime": "go", + "path": "bin/hello", + "sha256": "REPLACE_WITH_BUILD_OUTPUT" + }, + "exposes": [ + "hello.echo" + ], + "grants": [ + { + "cap": "audit.log", + "target": "*" + }, + { + "cap": "fs.write", + "target": "$APP/data.db" + }, + { + "cap": "fs.read", + "target": "$APP/data.db" + } + ], + "protection": "shareable", + "store": { + "publisher": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "signature": "sig:placeholder-replaced-by-pilotctl-appstore-sign" + } +} \ No newline at end of file