Skip to content

Commit eee302e

Browse files
committed
feat(examples): add hello-world reference app
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 #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.
1 parent 288503d commit eee302e

6 files changed

Lines changed: 337 additions & 0 deletions

File tree

examples/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# app-store examples
2+
3+
Reference Pilot apps. Read these to learn the supervisor lifecycle
4+
contract, the manifest schema, and the trust regimes.
5+
6+
| Example | What it shows |
7+
|---|---|
8+
| [`hello-world/`](hello-world/) | The smallest possible app: one IPC method, sideload-safe manifest, three-command build → install → call. Start here. |
9+
10+
For a production-scale example see the **wallet**
11+
([`pilot-protocol/wallet`](https://github.com/pilot-protocol/wallet)):
12+
multichain EVM payments, spend caps from manifest grants, hook
13+
participation in `send-message` primitives, settler integration.

examples/hello-world/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
bundle/
2+
bin/

examples/hello-world/Makefile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
.PHONY: bundle clean install-local uninstall
2+
3+
# bundle assembles a ready-to-install bundle under ./bundle/. The
4+
# committed manifest.json is the template; the build output (with
5+
# binary.sha256 pinned to the freshly-built binary) lives under
6+
# bundle/ so re-runs never rewrite the committed file.
7+
BUNDLE_DIR := bundle
8+
9+
bundle: $(BUNDLE_DIR)/bin/hello $(BUNDLE_DIR)/manifest.json
10+
11+
$(BUNDLE_DIR)/bin/hello: cmd/hello/main.go
12+
@mkdir -p $(BUNDLE_DIR)/bin
13+
go build -o $(BUNDLE_DIR)/bin/hello ./cmd/hello
14+
15+
# Copy the template manifest into the bundle, then pin the binary's
16+
# actual sha256. python3 is on every dev box this project supports;
17+
# no jq dependency.
18+
$(BUNDLE_DIR)/manifest.json: manifest.json $(BUNDLE_DIR)/bin/hello
19+
@cp manifest.json $(BUNDLE_DIR)/manifest.json
20+
@SHA=$$(shasum -a 256 $(BUNDLE_DIR)/bin/hello | awk '{print $$1}'); \
21+
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)"; \
22+
echo "bundled at $(BUNDLE_DIR)/ (binary sha256: $$SHA)"
23+
24+
# install-local sideloads the bundle into the local daemon's install
25+
# root. Requires pilotctl on $PATH. The --local flag is required
26+
# because the manifest is not catalogue-signed.
27+
install-local: bundle
28+
pilotctl appstore install $(CURDIR)/$(BUNDLE_DIR) --local
29+
30+
uninstall:
31+
pilotctl appstore uninstall io.example.hello --yes
32+
33+
clean:
34+
rm -rf $(BUNDLE_DIR)

examples/hello-world/README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# hello-world
2+
3+
The smallest end-to-end Pilot app — single IPC method, sideload-safe
4+
manifest, build → install → call in three commands. Read alongside
5+
[`cmd/hello/main.go`](cmd/hello/main.go) and
6+
[`manifest.json`](manifest.json); together they're the reference for
7+
what an app needs to do to be supervisable by a Pilot daemon.
8+
9+
## Try it
10+
11+
```sh
12+
# from this directory:
13+
make bundle # builds ./bundle/bin/hello and ./bundle/manifest.json with the binary sha pinned
14+
make install-local # pilotctl appstore install ./bundle --local
15+
16+
# wait ~30s for the supervisor to spawn it (or check progress):
17+
pilotctl appstore list # expect: io.example.hello … state: ready [sideloaded]
18+
19+
# call the one IPC method this app exposes:
20+
pilotctl appstore call io.example.hello hello.echo '{"message":"hi"}'
21+
# → {"echo":"hi","sideloaded":true}
22+
```
23+
24+
The committed `manifest.json` is the template; `make bundle` copies
25+
it into `bundle/manifest.json` and pins the binary's sha256 there,
26+
so re-running the build never rewrites the committed file.
27+
28+
## What the manifest declares
29+
30+
| Field | Why it matters |
31+
|---|---|
32+
| `id` | Reverse-DNS unique identifier. Reuses are install conflicts. |
33+
| `binary.path` + `binary.sha256` | Supervisor sha-verifies the binary at every spawn; mismatch → refuse. |
34+
| `exposes` | Method names the daemon will route to this app. Must match what `cmd/hello/main.go` registers on its dispatcher. |
35+
| `grants` | The *only* source of authority. The runtime brokers every privileged op against this list. |
36+
| `store.publisher` / `store.signature` | Catalogue-signed apps must verify here. Sideloaded apps skip this check (see below). |
37+
38+
The grants in this example are deliberately minimal — `fs.read`/`fs.write`
39+
limited to `$APP/data.db` and `audit.log` for forensics. This is
40+
exactly the surface a sideloaded app is permitted; the manifest will
41+
install without modification under `pilotctl appstore install . --local`.
42+
43+
## Catalogue vs sideload
44+
45+
Two trust regimes exist for installed apps. They affect what grants
46+
your manifest is allowed to declare and how the supervisor verifies
47+
its provenance:
48+
49+
| | Catalogue | Sideloaded (`--local`) |
50+
|---|---|---|
51+
| Install command | `pilotctl appstore install <id>` | `pilotctl appstore install <dir> --local` |
52+
| Provenance check | `store.signature` must verify against `store.publisher` | None (publisher key is honour-system) |
53+
| Grants allowed | Whatever the publisher signed for | `audit.log`, `fs.read $APP/*`, `fs.write $APP/*` only |
54+
| `extends` / `dynamic_extends` hooks | Yes | No |
55+
| Cross-app `ipc.call` | Yes | No |
56+
| `net.dial` / `key.sign` | Yes | No |
57+
| Sentinel on disk | (none) | `.sideloaded` (mode `0o400`) in install dir |
58+
59+
If your manifest declares a grant outside the sideload allow-list,
60+
`pilotctl appstore install --local` refuses up-front with a message
61+
naming the offending cap. Strip it and re-run, or take the manifest
62+
through publishing (signed catalogue entry) if the cap is necessary.
63+
64+
The sideload regime is a **manifest gate**. It guarantees no
65+
sideloaded app's *declared* surface escapes the allow-list. It does
66+
NOT prevent a hostile binary from ignoring its declarations at the
67+
syscall level — OS-level sandbox work is a follow-up
68+
(landlock/seccomp on Linux, sandbox-exec on macOS). Only install
69+
paths from sources you'd trust on the host shell.
70+
71+
## The supervisor lifecycle contract
72+
73+
The daemon's supervisor passes every app the same six flags at spawn
74+
time. `cmd/hello/main.go` shows the minimal handling — declare them
75+
even if your app ignores most:
76+
77+
| Flag | Purpose |
78+
|---|---|
79+
| `--addr` | The pilot address the daemon assigned this app. |
80+
| `--db` | Default path for app-local sqlite (`$APP/data.db`). |
81+
| `--socket` | Unix socket the app must listen on. Supervisor watches for this file to mark the app `ready`. |
82+
| `--identity` | Per-app ed25519 keypair (`$APP/identity.json`). Auto-created on first start by apps that use it. |
83+
| `--manifest` | Path to the pinned manifest. Used by spend-cap-aware apps to activate their declared `key.sign`-cap limits. |
84+
| `--cap-state` | JSONL spend-log path for rolling-window cap state. |
85+
86+
Beyond these, you may add your own flags. `os.Getenv("PILOT_SIDELOAD")`
87+
is set to `"1"` for sideloaded apps — surface it in your replies the
88+
way `echoResp.Sideloaded` does, or use it to refuse high-privilege
89+
operations even when your manifest authorises them.
90+
91+
## Releasing as a catalogue app
92+
93+
For internal testing the sideload path is fine. To publish via the
94+
public catalogue:
95+
96+
1. Generate a publisher keypair: `pilotctl appstore gen-key publisher.key`.
97+
2. Sign the manifest: `pilotctl appstore sign --key publisher.key manifest.json`.
98+
3. Build platform tarballs (linux/amd64, linux/arm64, darwin/amd64,
99+
darwin/arm64), pin per-arch `binary.sha256` in their respective
100+
manifests, host the tarballs at stable URLs.
101+
4. Open a PR to add the app to `web4/catalogue/catalogue.json`.
102+
103+
The wallet (`io.pilot.wallet`) is the working example — see its
104+
manifest in `pilot-protocol/wallet` for what a published catalogue
105+
app looks like at production scale.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Hello-world Pilot app.
2+
//
3+
// Demonstrates the smallest possible app that the daemon's supervisor
4+
// can spawn and route IPC calls into. Sideload-safe by design: it
5+
// declares only audit.log + fs.read/fs.write under $APP, so a user
6+
// can install it via `pilotctl appstore install ./examples/hello-world --local`
7+
// without tripping any sideload-policy refusal.
8+
//
9+
// Read alongside ../manifest.json — the manifest is the only thing that
10+
// authorises this binary to do anything privileged. Every flag below is
11+
// part of the standard lifecycle contract the supervisor passes to every
12+
// app at spawn time:
13+
//
14+
// --addr, --db, --socket, --identity, --manifest, --cap-state
15+
//
16+
// An app may add its own flags on top, but these six are guaranteed by
17+
// the supervisor and should not error if unrecognised by future tooling.
18+
package main
19+
20+
import (
21+
"context"
22+
"encoding/json"
23+
"flag"
24+
"fmt"
25+
"log"
26+
"net"
27+
"os"
28+
"os/signal"
29+
"syscall"
30+
31+
"github.com/pilot-protocol/app-store/pkg/ipc"
32+
)
33+
34+
const (
35+
// methodEcho is the only IPC entrypoint this app exposes. The
36+
// manifest's "exposes" array must mirror this — see manifest.json.
37+
methodEcho = "hello.echo"
38+
39+
// envSideloaded is the supervisor's hint that the app was installed
40+
// via `--local` rather than from the signed catalogue. Cap-aware
41+
// apps can use this to refuse high-privilege operations even when
42+
// their own manifest authorises them — defence in depth on top of
43+
// the supervisor's manifest gate.
44+
envSideloaded = "PILOT_SIDELOAD"
45+
)
46+
47+
type echoReq struct {
48+
Message string `json:"message"`
49+
}
50+
51+
type echoResp struct {
52+
Echo string `json:"echo"`
53+
Sideloaded bool `json:"sideloaded"`
54+
}
55+
56+
func main() {
57+
fs := flag.NewFlagSet("hello", flag.ExitOnError)
58+
var (
59+
// Pilot address the daemon assigned this app — opaque to the app
60+
// itself in the hello-world case, but real apps use it for
61+
// identity in peer-facing messages.
62+
_ = fs.String("addr", "", "pilot address (e.g. 0:0001.HHHH.LLLL)")
63+
_ = fs.String("db", "", "sqlite path (unused by hello-world; declared for lifecycle parity)")
64+
sockPath = fs.String("socket", "", "unix socket to listen on; supervisor sets this")
65+
_ = fs.String("identity", "", "ed25519 identity file (unused by hello-world)")
66+
_ = fs.String("manifest", "", "path to manifest.json (unused by hello-world)")
67+
_ = fs.String("cap-state", "", "spend-cap state log (unused by hello-world)")
68+
)
69+
if err := fs.Parse(os.Args[1:]); err != nil {
70+
log.Fatalf("flag parse: %v", err)
71+
}
72+
if *sockPath == "" {
73+
log.Fatalf("supervisor did not pass --socket; refusing to start")
74+
}
75+
76+
sideloaded := os.Getenv(envSideloaded) == "1"
77+
logger := log.New(os.Stderr, "hello-world: ", log.LstdFlags|log.Lmicroseconds)
78+
logger.Printf("starting (sideloaded=%v) listening on %s", sideloaded, *sockPath)
79+
80+
// Unix-domain socket sat exactly where the supervisor told us to
81+
// put it. The supervisor watches for this file's appearance to mark
82+
// the app "ready"; if we listen anywhere else, the supervisor will
83+
// time out and the app stays "stopped" from its perspective.
84+
if err := os.Remove(*sockPath); err != nil && !os.IsNotExist(err) {
85+
logger.Fatalf("remove stale socket: %v", err)
86+
}
87+
ln, err := net.Listen("unix", *sockPath)
88+
if err != nil {
89+
logger.Fatalf("listen: %v", err)
90+
}
91+
defer ln.Close()
92+
93+
d := ipc.NewDispatcher()
94+
d.Register(methodEcho, echoHandler(sideloaded))
95+
96+
ctx, cancel := context.WithCancel(context.Background())
97+
defer cancel()
98+
// Clean shutdown on SIGTERM: the supervisor sends SIGTERM to the
99+
// whole process group when uninstalling, restarting, or stopping
100+
// the daemon. Ignoring it would let the supervisor wait the full
101+
// grace period before SIGKILLing — slower restarts.
102+
sigCh := make(chan os.Signal, 1)
103+
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
104+
go func() {
105+
<-sigCh
106+
logger.Printf("shutdown signal received")
107+
cancel()
108+
_ = ln.Close()
109+
}()
110+
111+
for {
112+
conn, err := ln.Accept()
113+
if err != nil {
114+
if ctx.Err() != nil {
115+
return
116+
}
117+
logger.Printf("accept: %v", err)
118+
continue
119+
}
120+
// One Serve loop per connection, on its own goroutine. The
121+
// daemon may open multiple connections in parallel — this is
122+
// the standard concurrency model for every app.
123+
go func(c net.Conn) {
124+
defer c.Close()
125+
if err := ipc.Serve(ctx, c, d); err != nil {
126+
logger.Printf("serve: %v", err)
127+
}
128+
}(conn)
129+
}
130+
}
131+
132+
// echoHandler is the entire business logic of this app: take a
133+
// message, return it back. The sideloaded flag is surfaced in the
134+
// reply so callers can confirm at runtime which trust regime the
135+
// supervisor put the app in.
136+
func echoHandler(sideloaded bool) ipc.Handler {
137+
return func(_ context.Context, req *ipc.Envelope) (json.RawMessage, error) {
138+
var args echoReq
139+
if len(req.Payload) > 0 {
140+
if err := json.Unmarshal(req.Payload, &args); err != nil {
141+
return nil, fmt.Errorf("decode echo args: %w", err)
142+
}
143+
}
144+
resp := echoResp{Echo: args.Message, Sideloaded: sideloaded}
145+
body, err := json.Marshal(resp)
146+
if err != nil {
147+
return nil, fmt.Errorf("marshal echo resp: %w", err)
148+
}
149+
return body, nil
150+
}
151+
}

examples/hello-world/manifest.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"id": "io.example.hello",
3+
"app_version": "0.1.0",
4+
"manifest_version": 1,
5+
"binary": {
6+
"runtime": "go",
7+
"path": "bin/hello",
8+
"sha256": "REPLACE_WITH_BUILD_OUTPUT"
9+
},
10+
"exposes": [
11+
"hello.echo"
12+
],
13+
"grants": [
14+
{
15+
"cap": "audit.log",
16+
"target": "*"
17+
},
18+
{
19+
"cap": "fs.write",
20+
"target": "$APP/data.db"
21+
},
22+
{
23+
"cap": "fs.read",
24+
"target": "$APP/data.db"
25+
}
26+
],
27+
"protection": "shareable",
28+
"store": {
29+
"publisher": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
30+
"signature": "sig:placeholder-replaced-by-pilotctl-appstore-sign"
31+
}
32+
}

0 commit comments

Comments
 (0)