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
5 changes: 5 additions & 0 deletions agents/agents.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -471,6 +473,9 @@ func Serve(reg PluginRegistration) {
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
Expand Down
108 changes: 108 additions & 0 deletions agents/serve_solution_test.go
Original file line numberDiff line numberDiff line change
@@ -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 ""
}
}
26 changes: 26 additions & 0 deletions agents/testdata/solutionagent/main.go
Original file line numberDiff line numberDiff line change
@@ -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{}})
}
Loading