Skip to content
Merged
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
26 changes: 4 additions & 22 deletions embedded.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
110 changes: 110 additions & 0 deletions plugins.go
Original file line number Diff line number Diff line change
@@ -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}
}
111 changes: 111 additions & 0 deletions zz_plugins_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading