From 3096059d2828748cceeba78a9973825edf570d3f Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Sun, 26 Jul 2026 15:27:32 +0300 Subject: [PATCH] libpilot: register the webhook plugin in the embedded daemon PilotSetWebhook forwards to the daemon's set-webhook IPC command, which calls Daemon.SetWebhookURL. That method returns immediately when no WebhookManager is registered, so on an embedded daemon the call was acknowledged and then discarded: callers got a success reply and no events were ever delivered. Construct and register the webhook plugin alongside the trust, handshake and policy plugins, and install the daemon-side adapter so SetWebhookURL has a target. Composition moves out of the cgo file into plugins.go so it can be exercised from a Go test (test files cannot import "C"), and the "mirrors cmd/daemon composition root" comment is replaced with the actual set plus why skillinject and the app-store supervisor are left out of a daemon running inside a host application process. Tests cover the registered set and assert that SetWebhookURL now reaches the plugin, in both the set and clear directions. Co-Authored-By: Claude Opus 5 --- embedded.go | 26 ++--------- go.mod | 1 + go.sum | 2 + plugins.go | 110 ++++++++++++++++++++++++++++++++++++++++++++ zz_plugins_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 plugins.go create mode 100644 zz_plugins_test.go diff --git a/embedded.go b/embedded.go index 5a6b7cd..a0cd49b 100644 --- a/embedded.go +++ b/embedded.go @@ -34,11 +34,8 @@ import ( "time" "github.com/pilot-protocol/common/crypto" - "github.com/pilot-protocol/handshake" "github.com/pilot-protocol/pilotprotocol/pkg/daemon" - "github.com/pilot-protocol/policy" "github.com/pilot-protocol/runtime" - "github.com/pilot-protocol/trustedagents" ) type embeddedNode struct { @@ -119,27 +116,12 @@ func PilotEmbeddedStart(configJSON *C.char) *C.char { Encrypt: true, }) - dapi := d.DaemonAPI() - rt := runtime.New(dapi) + rt := runtime.New(d.DaemonAPI()) - // Register trust + handshake plugin (mirrors cmd/daemon composition root). - ta := trustedagents.NewService() - if err := rt.Register(ta); err != nil { - return errJSON(fmt.Errorf("register trustedagents: %w", err)) + // Plugin set + daemon-side adapters (see plugins.go). + if _, err := registerEmbeddedPlugins(d, rt); err != nil { + return errJSON(err) } - d.RegisterTrustChecker(ta) - - hsSvc := handshake.NewService(runtime.NewHandshakeRuntime(dapi)) - if err := rt.Register(hsSvc); err != nil { - return errJSON(fmt.Errorf("register handshake: %w", err)) - } - d.RegisterHandshakeService(runtime.NewHandshakeServiceAdapter(hsSvc)) - - policySvc := policy.NewService(runtime.NewPolicyRuntime(dapi)) - if err := rt.Register(policySvc); err != nil { - return errJSON(fmt.Errorf("register policy: %w", err)) - } - d.RegisterPolicyManager(runtime.AsDaemonPolicyManager(policySvc.Manager())) startCtx := context.Background() if err := rt.StartPlugins(startCtx); err != nil { diff --git a/go.mod b/go.mod index 2a19e60..2b95663 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/pilot-protocol/policy v0.2.3 github.com/pilot-protocol/runtime v0.3.1 github.com/pilot-protocol/trustedagents v0.2.5 + github.com/pilot-protocol/webhook v0.2.0 ) require ( diff --git a/go.sum b/go.sum index 19db51a..1db3e44 100644 --- a/go.sum +++ b/go.sum @@ -18,5 +18,7 @@ github.com/pilot-protocol/runtime v0.3.1 h1:+W9ww0dZY/FgOBtCmIOV3w5L5Z4Upt/RIsrY github.com/pilot-protocol/runtime v0.3.1/go.mod h1:GfFEIji0w7H9SSNR9Wl2q72pd2OYN3PHY9Qhcbvyrqk= github.com/pilot-protocol/trustedagents v0.2.5 h1:zdeezxalidXanOkEcdsageyAp6jVbfhAnPumREbEZt0= github.com/pilot-protocol/trustedagents v0.2.5/go.mod h1:6P0pBKmjKlfiSsCbl7EAIr96LW/9RnoLzdl+rEqmN5E= +github.com/pilot-protocol/webhook v0.2.0 h1:3UFU9X2yBb0iKlPbzVcism+Z6yCrBBaOgdo9+vd4Wf4= +github.com/pilot-protocol/webhook v0.2.0/go.mod h1:WVXhHFg+o0pHHk+4nXMCh1zl/ZAyZ3AXrtx6mNuZS6g= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/plugins.go b/plugins.go new file mode 100644 index 0000000..d27f6f9 --- /dev/null +++ b/plugins.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Plugin composition for the embedded daemon. +// +// This file deliberately avoids `import "C"` so the composition can be +// exercised from a Go test; embedded.go (which is cgo) calls into it. + +package main + +import ( + "fmt" + + "github.com/pilot-protocol/handshake" + "github.com/pilot-protocol/pilotprotocol/pkg/daemon" + "github.com/pilot-protocol/policy" + "github.com/pilot-protocol/runtime" + "github.com/pilot-protocol/trustedagents" + "github.com/pilot-protocol/webhook" +) + +// embeddedPlugins holds the in-process plugin set, so callers keep a +// handle on each service after registration. +type embeddedPlugins struct { + trust *trustedagents.Service + handshake *handshake.Service + policy *policy.Service + webhook *webhook.Service +} + +// registerEmbeddedPlugins constructs the plugin set, registers each +// service with rt, and installs the daemon-side adapters that the IPC +// handlers route through. +// +// This is a subset of what cmd/daemon composes, chosen for a daemon +// living inside a host application process: +// +// trustedagents trust decisions for incoming connections +// handshake manual trust handshake on port 444 +// policy per-peer policy evaluation +// webhook forwards bus events to a caller-set URL +// +// The plugins cmd/daemon additionally runs are host-level concerns that +// do not apply here: skillinject writes into agent tool directories on +// the machine, and the app-store supervisor spawns and supervises child +// binaries. Neither fits a single sandboxed app process. +// +// Registration order matches cmd/daemon; actual start order is decided by +// each service's Order(). +func registerEmbeddedPlugins(d *daemon.Daemon, rt *runtime.Runtime) (*embeddedPlugins, error) { + dapi := rt.Daemon() + p := &embeddedPlugins{} + + p.trust = trustedagents.NewService() + if err := rt.Register(p.trust); err != nil { + return nil, fmt.Errorf("register trustedagents: %w", err) + } + d.RegisterTrustChecker(p.trust) + + p.handshake = handshake.NewService(runtime.NewHandshakeRuntime(dapi)) + if err := rt.Register(p.handshake); err != nil { + return nil, fmt.Errorf("register handshake: %w", err) + } + d.RegisterHandshakeService(runtime.NewHandshakeServiceAdapter(p.handshake)) + + p.policy = policy.NewService(runtime.NewPolicyRuntime(dapi)) + if err := rt.Register(p.policy); err != nil { + return nil, fmt.Errorf("register policy: %w", err) + } + d.RegisterPolicyManager(runtime.AsDaemonPolicyManager(p.policy.Manager())) + + // The daemon publishes lifecycle events onto its in-process bus and + // this plugin forwards them to a URL. Registering the manager is what + // gives Daemon.SetWebhookURL — the target of the set-webhook IPC + // command behind PilotSetWebhook — something to route to; unregistered, + // that call is acknowledged and then discarded. Constructed with no + // URL: the plugin reads any persisted one on start, and callers set it + // at runtime. + p.webhook = webhook.NewService("") + if err := rt.Register(p.webhook); err != nil { + return nil, fmt.Errorf("register webhook: %w", err) + } + d.RegisterWebhookManager(webhookManagerAdapter{svc: p.webhook}) + + return p, nil +} + +// names lists the registered plugins in registration order. +func (p *embeddedPlugins) names() []string { + if p == nil { + return nil + } + return []string{ + p.trust.Name(), + p.handshake.Name(), + p.policy.Name(), + p.webhook.Name(), + } +} + +// webhookManagerAdapter bridges *webhook.Service to the daemon's +// WebhookManager interface. Defined here rather than in the plugin so the +// plugin stays free of pkg/daemon imports — same split cmd/daemon uses. +type webhookManagerAdapter struct{ svc *webhook.Service } + +func (a webhookManagerAdapter) SetURL(url string) { a.svc.SetURL(url) } + +func (a webhookManagerAdapter) Stats() daemon.WebhookStats { + s := a.svc.Stats() + return daemon.WebhookStats{Dropped: s.Dropped, CircuitSkips: s.CircuitSkips} +} diff --git a/zz_plugins_test.go b/zz_plugins_test.go new file mode 100644 index 0000000..cc2e7c6 --- /dev/null +++ b/zz_plugins_test.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +// Composition tests for the embedded plugin set. Like zz_internal_test.go +// this file avoids `import "C"`, so it exercises registerEmbeddedPlugins +// directly rather than the cgo entry point that calls it. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pilot-protocol/pilotprotocol/pkg/daemon" + "github.com/pilot-protocol/runtime" +) + +// newTestDaemon builds a daemon that is never started — New() only +// allocates in-memory state, so nothing binds a socket or touches the +// network here. +func newTestDaemon(t *testing.T) *daemon.Daemon { + t.Helper() + dir := t.TempDir() + return daemon.New(daemon.Config{ + SocketPath: filepath.Join(dir, "pilot.sock"), + IdentityPath: filepath.Join(dir, "identity.json"), + }) +} + +func TestRegisterEmbeddedPluginsRegistersWebhook(t *testing.T) { + d := newTestDaemon(t) + rt := runtime.New(d.DaemonAPI()) + + p, err := registerEmbeddedPlugins(d, rt) + if err != nil { + t.Fatalf("registerEmbeddedPlugins: %v", err) + } + if p.webhook == nil { + t.Fatal("webhook service was not constructed") + } + + want := []string{"trustedagents", "handshake", "policy", "webhook"} + got := p.names() + if len(got) != len(want) { + t.Fatalf("plugin names = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("plugin names = %v, want %v", got, want) + } + } +} + +// SetWebhookURL on the daemon only does anything once a WebhookManager is +// registered; unregistered it returns without touching the plugin. The +// plugin persists every URL it is handed, so the persisted file is the +// observable proof that the call reached it. +func TestEmbeddedSetWebhookURLReachesThePlugin(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + d := newTestDaemon(t) + rt := runtime.New(d.DaemonAPI()) + if _, err := registerEmbeddedPlugins(d, rt); err != nil { + t.Fatalf("registerEmbeddedPlugins: %v", err) + } + + const url = "https://example.com/pilot-hook" + d.SetWebhookURL(url) + + data, err := os.ReadFile(filepath.Join(home, ".pilot", "webhook_url")) + if err != nil { + t.Fatalf("SetWebhookURL did not reach the webhook plugin: %v", err) + } + if got := strings.TrimSpace(string(data)); got != url { + t.Fatalf("persisted URL = %q, want %q", got, url) + } + + // Clearing must reach it too. + d.SetWebhookURL("") + if _, err := os.Stat(filepath.Join(home, ".pilot", "webhook_url")); !os.IsNotExist(err) { + t.Fatalf("clearing the webhook left the persisted URL in place (err = %v)", err) + } +} + +// Registering twice on the same runtime must surface the failure rather +// than leaving a half-composed daemon behind. +func TestRegisterEmbeddedPluginsReportsRegistryErrors(t *testing.T) { + d := newTestDaemon(t) + rt := runtime.New(d.DaemonAPI()) + if _, err := registerEmbeddedPlugins(d, rt); err != nil { + t.Fatalf("first registration: %v", err) + } + if err := rt.StartPlugins(t.Context()); err != nil { + t.Fatalf("StartPlugins: %v", err) + } + t.Cleanup(func() { _ = rt.StopPlugins(t.Context()) }) + + // The registry refuses registration once it has started. + if _, err := registerEmbeddedPlugins(d, rt); err == nil { + t.Fatal("expected an error registering into a started runtime") + } +} + +func TestNilEmbeddedPluginsHasNoNames(t *testing.T) { + var p *embeddedPlugins + if got := p.names(); got != nil { + t.Fatalf("names() = %v, want nil", got) + } +}