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