Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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
Expand All @@ -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.**
Expand All @@ -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 `<fingerprint>@nodes.pilotprotocol.network`
identity. `PILOT_EMAIL` is documented for headless installs.

## [1.12.0] - 2026-06-21

Expand Down
16 changes: 16 additions & 0 deletions cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

② Same-version republish detection starts here (unrelated to ①). Records the catalogue bundle sha at install into .bundle-sha256 so outdated can flag a rebuilt-but-same-version adapter (the aegis argv-fix shape a version compare misses). Best-effort, catalogue-installs-only — correct.

// 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.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

① Next-steps self-heal — the titular feature. The one-line hook: ensureNextStepsFresh runs before every appstore call. Steady state is a single stat; on a TTL miss it spends one bounded ~400ms fetch. Clean. One thing worth a sentence in the body: this is an inline, pre-IPC fetch on the call's critical path (once per app per 12h).

// 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)
Expand Down
146 changes: 140 additions & 6 deletions cmd/pilotctl/appstore_nextsteps.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import (
"path/filepath"
"regexp"
"strings"
"time"
)

// nextStepsFileName is the per-app cache written at install, read at call.
Expand Down Expand Up @@ -371,27 +372,160 @@ 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
// app id. Best-effort and quiet: an app with no catalogue entry (a sideload), no
// 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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

① core. The marker design (.next-steps.checked / .next-steps.retry) and the authoritative-vs-transient split (fetchNextStepsAuthoritative returning ok) is the right shape — it correctly separates "no graph, stop asking for 12h" from "couldn't reach, retry in 2m". resolveUnder on every write/remove matches the existing cache path. LGTM.

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) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (not blocking): fetchNextStepsTimed returns on the 400ms deadline, but the spawned goroutine keeps running fetchNextStepsAuthoritative until the underlying HTTP finishes. The buffered channel avoids a send-leak, but the fetch itself is only bounded by whatever timeout loadCatalogue/loadAppMetadata carry. Worth confirming that path has its own deadline — otherwise a slow host accumulates one lingering goroutine per call past TTL.

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
}
86 changes: 86 additions & 0 deletions cmd/pilotctl/appstore_nextsteps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)

// The error strings below are VERBATIM, captured from a live daemon on this
Expand Down Expand Up @@ -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")
}
}
Loading
Loading