From d46b9a2fc391791acef911262faf759b79899e6a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 12:03:53 -0400 Subject: [PATCH 1/2] feat: wire SolutionServer into agents.Serve (#290) Add a Solution field to PluginRegistration and register it during agent startup, so a solution plugin can expose its gRPC contract through the standard Serve() path alongside Provider and the other capabilities. Extract the unconditional server registrations into a testable registerServices helper and cover Solution registration with a test. Co-Authored-By: Claude Opus 4.8 --- agents/agents.go | 55 +++++++++++++++++++++++-------------- agents/registration_test.go | 28 +++++++++++++++++++ 2 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 agents/registration_test.go diff --git a/agents/agents.go b/agents/agents.go index 7e12eaec..38ff038d 100644 --- a/agents/agents.go +++ b/agents/agents.go @@ -20,6 +20,7 @@ import ( codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" providerv0 "github.com/codefly-dev/core/generated/go/codefly/services/provider/v0" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" toolboxv0 "github.com/codefly-dev/core/generated/go/codefly/services/toolbox/v0" toolingv0 "github.com/codefly-dev/core/generated/go/codefly/services/tooling/v0" "github.com/codefly-dev/core/grpcconfig" @@ -84,6 +85,7 @@ type PluginRegistration struct { Tooling toolingv0.ToolingServer // Transitional: collapses into Toolbox via lang.* convention. Toolbox toolboxv0.ToolboxServer // The unified callable contract (MCP-shape). Provider providerv0.ProviderServer // Provider-neutral external binding reconciliation. + Solution solutionv0.SolutionServer // Solution executor: scaffold/package/render a solution spec. ExecutionExporter executionv1.ExecutionExporterServer // Product-neutral receipt exporter plugin. // PDP gates Toolbox tool calls when non-nil. Wires the @@ -347,6 +349,36 @@ func ResetRPCStats() { rpcStats = make(map[string]*rpcMethodStats) } +// registerServices wires each non-nil plugin server onto the gRPC server. +// The Toolbox path is handled separately in Serve because its PDP wrapping +// is env-dependent and fails closed via os.Exit. +func registerServices(s grpc.ServiceRegistrar, reg PluginRegistration) { + if reg.Agent != nil { + agentv0.RegisterAgentServer(s, reg.Agent) + } + if reg.Runtime != nil { + runtimev0.RegisterRuntimeServer(s, reg.Runtime) + } + if reg.Builder != nil { + builderv0.RegisterBuilderServer(s, reg.Builder) + } + if reg.Code != nil { + codev0.RegisterCodeServer(s, reg.Code) + } + if reg.ExecutionExporter != nil { + executionv1.RegisterExecutionExporterServer(s, reg.ExecutionExporter) + } + if reg.Tooling != nil { + toolingv0.RegisterToolingServer(s, reg.Tooling) + } + if reg.Provider != nil { + providerv0.RegisterProviderServer(s, reg.Provider) + } + if reg.Solution != nil { + solutionv0.RegisterSolutionServer(s, reg.Solution) + } +} + // Serve starts a gRPC server, registers the plugin's services, // signals its endpoint to the CLI via stdout, and blocks until the // process is terminated. @@ -450,27 +482,8 @@ func Serve(reg PluginRegistration) { ) s := grpc.NewServer(serverOptions...) - if reg.Agent != nil { - agentv0.RegisterAgentServer(s, reg.Agent) - } - if reg.Runtime != nil { - runtimev0.RegisterRuntimeServer(s, reg.Runtime) - } - if reg.Builder != nil { - builderv0.RegisterBuilderServer(s, reg.Builder) - } - if reg.Code != nil { - codev0.RegisterCodeServer(s, reg.Code) - } - if reg.ExecutionExporter != nil { - executionv1.RegisterExecutionExporterServer(s, reg.ExecutionExporter) - } - if reg.Tooling != nil { - toolingv0.RegisterToolingServer(s, reg.Tooling) - } - if reg.Provider != nil { - providerv0.RegisterProviderServer(s, reg.Provider) - } + registerServices(s, reg) + if reg.Toolbox != nil { // Wrap with policyguard.Guard when a PDP is configured. The // Guard intercepts CallTool/ReadResource/GetPrompt and routes diff --git a/agents/registration_test.go b/agents/registration_test.go new file mode 100644 index 00000000..45af7fad --- /dev/null +++ b/agents/registration_test.go @@ -0,0 +1,28 @@ +package agents + +import ( + "testing" + + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" + "google.golang.org/grpc" +) + +const solutionServiceName = "codefly.services.solution.v0.Solution" + +func TestRegisterServices_RegistersSolutionWhenSet(t *testing.T) { + s := grpc.NewServer() + registerServices(s, PluginRegistration{Solution: &solutionv0.UnimplementedSolutionServer{}}) + + if _, ok := s.GetServiceInfo()[solutionServiceName]; !ok { + t.Fatalf("Solution server not registered; got services %v", s.GetServiceInfo()) + } +} + +func TestRegisterServices_SkipsSolutionWhenNil(t *testing.T) { + s := grpc.NewServer() + registerServices(s, PluginRegistration{}) + + if _, ok := s.GetServiceInfo()[solutionServiceName]; ok { + t.Fatal("Solution server registered despite nil PluginRegistration.Solution") + } +} From cf6960fe1428563c87c9da95089707064ca36180 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 16 Aug 2026 12:22:53 -0400 Subject: [PATCH 2/2] test: exercise Serve's Solution registration end-to-end (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior test asserted a private registerServices helper via GetServiceInfo, so it could not catch a regression that stopped Serve from calling the registration (both tests passed against the helper, not against Serve). The helper existed only to enable that weaker test. Register Solution inline in Serve exactly like Provider (dropping the helper), and replace the unit test with a real spawn test: it builds a testdata solution agent, starts it through Serve, dials over gRPC, and asserts a Solution RPC routes to the fixture handler. Removing the registration in Serve now surfaces as an Unimplemented "unknown service" error — verified by temporarily deleting the registration. No mocks; the test drives the actual Serve path. Co-Authored-By: Claude Opus 4.8 --- agents/agents.go | 56 ++++++------- agents/registration_test.go | 28 ------- agents/serve_solution_test.go | 108 ++++++++++++++++++++++++++ agents/testdata/solutionagent/main.go | 26 +++++++ 4 files changed, 158 insertions(+), 60 deletions(-) delete mode 100644 agents/registration_test.go create mode 100644 agents/serve_solution_test.go create mode 100644 agents/testdata/solutionagent/main.go diff --git a/agents/agents.go b/agents/agents.go index 38ff038d..04211dc1 100644 --- a/agents/agents.go +++ b/agents/agents.go @@ -349,36 +349,6 @@ func ResetRPCStats() { rpcStats = make(map[string]*rpcMethodStats) } -// registerServices wires each non-nil plugin server onto the gRPC server. -// The Toolbox path is handled separately in Serve because its PDP wrapping -// is env-dependent and fails closed via os.Exit. -func registerServices(s grpc.ServiceRegistrar, reg PluginRegistration) { - if reg.Agent != nil { - agentv0.RegisterAgentServer(s, reg.Agent) - } - if reg.Runtime != nil { - runtimev0.RegisterRuntimeServer(s, reg.Runtime) - } - if reg.Builder != nil { - builderv0.RegisterBuilderServer(s, reg.Builder) - } - if reg.Code != nil { - codev0.RegisterCodeServer(s, reg.Code) - } - if reg.ExecutionExporter != nil { - executionv1.RegisterExecutionExporterServer(s, reg.ExecutionExporter) - } - if reg.Tooling != nil { - toolingv0.RegisterToolingServer(s, reg.Tooling) - } - if reg.Provider != nil { - providerv0.RegisterProviderServer(s, reg.Provider) - } - if reg.Solution != nil { - solutionv0.RegisterSolutionServer(s, reg.Solution) - } -} - // Serve starts a gRPC server, registers the plugin's services, // signals its endpoint to the CLI via stdout, and blocks until the // process is terminated. @@ -482,8 +452,30 @@ func Serve(reg PluginRegistration) { ) s := grpc.NewServer(serverOptions...) - registerServices(s, reg) - + if reg.Agent != nil { + agentv0.RegisterAgentServer(s, reg.Agent) + } + if reg.Runtime != nil { + runtimev0.RegisterRuntimeServer(s, reg.Runtime) + } + if reg.Builder != nil { + builderv0.RegisterBuilderServer(s, reg.Builder) + } + if reg.Code != nil { + codev0.RegisterCodeServer(s, reg.Code) + } + if reg.ExecutionExporter != nil { + executionv1.RegisterExecutionExporterServer(s, reg.ExecutionExporter) + } + if reg.Tooling != nil { + toolingv0.RegisterToolingServer(s, reg.Tooling) + } + if reg.Provider != nil { + providerv0.RegisterProviderServer(s, reg.Provider) + } + if reg.Solution != nil { + solutionv0.RegisterSolutionServer(s, reg.Solution) + } if reg.Toolbox != nil { // Wrap with policyguard.Guard when a PDP is configured. The // Guard intercepts CallTool/ReadResource/GetPrompt and routes diff --git a/agents/registration_test.go b/agents/registration_test.go deleted file mode 100644 index 45af7fad..00000000 --- a/agents/registration_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package agents - -import ( - "testing" - - solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" - "google.golang.org/grpc" -) - -const solutionServiceName = "codefly.services.solution.v0.Solution" - -func TestRegisterServices_RegistersSolutionWhenSet(t *testing.T) { - s := grpc.NewServer() - registerServices(s, PluginRegistration{Solution: &solutionv0.UnimplementedSolutionServer{}}) - - if _, ok := s.GetServiceInfo()[solutionServiceName]; !ok { - t.Fatalf("Solution server not registered; got services %v", s.GetServiceInfo()) - } -} - -func TestRegisterServices_SkipsSolutionWhenNil(t *testing.T) { - s := grpc.NewServer() - registerServices(s, PluginRegistration{}) - - if _, ok := s.GetServiceInfo()[solutionServiceName]; ok { - t.Fatal("Solution server registered despite nil PluginRegistration.Solution") - } -} diff --git a/agents/serve_solution_test.go b/agents/serve_solution_test.go new file mode 100644 index 00000000..bb539bb3 --- /dev/null +++ b/agents/serve_solution_test.go @@ -0,0 +1,108 @@ +package agents + +import ( + "bufio" + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +// TestServeRegistersSolution spawns a real agent binary that registers a +// Solution server through PluginRegistration and confirms Serve exposes the +// contract: a Solution RPC over the wire returns the fixture's sentinel. +// Because the fixture implements GetSolutionInformation (rather than +// inheriting the embedded Unimplemented), a nil error + sentinel proves Serve +// registered the service and routed to the handler — deleting the Solution +// registration in Serve would surface here as an Unimplemented "unknown +// service" error, which the embedded default could not produce. +func TestServeRegistersSolution(t *testing.T) { + binary := filepath.Join(t.TempDir(), "solutionagent") + build := exec.Command("go", "build", "-o", binary, "./testdata/solutionagent") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build solution fixture: %v\n%s", err, output) + } + + const token = "test-token" + cmd := exec.Command(binary) + // Empty UDS path forces the TCP loopback handshake (dns:///127.0.0.1:PORT), + // sidestepping the OS sun_path length limit on long temp-dir socket paths. + cmd.Env = append(os.Environ(), "CODEFLY_AGENT_TOKEN="+token, "CODEFLY_AGENT_UDS_PATH=") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + + endpoint := readHandshakeEndpoint(t, stdout, &stderr) + + conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("dial %s: %v", endpoint, err) + } + t.Cleanup(func() { _ = conn.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, AuthMetadataKey, token) + + resp, err := solutionv0.NewSolutionClient(conn).GetSolutionInformation(ctx, &solutionv0.GetSolutionInformationRequest{}) + if err != nil { + t.Fatalf("GetSolutionInformation: %v\nagent stderr:\n%s", err, stderr.String()) + } + if resp.GetArtifact().GetName() != "solution-fixture" { + t.Fatalf("Solution RPC did not route to the registered handler: %+v", resp) + } +} + +// readHandshakeEndpoint reads the "VERSION|endpoint" line the agent writes to +// stdout on startup, verifies the protocol version, and returns the endpoint. +func readHandshakeEndpoint(t *testing.T, stdout io.Reader, stderr *bytes.Buffer) string { + t.Helper() + type result struct { + line string + err error + } + lines := make(chan result, 1) + go func() { + line, err := bufio.NewReader(stdout).ReadString('\n') + lines <- result{line: line, err: err} + }() + + select { + case r := <-lines: + if r.err != nil { + t.Fatalf("read handshake: %v\nagent stderr:\n%s", r.err, stderr.String()) + } + parts := strings.SplitN(strings.TrimSpace(r.line), "|", 2) + if len(parts) != 2 { + t.Fatalf("malformed handshake %q", r.line) + } + if version, err := strconv.Atoi(parts[0]); err != nil || version != ProtocolVersion { + t.Fatalf("handshake version %q != %d", parts[0], ProtocolVersion) + } + return parts[1] + case <-time.After(10 * time.Second): + t.Fatalf("agent did not emit handshake within 10s\nagent stderr:\n%s", stderr.String()) + return "" + } +} diff --git a/agents/testdata/solutionagent/main.go b/agents/testdata/solutionagent/main.go new file mode 100644 index 00000000..9efbcac4 --- /dev/null +++ b/agents/testdata/solutionagent/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + + "github.com/codefly-dev/core/agents" + solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" +) + +// server implements a single Solution RPC returning a sentinel so the +// registration test can distinguish "Serve registered Solution and routed +// to this handler" from gRPC's Unimplemented (which the embedded +// UnimplementedSolutionServer would also return for an unregistered service). +type server struct { + solutionv0.UnimplementedSolutionServer +} + +func (server) GetSolutionInformation(context.Context, *solutionv0.GetSolutionInformationRequest) (*solutionv0.GetSolutionInformationResponse, error) { + return &solutionv0.GetSolutionInformationResponse{ + Artifact: &solutionv0.SolutionArtifact{Name: "solution-fixture"}, + }, nil +} + +func main() { + agents.Serve(agents.PluginRegistration{Solution: server{}}) +}