diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb25af4..23fc66f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ Detailed per-release notes are on the ## [Unreleased] -Reliable P2P data transfer across NAT. Tag intentionally held for review. +## [1.12.8] - 2026-07-16 + +Reliable P2P data transfer across NAT, plus cold-start onboarding fixes: agents +now reach the network on their first install instead of stalling before it. ### Added - **Inbound-path watchdog — the long-uptime NAT wedge now auto-recovers.** @@ -40,6 +43,16 @@ Reliable P2P data transfer across NAT. Tag intentionally held for review. full resolve + NAT hole-punch flow and prefers the direct path. - `send-file` reports `transport`, `sha256`, and `throughput_mbps`; adds `--timeout`. +- **Goose joins skill injection.** `pilotctl skills` now lists the real + injection targets — Claude Code, OpenClaw, PicoClaw, OpenHands, Hermes, and + Goose — instead of naming Cursor, which was never a target. (The daemon's + runtime inject-manifest adds `~/.config/goose` with its heartbeat in + `.goosehints`.) +- **Installer GET STARTED walkthrough.** The post-install output now walks a new + operator through the send-`--wait` / read-newest-inbox idiom, pilot-director + (live data), list-agents (known specialists), the app store (local + capabilities), and peers/trust — with copy-paste examples — instead of a + four-line hint. ### Changed - **Message of the day now rides the pilot-changelog pipeline.** The daemon's @@ -52,6 +65,13 @@ Reliable P2P data transfer across NAT. Tag intentionally held for review. banner, `important_update` field, and `motd` in `info` work exactly as before; only the source feed and its shape changed. Override with `--motd-feed-url` / `$PILOT_MOTD_URL` as before. (motd) +- **`pilotctl skills` and `pilotctl skills paths` are now read-only.** They ran a + mutating reconcile and then reported the *pre-write* state, so the first + `pilotctl skills` on a fresh host both created the skill files and labelled them + "absent — next: create" — a false failure an agent reads as a broken install. + They now use the injector's read-only dry run: nothing is written just by + looking, and the reported state reflects what is actually on disk. The mutating + `skills check` / `skills enable` are unchanged. ### Fixed - **`pilotctl daemon start` no longer reports a false failure on slow boots.** @@ -70,6 +90,19 @@ Reliable P2P data transfer across NAT. Tag intentionally held for review. - **Dual-NAT key-exchange convergence.** Key exchange is now sent over both the direct and relay paths, so two NAT'd peers reconverge in ~1 RTT instead of waiting 28 s–3 min for blackhole detection. +- **The installer now reaches the skill-injection step on the hosts agents + actually run on.** Where `systemctl` exists but systemd is not PID 1 + (containers, WSL, CI), the systemd setup ran `systemctl daemon-reload`, which + returns non-zero — and under `set -e` aborted the install ~200 lines before the + `pilotctl skills check` first pass, so injection fired on 0 of the tested cold + starts. The systemd block is now gated on a booted system + (`[ -d /run/systemd/system ]`), its calls can no longer abort the script, and a + non-systemd host is told to run `pilotctl daemon start`. +- **Headless installs no longer die at the email prompt.** A non-interactive + install (piped, no controlling terminal) blocked on `read … < /dev/tty` and + exited `rc=2`; email is now prompted only when a TTY is present, otherwise the + daemon auto-synthesizes its `@nodes.pilotprotocol.network` + identity. `PILOT_EMAIL` is documented for headless installs. ## [1.12.0] - 2026-06-21 diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 32c37079..03f31827 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -1281,6 +1281,15 @@ func cmdAppStoreInstall(args []string) { // catalogue simply means no hints — never a failed install. cacheNextSteps(finalDir, fetchNextStepsForInstall(m.ID)) + // Record the catalogue bundle sha we just installed so `outdated`/`upgrade` + // can detect a SAME-VERSION republish — a rebuilt adapter shipped under an + // unchanged app_version (the aegis argv-fix shape) that a version compare + // alone silently misses. Catalogue installs only; a sideload has no + // catalogue bundle to compare against. Best-effort. + if source == installSourceCatalogue { + recordInstalledBundleSHA(finalDir, m.ID) + } + // Mirror to the install-root-level pilotctl-audit log too, so // the install+uninstall lifecycle pair stays reconstructable // after the app dir (and its per-app supervisor.log) is gone. @@ -2194,6 +2203,13 @@ func cmdAppStoreCall(args []string) { fatalHint("invalid_argument", "json-args must be valid JSON", "%v", err) } } + // Lazily self-heal the cached graph before the call so it is current for + // whichever outcome renders — this is what lets an app installed before its + // graph existed (or a republished graph) reach the fleet without a manual + // reinstall. Off the render path, bounded, best-effort: steady state is a + // single stat and returns instantly (see ensureNextStepsFresh). + ensureNextStepsFresh(appID) + var result json.RawMessage if err := ipc.Call(conn, method, argsValue, &result); err != nil { hint := fmt.Sprintf("the app %q rejected or could not handle %q", appID, method) diff --git a/cmd/pilotctl/appstore_nextsteps.go b/cmd/pilotctl/appstore_nextsteps.go index 6f609348..4a0b67e8 100644 --- a/cmd/pilotctl/appstore_nextsteps.go +++ b/cmd/pilotctl/appstore_nextsteps.go @@ -44,6 +44,7 @@ import ( "path/filepath" "regexp" "strings" + "time" ) // nextStepsFileName is the per-app cache written at install, read at call. @@ -371,7 +372,7 @@ func cacheNextSteps(appDir string, g *nextStepsGraph) { if err != nil { return } - _ = os.WriteFile(filepath.Join(out, nextStepsFileName), data, 0o600) // #nosec G304 -- confined by resolveUnder + _ = os.WriteFile(filepath.Join(out, nextStepsFileName), data, 0o600) // #nosec G304 G703 -- path re-confined to the install root by resolveUnder above } // fetchNextStepsForInstall pulls the graph out of the catalogue metadata for an @@ -379,19 +380,152 @@ func cacheNextSteps(appDir string, g *nextStepsGraph) { // metadata_url, or an unreachable host simply gets no graph. It is called once // per install, never on the call path. func fetchNextStepsForInstall(appID string) *nextStepsGraph { + g, _ := fetchNextStepsAuthoritative(appID) + return g +} + +// fetchNextStepsAuthoritative fetches the app's graph from the catalogue and +// reports whether the answer is AUTHORITATIVE — i.e. whether the catalogue and +// (if present) the metadata doc were actually reached. It is the distinction +// ensureNextStepsFresh needs: a nil graph with ok=true means "this app has no +// graph, stop asking for a while", whereas ok=false means "we couldn't reach the +// catalogue, try again soon". Collapsing the two (as a bare *graph return does) +// would either re-fetch every call forever or give up on a transient blip. +func fetchNextStepsAuthoritative(appID string) (graph *nextStepsGraph, ok bool) { c, err := loadCatalogue() if err != nil { - return nil + return nil, false // could not reach/verify the catalogue } for i := range c.Apps { if c.Apps[i].ID != appID { continue } m, err := loadAppMetadata(c.Apps[i]) - if err != nil || m == nil { - return nil + if err != nil { + return nil, false // entry found but the detail fetch/verify failed } - return m.NextSteps + if m == nil { + return nil, true // no metadata doc = authoritatively no graph + } + return m.NextSteps, true + } + return nil, true // not in the catalogue (e.g. a sideload) = authoritatively none +} + +// ── lazy self-heal ─────────────────────────────────────────────────────────── +// +// The graph is cached at install, but the installed FLEET predates most graphs, +// and a republished graph never touches an existing cache. Without healing, the +// feature only reaches apps installed AFTER their graph shipped — a blocker. +// +// ensureNextStepsFresh closes that gap from the call path, off the render: +// - steady state is a single stat of a freshness marker → returns immediately, +// no network (the hot-path-is-local guarantee holds for all but the rare +// re-check); +// - when the TTL has elapsed it spends ONE bounded fetch, so an app installed +// before its graph existed renders on its very next call, and a republished +// graph propagates within the TTL; +// - a fetch that can't reach the catalogue backs off briefly rather than +// re-trying every call. +const ( + // nextStepsFreshTTL bounds how often we re-consult the catalogue per app. + // Graphs change rarely; a half-day is plenty fresh and keeps fetches rare. + nextStepsFreshTTL = 12 * time.Hour + // nextStepsRetryCool caps re-tries after a failed fetch so an offline host + // doesn't spend the fetch budget on every call. + nextStepsRetryCool = 2 * time.Minute + // nextStepsFetchBudget bounds the ONE inline fetch on the call path. Generous + // for a ~15 KiB metadata doc from a CDN, tight enough that a slow/hung host + // never noticeably delays the app call. On a miss past this budget the app + // simply renders no hint this once and the next call (past the retry cool) + // tries again. + nextStepsFetchBudget = 400 * time.Millisecond + nsCheckedMarker = ".next-steps.checked" // mtime = last authoritative check + nsRetryMarker = ".next-steps.retry" // mtime = last failed fetch +) + +// ensureNextStepsFresh is called once per `appstore call`, before the IPC call, +// so the cache is current for whichever outcome renders. It never blocks longer +// than nextStepsFetchBudget and never errors out of the call. +func ensureNextStepsFresh(appID string) { + if nextStepsDisabled() { + return + } + dir, err := resolveUnder(appStoreRoot(), appID) + if err != nil { + return + } + // Recent authoritative answer (a cached graph, or a confirmed absence) → done. + if markerFresh(filepath.Join(dir, nsCheckedMarker), nextStepsFreshTTL) { + return + } + // Recently failed → back off rather than re-fetch on every call. + if markerFresh(filepath.Join(dir, nsRetryMarker), nextStepsRetryCool) { + return + } + g, ok := fetchNextStepsTimed(appID, nextStepsFetchBudget) + if !ok { + touchMarker(dir, nsRetryMarker) // couldn't reach the catalogue; short cooldown + return + } + if g != nil && len(g.Edges) > 0 { + cacheNextSteps(dir, g) + } else { + // The catalogue authoritatively has no graph for this app now — drop any + // stale cache so we never render a graph the catalogue stopped publishing. + removeUnderAppDir(dir, nextStepsFileName) + } + touchMarker(dir, nsCheckedMarker) + removeUnderAppDir(dir, nsRetryMarker) +} + +// fetchNextStepsTimed runs the authoritative fetch under a hard deadline. On +// timeout it reports ok=false (treated as a transient failure) and lets the +// goroutine finish and fall off on its own — it never writes anything, so an +// abandoned fetch can't race the cache. +func fetchNextStepsTimed(appID string, budget time.Duration) (*nextStepsGraph, bool) { + type res struct { + g *nextStepsGraph + ok bool + } + ch := make(chan res, 1) + go func() { + g, ok := fetchNextStepsAuthoritative(appID) + ch <- res{g, ok} + }() + select { + case r := <-ch: + return r.g, r.ok + case <-time.After(budget): + return nil, false + } +} + +// markerFresh reports whether a marker file exists and is younger than ttl. +func markerFresh(path string, ttl time.Duration) bool { + fi, err := os.Stat(path) + if err != nil { + return false + } + return time.Since(fi.ModTime()) < ttl +} + +// touchMarker writes/updates a freshness marker in the app dir (mtime = now). +func touchMarker(dir, name string) { + out, err := resolveUnder(appStoreRoot(), filepath.Base(dir)) + if err != nil { + return + } + _ = os.WriteFile(filepath.Join(out, name), []byte(time.Now().UTC().Format(time.RFC3339)), 0o600) // #nosec G304 G703 -- path re-confined to the install root by resolveUnder above +} + +// removeUnderAppDir deletes a file inside an installed app dir, re-confining the +// path to the install root (the same guard the read and write paths use) so a +// crafted app id can never delete outside the tree the app store owns. +func removeUnderAppDir(dir, name string) { + out, err := resolveUnder(appStoreRoot(), filepath.Base(dir)) + if err != nil { + return } - return nil + _ = os.Remove(filepath.Join(out, name)) // #nosec G304 G703 -- path re-confined to the install root by resolveUnder above } diff --git a/cmd/pilotctl/appstore_nextsteps_test.go b/cmd/pilotctl/appstore_nextsteps_test.go index 66f6bd29..05962e8b 100644 --- a/cmd/pilotctl/appstore_nextsteps_test.go +++ b/cmd/pilotctl/appstore_nextsteps_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // The error strings below are VERBATIM, captured from a live daemon on this @@ -370,3 +371,88 @@ func TestCacheNextStepsRefusesTraversal(t *testing.T) { t.Fatal("cacheNextSteps wrote outside the install root") } } + +// ── self-heal (ensureNextStepsFresh) ───────────────────────────────────────── + +func TestMarkerFresh(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "m") + if markerFresh(p, time.Hour) { + t.Fatal("absent marker must not be fresh") + } + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if !markerFresh(p, time.Hour) { + t.Fatal("just-written marker must be fresh") + } + if markerFresh(p, time.Nanosecond) { + t.Fatal("marker older than a nanosecond TTL must be stale") + } +} + +// A recent authoritative check must short-circuit — no fetch, no new markers. +// This is the steady-state guarantee: the call path stays a local stat. +func TestEnsureFreshSkipsWhenChecked(t *testing.T) { + root := withAppRoot(t) + dir := filepath.Join(root, "io.pilot.testapp") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + touchMarker(dir, nsCheckedMarker) + // Point the catalogue at an unreachable URL: if a fetch were attempted it + // would fail and drop a retry marker. A fresh checked marker must prevent that. + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "http://127.0.0.1:1/nope.json") + ensureNextStepsFresh("io.pilot.testapp") + if _, err := os.Stat(filepath.Join(dir, nsRetryMarker)); err == nil { + t.Fatal("a fresh checked marker must short-circuit before any fetch") + } +} + +// With no markers and an unreachable catalogue, the fetch fails and a retry +// marker is written so the next call backs off instead of re-fetching. +func TestEnsureFreshWritesRetryOnFailure(t *testing.T) { + root := withAppRoot(t) + dir := filepath.Join(root, "io.pilot.testapp") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "http://127.0.0.1:1/nope.json") + ensureNextStepsFresh("io.pilot.testapp") + if !markerFresh(filepath.Join(dir, nsRetryMarker), time.Minute) { + t.Fatal("a failed fetch must drop a retry marker for back-off") + } + // A fresh retry marker must then short-circuit a second call. + // (Re-running must not clobber into a checked marker.) + ensureNextStepsFresh("io.pilot.testapp") + if markerFresh(filepath.Join(dir, nsCheckedMarker), time.Minute) { + t.Fatal("a still-failing fetch must not produce an authoritative checked marker") + } +} + +func TestEnsureFreshDisabledIsNoOp(t *testing.T) { + root := withAppRoot(t) + dir := filepath.Join(root, "io.pilot.testapp") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PILOT_NEXT_STEPS", "off") + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "http://127.0.0.1:1/nope.json") + ensureNextStepsFresh("io.pilot.testapp") + for _, m := range []string{nsCheckedMarker, nsRetryMarker} { + if _, err := os.Stat(filepath.Join(dir, m)); err == nil { + t.Fatalf("disabled self-heal must write no %s marker", m) + } + } +} + +// A traversing app id must not let the healer write or delete outside the root. +func TestEnsureFreshRefusesTraversal(t *testing.T) { + withAppRoot(t) + t.Setenv("PILOT_APPSTORE_CATALOG_URL", "http://127.0.0.1:1/nope.json") + ensureNextStepsFresh("../../../tmp") // must resolveUnder-reject and no-op + if _, err := os.Stat("/tmp/" + nsRetryMarker); err == nil { + os.Remove("/tmp/" + nsRetryMarker) + t.Fatal("traversal id must not write a marker outside the install root") + } +} diff --git a/cmd/pilotctl/appstore_update.go b/cmd/pilotctl/appstore_update.go index f1280615..a7999b91 100644 --- a/cmd/pilotctl/appstore_update.go +++ b/cmd/pilotctl/appstore_update.go @@ -16,6 +16,44 @@ import ( type installedApp struct { ID string AppVersion string + // BundleSHA is the catalogue bundle sha this app was installed from, recorded + // at install into $APP/.bundle-sha256. Empty for a sideload or an app + // installed before this was recorded (the pre-feature fleet) — in which case + // a same-version republish can't be detected until the next install/upgrade + // writes the marker, and only a version bump flags it. That graceful + // degradation is deliberate: no marker → behave exactly as before. + BundleSHA string +} + +// bundleSHAMarker records, in an installed app dir, the catalogue bundle sha the +// app was installed from — the signal `outdated` uses to catch a same-version +// republish. +const bundleSHAMarker = ".bundle-sha256" + +// recordInstalledBundleSHA writes the catalogue's host-appropriate bundle sha for +// appID into the installed app dir. Best-effort: a failure just means a +// same-version republish won't be auto-detected for this app (it still installs +// fine and version bumps are still caught). +func recordInstalledBundleSHA(appDir, appID string) { + c, err := loadCatalogue() + if err != nil { + return + } + for i := range c.Apps { + if c.Apps[i].ID != appID { + continue + } + _, sha, err := c.Apps[i].resolveBundle() + if err != nil || sha == "" { + return + } + out, err := resolveUnder(appStoreRoot(), filepath.Base(appDir)) + if err != nil { + return + } + _ = os.WriteFile(filepath.Join(out, bundleSHAMarker), []byte(sha), 0o600) // #nosec G304 G703 -- path re-confined to the install root by resolveUnder + return + } } // scanInstalledApps reads the install root and returns each installed app's id @@ -40,7 +78,12 @@ func scanInstalledApps() ([]installedApp, error) { if err != nil { continue } - apps = append(apps, installedApp{ID: m.ID, AppVersion: m.AppVersion}) + // Same confinement as the manifest read just above: root is the install + // root and e.Name() a direntry within it, joined to a fixed marker name. + bsha, _ := os.ReadFile(filepath.Join(root, e.Name(), bundleSHAMarker)) // #nosec G304 G703 -- install-root + direntry + constant filename + apps = append(apps, installedApp{ + ID: m.ID, AppVersion: m.AppVersion, BundleSHA: strings.TrimSpace(string(bsha)), + }) } sort.Slice(apps, func(i, j int) bool { return apps[i].ID < apps[j].ID }) return apps, nil @@ -51,6 +94,10 @@ type outdatedApp struct { ID string `json:"id"` Installed string `json:"installed"` Available string `json:"available"` + // Reason is why the app is outdated: "version" (a newer app_version) or + // "rebuilt" (a same-version republish — the catalogue bundle changed under a + // version we already have). Both upgrade the same way. + Reason string `json:"reason,omitempty"` } // findOutdated cross-references installed apps against the signed catalogue and @@ -66,19 +113,50 @@ func findOutdated() ([]outdatedApp, error) { if err != nil { return nil, err } - latest := make(map[string]string, len(cat.Apps)) + entries := make(map[string]catalogueEntry, len(cat.Apps)) for _, e := range cat.Apps { - latest[e.ID] = e.Version + entries[e.ID] = e } var out []outdatedApp for _, a := range installed { - if v, ok := latest[a.ID]; ok && semverCompare(v, a.AppVersion) > 0 { - out = append(out, outdatedApp{ID: a.ID, Installed: a.AppVersion, Available: v}) + e, ok := entries[a.ID] + if !ok { + continue + } + // resolveBundle picks THIS host's platform bundle, matching what install + // recorded; an error/empty sha just disables republish detection for the app. + catBundleSHA := "" + if _, sha, err := e.resolveBundle(); err == nil { + catBundleSHA = sha + } + if reason := outdatedReason(a.AppVersion, e.Version, a.BundleSHA, catBundleSHA); reason != "" { + out = append(out, outdatedApp{ID: a.ID, Installed: a.AppVersion, Available: e.Version, Reason: reason}) } } return out, nil } +// outdatedReason decides whether an installed app is outdated relative to the +// catalogue, and why. It is the pure core of findOutdated, split out so the +// version-vs-rebuilt logic is testable without a signed catalogue. +// +// - "version": the catalogue has a newer app_version. +// - "rebuilt": SAME app_version, but the catalogue bundle sha differs from the +// one recorded at install — a republished adapter (the aegis argv-fix shape) +// that a version compare alone misses. Requires both shas; a pre-feature +// install with no recorded sha degrades to version-only detection. +// - "": up to date (or not enough information to say otherwise). +func outdatedReason(installedVer, catVer, installedBundleSHA, catBundleSHA string) string { + switch cmp := semverCompare(catVer, installedVer); { + case cmp > 0: + return "version" + case cmp == 0 && installedBundleSHA != "" && catBundleSHA != "" && catBundleSHA != installedBundleSHA: + return "rebuilt" + default: + return "" + } +} + // cmdAppStoreOutdated lists installed apps that have a newer version in the // catalogue. Exit status is 0 even when some are outdated (it's a report); the // JSON form is stable for scripting an auto-upgrade. @@ -95,9 +173,13 @@ func cmdAppStoreOutdated(_ []string) { fmt.Println("all installed apps are up to date") return } - fmt.Printf("%-32s %-12s %-12s\n", "APP", "INSTALLED", "AVAILABLE") + fmt.Printf("%-32s %-12s %-12s %s\n", "APP", "INSTALLED", "AVAILABLE", "WHY") for _, o := range out { - fmt.Printf("%-32s %-12s %-12s\n", o.ID, o.Installed, o.Available) + why := o.Reason + if why == "rebuilt" { + why = "rebuilt (same version, new bundle)" + } + fmt.Printf("%-32s %-12s %-12s %s\n", o.ID, o.Installed, o.Available, why) } fmt.Printf("\nupgrade with: pilotctl appstore upgrade (or --all)\n") } diff --git a/cmd/pilotctl/appstore_update_test.go b/cmd/pilotctl/appstore_update_test.go index 77f718b2..a9f890fa 100644 --- a/cmd/pilotctl/appstore_update_test.go +++ b/cmd/pilotctl/appstore_update_test.go @@ -61,3 +61,35 @@ func TestScanInstalledApps(t *testing.T) { } const hex64 = "0000000000000000000000000000000000000000000000000000000000000000" + +func TestOutdatedReason(t *testing.T) { + cases := []struct { + name string + instVer, catVer, instSHA, catSHA, expected string + }{ + {"newer version", "1.0.0", "1.1.0", "aaa", "bbb", "version"}, + {"newer version even if sha same", "1.0.0", "1.1.0", "aaa", "aaa", "version"}, + {"same version, bundle rebuilt", "0.1.3", "0.1.3", "aaa", "bbb", "rebuilt"}, + {"same version, same bundle → up to date", "0.1.3", "0.1.3", "aaa", "aaa", ""}, + {"same version, no recorded sha (pre-feature) → up to date", "0.1.3", "0.1.3", "", "bbb", ""}, + {"same version, catalogue sha unknown → up to date", "0.1.3", "0.1.3", "aaa", "", ""}, + {"older installed than catalogue is 'version'", "2.0.0", "2.0.1", "aaa", "bbb", "version"}, + {"installed newer than catalogue → not outdated", "1.2.0", "1.1.0", "aaa", "bbb", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := outdatedReason(c.instVer, c.catVer, c.instSHA, c.catSHA); got != c.expected { + t.Fatalf("outdatedReason(inst=%s,cat=%s,iSHA=%s,cSHA=%s)=%q want %q", + c.instVer, c.catVer, c.instSHA, c.catSHA, got, c.expected) + } + }) + } +} + +// The aegis case, concretely: a same-version republish MUST be flagged as +// rebuilt so `upgrade --all` picks up the fixed bundle without a version bump. +func TestOutdatedReasonCatchesSameVersionRepublish(t *testing.T) { + if outdatedReason("0.1.3", "0.1.3", "ae40da40oldsha", "c8416c11newsha") != "rebuilt" { + t.Fatal("a rebuilt same-version bundle must be flagged 'rebuilt' (the aegis argv-fix case)") + } +} diff --git a/cmd/pilotctl/skills.go b/cmd/pilotctl/skills.go index f63e5b4b..c0218edc 100644 --- a/cmd/pilotctl/skills.go +++ b/cmd/pilotctl/skills.go @@ -62,13 +62,28 @@ func runTick() (*skillinject.Report, error) { return skillinject.ForceTick(ctx, skillinject.Config{}) } +// planTick performs a read-only dry run: same manifest fetch + classification +// as a real tick, but writes nothing to disk. Each Outcome carries the current +// on-disk State and the Action the next real tick WOULD take. Use this for +// display surfaces (status, paths, info summary) so that merely *looking* at +// skill state never mutates the filesystem — and never reports a file as +// "absent — next: create" in the same breath that a mutating tick just created +// it (the pre-write-state skew that ForceTick-backed status suffered from). +func planTick() (*skillinject.Report, error) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + return skillinject.Plan(ctx, skillinject.Config{}) +} + // cmdSkillsStatus runs one tick (fetching the manifest + entrypoint over // HTTPS) and prints a per-tool summary line (statusDot + what, if anything, // the next tick would change). Per-file detail lines are behind --verbose. func cmdSkillsStatus(args []string) { flags, _ := parseFlags(args) verbose := flagBool(flags, "verbose") - report, err := runTick() + // Read-only: status must not write. Plan reports the true on-disk state + // plus the action the next daemon tick would take. + report, err := planTick() if err != nil { fatalCode("internal", "skills tick: %v", err) } @@ -111,9 +126,10 @@ func cmdSkillsStatus(args []string) { fmt.Println("Supported (auto-detected by directory presence):") fmt.Println(" - Claude Code (~/.claude)") fmt.Println(" - OpenClaw (~/.openclaw)") - fmt.Println(" - Cursor (~/.cursor)") + fmt.Println(" - PicoClaw (~/.picoclaw)") fmt.Println(" - OpenHands (~/.openhands)") fmt.Println(" - Hermes (~/.hermes)") + fmt.Println(" - Goose (~/.config/goose)") return } @@ -182,7 +198,8 @@ func cmdSkillsStatus(args []string) { // cmdSkillsPaths prints just the install paths — one per line, no decoration — // suitable for shell pipelines (`pilotctl skills paths | xargs ls -la`). func cmdSkillsPaths(_ []string) { - report, err := runTick() + // Read-only: printing paths must not write to disk. + report, err := planTick() if err != nil { fatalCode("internal", "skills tick: %v", err) } @@ -450,7 +467,9 @@ func cmdSkillsSetMode(args []string) { // on the host. Same data source as `pilotctl skills`, collapsed to one // entry per tool. func skillInstallTools() []string { - report, err := runTick() + // Read-only: this feeds display surfaces (e.g. `pilotctl info`); it must + // not write to disk as a side effect of being looked at. + report, err := planTick() if err != nil || report == nil || len(report.Outcomes) == 0 { return nil } @@ -471,7 +490,8 @@ func skillInstallTools() []string { // printSkillInstallSummary surfaces the agent skill install paths. // Quiet (no header) when no agent tools are detected on the host. func printSkillInstallSummary() { - report, err := runTick() + // Read-only: summary display for `pilotctl info` — no writes. + report, err := planTick() if err != nil || report == nil || len(report.Outcomes) == 0 { return } diff --git a/cmd/pilotctl/zz_skills_test.go b/cmd/pilotctl/zz_skills_test.go index 968bfb52..a2b35123 100644 --- a/cmd/pilotctl/zz_skills_test.go +++ b/cmd/pilotctl/zz_skills_test.go @@ -58,6 +58,31 @@ func TestCmdSkillsStatusDisabledTextMode(t *testing.T) { } } +// TestCmdSkillsStatus_SupportedListAccurate pins the injected-target list the +// status view prints when no agent tools are detected. It must match the +// injection manifest's actual targets: PicoClaw and Goose are real targets, +// Cursor is not (it was never in the manifest). Assertions are guarded on the +// "no tools detected" branch so the test stays robust if the host happens to +// have an agent-tool dir. +func TestCmdSkillsStatus_SupportedListAccurate(t *testing.T) { + preDisableSkills(t) + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = false + out := captureStdout(t, func() { cmdSkillsStatus(nil) }) + if !strings.Contains(out, "Supported (auto-detected") { + t.Skip("host has agent tools detected; supported-list branch not exercised") + } + for _, want := range []string{"Goose", "PicoClaw"} { + if !strings.Contains(out, want) { + t.Errorf("supported list missing %q (a real injection target):\n%s", want, out) + } + } + if strings.Contains(out, "Cursor") { + t.Errorf("supported list still names Cursor, which is not an injection target:\n%s", out) + } +} + func TestCmdSkillsPathsDisabled(t *testing.T) { preDisableSkills(t) prev := jsonOutput diff --git a/cmd/updater/main.go b/cmd/updater/main.go index ccbb49c0..37fdec58 100644 --- a/cmd/updater/main.go +++ b/cmd/updater/main.go @@ -3,11 +3,15 @@ package main import ( + "context" + "encoding/json" "flag" "fmt" "log/slog" "os" + "os/exec" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -88,6 +92,19 @@ func main() { "repo", *repo, "interval", interval.String(), ) + + // Keep the installed app ADAPTERS current too, not just the pilot binaries. + // On the same cadence and behind the same enable switch as binary updates, + // run `pilotctl appstore upgrade --all` so a new app version OR a same-version + // republish (see recordInstalledBundleSHA / outdated "rebuilt") reaches the + // fleet without anyone running upgrade by hand. Each upgrade re-runs the full + // catalogue-signature + manifest-signature + trust-anchor gate that install + // does, so this adds automation, not trust. Opt out with + // PILOT_UPDATER_NO_APP_UPGRADE for hosts that want binary-only updates. + if !envBool("PILOT_UPDATER_NO_APP_UPGRADE") { + go appUpgradeLoop(*installDir, *statePath, *interval) + slog.Info("app auto-upgrade loop started", "interval", interval.String()) + } if *pin != "" { slog.Info("version pinned", "tag", *pin) } @@ -125,3 +142,57 @@ func setupLogging(level, format string) { } slog.SetDefault(slog.New(h)) } + +// autoUpdateEnabled reports whether automatic updates are switched on, reading +// the same {"enabled":bool} control file pilotctl writes (`pilotctl update +// enable`). Absent/unreadable/malformed → off, matching the "OFF by default" +// contract the updater library applies to binary updates. +func autoUpdateEnabled(statePath string) bool { + if statePath == "" { + return false + } + b, err := os.ReadFile(statePath) // #nosec G304 -- operator-provided control path, same file pilotctl manages + if err != nil { + return false + } + var s struct { + Enabled bool `json:"enabled"` + } + if json.Unmarshal(b, &s) != nil { + return false + } + return s.Enabled +} + +// appUpgradeLoop periodically runs `pilotctl appstore upgrade --all` while +// auto-update is enabled. First run is after one interval (not at boot), so a +// freshly-started updater doesn't immediately churn apps. +func appUpgradeLoop(installDir, statePath string, interval time.Duration) { + t := time.NewTicker(interval) + defer t.Stop() + for range t.C { + if !autoUpdateEnabled(statePath) { + continue + } + runAppUpgrade(installDir) + } +} + +// runAppUpgrade execs the co-located pilotctl to upgrade any outdated app. It is +// idempotent — when nothing is outdated `upgrade --all` is a no-op — and bounded +// so a hung download can't wedge the loop. +func runAppUpgrade(installDir string) { + bin := filepath.Join(installDir, "pilotctl") + if _, err := os.Stat(bin); err != nil { + return // no pilotctl beside us; nothing to drive the upgrade + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "appstore", "upgrade", "--all").CombinedOutput() // #nosec G204 -- fixed argv, bin is the co-located pilotctl + trimmed := strings.TrimSpace(string(out)) + if err != nil { + slog.Warn("app auto-upgrade run failed", "err", err, "output", trimmed) + return + } + slog.Info("app auto-upgrade complete", "output", trimmed) +} diff --git a/cmd/updater/zz_appupgrade_test.go b/cmd/updater/zz_appupgrade_test.go new file mode 100644 index 00000000..fc446adc --- /dev/null +++ b/cmd/updater/zz_appupgrade_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAutoUpdateEnabledGate(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "auto-update.json") + // absent → off + if autoUpdateEnabled(p) { + t.Fatal("absent state file must read as disabled") + } + // empty path → off + if autoUpdateEnabled("") { + t.Fatal("empty state path must read as disabled") + } + // malformed → off (fail safe) + _ = os.WriteFile(p, []byte("{not json"), 0o600) + if autoUpdateEnabled(p) { + t.Fatal("malformed state file must read as disabled") + } + // explicitly disabled + _ = os.WriteFile(p, []byte(`{"enabled":false}`), 0o600) + if autoUpdateEnabled(p) { + t.Fatal("enabled:false must read as disabled") + } + // enabled + _ = os.WriteFile(p, []byte(`{"enabled":true}`), 0o600) + if !autoUpdateEnabled(p) { + t.Fatal("enabled:true must read as enabled") + } +} diff --git a/go.mod b/go.mod index 3e3a1bc7..c0373000 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/pilot-protocol/policy v0.2.2 github.com/pilot-protocol/rendezvous v0.2.5 github.com/pilot-protocol/runtime v0.3.1 - github.com/pilot-protocol/skillinject v0.2.3 + github.com/pilot-protocol/skillinject v0.2.4-0.20260713095857-dc5109d7a33a github.com/pilot-protocol/trustedagents v0.2.4 github.com/pilot-protocol/updater v0.2.3 github.com/pilot-protocol/webhook v0.2.0 diff --git a/go.sum b/go.sum index b284cffb..85a924da 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,8 @@ github.com/pilot-protocol/rendezvous v0.2.5 h1:PvApwKHU2DnvK8pA6K9RAohUomZNu6vX6 github.com/pilot-protocol/rendezvous v0.2.5/go.mod h1:nUPZaM1R1x0iEKbTp1TuYqro2wgBArwT0E4wauR8I2E= github.com/pilot-protocol/runtime v0.3.1 h1:+W9ww0dZY/FgOBtCmIOV3w5L5Z4Upt/RIsrYElXZ1zs= github.com/pilot-protocol/runtime v0.3.1/go.mod h1:GfFEIji0w7H9SSNR9Wl2q72pd2OYN3PHY9Qhcbvyrqk= -github.com/pilot-protocol/skillinject v0.2.3 h1:Bf0tqRe7tqYY27X5RGCOf4LGjtWpyQvN/03YumDBDJs= -github.com/pilot-protocol/skillinject v0.2.3/go.mod h1:fCzivA/bjkXRgGjp6yd7nqfaIETtU+lQRocBu0J/O9g= +github.com/pilot-protocol/skillinject v0.2.4-0.20260713095857-dc5109d7a33a h1:MDblMoSuIXZ84um+AJt4vW3ebfBH2SG08b5o1SdGb1g= +github.com/pilot-protocol/skillinject v0.2.4-0.20260713095857-dc5109d7a33a/go.mod h1:+jEW+uCFkA6TnmZc41Ev5oczXCbOHD0NqVWG8RzjwXQ= github.com/pilot-protocol/trustedagents v0.2.4 h1:NqYjU3eoxBzyzzlhTQY2vV4q0l5+4eaADAhrOD1OkQU= github.com/pilot-protocol/trustedagents v0.2.4/go.mod h1:Y3Eq/IOZqAUIbtzVBcxW4epkciaFAFAYkEyu/E7DXe4= github.com/pilot-protocol/updater v0.2.3 h1:9+Y2XvyEnfxjbguho8AIKLdEK+Gzj2I7lkad2qIu6JY= diff --git a/install.sh b/install.sh index 0aca3125..9601dda7 100755 --- a/install.sh +++ b/install.sh @@ -454,7 +454,7 @@ echo "Config written to ${PILOT_DIR}/config.json" # --- Set up system service --- -if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1; then +if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then CAN_SUDO=false if [ "$(id -u)" = "0" ] || sudo -n true 2>/dev/null; then CAN_SUDO=true @@ -530,7 +530,10 @@ WantedBy=multi-user.target USVC fi - sudo systemctl daemon-reload + # Never let a systemctl call abort the install under `set -e`. Even with + # the booted-systemd gate above, daemon-reload/enable can fail on partial + # or degraded systemd hosts — the binaries and skill injection still matter. + sudo systemctl daemon-reload || true echo " Service: pilot-daemon.service" echo " Service: pilot-updater.service (auto-updates)" @@ -542,13 +545,22 @@ USVC # registry overrides) that the operator may want to set before # first start. if [ -f "$BIN_DIR/pilot-updater" ]; then - sudo systemctl enable --now pilot-updater - echo " Started: pilot-updater (auto-updates enabled)" + if sudo systemctl enable --now pilot-updater; then + echo " Started: pilot-updater (auto-updates enabled)" + else + echo " Note: could not enable pilot-updater via systemd (non-fatal)." + fi fi echo " Start daemon: sudo systemctl enable --now pilot-daemon" else echo " Skipped systemd setup (run as root or with passwordless sudo to enable)" fi +elif [ "$OS" = "linux" ]; then + # systemd is not the init system here (container / WSL / CI runner). + # There is no service to install — tell the agent the portable start path + # instead of silently leaving it with no daemon. + echo "No systemd detected (container / WSL / CI) — start the daemon manually:" + echo " pilotctl daemon start" fi if [ "$OS" = "darwin" ]; then @@ -701,12 +713,110 @@ echo " Socket: /tmp/pilot.sock" echo " Identity: ${PILOT_DIR}/identity.json" echo " Email: ${EMAIL}" echo "" -echo "Get started:" +echo "============================================" +echo " GET STARTED" echo "" -echo " export PATH=\"${BIN_DIR}:\$PATH\" # if not restarting your shell" -echo " pilotctl daemon start --hostname my-agent # email already saved" -echo " pilotctl info" -echo " pilotctl ping " +echo " 0) Put pilotctl on your PATH and bring the node online." +echo " ------------------------------------------------------------------" +echo " export PATH=\"${BIN_DIR}:\$PATH\" # if not restarting your shell" +cat <<'PILOT_GET_STARTED' + pilotctl daemon start --hostname my-agent # blocks until registered; email already saved + pilotctl daemon status # confirm it's running + pilotctl info # node ID, address, peer count, uptime + + Reading any reply is always the same two-step idiom — send with + --wait, then read the newest inbox file's .data field: + ------------------------------------------------------------------ + pilotctl send-message --data '' --wait + jq -r '.data' "$(ls -1t ~/.pilot/inbox/*.json | head -1)" + + + 1) PILOT DIRECTOR — your default for anything LIVE or fresh. + Describe the whole task in plain English; it picks the specialist + agents + filters and returns a validated, ready-to-run plan. + ------------------------------------------------------------------ + pilotctl send-message pilot-director --data 'current weather and air quality for Berlin' --wait + jq -r '.data' "$(ls -1t ~/.pilot/inbox/*.json | head -1)" + + # More examples — hand it the whole sentence, don't hunt for agents yourself: + pilotctl send-message pilot-director --data 'summarize this season F1 drivers' --wait + pilotctl send-message pilot-director --data 'latest CVEs for nginx' --wait + pilotctl send-message pilot-director --data "what's BTC at right now in USD" --wait + + + 2) DISCOVER SERVICE AGENTS — when you already know the specialist you want. + list-agents is the phonebook. Search is LITERAL token match, so use + short, single-word keywords (bitcoin, weather, nba, joke, iss). + Service agents auto-approve — no handshake required. + ------------------------------------------------------------------ + # Browse the whole live catalogue: + pilotctl send-message list-agents --data '/data' --wait + jq -r '.data' "$(ls -1t ~/.pilot/inbox/*.json | head -1)" + + # Search by keyword: + pilotctl send-message list-agents --data '/data {"search":"bitcoin","limit":10}' --wait + jq -r '.data | fromjson' "$(ls -1t ~/.pilot/inbox/*.json | head -1)" + + # Learn a specialist's query schema, then query it with filters: + pilotctl send-message --data '/help' --wait + pilotctl send-message --data '/data {"":""}' --wait + jq -r '.data' "$(ls -1t ~/.pilot/inbox/*.json | head -1)" + + # Stuck? pilot-ai is the natural-language help desk (also a service agent): + pilotctl send-message pilot-ai --data 'which agent has FX rates?' --wait + + + 3) APP STORE — install a LOCAL capability, then call it (JSON in → JSON out). + Use this to *do* something (run SQL, sandbox code, drive a browser, + enrich a contact, get a phone number) rather than look up fresh data. + ------------------------------------------------------------------ + # Browse — one line per app; the catalogue is your router: + pilotctl appstore catalogue + + # See an app's full details (methods, source, permissions, pricing): + pilotctl appstore view io.pilot.sqlite + + # Install it (daemon auto-spawns it; re-run `list` if state != ready): + pilotctl appstore install io.pilot.sqlite --force + pilotctl appstore list + + # ALWAYS call .help first — lists every method, its params, + # a latency class (fast <1s / med 1-5s / slow 5-30s), and cost: + pilotctl appstore call io.pilot.sqlite sqlite.help '{}' + + # Then do the work — JSON in, JSON on stdout: + pilotctl appstore call io.pilot.sqlite sqlite.query '{"sql":"select 1"}' + + # A few concrete capability examples (install first, then call): + pilotctl appstore install io.pilot.smol --force + pilotctl appstore call io.pilot.smol smol.push '{"image":"alpine","net":true}' + + pilotctl appstore install io.pilot.bowmark --force + pilotctl appstore call io.pilot.bowmark bowmark.ask '{"site":"amazon.com","task":"search for a product"}' + + pilotctl appstore install io.pilot.orthogonal --force + pilotctl appstore call io.pilot.orthogonal orthogonal.search '{"prompt":"work email for a person given name + company"}' + + Cost: most apps run locally and are free. A few (orthogonal, sixtyfour, + agentphone, cloud smol) are metered against a per-user $5 budget — + .help and `view` show the price, and discovery calls are free, so + check before the one call that spends. + + + 4) PEERS & TRUST — only for peer nodes (other AIs / human-run nodes). + Service agents and apps need NO handshake; this is just the p2p half. + ------------------------------------------------------------------ + pilotctl handshake "" # request trust + pilotctl pending # incoming requests waiting on you + pilotctl approve # accept one + pilotctl trust # confirm mutual trust + pilotctl send-message --data '' # talk, once trust is mutual + pilotctl send-file /path/to/file.tar.gz # exchange artifacts + + Full operator manual & task→agent/app maps: + ~/.claude/skills/pilotctl/SKILL.md +============================================ +PILOT_GET_STARTED echo "" # pilot-gateway no longer ships in release tarballs (extracted to the # sibling pilot-protocol/gateway repo) — only show the bridge hint when @@ -736,10 +846,12 @@ echo " + heartbeat ref in ~/.claude/CLAUDE.md" echo " OpenClaw ~/.openclaw/skills/pilotctl/SKILL.md" echo " + heartbeat ref in ~/.openclaw/workspace/AGENTS.md" echo " PicoClaw ~/.picoclaw/workspace/skills/pilotctl/SKILL.md" -echo " + heartbeat ref in ~/.picoclaw/workspace/AGENT.md" +echo " + heartbeat ref in ~/.picoclaw/workspace/HEARTBEAT.md" echo " OpenHands ~/.openhands/microagents/pilotctl.md (self-heartbeat)" echo " Hermes ~/.hermes/skills/pilotctl/SKILL.md" echo " + heartbeat ref in ~/.hermes/SOUL.md" +echo " Goose ~/.config/goose/skills/pilotctl/SKILL.md" +echo " + heartbeat ref in ~/.config/goose/.goosehints" echo "" echo " Inspect / force a refresh anytime:" echo " pilotctl skills # status of every install path"