源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
var cliContractHashPattern = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
|
||||
|
||||
const (
|
||||
maxCLIExecutionArguments = 256
|
||||
maxCLIExecutionArgBytes = 256 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCLIExecutionInvalid = errors.New("connector CLI execution request is invalid")
|
||||
ErrCLIExecutionUnavailable = errors.New("connector CLI execution is unavailable")
|
||||
ErrCLIExecutionIdentityMismatch = errors.New("connector CLI execution identity does not match the current route")
|
||||
)
|
||||
|
||||
// CLIExecutionRequest binds one raw CLI invocation to the exact immutable
|
||||
// route selected and authorized by a product-owned Connector broker.
|
||||
type CLIExecutionRequest struct {
|
||||
ConnectionID string
|
||||
ConnectorKey string
|
||||
ConnectorVersion string
|
||||
ReleaseDigest string
|
||||
Generation market.HostGeneration
|
||||
CLIContractHash string
|
||||
WorkingDirectory string
|
||||
Arguments []string
|
||||
}
|
||||
|
||||
// StartCLI launches one current managed CLI route. The caller supplies only
|
||||
// the per-invocation working directory and argv. The executable, environment,
|
||||
// state path, user home, and artifact identities remain owned by the exact
|
||||
// current Connector route.
|
||||
func (host *Host) StartCLI(ctx context.Context, request CLIExecutionRequest) (agentruntime.ProcessConnection, error) {
|
||||
if err := validateCLIExecutionRequest(ctx, request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if host == nil || host.routes == nil || host.processes == nil {
|
||||
return nil, ErrCLIExecutionUnavailable
|
||||
}
|
||||
route, _ := host.routes.Route(connectorRouteKey(request.ConnectionID, request.ConnectorKey)).(*connectorRoute)
|
||||
if route == nil || !host.routeCurrent(route) || route.cliLaunch == nil || route.cliContractHash == "" {
|
||||
return nil, ErrCLIExecutionUnavailable
|
||||
}
|
||||
if route.connectorVersion != request.ConnectorVersion || route.releaseDigest != request.ReleaseDigest ||
|
||||
route.generation != request.Generation || route.cliContractHash != request.CLIContractHash {
|
||||
return nil, ErrCLIExecutionIdentityMismatch
|
||||
}
|
||||
launch := route.cliLaunch
|
||||
arguments := append(append([]string(nil), launch.arguments...), request.Arguments...)
|
||||
spec := connectorruntime.ConnectorProcessSpec(route.connectionID, route.connectorKey, launch.language,
|
||||
launch.executable, request.WorkingDirectory, arguments, launch.stateDir, route.userHome, launch.artifactTrees)
|
||||
startCtx := ctx
|
||||
var executionDeadline time.Time
|
||||
var cancel context.CancelFunc
|
||||
if launch.timeout > 0 {
|
||||
executionDeadline = time.Now().Add(launch.timeout)
|
||||
startCtx, cancel = context.WithDeadline(ctx, executionDeadline)
|
||||
defer cancel()
|
||||
}
|
||||
connection, processID, err := host.startProcess(startCtx, route, spec, true)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrCLIExecutionUnavailable, err)
|
||||
}
|
||||
return wrapCLIExecutionConnection(route, processID, connection, executionDeadline), nil
|
||||
}
|
||||
|
||||
func validateCLIExecutionRequest(ctx context.Context, request CLIExecutionRequest) error {
|
||||
if ctx == nil || !hostIdentityPattern.MatchString(request.ConnectionID) || !hostIdentityPattern.MatchString(request.ConnectorKey) ||
|
||||
strings.TrimSpace(request.ConnectorVersion) == "" || strings.TrimSpace(request.ReleaseDigest) == "" ||
|
||||
request.Generation.BootEpoch == "" || request.Generation.Generation == 0 ||
|
||||
!cliContractHashPattern.MatchString(request.CLIContractHash) || !filepath.IsAbs(request.WorkingDirectory) ||
|
||||
strings.ContainsRune(request.WorkingDirectory, '\x00') || len(request.Arguments) > maxCLIExecutionArguments {
|
||||
return ErrCLIExecutionInvalid
|
||||
}
|
||||
totalBytes := 0
|
||||
for _, argument := range request.Arguments {
|
||||
totalBytes += len(argument)
|
||||
if strings.ContainsRune(argument, '\x00') || totalBytes > maxCLIExecutionArgBytes {
|
||||
return ErrCLIExecutionInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cliExecutionConnection struct {
|
||||
route *connectorRoute
|
||||
processID uint64
|
||||
connection agentruntime.ProcessConnection
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
execution context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (connection *cliExecutionConnection) Send(payload []byte) error {
|
||||
return connection.connection.Send(payload)
|
||||
}
|
||||
func (connection *cliExecutionConnection) Recv() (agentruntime.ProcessFrame, error) {
|
||||
return connection.connection.Recv()
|
||||
}
|
||||
func (connection *cliExecutionConnection) Close() error {
|
||||
connection.closeOnce.Do(func() {
|
||||
if connection.cancel != nil {
|
||||
connection.cancel()
|
||||
}
|
||||
connection.closeErr = connection.route.releaseProcess(connection.processID, connection.connection)
|
||||
})
|
||||
return connection.closeErr
|
||||
}
|
||||
|
||||
type contextualCLIExecutionConnection struct {
|
||||
*cliExecutionConnection
|
||||
contextual agentruntime.ContextProcessConnection
|
||||
}
|
||||
|
||||
func (connection *contextualCLIExecutionConnection) RecvContext(ctx context.Context) (agentruntime.ProcessFrame, error) {
|
||||
merged, cancel := mergeCLIExecutionContext(ctx, connection.execution)
|
||||
defer cancel()
|
||||
frame, err := connection.contextual.RecvContext(merged)
|
||||
if connection.execution != nil && errors.Is(connection.execution.Err(), context.DeadlineExceeded) {
|
||||
return agentruntime.ProcessFrame{}, context.DeadlineExceeded
|
||||
}
|
||||
return frame, err
|
||||
}
|
||||
|
||||
type gracefulCLIExecutionConnection struct {
|
||||
*cliExecutionConnection
|
||||
graceful agentruntime.GracefulProcessConnection
|
||||
}
|
||||
|
||||
func (connection *gracefulCLIExecutionConnection) CloseInput() error {
|
||||
return connection.graceful.CloseInput()
|
||||
}
|
||||
func (connection *gracefulCLIExecutionConnection) Terminate() error {
|
||||
return connection.graceful.Terminate()
|
||||
}
|
||||
func (connection *gracefulCLIExecutionConnection) Kill() error { return connection.graceful.Kill() }
|
||||
|
||||
type contextualGracefulCLIExecutionConnection struct {
|
||||
*gracefulCLIExecutionConnection
|
||||
contextual agentruntime.ContextProcessConnection
|
||||
}
|
||||
|
||||
func (connection *contextualGracefulCLIExecutionConnection) RecvContext(ctx context.Context) (agentruntime.ProcessFrame, error) {
|
||||
merged, cancel := mergeCLIExecutionContext(ctx, connection.execution)
|
||||
defer cancel()
|
||||
frame, err := connection.contextual.RecvContext(merged)
|
||||
if connection.execution != nil && errors.Is(connection.execution.Err(), context.DeadlineExceeded) {
|
||||
return agentruntime.ProcessFrame{}, context.DeadlineExceeded
|
||||
}
|
||||
return frame, err
|
||||
}
|
||||
|
||||
func wrapCLIExecutionConnection(route *connectorRoute, processID uint64,
|
||||
connection agentruntime.ProcessConnection, executionDeadline time.Time) agentruntime.ProcessConnection {
|
||||
base := &cliExecutionConnection{route: route, processID: processID, connection: connection}
|
||||
if !executionDeadline.IsZero() {
|
||||
base.execution, base.cancel = context.WithDeadline(context.Background(), executionDeadline)
|
||||
}
|
||||
contextual, hasContext := connection.(agentruntime.ContextProcessConnection)
|
||||
graceful, hasGraceful := connection.(agentruntime.GracefulProcessConnection)
|
||||
switch {
|
||||
case hasContext && hasGraceful:
|
||||
return &contextualGracefulCLIExecutionConnection{
|
||||
gracefulCLIExecutionConnection: &gracefulCLIExecutionConnection{cliExecutionConnection: base, graceful: graceful},
|
||||
contextual: contextual,
|
||||
}
|
||||
case hasContext:
|
||||
return &contextualCLIExecutionConnection{cliExecutionConnection: base, contextual: contextual}
|
||||
case hasGraceful:
|
||||
return &gracefulCLIExecutionConnection{cliExecutionConnection: base, graceful: graceful}
|
||||
default:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
func mergeCLIExecutionContext(caller, execution context.Context) (context.Context, context.CancelFunc) {
|
||||
if execution == nil {
|
||||
return context.WithCancel(caller)
|
||||
}
|
||||
merged, cancel := context.WithCancel(caller)
|
||||
stop := context.AfterFunc(execution, cancel)
|
||||
return merged, func() {
|
||||
stop()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
const cliExecutorTestDigest = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
const cliExecutorTestContractHash = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
|
||||
type cliExecutionTransportStub struct {
|
||||
mu sync.Mutex
|
||||
specs []agentruntime.ProcessSpec
|
||||
connection *cliExecutionConnectionStub
|
||||
}
|
||||
|
||||
func (transport *cliExecutionTransportStub) Start(_ context.Context, spec agentruntime.ProcessSpec) (agentruntime.ProcessConnection, error) {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
transport.specs = append(transport.specs, spec)
|
||||
transport.connection = &cliExecutionConnectionStub{}
|
||||
return transport.connection, nil
|
||||
}
|
||||
|
||||
type cliExecutionConnectionStub struct {
|
||||
mu sync.Mutex
|
||||
closed int
|
||||
inputClosed int
|
||||
terminated int
|
||||
killed int
|
||||
recvContextual int
|
||||
blockRecv bool
|
||||
}
|
||||
|
||||
func (*cliExecutionConnectionStub) Send([]byte) error { return nil }
|
||||
func (*cliExecutionConnectionStub) Recv() (agentruntime.ProcessFrame, error) {
|
||||
return agentruntime.ProcessFrame{}, io.EOF
|
||||
}
|
||||
func (connection *cliExecutionConnectionStub) RecvContext(ctx context.Context) (agentruntime.ProcessFrame, error) {
|
||||
connection.mu.Lock()
|
||||
connection.recvContextual++
|
||||
block := connection.blockRecv
|
||||
connection.mu.Unlock()
|
||||
if block {
|
||||
<-ctx.Done()
|
||||
return agentruntime.ProcessFrame{}, ctx.Err()
|
||||
}
|
||||
return agentruntime.ProcessFrame{}, io.EOF
|
||||
}
|
||||
func (connection *cliExecutionConnectionStub) Close() error {
|
||||
connection.mu.Lock()
|
||||
connection.closed++
|
||||
connection.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (connection *cliExecutionConnectionStub) CloseInput() error {
|
||||
connection.mu.Lock()
|
||||
connection.inputClosed++
|
||||
connection.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (connection *cliExecutionConnectionStub) Terminate() error {
|
||||
connection.mu.Lock()
|
||||
connection.terminated++
|
||||
connection.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (connection *cliExecutionConnectionStub) Kill() error {
|
||||
connection.mu.Lock()
|
||||
connection.killed++
|
||||
connection.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStartCLIExecutesExactCurrentRouteAndOwnsLifecycle(t *testing.T) {
|
||||
host, route, transport, request := newCLIExecutionTestHost(t)
|
||||
connection, err := host.StartCLI(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.processes.ActiveCount() != 1 {
|
||||
t.Fatalf("active processes = %d, want 1", route.processes.ActiveCount())
|
||||
}
|
||||
transport.mu.Lock()
|
||||
spec := transport.specs[0]
|
||||
inner := transport.connection
|
||||
transport.mu.Unlock()
|
||||
wantCommand := append([]string{route.cliLaunch.executable.Path}, route.cliLaunch.arguments...)
|
||||
wantCommand = append(wantCommand, request.Arguments...)
|
||||
if !reflect.DeepEqual(spec.Command, wantCommand) {
|
||||
t.Fatalf("command = %#v, want %#v", spec.Command, wantCommand)
|
||||
}
|
||||
if spec.CWD != request.WorkingDirectory || !reflect.DeepEqual(spec.Env, []string{
|
||||
"TUTTI_CONNECTOR_CONNECTION_ID=connection-1", "TUTTI_CONNECTOR_KEY=lark-cli", "TUTTI_CONNECTOR_LANGUAGE=node",
|
||||
"TUTTI_CONNECTOR_STATE_DIR=" + route.cliLaunch.stateDir, "HOME=" + route.userHome, "USERPROFILE=" + route.userHome,
|
||||
}) {
|
||||
t.Fatalf("process spec = %#v", spec)
|
||||
}
|
||||
if _, ok := connection.(agentruntime.ContextProcessConnection); !ok {
|
||||
t.Fatal("context process capability was not preserved")
|
||||
}
|
||||
graceful, ok := connection.(agentruntime.GracefulProcessConnection)
|
||||
if !ok {
|
||||
t.Fatal("graceful process capability was not preserved")
|
||||
}
|
||||
if err := graceful.CloseInput(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inner.mu.Lock()
|
||||
defer inner.mu.Unlock()
|
||||
if inner.closed != 1 || inner.inputClosed != 1 || route.processes.ActiveCount() != 0 {
|
||||
t.Fatalf("closed=%d inputClosed=%d active=%d", inner.closed, inner.inputClosed, route.processes.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCLIFailsClosedForStaleOrInvalidIdentity(t *testing.T) {
|
||||
host, _, transport, request := newCLIExecutionTestHost(t)
|
||||
|
||||
stale := request
|
||||
stale.CLIContractHash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
if _, err := host.StartCLI(context.Background(), stale); !errors.Is(err, ErrCLIExecutionIdentityMismatch) {
|
||||
t.Fatalf("stale identity error = %v", err)
|
||||
}
|
||||
invalid := request
|
||||
invalid.Arguments = []string{"bad\x00argument"}
|
||||
if _, err := host.StartCLI(context.Background(), invalid); !errors.Is(err, ErrCLIExecutionInvalid) {
|
||||
t.Fatalf("invalid argument error = %v", err)
|
||||
}
|
||||
invalid = request
|
||||
invalid.WorkingDirectory = "relative/workspace"
|
||||
if _, err := host.StartCLI(context.Background(), invalid); !errors.Is(err, ErrCLIExecutionInvalid) {
|
||||
t.Fatalf("relative working directory error = %v", err)
|
||||
}
|
||||
invalid = request
|
||||
invalid.WorkingDirectory += "\x00suffix"
|
||||
if _, err := host.StartCLI(context.Background(), invalid); !errors.Is(err, ErrCLIExecutionInvalid) {
|
||||
t.Fatalf("NUL working directory error = %v", err)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
if len(transport.specs) != 0 {
|
||||
t.Fatalf("unexpected process starts = %d", len(transport.specs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCLIEnforcesManifestTimeout(t *testing.T) {
|
||||
host, route, transport, request := newCLIExecutionTestHost(t)
|
||||
route.cliLaunch.timeout = 20 * time.Millisecond
|
||||
connection, err := host.StartCLI(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport.connection.mu.Lock()
|
||||
transport.connection.blockRecv = true
|
||||
transport.connection.mu.Unlock()
|
||||
contextual, ok := connection.(agentruntime.ContextProcessConnection)
|
||||
if !ok {
|
||||
t.Fatal("timed CLI connection is not contextual")
|
||||
}
|
||||
if _, err := contextual.RecvContext(context.Background()); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("timed CLI receive error = %v, want deadline exceeded", err)
|
||||
}
|
||||
if err := connection.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCLIUsesWorkingDirectoryForRelativePathResolution(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
workingDirectory := filepath.Join(workspace, "project", "reports")
|
||||
if err := os.MkdirAll(workingDirectory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workingDirectory, "report.pdf"), []byte("workspace-relative-content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executable, identity := copyCLIExecutorTestExecutable(t)
|
||||
processes, err := agentruntime.NewConnectorProcessTransport()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generation := market.HostGeneration{BootEpoch: "boot-fixture", Generation: 1}
|
||||
route := &connectorRoute{
|
||||
id: connectorRouteKey("cwd-fixture", "fixture-cli"), connectionID: "cwd-fixture", connectorKey: "fixture-cli",
|
||||
connectorVersion: "1.0.0", releaseDigest: cliExecutorTestDigest, generation: generation,
|
||||
processes: connectorruntime.NewProcessGroup(), userHome: filepath.Join(workspace, "connector-home"),
|
||||
cliContractHash: cliExecutorTestContractHash,
|
||||
cliLaunch: &managedCLILaunch{
|
||||
executable: connectorruntime.ConnectorExecutable{Path: executable, SHA256: identity.SHA256, SizeBytes: identity.SizeBytes},
|
||||
cwd: filepath.Join(workspace, "connector-installation"), language: "native", stateDir: filepath.Join(workspace, "state"),
|
||||
},
|
||||
}
|
||||
table := connectorruntime.NewRouteTable()
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := &Host{routes: table, processes: processes}
|
||||
connection, err := host.StartCLI(context.Background(), CLIExecutionRequest{
|
||||
ConnectionID: route.connectionID, ConnectorKey: route.connectorKey, ConnectorVersion: route.connectorVersion,
|
||||
ReleaseDigest: route.releaseDigest, Generation: route.generation, CLIContractHash: route.cliContractHash,
|
||||
WorkingDirectory: workingDirectory, Arguments: []string{"-test.run=^TestCLIExecutorWorkingDirectoryFixture$"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer connection.Close()
|
||||
var stdout strings.Builder
|
||||
for {
|
||||
frame, err := connection.Recv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Write(frame.Stdout)
|
||||
if frame.ExitCode == nil {
|
||||
continue
|
||||
}
|
||||
if *frame.ExitCode != 0 {
|
||||
t.Fatalf("fixture exit code = %d, stderr = %q", *frame.ExitCode, frame.Stderr)
|
||||
}
|
||||
break
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "workspace-relative-content") {
|
||||
t.Fatalf("fixture stdout = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExecutorWorkingDirectoryFixture(t *testing.T) {
|
||||
if os.Getenv("TUTTI_CONNECTOR_CONNECTION_ID") != "cwd-fixture" {
|
||||
return
|
||||
}
|
||||
contents, err := os.ReadFile("report.pdf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Print(string(contents))
|
||||
}
|
||||
|
||||
func copyCLIExecutorTestExecutable(t *testing.T) (string, *agentruntime.ExecutableIdentity) {
|
||||
t.Helper()
|
||||
source, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "connector-cli-fixture"+filepath.Ext(source))
|
||||
if err := os.WriteFile(target, contents, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(contents)
|
||||
return target, &agentruntime.ExecutableIdentity{SHA256: hex.EncodeToString(digest[:]), SizeBytes: int64(len(contents))}
|
||||
}
|
||||
|
||||
func newCLIExecutionTestHost(t *testing.T) (*Host, *connectorRoute, *cliExecutionTransportStub, CLIExecutionRequest) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
snapshot := filepath.Join(root, "snapshot")
|
||||
generation := market.HostGeneration{BootEpoch: "boot-1", Generation: 7}
|
||||
transport := &cliExecutionTransportStub{}
|
||||
table := connectorruntime.NewRouteTable()
|
||||
route := &connectorRoute{
|
||||
id: connectorRouteKey("connection-1", "lark-cli"), connectionID: "connection-1", connectorKey: "lark-cli",
|
||||
connectorVersion: "1.2.3", releaseDigest: cliExecutorTestDigest, generation: generation,
|
||||
processes: connectorruntime.NewProcessGroup(), userHome: filepath.Join(root, "home", "owner"), cliContractHash: cliExecutorTestContractHash,
|
||||
cliLaunch: &managedCLILaunch{
|
||||
arguments: []string{filepath.Join(snapshot, "lark-cli.mjs"), "--json"}, cwd: snapshot, language: "node",
|
||||
stateDir: filepath.Join(root, "state"),
|
||||
executable: connectorruntime.ConnectorExecutable{Path: filepath.Join(root, "managed", "node"), SHA256: "node-digest", SizeBytes: 42},
|
||||
},
|
||||
}
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := &Host{routes: table, processes: transport}
|
||||
request := CLIExecutionRequest{
|
||||
ConnectionID: "connection-1", ConnectorKey: "lark-cli", ConnectorVersion: "1.2.3", ReleaseDigest: cliExecutorTestDigest,
|
||||
Generation: generation, CLIContractHash: cliExecutorTestContractHash,
|
||||
WorkingDirectory: filepath.Join(root, "workspace", "project", "reports"),
|
||||
Arguments: []string{"message", "send", "--text", "hello"},
|
||||
}
|
||||
return host, route, transport, request
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
func TestConnectorCLIShimExecutesVerifiedEntrypointThroughNormalShellPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix shim execution test")
|
||||
}
|
||||
root := t.TempDir()
|
||||
working := filepath.Join(root, "working directory")
|
||||
binDir := filepath.Join(root, "bin")
|
||||
if err := os.MkdirAll(working, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entrypoint := filepath.Join(root, "connector executable")
|
||||
entrypointContent := "#!/bin/sh\nprintf '%s|%s|%s|%s' \"$TUTTI_CONNECTOR_KEY\" \"$PWD\" \"$1\" \"$2\"\n"
|
||||
if err := os.WriteFile(entrypoint, []byte(entrypointContent), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := &connectorRoute{
|
||||
connectionID: "default", connectorKey: "github", userHome: root,
|
||||
generation: market.HostGeneration{BootEpoch: "boot", Generation: 1},
|
||||
cliLaunch: &managedCLILaunch{executable: connectorruntime.ConnectorExecutable{Path: entrypoint},
|
||||
arguments: []string{"fixed argument"}, cwd: working, stateDir: filepath.Join(root, "state"), language: "node"},
|
||||
}
|
||||
if err := route.prepareCLIShim(binDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := route.activateCLIShim(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := exec.Command(route.cliShimPath, "user argument").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("execute CLI shim: %v: %s", err, output)
|
||||
}
|
||||
want := strings.Join([]string{"github", working, "fixed argument", "user argument"}, "|")
|
||||
if string(output) != want {
|
||||
t.Fatalf("CLI output = %q, want %q", output, want)
|
||||
}
|
||||
route.removeCLIShimIfCurrent()
|
||||
if _, err := os.Stat(route.cliShimPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI shim remained after route removal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachCLIUsesArtifactNativeExecutableWithoutManagedRuntimeArgument(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
entrypointRelative := "runtime/windows-amd64/gh.exe"
|
||||
entrypoint := filepath.Join(root, filepath.FromSlash(entrypointRelative))
|
||||
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := []byte("verified gh fixture")
|
||||
if err := os.WriteFile(entrypoint, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managed := &market.ManagedStdioImplementation{Runtime: market.RuntimeRequirement{Language: "node"},
|
||||
CLI: &market.ManagedCLIInterface{Entrypoint: entrypointRelative, Command: "gh", TimeoutMS: 30_000,
|
||||
Launch: &market.CLIArtifactLaunch{Kind: market.CLIArtifactLaunchKindNative,
|
||||
SHA256: strings.Repeat("a", 64), SizeBytes: int64(len(content))}}}
|
||||
route := &connectorRoute{}
|
||||
if err := (&Host{}).attachCLI(route, managed, market.PreparedArtifactReceipt{PreparedPath: root}, nil,
|
||||
connectorruntime.ConnectorExecutable{Path: filepath.Join(root, "node")}, filepath.Join(root, "state"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.cliLaunch.executable.Path != entrypoint || route.cliLaunch.executable.SHA256 != strings.Repeat("a", 64) ||
|
||||
route.cliLaunch.executable.SizeBytes != int64(len(content)) || len(route.cliLaunch.arguments) != 0 ||
|
||||
route.cliInvocationCommand != "gh" {
|
||||
t.Fatalf("artifact-native CLI launch = %#v", route.cliLaunch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
const credentialBrokerInitialEventTimeout = 30 * time.Second
|
||||
const credentialBrokerContinuationWait = time.Second
|
||||
|
||||
type managedCLILaunch struct {
|
||||
arguments []string
|
||||
artifactTrees []agentruntime.ArtifactTreeIdentity
|
||||
cwd string
|
||||
executable connectorruntime.ConnectorExecutable
|
||||
language string
|
||||
stateDir string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type credentialBrokerCLILaunch struct {
|
||||
Executable string `json:"executable"`
|
||||
Arguments []string `json:"arguments"`
|
||||
CWD string `json:"cwd"`
|
||||
}
|
||||
|
||||
type managedCredentialBrokerLaunch struct {
|
||||
entrypoint string
|
||||
timeout time.Duration
|
||||
allowedHosts map[string]struct{}
|
||||
cliLaunch credentialBrokerCLILaunch
|
||||
executable connectorruntime.ConnectorExecutable
|
||||
language string
|
||||
cwd string
|
||||
artifactTrees []agentruntime.ArtifactTreeIdentity
|
||||
stateDir string
|
||||
}
|
||||
|
||||
type managedCredentialAuthorizationHost interface {
|
||||
authorizationRoute(context.Context, market.OperationScope, market.Connector) (*connectorRoute, error)
|
||||
startCredentialBroker(context.Context, *connectorRoute, credentialBrokerRequest) (agentruntime.ProcessConnection, uint64, error)
|
||||
observeAuthorization(*connectorRoute, market.AuthorizationState)
|
||||
releaseAuthorizationRoute(*connectorRoute)
|
||||
}
|
||||
|
||||
// managedCredentialAuthorizationProvider owns the host-side lifecycle of a
|
||||
// connector-provided credential broker. Provider-specific commands, response
|
||||
// parsing, and secret handoffs stay inside the verified connector adapter.
|
||||
type managedCredentialAuthorizationProvider struct {
|
||||
host managedCredentialAuthorizationHost
|
||||
mu sync.Mutex
|
||||
sessions map[string]*credentialBrokerSession
|
||||
activeByRoute map[string]string
|
||||
}
|
||||
|
||||
type credentialBrokerRequest struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
|
||||
type credentialBrokerEvent struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type credentialBrokerSession struct {
|
||||
operationID string
|
||||
route *connectorRoute
|
||||
connection agentruntime.ProcessConnection
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
state market.AuthorizationState
|
||||
url string
|
||||
userCode string
|
||||
err error
|
||||
version uint64
|
||||
hasEvent bool
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
func newManagedCredentialAuthorizationProvider(host managedCredentialAuthorizationHost) *managedCredentialAuthorizationProvider {
|
||||
return &managedCredentialAuthorizationProvider{
|
||||
host: host, sessions: make(map[string]*credentialBrokerSession), activeByRoute: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (host *Host) BeginAuthorization(ctx context.Context, request market.AuthorizationStartRequest) (market.AuthorizationSession, error) {
|
||||
if host == nil || host.authorizationProvider == nil {
|
||||
return market.AuthorizationSession{}, errors.New("connector authorization provider is unavailable")
|
||||
}
|
||||
return host.authorizationProvider.Begin(ctx, request)
|
||||
}
|
||||
|
||||
func (host *Host) DisconnectAuthorization(ctx context.Context, request market.AuthorizationDisconnectRequest) error {
|
||||
if host == nil || host.authorizationProvider == nil {
|
||||
return errors.New("connector authorization provider is unavailable")
|
||||
}
|
||||
return host.authorizationProvider.Disconnect(ctx, request)
|
||||
}
|
||||
|
||||
func (host *Host) CancelAuthorization(ctx context.Context, request market.AuthorizationCancelRequest) error {
|
||||
if host == nil || host.authorizationProvider == nil {
|
||||
return errors.New("connector authorization provider is unavailable")
|
||||
}
|
||||
return host.authorizationProvider.Cancel(ctx, request)
|
||||
}
|
||||
|
||||
func (host *Host) InspectAuthorization(ctx context.Context, request market.AuthorizationInspectRequest) (market.AuthorizationObservation, error) {
|
||||
if host == nil || host.authorizationProvider == nil {
|
||||
return market.AuthorizationObservation{}, errors.New("connector authorization inspector is unavailable")
|
||||
}
|
||||
return host.authorizationProvider.Inspect(ctx, request)
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) Begin(
|
||||
ctx context.Context,
|
||||
request market.AuthorizationStartRequest,
|
||||
) (market.AuthorizationSession, error) {
|
||||
connector := request.Connector
|
||||
connector.Release = request.Release
|
||||
route, err := provider.host.authorizationRoute(ctx, request.Scope, connector)
|
||||
if err != nil {
|
||||
return market.AuthorizationSession{}, err
|
||||
}
|
||||
session, err := provider.authorizationSessionOrStart(route, request.OperationID, request.StepRevisionBase)
|
||||
if err != nil {
|
||||
return market.AuthorizationSession{}, err
|
||||
}
|
||||
if err := session.awaitEventAfter(ctx, request.AfterStepRevision); err != nil {
|
||||
cleanupContext, cancelCleanup := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
cleanupErr := provider.cancelAuthorizationSession(cleanupContext, session)
|
||||
cancelCleanup()
|
||||
return market.AuthorizationSession{}, errors.Join(err, cleanupErr)
|
||||
}
|
||||
state, authorizationURL, userCode, stepRevision, sessionErr := session.snapshot()
|
||||
if sessionErr != nil {
|
||||
provider.clearAuthorizationSession(request.OperationID, session)
|
||||
provider.host.releaseAuthorizationRoute(route)
|
||||
return market.AuthorizationSession{}, sessionErr
|
||||
}
|
||||
result := market.AuthorizationSession{
|
||||
OperationID: request.OperationID,
|
||||
ConnectorKey: request.Connector.Key,
|
||||
ConnectionID: route.connectionID,
|
||||
SessionID: request.OperationID + "/credential-broker",
|
||||
AuthorizationURL: authorizationURL,
|
||||
UserCode: userCode,
|
||||
StepRevision: stepRevision,
|
||||
State: state,
|
||||
}
|
||||
if state == market.AuthorizationStateConnected {
|
||||
provider.clearAuthorizationSession(request.OperationID, session)
|
||||
provider.host.releaseAuthorizationRoute(route)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) Cancel(
|
||||
ctx context.Context,
|
||||
request market.AuthorizationCancelRequest,
|
||||
) error {
|
||||
provider.mu.Lock()
|
||||
session := provider.sessions[strings.TrimSpace(request.OperationID)]
|
||||
provider.mu.Unlock()
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
return provider.cancelAuthorizationSession(ctx, session)
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) Disconnect(
|
||||
ctx context.Context,
|
||||
request market.AuthorizationDisconnectRequest,
|
||||
) error {
|
||||
route, err := provider.host.authorizationRoute(ctx, request.Scope, request.Connector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session := provider.takeAuthorizationSessionByRoute(route.id); session != nil {
|
||||
session.cancel()
|
||||
select {
|
||||
case <-session.done:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
operationContext, cancel := context.WithTimeout(ctx, route.credentialBrokerLaunch.timeout)
|
||||
defer cancel()
|
||||
connection, processID, err := provider.host.startCredentialBroker(operationContext, route, credentialBrokerRequest{
|
||||
Protocol: market.CredentialBrokerProtocolV1, Operation: "disconnect",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("start connector credential broker disconnect: %w", err)
|
||||
}
|
||||
defer func() { _ = route.releaseProcess(processID, connection) }()
|
||||
event, err := readCredentialBrokerTerminalEvent(operationContext, connection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("disconnect connector authorization: %w", err)
|
||||
}
|
||||
if event.Type != "disconnected" {
|
||||
return credentialBrokerEventError(event, "disconnect")
|
||||
}
|
||||
provider.host.observeAuthorization(route, market.AuthorizationStateDisconnected)
|
||||
provider.host.releaseAuthorizationRoute(route)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) Inspect(
|
||||
ctx context.Context,
|
||||
request market.AuthorizationInspectRequest,
|
||||
) (market.AuthorizationObservation, error) {
|
||||
route, err := provider.host.authorizationRoute(ctx, request.Scope, request.Connector)
|
||||
if err != nil {
|
||||
return market.AuthorizationObservation{}, err
|
||||
}
|
||||
operationContext, cancel := context.WithTimeout(ctx, route.credentialBrokerLaunch.timeout)
|
||||
defer cancel()
|
||||
connection, processID, err := provider.host.startCredentialBroker(operationContext, route, credentialBrokerRequest{
|
||||
Protocol: market.CredentialBrokerProtocolV1, Operation: "inspect",
|
||||
})
|
||||
if err != nil {
|
||||
return market.AuthorizationObservation{}, fmt.Errorf("start connector credential broker inspect: %w", err)
|
||||
}
|
||||
defer func() { _ = route.releaseProcess(processID, connection) }()
|
||||
event, err := readCredentialBrokerInspectionEvent(operationContext, connection)
|
||||
if err != nil {
|
||||
return market.AuthorizationObservation{}, fmt.Errorf("inspect connector authorization: %w", err)
|
||||
}
|
||||
var state market.AuthorizationObservationState
|
||||
switch event.Type {
|
||||
case "connected":
|
||||
state = market.AuthorizationObservationConnected
|
||||
case "disconnected":
|
||||
state = market.AuthorizationObservationDisconnected
|
||||
case "expired":
|
||||
state = market.AuthorizationObservationExpired
|
||||
case "error":
|
||||
state = market.AuthorizationObservationFailed
|
||||
default:
|
||||
return market.AuthorizationObservation{}, fmt.Errorf("connector credential broker returned unsupported inspect event %q", event.Type)
|
||||
}
|
||||
connector := request.Connector
|
||||
return market.AuthorizationObservation{
|
||||
AccountID: request.Scope.AccountID, AccountGeneration: request.AccountGeneration,
|
||||
VMAssignmentID: request.VMAssignmentID, ConnectorKey: connector.Key, ConnectionID: route.connectionID,
|
||||
ReleaseDigest: connector.Release.ReleaseDigest, AuthorizationSessionID: request.AuthorizationSessionID,
|
||||
AuthorizationGeneration: request.AuthorizationGeneration, DesktopBootEpoch: request.DesktopBootEpoch,
|
||||
GuestBootID: request.GuestBootID, RuntimeEpoch: request.RuntimeEpoch, StateRevision: request.StateRevision,
|
||||
State: state, Reason: strings.TrimSpace(event.Message), FailureCode: strings.TrimSpace(event.Code), ObservedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) authorizationSessionOrStart(
|
||||
route *connectorRoute,
|
||||
operationID string,
|
||||
stepRevisionBase uint64,
|
||||
) (*credentialBrokerSession, error) {
|
||||
operationID = strings.TrimSpace(operationID)
|
||||
provider.mu.Lock()
|
||||
if session := provider.sessions[operationID]; session != nil {
|
||||
_, _, _, _, sessionErr := session.snapshot()
|
||||
if sessionErr == nil {
|
||||
provider.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
// A broker can fail after emitting its first authorization URL. Do not
|
||||
// make the next user action consume that terminal error merely to clear
|
||||
// the cache; replace it with a fresh broker in this same request.
|
||||
delete(provider.sessions, operationID)
|
||||
if provider.activeByRoute[route.id] == operationID {
|
||||
delete(provider.activeByRoute, route.id)
|
||||
}
|
||||
}
|
||||
if activeOperationID := provider.activeByRoute[route.id]; activeOperationID != "" {
|
||||
active := provider.sessions[activeOperationID]
|
||||
if active != nil {
|
||||
_, _, _, _, activeErr := active.snapshot()
|
||||
if activeErr != nil {
|
||||
select {
|
||||
case <-active.done:
|
||||
delete(provider.sessions, activeOperationID)
|
||||
delete(provider.activeByRoute, route.id)
|
||||
default:
|
||||
provider.mu.Unlock()
|
||||
return nil, errors.New("connector credential broker termination is still pending")
|
||||
}
|
||||
} else {
|
||||
provider.mu.Unlock()
|
||||
return nil, errors.New("connector credential broker has an active authorization attempt")
|
||||
}
|
||||
} else {
|
||||
delete(provider.activeByRoute, route.id)
|
||||
}
|
||||
}
|
||||
processContext, cancel := context.WithTimeout(context.Background(), route.credentialBrokerLaunch.timeout)
|
||||
connection, processID, err := provider.host.startCredentialBroker(processContext, route, credentialBrokerRequest{
|
||||
Protocol: market.CredentialBrokerProtocolV1, Operation: "begin",
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
provider.mu.Unlock()
|
||||
return nil, fmt.Errorf("start connector credential broker: %w", err)
|
||||
}
|
||||
session := &credentialBrokerSession{
|
||||
operationID: operationID, route: route, connection: connection, cancel: cancel,
|
||||
done: make(chan struct{}), version: stepRevisionBase, changed: make(chan struct{}),
|
||||
}
|
||||
provider.sessions[operationID] = session
|
||||
provider.activeByRoute[route.id] = operationID
|
||||
provider.mu.Unlock()
|
||||
go consumeAuthorizationEvents(processContext, provider.host, route, connection, processID, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func consumeAuthorizationEvents(
|
||||
ctx context.Context,
|
||||
host managedCredentialAuthorizationHost,
|
||||
route *connectorRoute,
|
||||
connection agentruntime.ProcessConnection,
|
||||
processID uint64,
|
||||
session *credentialBrokerSession,
|
||||
) {
|
||||
defer session.cancel()
|
||||
defer close(session.done)
|
||||
defer func() { _ = route.releaseProcess(processID, connection) }()
|
||||
var stdout, stderr strings.Builder
|
||||
for {
|
||||
frame, err := receiveCredentialBrokerFrameContext(ctx, connection)
|
||||
if err != nil {
|
||||
// Explicit cancellation owns the terminal state transition. Do not race
|
||||
// the caller by projecting a transient broker failure while shutdown is
|
||||
// already in progress.
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, io.EOF) {
|
||||
failAuthorizationSession(host, route, session, fmt.Errorf("receive connector credential broker event: %w", err))
|
||||
} else if !session.terminal() {
|
||||
failAuthorizationSession(host, route, session, errors.New("connector credential broker exited before a terminal event"))
|
||||
}
|
||||
return
|
||||
}
|
||||
stdout.Write(frame.Stdout)
|
||||
stderr.Write(frame.Stderr)
|
||||
if stdout.Len()+stderr.Len() > 1<<20 {
|
||||
failAuthorizationSession(host, route, session, errors.New("connector credential broker output exceeded its limit"))
|
||||
return
|
||||
}
|
||||
for {
|
||||
line, remaining, ok := strings.Cut(stdout.String(), "\n")
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
stdout.Reset()
|
||||
stdout.WriteString(remaining)
|
||||
if err := applyCredentialBrokerEvent(host, route, session, line); err != nil {
|
||||
failAuthorizationSession(host, route, session, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if frame.ExitCode != nil {
|
||||
if trailing := strings.TrimSpace(stdout.String()); trailing != "" {
|
||||
if err := applyCredentialBrokerEvent(host, route, session, trailing); err != nil {
|
||||
failAuthorizationSession(host, route, session, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if *frame.ExitCode != 0 && !session.terminal() {
|
||||
failAuthorizationSession(host, route, session, fmt.Errorf("connector credential broker exited with code %d: %s", *frame.ExitCode, boundedBrokerMessage(stderr.String())))
|
||||
} else if !session.terminal() {
|
||||
failAuthorizationSession(host, route, session, errors.New("connector credential broker exited before a terminal event"))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyCredentialBrokerEvent(host managedCredentialAuthorizationHost, route *connectorRoute, session *credentialBrokerSession, payload string) error {
|
||||
if strings.TrimSpace(payload) == "" {
|
||||
return nil
|
||||
}
|
||||
var event credentialBrokerEvent
|
||||
if err := decodeCredentialBrokerEvent(payload, &event); err != nil {
|
||||
return fmt.Errorf("decode connector credential broker event: %w", err)
|
||||
}
|
||||
switch event.Type {
|
||||
case "authorization_url":
|
||||
if !safeCredentialBrokerURL(event.URL, route.credentialBrokerLaunch.allowedHosts) {
|
||||
return errors.New("connector credential broker returned an unauthorized URL")
|
||||
}
|
||||
userCode, err := normalizeCredentialBrokerUserCode(event.Code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.update(market.AuthorizationStatePending, event.URL, userCode, nil)
|
||||
host.observeAuthorization(route, market.AuthorizationStatePending)
|
||||
case "connected":
|
||||
session.update(market.AuthorizationStateConnected, "", "", nil)
|
||||
host.observeAuthorization(route, market.AuthorizationStateConnected)
|
||||
case "error":
|
||||
return credentialBrokerEventError(event, "authorize")
|
||||
default:
|
||||
return fmt.Errorf("connector credential broker returned unsupported event type %q", event.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (host *Host) authorizationRoute(ctx context.Context, scope market.OperationScope, connector market.Connector) (*connectorRoute, error) {
|
||||
if host == nil {
|
||||
return nil, errors.New("managed connector authorization is unavailable")
|
||||
}
|
||||
releaseLane := host.enterConnectorLane(connector.Key)
|
||||
defer releaseLane()
|
||||
managed := connector.Release.Manifest.Implementation.ManagedStdio
|
||||
if connector.Release.Manifest.Implementation.Kind != market.ImplementationKindManagedStdio ||
|
||||
connector.Release.Manifest.AuthorizationKind == "none" || managed == nil || managed.CredentialBroker == nil ||
|
||||
!installationTargetsRelease(connector.Installation, connector.Release.ReleaseDigest) {
|
||||
return nil, errors.New("managed connector authorization is unavailable")
|
||||
}
|
||||
connectionID := "default"
|
||||
if accountID := strings.TrimSpace(scope.AccountID); accountID != "" {
|
||||
connectionID = market.AccountRuntimeConnectionID(accountID, connector.Key)
|
||||
}
|
||||
key := connectorRouteKey(connectionID, connector.Key)
|
||||
if route, _ := host.routes.Route(key).(*connectorRoute); route != nil && host.routeCurrent(route) && route.credentialBrokerLaunch != nil {
|
||||
return route, nil
|
||||
}
|
||||
host.authorizationMu.Lock()
|
||||
if route := host.authorizationRoutes[key]; route != nil && route.releaseDigest == connector.Release.ReleaseDigest &&
|
||||
route.credentialBrokerLaunch != nil && !route.processes.IsFenced() {
|
||||
host.authorizationMu.Unlock()
|
||||
return route, nil
|
||||
}
|
||||
host.authorizationMu.Unlock()
|
||||
route, err := host.buildAuthorizationRoute(ctx, connectionID, connector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host.authorizationMu.Lock()
|
||||
current := host.authorizationRoutes[key]
|
||||
if current == nil || current.releaseDigest != connector.Release.ReleaseDigest || current.processes.IsFenced() {
|
||||
host.authorizationRoutes[key] = route
|
||||
host.authorizationMu.Unlock()
|
||||
if current != nil {
|
||||
current.Fence()
|
||||
_ = current.Close(time.Now().Add(3 * time.Second))
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
host.authorizationMu.Unlock()
|
||||
route.Fence()
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (host *Host) buildAuthorizationRoute(ctx context.Context, connectionID string, connector market.Connector) (*connectorRoute, error) {
|
||||
if err := market.ValidateRuntimeReleaseShape(connector.Release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !installationTargetsRelease(connector.Installation, connector.Release.ReleaseDigest) {
|
||||
return nil, errors.New("managed connector authorization release is not installed")
|
||||
}
|
||||
prepared, err := host.artifacts.ResolvePrepared(ctx, connector.Release)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve prepared connector artifact for authorization: %w", err)
|
||||
}
|
||||
installedRoot := prepared.PreparedPath
|
||||
executionRoot, err := host.snapshots.Create(prepared, artifactNativeEntrypoints(connector.Release)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create connector authorization snapshot: %w", err)
|
||||
}
|
||||
prepared.PreparedPath = executionRoot
|
||||
generation := connector.Revision
|
||||
if generation == 0 {
|
||||
generation = 1
|
||||
}
|
||||
runtimeRequest := market.RuntimeReconcileRequest{
|
||||
ConnectionID: connectionID,
|
||||
Connector: connector,
|
||||
Generation: market.HostGeneration{BootEpoch: "authorization", Generation: generation},
|
||||
}
|
||||
plan, err := host.planner.Build(ctx, runtimeRequest, prepared)
|
||||
if err != nil {
|
||||
_ = host.snapshots.Remove(executionRoot)
|
||||
return nil, err
|
||||
}
|
||||
route := newConnectorRoute(runtimeRequest)
|
||||
route.userHome = plan.UserHome
|
||||
route.executionRoot = executionRoot
|
||||
route.installedRoot = installedRoot
|
||||
route.snapshots = host.snapshots
|
||||
if plan.Managed.CLI == nil || plan.Managed.CredentialBroker == nil {
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return nil, errors.New("managed connector authorization requires a CLI credential broker")
|
||||
}
|
||||
if err := host.attachCLI(route, plan.Managed, prepared, plan.InstalledCLI, plan.Executable, plan.StateDir, plan.ArtifactTrees); err != nil {
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return nil, err
|
||||
}
|
||||
if err := host.attachCredentialBroker(route, plan.Managed.CredentialBroker, prepared, plan.Executable, plan.StateDir, plan.ArtifactTrees); err != nil {
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return nil, err
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func (host *Host) observeAuthorization(route *connectorRoute, state market.AuthorizationState) {
|
||||
if host == nil || host.authorizationObserver == nil || route == nil {
|
||||
return
|
||||
}
|
||||
host.authorizationObserver.ObserveAuthorization(context.Background(), AuthorizationObservation{
|
||||
ConnectorKey: route.connectorKey, ConnectionID: route.connectionID, State: state, ObservedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (host *Host) releaseAuthorizationRoute(route *connectorRoute) {
|
||||
if host == nil || route == nil {
|
||||
return
|
||||
}
|
||||
host.authorizationMu.Lock()
|
||||
if host.authorizationRoutes[route.id] == route {
|
||||
delete(host.authorizationRoutes, route.id)
|
||||
} else {
|
||||
host.authorizationMu.Unlock()
|
||||
return
|
||||
}
|
||||
host.authorizationMu.Unlock()
|
||||
route.Fence()
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
}
|
||||
|
||||
func (host *Host) releaseAuthorizationRouteByKey(key string) {
|
||||
if host == nil {
|
||||
return
|
||||
}
|
||||
host.authorizationMu.Lock()
|
||||
route := host.authorizationRoutes[key]
|
||||
delete(host.authorizationRoutes, key)
|
||||
host.authorizationMu.Unlock()
|
||||
if route != nil {
|
||||
route.Fence()
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
func failAuthorizationSession(host managedCredentialAuthorizationHost, route *connectorRoute, session *credentialBrokerSession, err error) {
|
||||
session.fail(err)
|
||||
host.observeAuthorization(route, market.AuthorizationStateFailed)
|
||||
}
|
||||
|
||||
func (host *Host) startCredentialBroker(
|
||||
ctx context.Context,
|
||||
route *connectorRoute,
|
||||
request credentialBrokerRequest,
|
||||
) (agentruntime.ProcessConnection, uint64, error) {
|
||||
launch := route.credentialBrokerLaunch
|
||||
if launch == nil || request.Protocol != market.CredentialBrokerProtocolV1 ||
|
||||
(request.Operation != "begin" && request.Operation != "inspect" && request.Operation != "disconnect") {
|
||||
return nil, 0, errors.New("connector credential broker request is invalid")
|
||||
}
|
||||
spec := connectorruntime.ConnectorProcessSpec(route.connectionID, route.connectorKey, launch.language, launch.executable,
|
||||
launch.cwd, []string{launch.entrypoint}, launch.stateDir, route.userHome, launch.artifactTrees)
|
||||
cliLaunch, err := json.Marshal(launch.cliLaunch)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
spec.Env = append(spec.Env,
|
||||
"TUTTI_CONNECTOR_CREDENTIAL_BROKER_PROTOCOL="+market.CredentialBrokerProtocolV1,
|
||||
"TUTTI_CONNECTOR_CLI_LAUNCH_JSON="+string(cliLaunch),
|
||||
)
|
||||
connection, processID, err := host.startProcess(ctx, route, spec, false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
payload, err := json.Marshal(request)
|
||||
if err == nil {
|
||||
err = connection.Send(append(payload, '\n'))
|
||||
}
|
||||
if err == nil {
|
||||
if graceful, ok := connection.(agentruntime.GracefulProcessConnection); ok {
|
||||
err = graceful.CloseInput()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
_ = route.releaseProcess(processID, connection)
|
||||
return nil, 0, err
|
||||
}
|
||||
return connection, processID, nil
|
||||
}
|
||||
|
||||
func receiveCredentialBrokerFrameContext(ctx context.Context, connection agentruntime.ProcessConnection) (agentruntime.ProcessFrame, error) {
|
||||
if contextual, ok := connection.(agentruntime.ContextProcessConnection); ok {
|
||||
return contextual.RecvContext(ctx)
|
||||
}
|
||||
return connection.Recv()
|
||||
}
|
||||
|
||||
func readCredentialBrokerTerminalEvent(ctx context.Context, connection agentruntime.ProcessConnection) (credentialBrokerEvent, error) {
|
||||
var output strings.Builder
|
||||
for {
|
||||
frame, err := receiveCredentialBrokerFrameContext(ctx, connection)
|
||||
if err != nil {
|
||||
return credentialBrokerEvent{}, err
|
||||
}
|
||||
output.Write(frame.Stdout)
|
||||
if output.Len() > 1<<20 {
|
||||
return credentialBrokerEvent{}, errors.New("connector credential broker output exceeded its limit")
|
||||
}
|
||||
for {
|
||||
line, remaining, ok := strings.Cut(output.String(), "\n")
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
output.Reset()
|
||||
output.WriteString(remaining)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var event credentialBrokerEvent
|
||||
if err := decodeCredentialBrokerEvent(line, &event); err != nil {
|
||||
return credentialBrokerEvent{}, err
|
||||
}
|
||||
if event.Type == "disconnected" || event.Type == "error" {
|
||||
return event, nil
|
||||
}
|
||||
return credentialBrokerEvent{}, fmt.Errorf("unexpected credential broker disconnect event %q", event.Type)
|
||||
}
|
||||
if frame.ExitCode != nil {
|
||||
return credentialBrokerEvent{}, errors.New("connector credential broker exited before disconnect completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readCredentialBrokerInspectionEvent(ctx context.Context, connection agentruntime.ProcessConnection) (credentialBrokerEvent, error) {
|
||||
var output strings.Builder
|
||||
for {
|
||||
frame, err := receiveCredentialBrokerFrameContext(ctx, connection)
|
||||
if err != nil {
|
||||
return credentialBrokerEvent{}, err
|
||||
}
|
||||
output.Write(frame.Stdout)
|
||||
if output.Len() > 1<<20 {
|
||||
return credentialBrokerEvent{}, errors.New("connector credential broker output exceeded its limit")
|
||||
}
|
||||
for {
|
||||
line, remaining, ok := strings.Cut(output.String(), "\n")
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
output.Reset()
|
||||
output.WriteString(remaining)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var event credentialBrokerEvent
|
||||
if err := decodeCredentialBrokerEvent(line, &event); err != nil {
|
||||
return credentialBrokerEvent{}, err
|
||||
}
|
||||
switch event.Type {
|
||||
case "connected", "disconnected", "expired", "error":
|
||||
return event, nil
|
||||
default:
|
||||
return credentialBrokerEvent{}, fmt.Errorf("unexpected credential broker inspect event %q", event.Type)
|
||||
}
|
||||
}
|
||||
if frame.ExitCode != nil {
|
||||
return credentialBrokerEvent{}, errors.New("connector credential broker exited before inspect completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCredentialBrokerEvent(payload string, event *credentialBrokerEvent) error {
|
||||
decoder := json.NewDecoder(strings.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(event); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return errors.New("credential broker event contains trailing JSON")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeCredentialBrokerURL(value string, allowedHosts map[string]struct{}) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() != "" {
|
||||
return false
|
||||
}
|
||||
_, allowed := allowedHosts[strings.ToLower(parsed.Hostname())]
|
||||
return allowed
|
||||
}
|
||||
|
||||
func credentialBrokerEventError(event credentialBrokerEvent, operation string) error {
|
||||
message := boundedBrokerMessage(event.Message)
|
||||
if message == "" {
|
||||
message = "connector credential broker reported an error"
|
||||
}
|
||||
if code := strings.TrimSpace(event.Code); code != "" {
|
||||
return fmt.Errorf("%s connector authorization (%s): %s", operation, code, message)
|
||||
}
|
||||
return fmt.Errorf("%s connector authorization: %s", operation, message)
|
||||
}
|
||||
|
||||
func boundedBrokerMessage(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) > 4096 {
|
||||
message = message[len(message)-4096:]
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func normalizeCredentialBrokerUserCode(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if utf8.RuneCountInString(value) > 128 {
|
||||
return "", errors.New("connector credential broker returned an oversized device code")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (session *credentialBrokerSession) awaitEventAfter(ctx context.Context, afterStepRevision uint64) error {
|
||||
session.mu.Lock()
|
||||
hasEvent := session.hasEvent
|
||||
session.mu.Unlock()
|
||||
wait := credentialBrokerContinuationWait
|
||||
if !hasEvent {
|
||||
wait = credentialBrokerInitialEventTimeout
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
session.mu.Lock()
|
||||
hasEvent = session.hasEvent
|
||||
version := session.version
|
||||
state := session.state
|
||||
changed := session.changed
|
||||
session.mu.Unlock()
|
||||
if hasEvent && (version > afterStepRevision || state == market.AuthorizationStateConnected || state == market.AuthorizationStateFailed) {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-changed:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
session.mu.Lock()
|
||||
hasEvent = session.hasEvent
|
||||
session.mu.Unlock()
|
||||
if !hasEvent {
|
||||
return errors.New("connector credential broker did not return an initial event")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (session *credentialBrokerSession) update(state market.AuthorizationState, authorizationURL, userCode string, err error) {
|
||||
session.mu.Lock()
|
||||
session.state = state
|
||||
session.url = authorizationURL
|
||||
session.userCode = userCode
|
||||
session.err = err
|
||||
session.version++
|
||||
session.hasEvent = true
|
||||
close(session.changed)
|
||||
session.changed = make(chan struct{})
|
||||
session.mu.Unlock()
|
||||
}
|
||||
|
||||
func (session *credentialBrokerSession) fail(err error) {
|
||||
session.update(market.AuthorizationStateFailed, "", "", err)
|
||||
}
|
||||
|
||||
func (session *credentialBrokerSession) snapshot() (market.AuthorizationState, string, string, uint64, error) {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
return session.state, session.url, session.userCode, session.version, session.err
|
||||
}
|
||||
|
||||
func (session *credentialBrokerSession) terminal() bool {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
return session.state == market.AuthorizationStateConnected || session.err != nil
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) takeAuthorizationSessionByRoute(routeID string) *credentialBrokerSession {
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
operationID := provider.activeByRoute[routeID]
|
||||
session := provider.sessions[operationID]
|
||||
delete(provider.activeByRoute, routeID)
|
||||
delete(provider.sessions, operationID)
|
||||
return session
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) cancelAuthorizationSession(
|
||||
ctx context.Context,
|
||||
session *credentialBrokerSession,
|
||||
) error {
|
||||
if provider == nil || session == nil {
|
||||
return nil
|
||||
}
|
||||
session.cancel()
|
||||
var closeErr error
|
||||
if session.connection != nil {
|
||||
closeErr = session.connection.Close()
|
||||
}
|
||||
select {
|
||||
case <-session.done:
|
||||
provider.clearAuthorizationSession(session.operationID, session)
|
||||
provider.host.releaseAuthorizationRoute(session.route)
|
||||
return closeErr
|
||||
case <-ctx.Done():
|
||||
return errors.Join(closeErr, fmt.Errorf("wait for connector credential broker termination: %w", ctx.Err()))
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) cancelAuthorizationSessionByRoute(routeID string) {
|
||||
if provider == nil {
|
||||
return
|
||||
}
|
||||
if session := provider.takeAuthorizationSessionByRoute(routeID); session != nil {
|
||||
session.cancel()
|
||||
if session.connection != nil {
|
||||
_ = session.connection.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) clearAuthorizationSession(operationID string, session *credentialBrokerSession) {
|
||||
provider.mu.Lock()
|
||||
if provider.sessions[operationID] == session {
|
||||
delete(provider.sessions, operationID)
|
||||
}
|
||||
if session != nil && provider.activeByRoute[session.route.id] == operationID {
|
||||
delete(provider.activeByRoute, session.route.id)
|
||||
}
|
||||
provider.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
)
|
||||
|
||||
func (host *Host) ObserveAuthorization(ctx context.Context, request market.AuthorizationObserveRequest) (market.AuthorizationObservation, error) {
|
||||
if host == nil || host.authorizationProvider == nil {
|
||||
return market.AuthorizationObservation{}, errors.New("connector authorization observer is unavailable")
|
||||
}
|
||||
return host.authorizationProvider.Observe(ctx, request)
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) Observe(
|
||||
ctx context.Context,
|
||||
request market.AuthorizationObserveRequest,
|
||||
) (market.AuthorizationObservation, error) {
|
||||
connector := request.Connector
|
||||
connector.Release = request.Release
|
||||
route, err := provider.host.authorizationRoute(ctx, request.Scope, connector)
|
||||
if err != nil {
|
||||
return market.AuthorizationObservation{}, err
|
||||
}
|
||||
if session := provider.activeAuthorizationSession(route, request.Session); session != nil {
|
||||
state, _, _, _, sessionErr := session.snapshot()
|
||||
observationState := market.AuthorizationObservationPending
|
||||
failureCode := ""
|
||||
reason := ""
|
||||
switch state {
|
||||
case market.AuthorizationStateConnected:
|
||||
observationState = market.AuthorizationObservationConnected
|
||||
case market.AuthorizationStateFailed:
|
||||
observationState = market.AuthorizationObservationFailed
|
||||
failureCode = "credential_broker_failed"
|
||||
}
|
||||
if sessionErr != nil {
|
||||
observationState = market.AuthorizationObservationFailed
|
||||
failureCode = "credential_broker_failed"
|
||||
reason = boundedBrokerMessage(sessionErr.Error())
|
||||
}
|
||||
return market.AuthorizationObservation{
|
||||
AccountID: request.Scope.AccountID, ConnectorKey: connector.Key, ConnectionID: route.connectionID,
|
||||
ReleaseDigest: request.Release.ReleaseDigest, AuthorizationSessionID: request.Session.SessionID,
|
||||
State: observationState, Reason: reason, FailureCode: failureCode, ObservedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
observation, err := provider.Inspect(ctx, market.AuthorizationInspectRequest{
|
||||
Scope: request.Scope, Connector: connector, AuthorizationSessionID: request.Session.SessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return market.AuthorizationObservation{}, err
|
||||
}
|
||||
switch observation.State {
|
||||
case market.AuthorizationObservationDisconnected:
|
||||
observation.State = market.AuthorizationObservationPending
|
||||
case market.AuthorizationObservationExpired:
|
||||
observation.State = market.AuthorizationObservationFailed
|
||||
if observation.FailureCode == "" {
|
||||
observation.FailureCode = "connector_authorization_expired"
|
||||
}
|
||||
}
|
||||
return observation, nil
|
||||
}
|
||||
|
||||
func (provider *managedCredentialAuthorizationProvider) activeAuthorizationSession(
|
||||
route *connectorRoute,
|
||||
wanted market.AuthorizationSession,
|
||||
) *credentialBrokerSession {
|
||||
operationID := strings.TrimSpace(wanted.OperationID)
|
||||
if route == nil || operationID == "" || strings.TrimSpace(wanted.SessionID) != operationID+"/credential-broker" {
|
||||
return nil
|
||||
}
|
||||
provider.mu.Lock()
|
||||
defer provider.mu.Unlock()
|
||||
session := provider.sessions[operationID]
|
||||
if session == nil || session.route == nil || session.route.id != route.id {
|
||||
return nil
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
func TestAttachCredentialBrokerPreservesEmptyNativeCLIArguments(t *testing.T) {
|
||||
preparedPath := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(preparedPath, "credential-broker.mjs"), []byte("export {};\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := &connectorRoute{cliLaunch: &managedCLILaunch{
|
||||
arguments: []string{}, cwd: preparedPath,
|
||||
executable: connectorruntime.ConnectorExecutable{Path: "/managed/lark-cli"},
|
||||
}}
|
||||
err := (&Host{}).attachCredentialBroker(route, &market.ManagedCredentialBroker{
|
||||
Entrypoint: "credential-broker.mjs", TimeoutMS: 300_000,
|
||||
}, market.PreparedArtifactReceipt{PreparedPath: preparedPath}, connectorruntime.ConnectorExecutable{}, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := json.Marshal(route.credentialBrokerLaunch.cliLaunch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(payload), `"arguments":[]`) {
|
||||
t.Fatalf("credential broker CLI launch = %s, want empty JSON array", payload)
|
||||
}
|
||||
}
|
||||
|
||||
type credentialAuthorizationHostStub struct {
|
||||
mu sync.Mutex
|
||||
route *connectorRoute
|
||||
connections []agentruntime.ProcessConnection
|
||||
requests []credentialBrokerRequest
|
||||
observed []market.AuthorizationState
|
||||
}
|
||||
|
||||
func (stub *credentialAuthorizationHostStub) authorizationRoute(context.Context, market.OperationScope, market.Connector) (*connectorRoute, error) {
|
||||
if stub.route == nil {
|
||||
return nil, errors.New("route unavailable")
|
||||
}
|
||||
return stub.route, nil
|
||||
}
|
||||
|
||||
func (stub *credentialAuthorizationHostStub) observeAuthorization(_ *connectorRoute, state market.AuthorizationState) {
|
||||
stub.mu.Lock()
|
||||
defer stub.mu.Unlock()
|
||||
stub.observed = append(stub.observed, state)
|
||||
}
|
||||
|
||||
func (stub *credentialAuthorizationHostStub) authorizationObservations() []market.AuthorizationState {
|
||||
stub.mu.Lock()
|
||||
defer stub.mu.Unlock()
|
||||
return append([]market.AuthorizationState(nil), stub.observed...)
|
||||
}
|
||||
|
||||
func (*credentialAuthorizationHostStub) releaseAuthorizationRoute(*connectorRoute) {}
|
||||
|
||||
func (stub *credentialAuthorizationHostStub) startCredentialBroker(
|
||||
_ context.Context,
|
||||
_ *connectorRoute,
|
||||
request credentialBrokerRequest,
|
||||
) (agentruntime.ProcessConnection, uint64, error) {
|
||||
stub.requests = append(stub.requests, request)
|
||||
if len(stub.connections) == 0 {
|
||||
return nil, 0, errors.New("unexpected credential broker start")
|
||||
}
|
||||
connection := stub.connections[0]
|
||||
stub.connections = stub.connections[1:]
|
||||
return connection, uint64(len(stub.requests)), nil
|
||||
}
|
||||
|
||||
type credentialBrokerConnection struct {
|
||||
frames chan agentruntime.ProcessFrame
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func newCredentialBrokerConnection() *credentialBrokerConnection {
|
||||
return &credentialBrokerConnection{frames: make(chan agentruntime.ProcessFrame, 8), closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (*credentialBrokerConnection) Send([]byte) error { return nil }
|
||||
func (*credentialBrokerConnection) CloseInput() error { return nil }
|
||||
func (*credentialBrokerConnection) Terminate() error { return nil }
|
||||
func (*credentialBrokerConnection) Kill() error { return nil }
|
||||
|
||||
func (connection *credentialBrokerConnection) Close() error {
|
||||
connection.closeOnce.Do(func() {
|
||||
close(connection.closed)
|
||||
close(connection.frames)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (connection *credentialBrokerConnection) Recv() (agentruntime.ProcessFrame, error) {
|
||||
frame, ok := <-connection.frames
|
||||
if !ok {
|
||||
return agentruntime.ProcessFrame{}, io.EOF
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
|
||||
func (connection *credentialBrokerConnection) RecvContext(ctx context.Context) (agentruntime.ProcessFrame, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return agentruntime.ProcessFrame{}, ctx.Err()
|
||||
case frame, ok := <-connection.frames:
|
||||
if !ok {
|
||||
return agentruntime.ProcessFrame{}, io.EOF
|
||||
}
|
||||
return frame, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationContinuesConnectorOwnedBroker(t *testing.T) {
|
||||
connection := newCredentialBrokerConnection()
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: &connectorRoute{id: "default\x00lark-cli", credentialBrokerLaunch: &managedCredentialBrokerLaunch{
|
||||
timeout: 5 * time.Minute,
|
||||
allowedHosts: map[string]struct{}{
|
||||
"open.feishu.cn": {},
|
||||
"accounts.feishu.cn": {},
|
||||
},
|
||||
}},
|
||||
connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
request := market.AuthorizationStartRequest{OperationID: "authorize-1", Connector: market.Connector{Key: "lark-cli"}}
|
||||
|
||||
firstResult := make(chan market.AuthorizationSession, 1)
|
||||
firstError := make(chan error, 1)
|
||||
go func() {
|
||||
session, err := provider.Begin(context.Background(), request)
|
||||
firstResult <- session
|
||||
firstError <- err
|
||||
}()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://open.feishu.cn/page/cli?user_code=opaque"}` + "\n")}
|
||||
if err := <-firstError; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := <-firstResult
|
||||
if first.State != market.AuthorizationStatePending || first.AuthorizationURL != "https://open.feishu.cn/page/cli?user_code=opaque" {
|
||||
t.Fatalf("first session = %#v", first)
|
||||
}
|
||||
if first.StepRevision != 1 {
|
||||
t.Fatalf("first step revision = %d, want 1", first.StepRevision)
|
||||
}
|
||||
|
||||
secondResult := make(chan market.AuthorizationSession, 1)
|
||||
secondError := make(chan error, 1)
|
||||
go func() {
|
||||
continuedRequest := request
|
||||
continuedRequest.AfterStepRevision = first.StepRevision
|
||||
session, err := provider.Begin(context.Background(), continuedRequest)
|
||||
secondResult <- session
|
||||
secondError <- err
|
||||
}()
|
||||
select {
|
||||
case result := <-secondResult:
|
||||
t.Fatalf("continuation returned the current step before a later event: %#v", result)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://accounts.feishu.cn/device?user_code=user"}` + "\n")}
|
||||
if err := <-secondError; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := <-secondResult
|
||||
if second.State != market.AuthorizationStatePending {
|
||||
t.Fatalf("second session = %#v", second)
|
||||
}
|
||||
if second.AuthorizationURL != "https://accounts.feishu.cn/device?user_code=user" || second.StepRevision != 2 {
|
||||
t.Fatalf("second step = %#v", second)
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"connected"}` + "\n"), ExitCode: &exitCode}
|
||||
terminalRequest := request
|
||||
terminalRequest.AfterStepRevision = second.StepRevision
|
||||
connected := awaitAuthorizationSession(t, provider, terminalRequest, "")
|
||||
if connected.State != market.AuthorizationStateConnected {
|
||||
t.Fatalf("connected session = %#v", connected)
|
||||
}
|
||||
if connected.StepRevision != 3 {
|
||||
t.Fatalf("connected step revision = %d, want 3", connected.StepRevision)
|
||||
}
|
||||
if !reflect.DeepEqual(host.requests, []credentialBrokerRequest{{Protocol: market.CredentialBrokerProtocolV1, Operation: "begin"}}) {
|
||||
t.Fatalf("broker requests = %#v", host.requests)
|
||||
}
|
||||
observed := awaitAuthorizationObservations(t, host, 3)
|
||||
if !reflect.DeepEqual(observed, []market.AuthorizationState{market.AuthorizationStatePending, market.AuthorizationStatePending, market.AuthorizationStateConnected}) {
|
||||
t.Fatalf("authorization observations = %#v", observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationObservesActiveBrokerWithoutConcurrentInspection(t *testing.T) {
|
||||
connection := newCredentialBrokerConnection()
|
||||
route := &connectorRoute{
|
||||
id: "account-1\x00lark-cli", connectorKey: "lark-cli", connectionID: "account-1",
|
||||
releaseDigest: strings.Repeat("a", 64), credentialBrokerLaunch: &managedCredentialBrokerLaunch{
|
||||
timeout: 5 * time.Minute, allowedHosts: map[string]struct{}{"accounts.feishu.cn": {}},
|
||||
},
|
||||
}
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: route, connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
connector := market.Connector{Key: "lark-cli", Release: market.Release{ReleaseDigest: route.releaseDigest}}
|
||||
request := market.AuthorizationStartRequest{
|
||||
OperationID: "authorize-lark", Scope: market.OperationScope{AccountID: "user-1"}, Connector: connector,
|
||||
}
|
||||
|
||||
beginResult := make(chan market.AuthorizationSession, 1)
|
||||
beginError := make(chan error, 1)
|
||||
go func() {
|
||||
session, err := provider.Begin(context.Background(), request)
|
||||
beginResult <- session
|
||||
beginError <- err
|
||||
}()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://accounts.feishu.cn/device"}` + "\n")}
|
||||
if err := <-beginError; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := <-beginResult
|
||||
|
||||
observation, err := provider.Observe(context.Background(), market.AuthorizationObserveRequest{
|
||||
Scope: request.Scope, Connector: connector, Release: connector.Release, Session: session,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observation.State != market.AuthorizationObservationPending || observation.ConnectionID != route.connectionID ||
|
||||
observation.AuthorizationSessionID != session.SessionID {
|
||||
t.Fatalf("active authorization observation = %#v", observation)
|
||||
}
|
||||
if !reflect.DeepEqual(host.requests, []credentialBrokerRequest{{Protocol: market.CredentialBrokerProtocolV1, Operation: "begin"}}) {
|
||||
t.Fatalf("broker requests = %#v, want no concurrent inspection", host.requests)
|
||||
}
|
||||
if err := provider.Cancel(context.Background(), market.AuthorizationCancelRequest{OperationID: request.OperationID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationObservesPersistedSessionWithInspection(t *testing.T) {
|
||||
exitCode := 0
|
||||
connection := newCredentialBrokerConnection()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"disconnected"}` + "\n"), ExitCode: &exitCode}
|
||||
route := &connectorRoute{
|
||||
id: "account-1\x00lark-cli", connectorKey: "lark-cli", connectionID: "account-1",
|
||||
releaseDigest: strings.Repeat("a", 64), credentialBrokerLaunch: &managedCredentialBrokerLaunch{timeout: 5 * time.Minute},
|
||||
}
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: route, connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
release := market.Release{ReleaseDigest: route.releaseDigest}
|
||||
observation, err := provider.Observe(context.Background(), market.AuthorizationObserveRequest{
|
||||
Scope: market.OperationScope{AccountID: "user-1"}, Connector: market.Connector{Key: "lark-cli"}, Release: release,
|
||||
Session: market.AuthorizationSession{OperationID: "persisted", SessionID: "persisted/credential-broker"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observation.State != market.AuthorizationObservationPending {
|
||||
t.Fatalf("persisted authorization observation = %#v", observation)
|
||||
}
|
||||
if !reflect.DeepEqual(host.requests, []credentialBrokerRequest{{Protocol: market.CredentialBrokerProtocolV1, Operation: "inspect"}}) {
|
||||
t.Fatalf("broker requests = %#v, want durable inspection", host.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationReturnsDeviceCodeFromBroker(t *testing.T) {
|
||||
connection := newCredentialBrokerConnection()
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: &connectorRoute{id: "default\x00github-cli", credentialBrokerLaunch: &managedCredentialBrokerLaunch{
|
||||
timeout: 5 * time.Minute, allowedHosts: map[string]struct{}{"github.com": {}},
|
||||
}},
|
||||
connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
result := make(chan market.AuthorizationSession, 1)
|
||||
resultErr := make(chan error, 1)
|
||||
go func() {
|
||||
session, err := provider.Begin(context.Background(), market.AuthorizationStartRequest{
|
||||
OperationID: "authorize-github", Connector: market.Connector{Key: "github-cli"},
|
||||
AfterStepRevision: 4, StepRevisionBase: 4,
|
||||
})
|
||||
result <- session
|
||||
resultErr <- err
|
||||
}()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://github.com/login/device","code":"ABCD-EFGH"}` + "\n")}
|
||||
|
||||
if err := <-resultErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := <-result
|
||||
if session.AuthorizationURL != "https://github.com/login/device" || session.UserCode != "ABCD-EFGH" || session.StepRevision != 5 {
|
||||
t.Fatalf("authorization session = %#v", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationDisconnectUsesBrokerProtocol(t *testing.T) {
|
||||
exitCode := 0
|
||||
connection := newCredentialBrokerConnection()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"disconnected"}` + "\n"), ExitCode: &exitCode}
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: &connectorRoute{id: "default\x00lark-cli", credentialBrokerLaunch: &managedCredentialBrokerLaunch{timeout: 5 * time.Minute}},
|
||||
connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
err := provider.Disconnect(context.Background(), market.AuthorizationDisconnectRequest{Connector: market.Connector{Key: "lark-cli"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(host.requests, []credentialBrokerRequest{{Protocol: market.CredentialBrokerProtocolV1, Operation: "disconnect"}}) {
|
||||
t.Fatalf("broker requests = %#v", host.requests)
|
||||
}
|
||||
observed := awaitAuthorizationObservations(t, host, 1)
|
||||
if !reflect.DeepEqual(observed, []market.AuthorizationState{market.AuthorizationStateDisconnected}) {
|
||||
t.Fatalf("authorization observations = %#v", observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationInspectReturnsFencedObservation(t *testing.T) {
|
||||
exitCode := 0
|
||||
connection := newCredentialBrokerConnection()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"expired","code":"token_expired","message":"login expired"}` + "\n"), ExitCode: &exitCode}
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: &connectorRoute{id: "account-1\x00lark-cli", connectorKey: "lark-cli", connectionID: "account-1",
|
||||
releaseDigest: strings.Repeat("a", 64), credentialBrokerLaunch: &managedCredentialBrokerLaunch{timeout: 5 * time.Minute}},
|
||||
connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
connector := market.Connector{Key: "lark-cli", Release: market.Release{ReleaseDigest: strings.Repeat("a", 64)}}
|
||||
observation, err := provider.Inspect(context.Background(), market.AuthorizationInspectRequest{
|
||||
Scope: market.OperationScope{AccountID: "user-1"}, Connector: connector,
|
||||
AccountGeneration: 3, VMAssignmentID: "vm-1", AuthorizationSessionID: "auth-1",
|
||||
AuthorizationGeneration: 4, DesktopBootEpoch: "desktop-1", GuestBootID: "guest-1",
|
||||
RuntimeEpoch: "runtime-1", StateRevision: 9,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if observation.State != market.AuthorizationObservationExpired || observation.FailureCode != "token_expired" ||
|
||||
observation.AccountID != "user-1" || observation.AccountGeneration != 3 || observation.VMAssignmentID != "vm-1" ||
|
||||
observation.ConnectorKey != "lark-cli" || observation.ConnectionID != "account-1" ||
|
||||
observation.ReleaseDigest != strings.Repeat("a", 64) || observation.StateRevision != 9 || observation.ObservedAt.IsZero() {
|
||||
t.Fatalf("observation = %#v", observation)
|
||||
}
|
||||
if !reflect.DeepEqual(host.requests, []credentialBrokerRequest{{Protocol: market.CredentialBrokerProtocolV1, Operation: "inspect"}}) {
|
||||
t.Fatalf("broker requests = %#v", host.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallationTargetsReleaseAcceptsOnlyActiveCurrentOrCandidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
installation market.Installation
|
||||
releaseDigest string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "installed current release",
|
||||
installation: market.Installation{
|
||||
State: market.InstallationStateInstalled, InstalledReleaseDigest: "current",
|
||||
},
|
||||
releaseDigest: "current",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "installing candidate release",
|
||||
installation: market.Installation{
|
||||
State: market.InstallationStateInstalling, CandidateReleaseDigest: "candidate",
|
||||
},
|
||||
releaseDigest: "candidate",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "updating candidate release",
|
||||
installation: market.Installation{
|
||||
State: market.InstallationStateUpdating, InstalledReleaseDigest: "current", CandidateReleaseDigest: "candidate",
|
||||
},
|
||||
releaseDigest: "candidate",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "updating superseded current release",
|
||||
installation: market.Installation{
|
||||
State: market.InstallationStateUpdating, InstalledReleaseDigest: "current", CandidateReleaseDigest: "candidate",
|
||||
},
|
||||
releaseDigest: "current",
|
||||
},
|
||||
{
|
||||
name: "failed candidate release",
|
||||
installation: market.Installation{
|
||||
State: market.InstallationStateFailed, CandidateReleaseDigest: "candidate",
|
||||
},
|
||||
releaseDigest: "candidate",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := installationTargetsRelease(test.installation, test.releaseDigest); got != test.want {
|
||||
t.Fatalf("installationTargetsRelease() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func awaitAuthorizationObservations(t *testing.T, host *credentialAuthorizationHostStub, count int) []market.AuthorizationState {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
observed := host.authorizationObservations()
|
||||
if len(observed) >= count {
|
||||
return observed
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("authorization observations = %#v, want at least %d", observed, count)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationSharesOneBrokerAcrossConcurrentBegins(t *testing.T) {
|
||||
connection := newCredentialBrokerConnection()
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: &connectorRoute{id: "default\x00example", credentialBrokerLaunch: &managedCredentialBrokerLaunch{
|
||||
timeout: 5 * time.Minute, allowedHosts: map[string]struct{}{"accounts.example.com": {}},
|
||||
}},
|
||||
connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
results := make(chan market.AuthorizationSession, 2)
|
||||
errors := make(chan error, 2)
|
||||
for index := 0; index < 2; index++ {
|
||||
go func() {
|
||||
session, err := provider.Begin(context.Background(), market.AuthorizationStartRequest{
|
||||
OperationID: "authorize", Connector: market.Connector{Key: "example"},
|
||||
})
|
||||
results <- session
|
||||
errors <- err
|
||||
}()
|
||||
}
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://accounts.example.com/device"}` + "\n")}
|
||||
for index := 0; index < 2; index++ {
|
||||
if err := <-errors; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := <-results; result.AuthorizationURL != "https://accounts.example.com/device" {
|
||||
t.Fatalf("authorization session = %#v", result)
|
||||
}
|
||||
}
|
||||
if len(host.requests) != 1 {
|
||||
t.Fatalf("credential broker starts = %d, want 1", len(host.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationRestartsFailedBrokerOnFirstRetry(t *testing.T) {
|
||||
failedConnection := newCredentialBrokerConnection()
|
||||
retryConnection := newCredentialBrokerConnection()
|
||||
route := &connectorRoute{id: "default\x00example", credentialBrokerLaunch: &managedCredentialBrokerLaunch{
|
||||
timeout: 5 * time.Minute, allowedHosts: map[string]struct{}{"accounts.example.com": {}},
|
||||
}}
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: route, connections: []agentruntime.ProcessConnection{failedConnection, retryConnection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
firstRequest := market.AuthorizationStartRequest{OperationID: "authorize-first", Connector: market.Connector{Key: "example"}}
|
||||
firstResult := make(chan market.AuthorizationSession, 1)
|
||||
firstError := make(chan error, 1)
|
||||
go func() {
|
||||
session, err := provider.Begin(context.Background(), firstRequest)
|
||||
firstResult <- session
|
||||
firstError <- err
|
||||
}()
|
||||
failedConnection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://accounts.example.com/first"}` + "\n")}
|
||||
if err := <-firstError; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := <-firstResult; result.AuthorizationURL != "https://accounts.example.com/first" {
|
||||
t.Fatalf("first authorization session = %#v", result)
|
||||
}
|
||||
exitCode := 1
|
||||
failedConnection.frames <- agentruntime.ProcessFrame{Stderr: []byte("broker failed"), ExitCode: &exitCode}
|
||||
awaitCachedAuthorizationFailure(t, provider, firstRequest.OperationID)
|
||||
|
||||
retryConnection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://accounts.example.com/retry"}` + "\n")}
|
||||
retry, err := provider.Begin(context.Background(), market.AuthorizationStartRequest{
|
||||
OperationID: "authorize-retry", Connector: market.Connector{Key: "example"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retry.AuthorizationURL != "https://accounts.example.com/retry" {
|
||||
t.Fatalf("retry authorization session = %#v", retry)
|
||||
}
|
||||
if len(host.requests) != 2 {
|
||||
t.Fatalf("credential broker starts = %d, want 2", len(host.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationCancelTerminatesAndWaitsForBrokerExit(t *testing.T) {
|
||||
connection := newCredentialBrokerConnection()
|
||||
route := &connectorRoute{id: "default\x00dingtalk-cli", credentialBrokerLaunch: &managedCredentialBrokerLaunch{
|
||||
timeout: 5 * time.Minute, allowedHosts: map[string]struct{}{"login.dingtalk.com": {}},
|
||||
}}
|
||||
host := &credentialAuthorizationHostStub{route: route, connections: []agentruntime.ProcessConnection{connection}}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
request := market.AuthorizationStartRequest{OperationID: "authorize-a", Connector: market.Connector{Key: "dingtalk-cli"}}
|
||||
beginDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := provider.Begin(context.Background(), request)
|
||||
beginDone <- err
|
||||
}()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://login.dingtalk.com/oauth"}` + "\n")}
|
||||
if err := <-beginDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cancelDone := make(chan error, 1)
|
||||
go func() {
|
||||
cancelDone <- provider.Cancel(context.Background(), market.AuthorizationCancelRequest{OperationID: request.OperationID})
|
||||
}()
|
||||
select {
|
||||
case <-connection.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cancel did not close the credential broker connection")
|
||||
}
|
||||
select {
|
||||
case err := <-cancelDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cancel did not wait for credential broker termination")
|
||||
}
|
||||
provider.mu.Lock()
|
||||
if provider.sessions[request.OperationID] != nil || provider.activeByRoute[route.id] != "" {
|
||||
t.Fatalf("canceled session remained active: sessions=%#v routes=%#v", provider.sessions, provider.activeByRoute)
|
||||
}
|
||||
provider.mu.Unlock()
|
||||
if observed := host.authorizationObservations(); !reflect.DeepEqual(observed, []market.AuthorizationState{market.AuthorizationStatePending}) {
|
||||
t.Fatalf("authorization observations after cancel = %#v", observed)
|
||||
}
|
||||
}
|
||||
|
||||
func awaitCachedAuthorizationFailure(t *testing.T, provider *managedCredentialAuthorizationProvider, operationID string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
provider.mu.Lock()
|
||||
session := provider.sessions[operationID]
|
||||
provider.mu.Unlock()
|
||||
if session != nil {
|
||||
_, _, _, _, err := session.snapshot()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("credential broker session did not fail")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedCredentialAuthorizationRejectsUntrustedURL(t *testing.T) {
|
||||
connection := newCredentialBrokerConnection()
|
||||
host := &credentialAuthorizationHostStub{
|
||||
route: &connectorRoute{id: "default\x00example", credentialBrokerLaunch: &managedCredentialBrokerLaunch{
|
||||
timeout: 5 * time.Minute, allowedHosts: map[string]struct{}{"accounts.example.com": {}},
|
||||
}},
|
||||
connections: []agentruntime.ProcessConnection{connection},
|
||||
}
|
||||
provider := newManagedCredentialAuthorizationProvider(host)
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := provider.Begin(context.Background(), market.AuthorizationStartRequest{
|
||||
OperationID: "authorize-unsafe", Connector: market.Connector{Key: "example"},
|
||||
})
|
||||
result <- err
|
||||
}()
|
||||
connection.frames <- agentruntime.ProcessFrame{Stdout: []byte(`{"type":"authorization_url","url":"https://accounts.example.com.attacker.test/login"}` + "\n")}
|
||||
if err := <-result; err == nil {
|
||||
t.Fatal("untrusted authorization URL was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func awaitAuthorizationSession(
|
||||
t *testing.T,
|
||||
provider *managedCredentialAuthorizationProvider,
|
||||
request market.AuthorizationStartRequest,
|
||||
wantedURL string,
|
||||
) market.AuthorizationSession {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
session, err := provider.Begin(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.AuthorizationURL == wantedURL &&
|
||||
(wantedURL != "" || session.State == market.AuthorizationStateConnected) {
|
||||
return session
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("authorization session did not reach URL %q: %#v", wantedURL, session)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeCredentialBrokerURLRequiresExactHTTPSHost(t *testing.T) {
|
||||
allowed := map[string]struct{}{"accounts.feishu.cn": {}}
|
||||
if !safeCredentialBrokerURL("https://accounts.feishu.cn/device", allowed) {
|
||||
t.Fatal("allowed credential broker URL was rejected")
|
||||
}
|
||||
for _, value := range []string{
|
||||
"http://accounts.feishu.cn/device",
|
||||
"https://accounts.feishu.cn.attacker.test/device",
|
||||
"https://user@accounts.feishu.cn/device",
|
||||
"https://accounts.feishu.cn:444/device",
|
||||
} {
|
||||
if safeCredentialBrokerURL(value, allowed) {
|
||||
t.Fatalf("unsafe credential broker URL was accepted: %s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
// Package implementationhost owns the host-neutral managed Connector runtime.
|
||||
// Products provide artifact, runtime, process, and credential ports and expose
|
||||
// immutable route projections through their native CLI and Agent runtimes.
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
connectorartifact "github.com/tutti-os/tutti/packages/connector/runtime/artifact"
|
||||
"github.com/tutti-os/tutti/packages/connector/runtime/command"
|
||||
"github.com/tutti-os/tutti/packages/connector/runtime/mcp"
|
||||
)
|
||||
|
||||
type PreparedArtifactResolver interface {
|
||||
ResolvePrepared(context.Context, market.Release) (market.PreparedArtifactReceipt, error)
|
||||
}
|
||||
|
||||
type ReconcileRequest struct {
|
||||
Runtime market.RuntimeReconcileRequest
|
||||
}
|
||||
|
||||
// RemoteMCPClient is the protocol client required by one remote Connector
|
||||
// route. Products decide whether the client connects directly to their MCP
|
||||
// Gateway or through a product-owned relay.
|
||||
type RemoteMCPClient interface {
|
||||
Call(context.Context, string, any) (json.RawMessage, error)
|
||||
RegisterTool(string, map[string]any) error
|
||||
ReplaceTools(map[string]map[string]any) error
|
||||
Close(context.Context) error
|
||||
}
|
||||
|
||||
// RemoteMCPClientRequest carries the complete non-credential identity of one
|
||||
// remote Connector route. A product adapter may use it to bind a relay request
|
||||
// to the lifecycle operation without exposing account credentials to the
|
||||
// runtime machine.
|
||||
type RemoteMCPClientRequest struct {
|
||||
OperationID string
|
||||
ConnectionID string
|
||||
ConnectorKey string
|
||||
AccountID string
|
||||
ReleaseDigest string
|
||||
Version string
|
||||
Generation market.HostGeneration
|
||||
Implementation market.RemoteStreamableHTTPImplementation
|
||||
}
|
||||
|
||||
// RemoteMCPClientFactory is the product port for remote Connector MCP
|
||||
// connectivity. ImplementationHost owns protocol bootstrap and route
|
||||
// lifecycle; the factory owns the physical connection path and request
|
||||
// authorization.
|
||||
type RemoteMCPClientFactory interface {
|
||||
NewRemoteMCPClient(context.Context, RemoteMCPClientRequest) (RemoteMCPClient, error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Artifacts PreparedArtifactResolver
|
||||
CLIInstallations market.CLIInstallationManager
|
||||
Runtimes connectorruntime.ConnectorRuntimeResolver
|
||||
Processes agentruntime.ProcessTransport
|
||||
Routes RouteObserver
|
||||
Authorization AuthorizationObserver
|
||||
Registry *RouteRegistry
|
||||
MCP *MCPRegistry
|
||||
StateRoot string
|
||||
BinDir string
|
||||
UserHome string
|
||||
MCPStartupTimeout time.Duration
|
||||
RemoteMCPClientFactory RemoteMCPClientFactory
|
||||
}
|
||||
|
||||
type Host struct {
|
||||
admission sync.RWMutex
|
||||
laneMu sync.Mutex
|
||||
connectorLanes map[string]*sync.Mutex
|
||||
artifacts PreparedArtifactResolver
|
||||
planner *connectorruntime.ManagedRoutePlanner
|
||||
processes agentruntime.ProcessTransport
|
||||
routeObserver RouteObserver
|
||||
authorizationObserver AuthorizationObserver
|
||||
mcpStartupTimeout time.Duration
|
||||
routes *connectorruntime.RouteTable
|
||||
snapshots *connectorruntime.ExecutionSnapshotter
|
||||
authorizationProvider *managedCredentialAuthorizationProvider
|
||||
authorizationMu sync.Mutex
|
||||
authorizationRoutes map[string]*connectorRoute
|
||||
remoteMCPClientFactory RemoteMCPClientFactory
|
||||
mcpRegistry *MCPRegistry
|
||||
registry *RouteRegistry
|
||||
binDir string
|
||||
remoteMCPTools *remoteMCPToolCache
|
||||
}
|
||||
|
||||
type connectorRoute struct {
|
||||
id string
|
||||
connectionID string
|
||||
connectorKey string
|
||||
connectorVersion string
|
||||
releaseDigest string
|
||||
generation market.HostGeneration
|
||||
mcpTools map[string]registeredMCPTool
|
||||
closeMu sync.Mutex
|
||||
mcpClient *mcp.StdioClient
|
||||
remoteMCP RemoteMCPClient
|
||||
executionRoot string
|
||||
installedRoot string
|
||||
displayName string
|
||||
description string
|
||||
routingAliases []string
|
||||
skillRoot string
|
||||
skills []connectorartifact.SkillSummary
|
||||
processes *connectorruntime.ProcessGroup
|
||||
snapshots *connectorruntime.ExecutionSnapshotter
|
||||
userHome string
|
||||
cliLaunch *managedCLILaunch
|
||||
cliCommand string
|
||||
cliInvocationCommand string
|
||||
cliContractHash string
|
||||
cliCommands []market.CLICommand
|
||||
cliShimPath string
|
||||
cliShimContent []byte
|
||||
credentialBrokerLaunch *managedCredentialBrokerLaunch
|
||||
readiness market.RuntimeReadiness
|
||||
}
|
||||
|
||||
func New(config Config) (*Host, error) {
|
||||
if config.Artifacts == nil || config.Runtimes == nil || config.Processes == nil || config.Registry == nil || config.MCP == nil {
|
||||
return nil, errors.New("connector implementation host dependencies are required")
|
||||
}
|
||||
if !filepath.IsAbs(strings.TrimSpace(config.StateRoot)) {
|
||||
return nil, errors.New("connector implementation state root must be absolute")
|
||||
}
|
||||
if !filepath.IsAbs(strings.TrimSpace(config.UserHome)) {
|
||||
return nil, errors.New("connector implementation user home must be absolute")
|
||||
}
|
||||
if strings.TrimSpace(config.BinDir) == "" {
|
||||
config.BinDir = filepath.Join(config.StateRoot, "bin")
|
||||
}
|
||||
if !filepath.IsAbs(config.BinDir) {
|
||||
return nil, errors.New("connector CLI bin directory must be absolute")
|
||||
}
|
||||
if config.MCPStartupTimeout <= 0 {
|
||||
config.MCPStartupTimeout = 15 * time.Second
|
||||
}
|
||||
snapshots, err := connectorruntime.NewExecutionSnapshotter(config.StateRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := snapshots.CleanupOrphans(); err != nil {
|
||||
return nil, fmt.Errorf("clean orphaned connector execution snapshots: %w", err)
|
||||
}
|
||||
routes := connectorruntime.NewRouteTable()
|
||||
planner, err := connectorruntime.NewManagedRoutePlanner(connectorruntime.ManagedRoutePlannerConfig{
|
||||
StateRoot: config.StateRoot, UserHome: config.UserHome, Runtimes: config.Runtimes, CLIInstallations: config.CLIInstallations,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.Registry.attach(routes)
|
||||
config.MCP.attach(routes)
|
||||
host := &Host{
|
||||
artifacts: config.Artifacts,
|
||||
planner: planner,
|
||||
processes: config.Processes,
|
||||
routeObserver: config.Routes,
|
||||
authorizationObserver: config.Authorization,
|
||||
mcpStartupTimeout: config.MCPStartupTimeout,
|
||||
routes: routes,
|
||||
snapshots: snapshots,
|
||||
authorizationRoutes: make(map[string]*connectorRoute),
|
||||
connectorLanes: make(map[string]*sync.Mutex),
|
||||
remoteMCPClientFactory: config.RemoteMCPClientFactory,
|
||||
mcpRegistry: config.MCP,
|
||||
registry: config.Registry,
|
||||
binDir: config.BinDir,
|
||||
remoteMCPTools: newRemoteMCPToolCache(),
|
||||
}
|
||||
host.authorizationProvider = newManagedCredentialAuthorizationProvider(host)
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func (host *Host) Reconcile(ctx context.Context, request ReconcileRequest) (market.RuntimeReceipt, error) {
|
||||
runtimeRequest := request.Runtime
|
||||
if host == nil || !hostIdentityPattern.MatchString(runtimeRequest.ConnectionID) ||
|
||||
!hostIdentityPattern.MatchString(runtimeRequest.Connector.Key) || runtimeRequest.Generation.BootEpoch == "" || runtimeRequest.Generation.Generation == 0 {
|
||||
return market.RuntimeReceipt{}, errors.New("connector runtime reconcile identity is invalid")
|
||||
}
|
||||
releaseLane := host.enterConnectorLane(runtimeRequest.Connector.Key)
|
||||
defer releaseLane()
|
||||
if !runtimeRequest.Enabled {
|
||||
// A connection id can rotate while an older account route is still
|
||||
// present. Disabled is connector-level desired state, so convergence must
|
||||
// fence every route and authorization session for the connector instead
|
||||
// of removing only the latest route key.
|
||||
if err := host.deactivateConnector(market.RuntimeDeactivationRequest{
|
||||
ConnectionID: runtimeRequest.ConnectionID,
|
||||
ConnectorKey: runtimeRequest.Connector.Key,
|
||||
AllConnections: true,
|
||||
Generation: runtimeRequest.Generation,
|
||||
Deadline: time.Now().Add(3 * time.Second),
|
||||
}); err != nil {
|
||||
return market.RuntimeReceipt{}, err
|
||||
}
|
||||
return market.RuntimeReceipt{OperationID: runtimeRequest.OperationID, ConnectionID: runtimeRequest.ConnectionID,
|
||||
ConnectorKey: runtimeRequest.Connector.Key, ReleaseDigest: runtimeRequest.Connector.Release.ReleaseDigest,
|
||||
Generation: runtimeRequest.Generation,
|
||||
Readiness: market.RuntimeReadiness{State: market.RuntimeReadinessBlocked,
|
||||
ReasonCode: market.RuntimeReadinessReasonRuntimeDisabled}}, nil
|
||||
}
|
||||
if err := market.ValidateRuntimeReleaseShape(runtimeRequest.Connector.Release); err != nil {
|
||||
return market.RuntimeReceipt{}, err
|
||||
}
|
||||
key := connectorRouteKey(runtimeRequest.ConnectionID, runtimeRequest.Connector.Key)
|
||||
if !installationTargetsRelease(runtimeRequest.Connector.Installation, runtimeRequest.Connector.Release.ReleaseDigest) {
|
||||
return market.RuntimeReceipt{}, errors.New("connector installed release is not active")
|
||||
}
|
||||
if err := host.validateAuthorization(runtimeRequest); err != nil {
|
||||
return market.RuntimeReceipt{}, err
|
||||
}
|
||||
var route *connectorRoute
|
||||
var err error
|
||||
if runtimeRequest.Connector.Release.Manifest.Implementation.Kind == market.ImplementationKindRemoteStreamableHTTP {
|
||||
route, err = host.buildRemoteRoute(ctx, runtimeRequest)
|
||||
if err == nil {
|
||||
prepared, resolveErr := host.artifacts.ResolvePrepared(ctx, runtimeRequest.Connector.Release)
|
||||
if resolveErr != nil {
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return market.RuntimeReceipt{}, fmt.Errorf("resolve remote connector artifact: %w", resolveErr)
|
||||
}
|
||||
route.installedRoot = prepared.PreparedPath
|
||||
}
|
||||
} else {
|
||||
prepared, resolveErr := host.artifacts.ResolvePrepared(ctx, runtimeRequest.Connector.Release)
|
||||
if resolveErr != nil {
|
||||
return market.RuntimeReceipt{}, fmt.Errorf("resolve prepared connector artifact: %w", resolveErr)
|
||||
}
|
||||
installedRoot := prepared.PreparedPath
|
||||
executionRoot, snapshotErr := host.snapshots.Create(prepared, artifactNativeEntrypoints(runtimeRequest.Connector.Release)...)
|
||||
if snapshotErr != nil {
|
||||
return market.RuntimeReceipt{}, fmt.Errorf("create connector execution snapshot: %w", snapshotErr)
|
||||
}
|
||||
prepared.PreparedPath = executionRoot
|
||||
route, err = host.buildManagedRoute(ctx, runtimeRequest, prepared)
|
||||
if err != nil {
|
||||
_ = host.snapshots.Remove(executionRoot)
|
||||
return market.RuntimeReceipt{}, err
|
||||
}
|
||||
route.executionRoot, route.installedRoot = executionRoot, installedRoot
|
||||
}
|
||||
if err != nil {
|
||||
return market.RuntimeReceipt{}, wrapRemoteMCPAuthorizationError(err)
|
||||
}
|
||||
route.displayName = runtimeRequest.Connector.Release.Manifest.DisplayName
|
||||
route.description = runtimeRequest.Connector.Release.Manifest.Description
|
||||
if routing := runtimeRequest.Connector.Release.Manifest.AgentRouting; routing != nil {
|
||||
route.routingAliases = append([]string(nil), routing.Aliases...)
|
||||
}
|
||||
route.snapshots = host.snapshots
|
||||
skillProjection, err := connectorartifact.InspectSkills(route.installedRoot)
|
||||
if err != nil {
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return market.RuntimeReceipt{}, fmt.Errorf("inspect connector Skills: %w", err)
|
||||
}
|
||||
route.skillRoot = skillProjection.Root
|
||||
route.skills = append([]connectorartifact.SkillSummary(nil), skillProjection.Skills...)
|
||||
if route.readiness.State == "" {
|
||||
route.readiness = readyRuntimeReadiness(route)
|
||||
}
|
||||
previous, _ := host.routes.Route(key).(*connectorRoute)
|
||||
if err := route.activateCLIShim(); err != nil {
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return market.RuntimeReceipt{}, err
|
||||
}
|
||||
if err := host.routes.Commit(route); err != nil {
|
||||
if previous != nil {
|
||||
_ = previous.activateCLIShim()
|
||||
} else {
|
||||
route.removeCLIShimIfCurrent()
|
||||
}
|
||||
_ = route.Close(time.Now().Add(3 * time.Second))
|
||||
return market.RuntimeReceipt{}, err
|
||||
}
|
||||
host.releaseAuthorizationRouteByKey(key)
|
||||
host.notifyRouteChanged()
|
||||
if route.mcpClient != nil {
|
||||
go host.monitorMCPRoute(route, route.mcpClient)
|
||||
}
|
||||
summary := connectorSummaryFromDescriptor(routeDescriptor(route))
|
||||
return market.RuntimeReceipt{OperationID: runtimeRequest.OperationID, ConnectionID: runtimeRequest.ConnectionID,
|
||||
ConnectorKey: runtimeRequest.Connector.Key, ReleaseDigest: route.releaseDigest,
|
||||
Generation: runtimeRequest.Generation, Readiness: cloneRuntimeReadiness(route.readiness), Summary: &summary}, nil
|
||||
}
|
||||
|
||||
func installationTargetsRelease(installation market.Installation, releaseDigest string) bool {
|
||||
switch installation.State {
|
||||
case market.InstallationStateInstalled:
|
||||
return installation.InstalledReleaseDigest == releaseDigest
|
||||
case market.InstallationStateInstalling, market.InstallationStateUpdating:
|
||||
return installation.CandidateReleaseDigest == releaseDigest
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (*Host) validateAuthorization(request market.RuntimeReconcileRequest) error {
|
||||
authKind := request.Connector.Release.Manifest.AuthorizationKind
|
||||
if request.Connector.Release.Manifest.Implementation.Kind == market.ImplementationKindRemoteStreamableHTTP {
|
||||
if authKind == "none" {
|
||||
if request.Connector.Authorization.State != market.AuthorizationStateNotRequired {
|
||||
return errors.New("authorization-free connector has an invalid credential binding")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if request.Connector.Authorization.State != market.AuthorizationStateConnected {
|
||||
return market.NewDomainError(
|
||||
market.ErrorCodeAuthorizationFailed,
|
||||
"authorized remote connector is not connected",
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
managed := request.Connector.Release.Manifest.Implementation.ManagedStdio
|
||||
if authKind == "none" {
|
||||
if request.Connector.Authorization.State != market.AuthorizationStateNotRequired {
|
||||
return errors.New("authorization-free connector has an invalid credential binding")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if managed == nil || managed.CredentialBroker == nil {
|
||||
return errors.New("authorized connector credential broker binding is unavailable")
|
||||
}
|
||||
if request.Connector.Authorization.State != market.AuthorizationStateConnected {
|
||||
return market.NewDomainError(
|
||||
market.ErrorCodeAuthorizationFailed,
|
||||
"authorized managed connector is not connected",
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newConnectorRoute(request market.RuntimeReconcileRequest) *connectorRoute {
|
||||
return &connectorRoute{id: connectorRouteKey(request.ConnectionID, request.Connector.Key), connectionID: request.ConnectionID,
|
||||
connectorKey: request.Connector.Key, connectorVersion: request.Connector.Release.Version,
|
||||
releaseDigest: request.Connector.Release.ReleaseDigest,
|
||||
generation: request.Generation, mcpTools: make(map[string]registeredMCPTool),
|
||||
processes: connectorruntime.NewProcessGroup()}
|
||||
}
|
||||
|
||||
func (host *Host) Close() error {
|
||||
if host == nil {
|
||||
return nil
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
host.admission.Lock()
|
||||
defer host.admission.Unlock()
|
||||
host.authorizationMu.Lock()
|
||||
authorizationRoutes := make([]*connectorRoute, 0, len(host.authorizationRoutes))
|
||||
for key, route := range host.authorizationRoutes {
|
||||
delete(host.authorizationRoutes, key)
|
||||
authorizationRoutes = append(authorizationRoutes, route)
|
||||
}
|
||||
host.authorizationMu.Unlock()
|
||||
errs := []error{host.routes.Close(deadline)}
|
||||
for _, route := range authorizationRoutes {
|
||||
route.Fence()
|
||||
errs = append(errs, route.Close(deadline))
|
||||
}
|
||||
host.remoteMCPTools.clear()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (host *Host) SetCapabilityPublication(enabled bool) {
|
||||
if host != nil {
|
||||
host.routes.SetPublished(enabled)
|
||||
host.notifyRouteChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func (host *Host) FenceAll(_ context.Context, deadline time.Time) error {
|
||||
if host == nil {
|
||||
return nil
|
||||
}
|
||||
host.admission.Lock()
|
||||
defer host.admission.Unlock()
|
||||
err := host.routes.FenceAll(deadline)
|
||||
host.notifyRouteChanged()
|
||||
return err
|
||||
}
|
||||
|
||||
func (host *Host) FailClosed(ctx context.Context, deadline time.Time) error {
|
||||
if host == nil {
|
||||
return nil
|
||||
}
|
||||
host.SetCapabilityPublication(false)
|
||||
return host.FenceAll(ctx, deadline)
|
||||
}
|
||||
|
||||
func (host *Host) DeactivateRuntime(ctx context.Context, request market.RuntimeDeactivationRequest) error {
|
||||
if host == nil {
|
||||
return errors.New("connector implementation host is unavailable")
|
||||
}
|
||||
if !request.Deadline.IsZero() && time.Now().After(request.Deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
releaseLane := host.enterConnectorLane(request.ConnectorKey)
|
||||
defer releaseLane()
|
||||
if request.AllConnections {
|
||||
return host.deactivateConnector(request)
|
||||
}
|
||||
err := host.routes.Remove(connectorRouteKey(request.ConnectionID, request.ConnectorKey), request.Generation, request.ReleaseDigest, request.Deadline)
|
||||
host.notifyRouteChanged()
|
||||
return err
|
||||
}
|
||||
|
||||
func (host *Host) enterConnectorLane(connectorKey string) func() {
|
||||
host.admission.RLock()
|
||||
host.laneMu.Lock()
|
||||
if host.connectorLanes == nil {
|
||||
host.connectorLanes = make(map[string]*sync.Mutex)
|
||||
}
|
||||
lane := host.connectorLanes[connectorKey]
|
||||
if lane == nil {
|
||||
lane = &sync.Mutex{}
|
||||
host.connectorLanes[connectorKey] = lane
|
||||
}
|
||||
host.laneMu.Unlock()
|
||||
lane.Lock()
|
||||
return func() {
|
||||
lane.Unlock()
|
||||
host.admission.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (host *Host) buildManagedRoute(ctx context.Context, request market.RuntimeReconcileRequest,
|
||||
prepared market.PreparedArtifactReceipt) (*connectorRoute, error) {
|
||||
plan, err := host.planner.Build(ctx, request, prepared)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
route := newConnectorRoute(request)
|
||||
route.userHome = plan.UserHome
|
||||
if plan.Managed.MCP != nil {
|
||||
if err := host.attachMCP(ctx, route, plan.Managed, prepared, plan.Executable, plan.StateDir, plan.UserHome, plan.ArtifactTrees); err != nil {
|
||||
_ = route.close(time.Now().Add(3 * time.Second))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if plan.Managed.CLI != nil {
|
||||
if err := host.attachCLI(route, plan.Managed, prepared, plan.InstalledCLI, plan.Executable, plan.StateDir, plan.ArtifactTrees); err != nil {
|
||||
_ = route.close(time.Now().Add(3 * time.Second))
|
||||
return nil, err
|
||||
}
|
||||
if err := route.prepareCLIShim(host.binDir); err != nil {
|
||||
_ = route.close(time.Now().Add(3 * time.Second))
|
||||
return nil, err
|
||||
}
|
||||
if err := host.checkCLIReadiness(ctx, route, plan.Managed.CLI.ReadinessProbe); err != nil {
|
||||
_ = route.close(time.Now().Add(3 * time.Second))
|
||||
return nil, fmt.Errorf("check connector CLI readiness: %w", err)
|
||||
}
|
||||
}
|
||||
if plan.Managed.CredentialBroker != nil {
|
||||
if err := host.attachCredentialBroker(route, plan.Managed.CredentialBroker, prepared, plan.Executable, plan.StateDir, plan.ArtifactTrees); err != nil {
|
||||
_ = route.close(time.Now().Add(3 * time.Second))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if route.cliLaunch == nil && len(route.mcpTools) == 0 {
|
||||
_ = route.close(time.Now().Add(3 * time.Second))
|
||||
return nil, errors.New("connector implementation exposed no MCP tools or CLI commands")
|
||||
}
|
||||
route.readiness = readyRuntimeReadiness(route)
|
||||
return route, nil
|
||||
}
|
||||
|
||||
var mcpLocalToolNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`)
|
||||
var hostIdentityPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,190}$`)
|
||||
|
||||
func (*Host) attachCredentialBroker(route *connectorRoute, broker *market.ManagedCredentialBroker,
|
||||
prepared market.PreparedArtifactReceipt, executable connectorruntime.ConnectorExecutable,
|
||||
stateDir string, artifactTrees []agentruntime.ArtifactTreeIdentity) error {
|
||||
if route.cliLaunch == nil {
|
||||
return errors.New("connector credential broker requires a managed CLI")
|
||||
}
|
||||
entrypoint, err := connectorruntime.PreparedEntrypoint(prepared.PreparedPath, broker.Entrypoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve connector credential broker entrypoint: %w", err)
|
||||
}
|
||||
allowedHosts := make(map[string]struct{}, len(broker.AllowedHosts))
|
||||
for _, allowedHost := range broker.AllowedHosts {
|
||||
allowedHosts[strings.ToLower(strings.TrimSpace(allowedHost))] = struct{}{}
|
||||
}
|
||||
route.credentialBrokerLaunch = &managedCredentialBrokerLaunch{
|
||||
entrypoint: entrypoint, timeout: time.Duration(broker.TimeoutMS) * time.Millisecond, allowedHosts: allowedHosts,
|
||||
cliLaunch: credentialBrokerCLILaunch{Executable: route.cliLaunch.executable.Path,
|
||||
// Native CLIs legitimately have no argv; preserve [] instead of encoding null for the broker protocol.
|
||||
Arguments: append([]string{}, route.cliLaunch.arguments...), CWD: route.cliLaunch.cwd},
|
||||
executable: executable, language: route.cliLaunch.language, cwd: prepared.PreparedPath, stateDir: stateDir,
|
||||
artifactTrees: append([]agentruntime.ArtifactTreeIdentity(nil), artifactTrees...),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (host *Host) attachMCP(ctx context.Context, route *connectorRoute, managed *market.ManagedStdioImplementation,
|
||||
prepared market.PreparedArtifactReceipt, executable connectorruntime.ConnectorExecutable,
|
||||
stateDir, userHome string, artifactTrees []agentruntime.ArtifactTreeIdentity) error {
|
||||
entrypoint, err := connectorruntime.PreparedEntrypoint(prepared.PreparedPath, managed.MCP.Entrypoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
startupContext, cancelStartup := context.WithTimeout(ctx, host.mcpStartupTimeout)
|
||||
defer cancelStartup()
|
||||
spec := connectorruntime.ConnectorProcessSpec(route.connectionID, route.connectorKey, managed.Runtime.Language,
|
||||
executable, prepared.PreparedPath, append([]string{entrypoint}, managed.MCP.Arguments...), stateDir, userHome, artifactTrees)
|
||||
connection, processID, err := host.startProcess(startupContext, route, spec, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start connector MCP process: %w", err)
|
||||
}
|
||||
release := func() { _ = route.releaseProcess(processID, connection) }
|
||||
client, err := mcp.NewStdioClient(mcp.StdioClientConfig{Connection: connection, ProcessName: route.connectorKey + " MCP"})
|
||||
if err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
if _, err := client.Call(startupContext, "initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "tutti-connector-host", "version": "1"}}); err != nil {
|
||||
release()
|
||||
return fmt.Errorf("initialize connector MCP process: %w", err)
|
||||
}
|
||||
if err := client.Notify("notifications/initialized", map[string]any{}); err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
tools, err := listMCPTools(startupContext, client)
|
||||
if err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
release()
|
||||
return errors.New("connector MCP tools/list response is invalid")
|
||||
}
|
||||
if err := host.registerMCPTools(route, client, tools); err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
route.mcpClient = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func readyRuntimeReadiness(route *connectorRoute) market.RuntimeReadiness {
|
||||
interfaces := make([]market.InterfaceReadiness, 0, 2)
|
||||
if len(route.mcpTools) > 0 {
|
||||
routeIDs := make([]string, 0, len(route.mcpTools))
|
||||
for _, tool := range route.mcpTools {
|
||||
routeIDs = append(routeIDs, tool.routeID)
|
||||
}
|
||||
sort.Strings(routeIDs)
|
||||
interfaces = append(interfaces, market.InterfaceReadiness{Kind: "mcp", State: market.RuntimeReadinessReady, RouteIDs: routeIDs})
|
||||
}
|
||||
if route.cliLaunch != nil {
|
||||
interfaces = append(interfaces, market.InterfaceReadiness{Kind: "cli", State: market.RuntimeReadinessReady})
|
||||
}
|
||||
return market.RuntimeReadiness{State: market.RuntimeReadinessReady, Interfaces: interfaces}
|
||||
}
|
||||
|
||||
func cloneRuntimeReadiness(readiness market.RuntimeReadiness) market.RuntimeReadiness {
|
||||
cloned := readiness
|
||||
cloned.Interfaces = append([]market.InterfaceReadiness(nil), readiness.Interfaces...)
|
||||
for index := range cloned.Interfaces {
|
||||
cloned.Interfaces[index].RouteIDs = append([]string(nil), readiness.Interfaces[index].RouteIDs...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func (host *Host) notifyRouteChanged() {
|
||||
if host == nil {
|
||||
return
|
||||
}
|
||||
host.registry.notifyChanged()
|
||||
host.mcpRegistry.notifyChanged()
|
||||
}
|
||||
|
||||
func (host *Host) monitorMCPRoute(route *connectorRoute, client *mcp.StdioClient) {
|
||||
<-client.Done()
|
||||
unexpected := host.routes.IsCurrent(route)
|
||||
_ = host.routes.RetireExact(route, time.Now().Add(3*time.Second))
|
||||
host.notifyRouteChanged()
|
||||
if unexpected && host.routeObserver != nil {
|
||||
host.routeObserver.ObserveRoute(context.Background(), RouteObservation{
|
||||
ConnectorKey: route.connectorKey, ConnectionID: route.connectionID,
|
||||
ReleaseDigest: route.releaseDigest, Generation: route.generation, ObservedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (*Host) attachCLI(route *connectorRoute, managed *market.ManagedStdioImplementation,
|
||||
prepared market.PreparedArtifactReceipt, installed *market.CLIInstallationReceipt,
|
||||
executable connectorruntime.ConnectorExecutable, stateDir string, artifactTrees []agentruntime.ArtifactTreeIdentity) error {
|
||||
entrypointRoot, entrypointRelative := prepared.PreparedPath, managed.CLI.Entrypoint
|
||||
if installed != nil {
|
||||
entrypointRoot, entrypointRelative = installed.InstallRoot, installed.Entrypoint
|
||||
}
|
||||
entrypoint, err := connectorruntime.PreparedEntrypoint(entrypointRoot, entrypointRelative)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contractHash, err := market.ManagedCLIContractHash(*managed.CLI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash connector CLI contract: %w", err)
|
||||
}
|
||||
launchArguments := []string{entrypoint}
|
||||
launchExecutable := executable
|
||||
if managed.CLI.Launch != nil && managed.CLI.Launch.Kind == market.CLIArtifactLaunchKindNative {
|
||||
launchArguments = nil
|
||||
launchExecutable = connectorruntime.ConnectorExecutable{Path: entrypoint, SHA256: managed.CLI.Launch.SHA256,
|
||||
SizeBytes: managed.CLI.Launch.SizeBytes}
|
||||
} else if installed != nil && installed.LaunchKind == "native" {
|
||||
launchArguments = nil
|
||||
launchExecutable = connectorruntime.ConnectorExecutable{Path: entrypoint, SHA256: installed.EntrypointSHA256,
|
||||
SizeBytes: installed.EntrypointSize}
|
||||
}
|
||||
route.cliLaunch = &managedCLILaunch{arguments: append(append([]string{}, launchArguments...), managed.CLI.Arguments...),
|
||||
artifactTrees: append([]agentruntime.ArtifactTreeIdentity(nil), artifactTrees...), cwd: prepared.PreparedPath,
|
||||
executable: launchExecutable, language: managed.Runtime.Language, stateDir: stateDir,
|
||||
timeout: time.Duration(managed.CLI.TimeoutMS) * time.Millisecond}
|
||||
route.cliContractHash = contractHash
|
||||
route.cliInvocationCommand = market.ManagedCLICommandName(*managed.CLI)
|
||||
route.cliCommands = cloneCLICommands(managed.CLI.Commands)
|
||||
return nil
|
||||
}
|
||||
|
||||
func artifactNativeEntrypoints(release market.Release) []string {
|
||||
managed := release.Manifest.Implementation.ManagedStdio
|
||||
if managed == nil || managed.CLI == nil || managed.CLI.Launch == nil ||
|
||||
managed.CLI.Launch.Kind != market.CLIArtifactLaunchKindNative {
|
||||
return nil
|
||||
}
|
||||
return []string{managed.CLI.Entrypoint}
|
||||
}
|
||||
|
||||
func (host *Host) startProcess(ctx context.Context, route *connectorRoute, spec agentruntime.ProcessSpec,
|
||||
requireCurrent bool) (agentruntime.ProcessConnection, uint64, error) {
|
||||
if requireCurrent && !host.routes.IsCurrent(route) {
|
||||
return nil, 0, command.ErrServiceUnavailable
|
||||
}
|
||||
startContext, processID, accepted := route.processes.Begin(context.Background())
|
||||
if !accepted {
|
||||
return nil, 0, command.ErrServiceUnavailable
|
||||
}
|
||||
type startResult struct {
|
||||
connection agentruntime.ProcessConnection
|
||||
err error
|
||||
}
|
||||
result := make(chan startResult, 1)
|
||||
go func() {
|
||||
connection, startErr := host.processes.Start(startContext, spec)
|
||||
result <- startResult{connection: connection, err: startErr}
|
||||
}()
|
||||
select {
|
||||
case started := <-result:
|
||||
if started.err != nil {
|
||||
route.processes.FailStart(processID)
|
||||
return nil, 0, started.err
|
||||
}
|
||||
if !route.processes.CommitStart(processID, started.connection) {
|
||||
_ = started.connection.Close()
|
||||
return nil, 0, command.ErrServiceUnavailable
|
||||
}
|
||||
return started.connection, processID, nil
|
||||
case <-ctx.Done():
|
||||
route.processes.FailStart(processID)
|
||||
go func() {
|
||||
started := <-result
|
||||
if started.connection != nil {
|
||||
_ = started.connection.Close()
|
||||
}
|
||||
}()
|
||||
return nil, 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func connectorRouteKey(connectionID, connectorKey string) string {
|
||||
return connectionID + "\x00" + connectorKey
|
||||
}
|
||||
|
||||
func (host *Host) routeCurrent(route *connectorRoute) bool {
|
||||
return host.routes.IsCurrent(route) && !route.processes.IsFenced()
|
||||
}
|
||||
|
||||
func (route *connectorRoute) RouteID() string { return route.id }
|
||||
func (route *connectorRoute) RouteGeneration() market.HostGeneration { return route.generation }
|
||||
func (route *connectorRoute) RouteReleaseDigest() string { return route.releaseDigest }
|
||||
func (route *connectorRoute) Fence() { route.processes.Fence() }
|
||||
func (route *connectorRoute) close(deadline time.Time) error { return route.Close(deadline) }
|
||||
func (route *connectorRoute) releaseProcess(id uint64, connection agentruntime.ProcessConnection) error {
|
||||
if route != nil && route.processes != nil {
|
||||
return route.processes.ReleaseWithError(id, connection)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (route *connectorRoute) Close(deadline time.Time) error {
|
||||
if route == nil {
|
||||
return nil
|
||||
}
|
||||
route.closeMu.Lock()
|
||||
defer route.closeMu.Unlock()
|
||||
route.removeCLIShimIfCurrent()
|
||||
var remoteErr error
|
||||
if route.remoteMCP != nil {
|
||||
closeCtx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
remoteErr = route.remoteMCP.Close(closeCtx)
|
||||
cancel()
|
||||
}
|
||||
closeErr := route.processes.Close(deadline)
|
||||
if closeErr == nil {
|
||||
closeErr = remoteErr
|
||||
}
|
||||
if closeErr == nil && route.executionRoot != "" {
|
||||
if err := route.snapshots.Remove(route.executionRoot); err != nil {
|
||||
closeErr = err
|
||||
} else {
|
||||
route.executionRoot = ""
|
||||
}
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (route *connectorRoute) prepareCLIShim(binDir string) error {
|
||||
if route == nil || route.cliLaunch == nil {
|
||||
return nil
|
||||
}
|
||||
command := "tutti-connector-" + route.connectorKey
|
||||
if runtime.GOOS == "windows" {
|
||||
command += ".cmd"
|
||||
}
|
||||
path := filepath.Join(binDir, command)
|
||||
content, err := connectorCLIShimContent(route)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
route.cliCommand, route.cliShimPath, route.cliShimContent = command, path, content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (route *connectorRoute) activateCLIShim() error {
|
||||
if route == nil || route.cliShimPath == "" || len(route.cliShimContent) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(route.cliShimPath), 0o700); err != nil {
|
||||
return fmt.Errorf("create connector CLI bin directory: %w", err)
|
||||
}
|
||||
temporary := route.cliShimPath + ".tmp-" + fmt.Sprintf("%d", route.generation.Generation)
|
||||
if err := os.WriteFile(temporary, route.cliShimContent, 0o700); err != nil {
|
||||
return fmt.Errorf("write connector CLI shim: %w", err)
|
||||
}
|
||||
if err := os.Rename(temporary, route.cliShimPath); err != nil {
|
||||
_ = os.Remove(temporary)
|
||||
return fmt.Errorf("activate connector CLI shim: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (route *connectorRoute) removeCLIShimIfCurrent() {
|
||||
if route == nil || route.cliShimPath == "" {
|
||||
return
|
||||
}
|
||||
current, err := os.ReadFile(route.cliShimPath)
|
||||
if err == nil && string(current) == string(route.cliShimContent) {
|
||||
_ = os.Remove(route.cliShimPath)
|
||||
}
|
||||
}
|
||||
|
||||
func connectorCLIShimContent(route *connectorRoute) ([]byte, error) {
|
||||
launch := route.cliLaunch
|
||||
if launch == nil || strings.TrimSpace(launch.executable.Path) == "" {
|
||||
return nil, errors.New("connector CLI launch is unavailable")
|
||||
}
|
||||
arguments := append([]string(nil), launch.arguments...)
|
||||
if runtime.GOOS == "windows" {
|
||||
values := append([]string{launch.executable.Path}, arguments...)
|
||||
for _, value := range append(values, launch.cwd, launch.stateDir, route.userHome) {
|
||||
if strings.ContainsAny(value, "\r\n\"") {
|
||||
return nil, errors.New("connector CLI path cannot be represented by Windows shim")
|
||||
}
|
||||
}
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, `"`+value+`"`)
|
||||
}
|
||||
content := "@echo off\r\n" +
|
||||
"set \"TUTTI_CONNECTOR_CONNECTION_ID=" + route.connectionID + "\"\r\n" +
|
||||
"set \"TUTTI_CONNECTOR_KEY=" + route.connectorKey + "\"\r\n" +
|
||||
"set \"TUTTI_CONNECTOR_LANGUAGE=" + launch.language + "\"\r\n" +
|
||||
"set \"TUTTI_CONNECTOR_STATE_DIR=" + launch.stateDir + "\"\r\n" +
|
||||
"set \"HOME=" + route.userHome + "\"\r\n" +
|
||||
"set \"USERPROFILE=" + route.userHome + "\"\r\n" +
|
||||
"cd /d \"" + launch.cwd + "\"\r\n" + strings.Join(quoted, " ") + " %*\r\n"
|
||||
return []byte(content), nil
|
||||
}
|
||||
values := append([]string{launch.executable.Path}, arguments...)
|
||||
quoted := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
quoted = append(quoted, shellQuote(value))
|
||||
}
|
||||
content := "#!/bin/sh\n" +
|
||||
"export TUTTI_CONNECTOR_CONNECTION_ID=" + shellQuote(route.connectionID) + "\n" +
|
||||
"export TUTTI_CONNECTOR_KEY=" + shellQuote(route.connectorKey) + "\n" +
|
||||
"export TUTTI_CONNECTOR_LANGUAGE=" + shellQuote(launch.language) + "\n" +
|
||||
"export TUTTI_CONNECTOR_STATE_DIR=" + shellQuote(launch.stateDir) + "\n" +
|
||||
"export HOME=" + shellQuote(route.userHome) + "\n" +
|
||||
"export USERPROFILE=" + shellQuote(route.userHome) + "\n" +
|
||||
"cd " + shellQuote(launch.cwd) + " || exit 1\n" +
|
||||
"exec " + strings.Join(quoted, " ") + " \"$@\"\n"
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
var _ connectorruntime.ManagedRoute = (*connectorRoute)(nil)
|
||||
@@ -0,0 +1,36 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConnectorLifecycleLanesSerializePerConnectorAndAllowCrossConnectorConcurrency(t *testing.T) {
|
||||
host := &Host{}
|
||||
releaseA := host.enterConnectorLane("alpha")
|
||||
|
||||
bAcquired := make(chan func(), 1)
|
||||
go func() { bAcquired <- host.enterConnectorLane("beta") }()
|
||||
select {
|
||||
case releaseB := <-bAcquired:
|
||||
releaseB()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("unrelated connector lane was globally serialized")
|
||||
}
|
||||
|
||||
secondA := make(chan func(), 1)
|
||||
go func() { secondA <- host.enterConnectorLane("alpha") }()
|
||||
select {
|
||||
case release := <-secondA:
|
||||
release()
|
||||
t.Fatal("same connector lane was entered concurrently")
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
releaseA()
|
||||
select {
|
||||
case release := <-secondA:
|
||||
release()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("same connector lane did not resume after release")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
"github.com/tutti-os/tutti/packages/connector/runtime/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
mcpAuthorizationRequiredCode = -33001
|
||||
mcpAuthorizationExpiredCode = -33002
|
||||
)
|
||||
|
||||
func wrapRemoteMCPAuthorizationError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var domain *market.DomainError
|
||||
if errors.As(err, &domain) && domain.Code == market.ErrorCodeAuthorizationFailed {
|
||||
return err
|
||||
}
|
||||
if !remoteMCPAuthorizationRequired(err) {
|
||||
return err
|
||||
}
|
||||
return market.NewDomainError(
|
||||
market.ErrorCodeAuthorizationFailed,
|
||||
"connector authorization is required",
|
||||
false,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
func remoteMCPAuthorizationRequired(err error) bool {
|
||||
var rpcErr *mcp.RPCError
|
||||
if errors.As(err, &rpcErr) &&
|
||||
(rpcErr.Code == mcpAuthorizationRequiredCode || rpcErr.Code == mcpAuthorizationExpiredCode) {
|
||||
return true
|
||||
}
|
||||
var httpErr *mcp.ModernHTTPError
|
||||
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusPreconditionRequired
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
"github.com/tutti-os/tutti/packages/connector/runtime/mcp"
|
||||
)
|
||||
|
||||
func TestWrapRemoteMCPAuthorizationErrorMapsPreconditionRequired(t *testing.T) {
|
||||
cause := &mcp.ModernHTTPError{
|
||||
StatusCode: http.StatusPreconditionRequired,
|
||||
Cause: &mcp.RPCError{Code: -33001, Message: "authorization required"},
|
||||
}
|
||||
err := wrapRemoteMCPAuthorizationError(cause)
|
||||
var domain *market.DomainError
|
||||
if !errors.As(err, &domain) || domain.Code != market.ErrorCodeAuthorizationFailed || domain.Retryable {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapRemoteMCPAuthorizationErrorLeavesTransientFailures(t *testing.T) {
|
||||
cause := &mcp.ModernHTTPError{StatusCode: http.StatusBadGateway}
|
||||
err := wrapRemoteMCPAuthorizationError(cause)
|
||||
var domain *market.DomainError
|
||||
if err != cause || errors.As(err, &domain) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
connectormcp "github.com/tutti-os/tutti/packages/connector/runtime/mcp"
|
||||
)
|
||||
|
||||
// MCPTool is the connector-scoped projection exposed by the local connector
|
||||
// MCP server. UpstreamName is deliberately not serialized: callers use the
|
||||
// namespaced local name and the registry resolves the upstream binding.
|
||||
type MCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
// ConnectorKey and ConnectorVersion are trusted in-process route provenance.
|
||||
// They are never exposed through the MCP tools/list JSON contract or accepted
|
||||
// from Agent input.
|
||||
ConnectorKey string `json:"-"`
|
||||
ConnectorVersion string `json:"-"`
|
||||
}
|
||||
|
||||
// MCPToolProjection derives the exact transport-validation contract for a
|
||||
// resolved live Tool. A Session router can use this to inject an
|
||||
// authority-specific discriminator after it has selected Owner or Caller,
|
||||
// without asking the transport validator to interpret a presentation oneOf.
|
||||
type MCPToolProjection func(MCPTool) (MCPTool, error)
|
||||
|
||||
type registeredMCPTool struct {
|
||||
routeID string
|
||||
localName string
|
||||
upstreamName string
|
||||
description string
|
||||
inputSchema map[string]any
|
||||
client mcpCaller
|
||||
}
|
||||
|
||||
// MCPRegistry exposes only Connector-owned MCP routes. It intentionally
|
||||
// shares the implementation host's generation-fenced RouteTable so MCP and
|
||||
// CLI publication observe the same lifecycle boundary.
|
||||
type MCPRegistry struct {
|
||||
mu sync.RWMutex
|
||||
routes *connectorruntime.RouteTable
|
||||
subscribers map[uint64]chan struct{}
|
||||
nextID uint64
|
||||
authorizationErrorObserver func()
|
||||
}
|
||||
|
||||
func NewMCPRegistry() *MCPRegistry {
|
||||
return &MCPRegistry{subscribers: make(map[uint64]chan struct{})}
|
||||
}
|
||||
|
||||
func (registry *MCPRegistry) attach(routes *connectorruntime.RouteTable) {
|
||||
registry.mu.Lock()
|
||||
registry.routes = routes
|
||||
registry.mu.Unlock()
|
||||
}
|
||||
|
||||
func (registry *MCPRegistry) activeRoutes() []*connectorRoute {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
registry.mu.RLock()
|
||||
table := registry.routes
|
||||
registry.mu.RUnlock()
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
portable := table.PublishedRoutes()
|
||||
routes := make([]*connectorRoute, 0, len(portable))
|
||||
for _, candidate := range portable {
|
||||
if route, ok := candidate.(*connectorRoute); ok && len(route.mcpTools) > 0 {
|
||||
routes = append(routes, route)
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
// Tools reads each active downstream MCP directly. Route activation keeps only
|
||||
// bootstrap evidence; it is not an Agent-visible tool-list cache.
|
||||
func (registry *MCPRegistry) Tools(ctx context.Context) ([]MCPTool, error) {
|
||||
routes := registry.activeRoutes()
|
||||
type routeToolsResult struct {
|
||||
route *connectorRoute
|
||||
tools []mcpTool
|
||||
err error
|
||||
}
|
||||
results := make(chan routeToolsResult, len(routes))
|
||||
for _, route := range routes {
|
||||
go func(route *connectorRoute) {
|
||||
tools, err := registry.listRouteTools(ctx, route)
|
||||
results <- routeToolsResult{route: route, tools: tools, err: err}
|
||||
}(route)
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]MCPTool, 0)
|
||||
var listErr error
|
||||
succeeded := 0
|
||||
for range routes {
|
||||
listed := <-results
|
||||
if listed.err != nil {
|
||||
listErr = errors.Join(listErr, fmt.Errorf("%s: %w", listed.route.connectorKey, listed.err))
|
||||
slog.Warn("connector MCP tools/list omitted unavailable route", "connectorKey", listed.route.connectorKey, "error", listed.err)
|
||||
continue
|
||||
}
|
||||
succeeded++
|
||||
for _, tool := range listed.tools {
|
||||
localName := listed.route.connectorKey + "_" + tool.Name
|
||||
if _, duplicate := seen[localName]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[localName] = struct{}{}
|
||||
result = append(result, MCPTool{Name: localName, Description: tool.Description,
|
||||
InputSchema: cloneJSONMap(tool.InputSchema), ConnectorKey: listed.route.connectorKey,
|
||||
ConnectorVersion: listed.route.connectorVersion})
|
||||
}
|
||||
}
|
||||
if len(routes) > 0 && succeeded == 0 {
|
||||
return nil, listErr
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Call resolves a native namespaced tool against an immutable current route.
|
||||
// Duplicate bindings fail closed instead of selecting an arbitrary account or
|
||||
// generation.
|
||||
func (registry *MCPRegistry) Call(ctx context.Context, name string, arguments map[string]any) (json.RawMessage, error) {
|
||||
return registry.CallValidated(ctx, name, arguments, nil)
|
||||
}
|
||||
|
||||
// CallValidated resolves the current downstream Tool once, lets the local
|
||||
// transport validate request-integrity headers against that exact live schema,
|
||||
// and then calls the same binding.
|
||||
func (registry *MCPRegistry) CallValidated(ctx context.Context, name string, arguments map[string]any, validate func(MCPTool) error) (json.RawMessage, error) {
|
||||
return registry.CallProjectedValidated(ctx, name, arguments, nil, validate)
|
||||
}
|
||||
|
||||
// CallProjectedValidated resolves the current downstream Tool once, projects
|
||||
// an authority-specific validation schema, validates that projected contract,
|
||||
// and calls the same immutable live binding. Projection cannot rename a Tool,
|
||||
// change its Connector provenance, or remove its input schema.
|
||||
func (registry *MCPRegistry) CallProjectedValidated(
|
||||
ctx context.Context,
|
||||
name string,
|
||||
arguments map[string]any,
|
||||
project MCPToolProjection,
|
||||
validate func(MCPTool) error,
|
||||
) (json.RawMessage, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("connector MCP tool was not found")
|
||||
}
|
||||
type candidateResult struct {
|
||||
route *connectorRoute
|
||||
tools []mcpTool
|
||||
err error
|
||||
}
|
||||
candidates := make([]*connectorRoute, 0)
|
||||
for _, route := range registry.activeRoutes() {
|
||||
if strings.HasPrefix(name, route.connectorKey+"_") {
|
||||
candidates = append(candidates, route)
|
||||
}
|
||||
}
|
||||
results := make(chan candidateResult, len(candidates))
|
||||
for _, route := range candidates {
|
||||
go func(route *connectorRoute) {
|
||||
tools, err := registry.listRouteTools(ctx, route)
|
||||
results <- candidateResult{route: route, tools: tools, err: err}
|
||||
}(route)
|
||||
}
|
||||
type resolvedBinding struct {
|
||||
binding registeredMCPTool
|
||||
owner *connectorRoute
|
||||
tool MCPTool
|
||||
}
|
||||
bindings := make([]resolvedBinding, 0, 1)
|
||||
var resolveErr error
|
||||
for range candidates {
|
||||
listed := <-results
|
||||
if listed.err != nil {
|
||||
resolveErr = errors.Join(resolveErr, fmt.Errorf("%s: %w", listed.route.connectorKey, listed.err))
|
||||
continue
|
||||
}
|
||||
for _, tool := range listed.tools {
|
||||
localName := listed.route.connectorKey + "_" + tool.Name
|
||||
if localName != name {
|
||||
continue
|
||||
}
|
||||
bindings = append(bindings, resolvedBinding{
|
||||
binding: registeredMCPTool{localName: localName, upstreamName: tool.Name, client: routeMCPCaller(listed.route)}, owner: listed.route,
|
||||
tool: MCPTool{Name: localName, Description: tool.Description, InputSchema: cloneJSONMap(tool.InputSchema),
|
||||
ConnectorKey: listed.route.connectorKey, ConnectorVersion: listed.route.connectorVersion},
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
return nil, errors.New("connector MCP tool was not found")
|
||||
}
|
||||
if len(bindings) != 1 {
|
||||
return nil, errors.New("connector MCP tool binding is ambiguous")
|
||||
}
|
||||
resolved := bindings[0]
|
||||
validationTool := resolved.tool
|
||||
if project != nil {
|
||||
projected, err := project(validationTool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(projected.Name) != validationTool.Name || projected.InputSchema == nil ||
|
||||
strings.TrimSpace(projected.ConnectorKey) != validationTool.ConnectorKey ||
|
||||
strings.TrimSpace(projected.ConnectorVersion) != validationTool.ConnectorVersion {
|
||||
return nil, errors.New("connector MCP projected tool contract is invalid")
|
||||
}
|
||||
validationTool = projected
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(validationTool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !registry.routeCurrent(resolved.owner) {
|
||||
return nil, errors.New("connector MCP route is no longer active")
|
||||
}
|
||||
result, err := resolved.binding.client.Call(ctx, "tools/call", map[string]any{
|
||||
"name": resolved.binding.upstreamName, "arguments": arguments,
|
||||
})
|
||||
if err != nil {
|
||||
registry.observeAuthorizationError(err)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (registry *MCPRegistry) listRouteTools(ctx context.Context, route *connectorRoute) ([]mcpTool, error) {
|
||||
client := routeMCPCaller(route)
|
||||
if client == nil {
|
||||
return nil, errors.New("connector MCP route is unavailable")
|
||||
}
|
||||
var tools []mcpTool
|
||||
var err error
|
||||
if route.remoteMCP != nil {
|
||||
tools, err = listModernMCPTools(ctx, client)
|
||||
} else {
|
||||
tools, err = listMCPTools(ctx, client)
|
||||
}
|
||||
if err != nil {
|
||||
registry.observeAuthorizationError(err)
|
||||
return nil, err
|
||||
}
|
||||
if err := validateMCPToolContracts(route.connectorKey, tools); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if route.remoteMCP != nil {
|
||||
schemas := make(map[string]map[string]any, len(tools))
|
||||
for _, tool := range tools {
|
||||
schemas[tool.Name] = tool.InputSchema
|
||||
}
|
||||
if err := route.remoteMCP.ReplaceTools(schemas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
func routeMCPCaller(route *connectorRoute) mcpCaller {
|
||||
if route == nil {
|
||||
return nil
|
||||
}
|
||||
if route.remoteMCP != nil {
|
||||
return route.remoteMCP
|
||||
}
|
||||
if route.mcpClient != nil {
|
||||
return route.mcpClient
|
||||
}
|
||||
for _, tool := range route.mcpTools {
|
||||
return tool.client
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (registry *MCPRegistry) observeAuthorizationError(err error) {
|
||||
var rpcErr *connectormcp.RPCError
|
||||
if !errors.As(err, &rpcErr) ||
|
||||
(rpcErr.Code != mcpAuthorizationRequiredCode && rpcErr.Code != mcpAuthorizationExpiredCode) {
|
||||
return
|
||||
}
|
||||
registry.mu.RLock()
|
||||
observer := registry.authorizationErrorObserver
|
||||
registry.mu.RUnlock()
|
||||
if observer != nil {
|
||||
observer()
|
||||
}
|
||||
}
|
||||
|
||||
func (registry *MCPRegistry) SetAuthorizationErrorObserver(observer func()) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.mu.Lock()
|
||||
registry.authorizationErrorObserver = observer
|
||||
registry.mu.Unlock()
|
||||
}
|
||||
|
||||
func (registry *MCPRegistry) routeCurrent(route *connectorRoute) bool {
|
||||
registry.mu.RLock()
|
||||
table := registry.routes
|
||||
registry.mu.RUnlock()
|
||||
return table != nil && table.IsCurrent(route)
|
||||
}
|
||||
|
||||
// Subscribe is used by the local MCP server to fan out
|
||||
// notifications/tools/list_changed. Notifications are edge-triggered and
|
||||
// coalesced for slow consumers.
|
||||
func (registry *MCPRegistry) Subscribe() (<-chan struct{}, func()) {
|
||||
if registry == nil {
|
||||
closed := make(chan struct{})
|
||||
close(closed)
|
||||
return closed, func() {}
|
||||
}
|
||||
registry.mu.Lock()
|
||||
registry.nextID++
|
||||
id := registry.nextID
|
||||
updates := make(chan struct{}, 1)
|
||||
registry.subscribers[id] = updates
|
||||
registry.mu.Unlock()
|
||||
return updates, func() {
|
||||
registry.mu.Lock()
|
||||
if current, ok := registry.subscribers[id]; ok {
|
||||
delete(registry.subscribers, id)
|
||||
close(current)
|
||||
}
|
||||
registry.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (registry *MCPRegistry) notifyChanged() {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.mu.RLock()
|
||||
defer registry.mu.RUnlock()
|
||||
for _, updates := range registry.subscribers {
|
||||
select {
|
||||
case updates <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneJSONMap(input map[string]any) map[string]any {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var result map[string]any
|
||||
if json.Unmarshal(raw, &result) != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
type registryMCPCaller struct {
|
||||
method string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type failingListCaller struct{}
|
||||
|
||||
func (failingListCaller) Call(_ context.Context, method string, _ any) (json.RawMessage, error) {
|
||||
if method == "tools/list" {
|
||||
return nil, errors.New("unrelated connector unavailable")
|
||||
}
|
||||
return nil, errors.New("unexpected call")
|
||||
}
|
||||
|
||||
func (caller *registryMCPCaller) Call(_ context.Context, method string, params any) (json.RawMessage, error) {
|
||||
caller.method = method
|
||||
caller.params, _ = params.(map[string]any)
|
||||
if method == "tools/list" {
|
||||
return json.RawMessage(`{"tools":[{"name":"status","description":"Read status","inputSchema":{"type":"object","oneOf":[{"required":["id"]}]}}]}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"content":[{"type":"text","text":"ready"}]}`), nil
|
||||
}
|
||||
|
||||
func TestMCPRegistryListsCallsAndNotifies(t *testing.T) {
|
||||
table := connectorruntime.NewRouteTable()
|
||||
registry := NewMCPRegistry()
|
||||
registry.attach(table)
|
||||
caller := ®istryMCPCaller{}
|
||||
route := &connectorRoute{id: connectorRouteKey("default", "github"), connectionID: "default", connectorKey: "github", connectorVersion: "2.0.0",
|
||||
releaseDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
generation: market.HostGeneration{BootEpoch: "boot", Generation: 1}, processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{
|
||||
"github_status": {routeID: "connector.github.mcp.status", localName: "github_status", upstreamName: "status",
|
||||
description: "Read status", inputSchema: map[string]any{"type": "object", "oneOf": []any{map[string]any{"required": []any{"id"}}}}, client: caller},
|
||||
}}
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tools, err := registry.Tools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 1 || tools[0].Name != "github_status" || tools[0].InputSchema["type"] != "object" ||
|
||||
tools[0].ConnectorKey != "github" || tools[0].ConnectorVersion != "2.0.0" {
|
||||
t.Fatalf("tools = %#v", tools)
|
||||
}
|
||||
if _, preserved := tools[0].InputSchema["oneOf"]; !preserved {
|
||||
t.Fatalf("native MCP JSON Schema was narrowed: %#v", tools[0].InputSchema)
|
||||
}
|
||||
encoded, err := json.Marshal(tools[0])
|
||||
if err != nil || strings.Contains(string(encoded), "ConnectorKey") || strings.Contains(string(encoded), "connectorKey") ||
|
||||
strings.Contains(string(encoded), "ConnectorVersion") || strings.Contains(string(encoded), "connectorVersion") {
|
||||
t.Fatalf("trusted Connector provenance leaked into MCP JSON: %s, %v", encoded, err)
|
||||
}
|
||||
raw, err := registry.Call(context.Background(), "github_status", map[string]any{"verbose": true})
|
||||
if err != nil || len(raw) == 0 || caller.method != "tools/call" || caller.params["name"] != "status" {
|
||||
t.Fatalf("call raw=%s method=%q params=%#v err=%v", raw, caller.method, caller.params, err)
|
||||
}
|
||||
updates, unsubscribe := registry.Subscribe()
|
||||
defer unsubscribe()
|
||||
registry.notifyChanged()
|
||||
select {
|
||||
case <-updates:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("registry notification was not delivered")
|
||||
}
|
||||
if err := table.Remove(route.id, route.generation, route.releaseDigest, time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tools, err := registry.Tools(context.Background()); err != nil || len(tools) != 0 {
|
||||
t.Fatalf("retired tools = %#v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMCPToolsKeepsNativeNamesAndSchemasOutsideLegacyCommandSubset(t *testing.T) {
|
||||
route := &connectorRoute{connectorKey: "lark-cli", mcpTools: make(map[string]registeredMCPTool)}
|
||||
caller := ®istryMCPCaller{}
|
||||
err := (&Host{}).registerMCPTools(route, caller, []mcpTool{{
|
||||
Name: "Read:Item", InputSchema: map[string]any{
|
||||
"oneOf": []any{map[string]any{"type": "object"}, map[string]any{"type": "null"}},
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered, ok := route.mcpTools["lark-cli_Read:Item"]
|
||||
if !ok || registered.upstreamName != "Read:Item" || registered.routeID != "connector.lark-cli.mcp.Read:Item" {
|
||||
t.Fatalf("registered MCP tool = %#v", route.mcpTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRegistryCallDoesNotListUnrelatedConnector(t *testing.T) {
|
||||
table := connectorruntime.NewRouteTable()
|
||||
registry := NewMCPRegistry()
|
||||
registry.attach(table)
|
||||
target := ®istryMCPCaller{}
|
||||
routes := []*connectorRoute{
|
||||
{id: connectorRouteKey("default", "slow"), connectionID: "default", connectorKey: "slow", releaseDigest: strings.Repeat("a", 64),
|
||||
generation: market.HostGeneration{BootEpoch: "boot", Generation: 1}, processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{"slow_wait": {client: failingListCaller{}}}},
|
||||
{id: connectorRouteKey("default", "github"), connectionID: "default", connectorKey: "github", releaseDigest: strings.Repeat("b", 64),
|
||||
generation: market.HostGeneration{BootEpoch: "boot", Generation: 1}, processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{"github_status": {client: target}}},
|
||||
}
|
||||
for _, route := range routes {
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tools, err := registry.Tools(context.Background())
|
||||
if err != nil || len(tools) != 1 || tools[0].Name != "github_status" {
|
||||
t.Fatalf("partial tools = %#v, %v", tools, err)
|
||||
}
|
||||
if _, err := registry.Call(context.Background(), "github_status", map[string]any{}); err != nil {
|
||||
t.Fatalf("target call was blocked by unrelated connector: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRegistryCallIsolatesFailingOverlappingNamespaceCandidate(t *testing.T) {
|
||||
table := connectorruntime.NewRouteTable()
|
||||
registry := NewMCPRegistry()
|
||||
registry.attach(table)
|
||||
target := ®istryMCPCaller{}
|
||||
routes := []*connectorRoute{
|
||||
{id: connectorRouteKey("default", "github"), connectionID: "default", connectorKey: "github", releaseDigest: strings.Repeat("a", 64),
|
||||
generation: market.HostGeneration{BootEpoch: "boot", Generation: 1}, processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{"github_wait": {client: failingListCaller{}}}},
|
||||
{id: connectorRouteKey("default", "github_enterprise"), connectionID: "default", connectorKey: "github_enterprise", releaseDigest: strings.Repeat("b", 64),
|
||||
generation: market.HostGeneration{BootEpoch: "boot", Generation: 1}, processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{"github_enterprise_status": {client: target}}},
|
||||
}
|
||||
for _, route := range routes {
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := registry.Call(context.Background(), "github_enterprise_status", map[string]any{}); err != nil {
|
||||
t.Fatalf("overlapping failed candidate blocked target call: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRegistryCallProjectedValidatedUsesSelectedLiveSchemaAndBinding(t *testing.T) {
|
||||
table := connectorruntime.NewRouteTable()
|
||||
registry := NewMCPRegistry()
|
||||
registry.attach(table)
|
||||
caller := ®istryMCPCaller{}
|
||||
route := &connectorRoute{
|
||||
id: connectorRouteKey("default", "github"), connectionID: "default", connectorKey: "github", connectorVersion: "2.0.0",
|
||||
releaseDigest: strings.Repeat("a", 64), generation: market.HostGeneration{BootEpoch: "boot", Generation: 1},
|
||||
processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{"github_status": {client: caller}},
|
||||
}
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projected := false
|
||||
validated := false
|
||||
raw, err := registry.CallProjectedValidated(
|
||||
context.Background(), "github_status", map[string]any{"connectorAuthority": "owner"},
|
||||
func(tool MCPTool) (MCPTool, error) {
|
||||
projected = true
|
||||
if tool.ConnectorKey != "github" || tool.ConnectorVersion != "2.0.0" {
|
||||
t.Fatalf("projection Connector provenance = %q@%q", tool.ConnectorKey, tool.ConnectorVersion)
|
||||
}
|
||||
if _, ok := tool.InputSchema["oneOf"]; !ok {
|
||||
t.Fatalf("projection did not receive live native schema: %#v", tool.InputSchema)
|
||||
}
|
||||
tool.InputSchema["properties"] = map[string]any{
|
||||
"connectorAuthority": map[string]any{"type": "string", "enum": []any{"owner"}},
|
||||
}
|
||||
return tool, nil
|
||||
},
|
||||
func(tool MCPTool) error {
|
||||
validated = true
|
||||
properties, _ := tool.InputSchema["properties"].(map[string]any)
|
||||
if properties["connectorAuthority"] == nil {
|
||||
t.Fatalf("validator did not receive authority-specific schema: %#v", tool.InputSchema)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil || len(raw) == 0 || !projected || !validated || caller.method != "tools/call" || caller.params["name"] != "status" {
|
||||
t.Fatalf("projected call raw=%s projected=%v validated=%v method=%q params=%#v err=%v", raw, projected, validated, caller.method, caller.params, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRegistryCallProjectedValidatedRejectsRenamedContractBeforeCall(t *testing.T) {
|
||||
table := connectorruntime.NewRouteTable()
|
||||
registry := NewMCPRegistry()
|
||||
registry.attach(table)
|
||||
caller := ®istryMCPCaller{}
|
||||
route := &connectorRoute{
|
||||
id: connectorRouteKey("default", "github"), connectionID: "default", connectorKey: "github",
|
||||
releaseDigest: strings.Repeat("b", 64), generation: market.HostGeneration{BootEpoch: "boot", Generation: 1},
|
||||
processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{"github_status": {client: caller}},
|
||||
}
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := registry.CallProjectedValidated(context.Background(), "github_status", nil, func(tool MCPTool) (MCPTool, error) {
|
||||
tool.Name = "github_other"
|
||||
return tool, nil
|
||||
}, nil)
|
||||
if err == nil || caller.method == "tools/call" {
|
||||
t.Fatalf("renamed projected contract reached upstream: method=%q err=%v", caller.method, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRegistryCallProjectedValidatedRejectsChangedConnectorProvenance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*MCPTool)
|
||||
}{
|
||||
{name: "key", mutate: func(tool *MCPTool) { tool.ConnectorKey = "foo" }},
|
||||
{name: "version", mutate: func(tool *MCPTool) { tool.ConnectorVersion = "1.0.0" }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
table := connectorruntime.NewRouteTable()
|
||||
registry := NewMCPRegistry()
|
||||
registry.attach(table)
|
||||
caller := ®istryMCPCaller{}
|
||||
route := &connectorRoute{
|
||||
id: connectorRouteKey("default", "foo_bar"), connectionID: "default",
|
||||
connectorKey: "foo_bar", connectorVersion: "2.0.0",
|
||||
releaseDigest: strings.Repeat("c", 64), generation: market.HostGeneration{BootEpoch: "boot", Generation: 1},
|
||||
processes: connectorruntime.NewProcessGroup(),
|
||||
mcpTools: map[string]registeredMCPTool{"foo_bar_status": {client: caller}},
|
||||
}
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := registry.CallProjectedValidated(context.Background(), "foo_bar_status", nil, func(tool MCPTool) (MCPTool, error) {
|
||||
if tool.ConnectorKey != "foo_bar" || tool.ConnectorVersion != "2.0.0" {
|
||||
t.Fatalf("exact route provenance = %q@%q", tool.ConnectorKey, tool.ConnectorVersion)
|
||||
}
|
||||
test.mutate(&tool)
|
||||
return tool, nil
|
||||
}, nil)
|
||||
if err == nil || caller.method == "tools/call" {
|
||||
t.Fatalf("changed Connector provenance reached upstream: method=%q err=%v", caller.method, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
)
|
||||
|
||||
func (host *Host) buildRemoteRoute(ctx context.Context, request market.RuntimeReconcileRequest) (*connectorRoute, error) {
|
||||
remote := request.Connector.Release.Manifest.Implementation.RemoteStreamableHTTP
|
||||
if remote == nil {
|
||||
return nil, errors.New("remote_streamable_http connector config is unavailable")
|
||||
}
|
||||
accountID := strings.TrimSpace(request.Scope.AccountID)
|
||||
if accountID == "" || host.remoteMCPClientFactory == nil {
|
||||
return nil, errors.New("remote MCP product client factory is unavailable")
|
||||
}
|
||||
client, err := host.remoteMCPClientFactory.NewRemoteMCPClient(ctx, RemoteMCPClientRequest{
|
||||
OperationID: request.OperationID, ConnectionID: request.ConnectionID,
|
||||
ConnectorKey: request.Connector.Key, AccountID: accountID,
|
||||
ReleaseDigest: request.Connector.Release.ReleaseDigest,
|
||||
Version: request.Connector.Release.Version,
|
||||
Generation: request.Generation,
|
||||
Implementation: *remote,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create remote connector MCP client: %w", err)
|
||||
}
|
||||
closeClient := func() { _ = client.Close(context.Background()) }
|
||||
if _, err := client.Call(ctx, "server/discover", map[string]any{}); err != nil {
|
||||
closeClient()
|
||||
return nil, wrapRemoteMCPAuthorizationError(fmt.Errorf("discover remote connector MCP: %w", err))
|
||||
}
|
||||
cacheKey := remoteMCPToolCacheKeyFrom(request)
|
||||
tools, cached := host.remoteMCPTools.lookup(cacheKey)
|
||||
if !cached {
|
||||
var err error
|
||||
tools, err = listModernMCPTools(ctx, client)
|
||||
if err != nil {
|
||||
closeClient()
|
||||
return nil, wrapRemoteMCPAuthorizationError(err)
|
||||
}
|
||||
}
|
||||
for _, tool := range tools {
|
||||
if err := client.RegisterTool(tool.Name, tool.InputSchema); err != nil {
|
||||
closeClient()
|
||||
return nil, fmt.Errorf("register remote connector MCP Tool %q: %w", tool.Name, err)
|
||||
}
|
||||
}
|
||||
route := newConnectorRoute(request)
|
||||
if err := host.registerMCPTools(route, client, tools); err != nil {
|
||||
closeClient()
|
||||
return nil, err
|
||||
}
|
||||
if !cached {
|
||||
host.remoteMCPTools.store(cacheKey, tools)
|
||||
}
|
||||
route.remoteMCP = client
|
||||
return route, nil
|
||||
}
|
||||
|
||||
type mcpCaller interface {
|
||||
Call(context.Context, string, any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (*Host) registerMCPTools(route *connectorRoute, client mcpCaller, tools []mcpTool) error {
|
||||
if len(tools) == 0 {
|
||||
return errors.New("connector MCP tools/list response is invalid")
|
||||
}
|
||||
if err := validateMCPToolContracts(route.connectorKey, tools); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tool := range tools {
|
||||
localName := route.connectorKey + "_" + tool.Name
|
||||
// Keep the upstream JSON Schema intact. MCP schemas are not constrained
|
||||
// by Tutti's legacy command-input schema subset.
|
||||
routeID := "connector." + route.connectorKey + ".mcp." + tool.Name
|
||||
route.mcpTools[localName] = registeredMCPTool{routeID: routeID, localName: localName,
|
||||
upstreamName: tool.Name, description: tool.Description, inputSchema: cloneJSONMap(tool.InputSchema), client: client}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMCPToolContracts(connectorKey string, tools []mcpTool) error {
|
||||
seen := make(map[string]struct{}, len(tools))
|
||||
for _, tool := range tools {
|
||||
localName := connectorKey + "_" + tool.Name
|
||||
if strings.TrimSpace(tool.Name) == "" || tool.InputSchema == nil || len(localName) > 255 || !mcpLocalToolNamePattern.MatchString(localName) {
|
||||
return errors.New("connector MCP tool contract is invalid")
|
||||
}
|
||||
if _, duplicate := seen[localName]; duplicate {
|
||||
return errors.New("connector MCP tool capability id is duplicated")
|
||||
}
|
||||
seen[localName] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mcpTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
}
|
||||
|
||||
func listMCPTools(ctx context.Context, client mcpCaller) ([]mcpTool, error) {
|
||||
return listMCPToolsWithProtocol(ctx, client, false)
|
||||
}
|
||||
|
||||
func listModernMCPTools(ctx context.Context, client mcpCaller) ([]mcpTool, error) {
|
||||
return listMCPToolsWithProtocol(ctx, client, true)
|
||||
}
|
||||
|
||||
func listMCPToolsWithProtocol(ctx context.Context, client mcpCaller, requireComplete bool) ([]mcpTool, error) {
|
||||
const maxPages = 64
|
||||
const maxTools = 512
|
||||
result := make([]mcpTool, 0)
|
||||
var cursor *string
|
||||
seen := map[string]struct{}{}
|
||||
for page := 0; page < maxPages; page++ {
|
||||
params := map[string]any{}
|
||||
if cursor != nil {
|
||||
params["cursor"] = *cursor
|
||||
}
|
||||
raw, err := client.Call(ctx, "tools/list", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list connector MCP tools: %w", err)
|
||||
}
|
||||
var listing struct {
|
||||
ResultType string `json:"resultType"`
|
||||
Tools []mcpTool `json:"tools"`
|
||||
NextCursor *string `json:"nextCursor"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &listing); err != nil ||
|
||||
(requireComplete && listing.ResultType != "" && listing.ResultType != "complete") {
|
||||
return nil, errors.New("connector MCP tools/list response is invalid")
|
||||
}
|
||||
result = append(result, listing.Tools...)
|
||||
if len(result) > maxTools {
|
||||
return nil, errors.New("connector MCP tools/list exceeds tool limit")
|
||||
}
|
||||
if listing.NextCursor == nil {
|
||||
return result, nil
|
||||
}
|
||||
next := *listing.NextCursor
|
||||
if _, duplicate := seen[next]; duplicate {
|
||||
return nil, errors.New("connector MCP tools/list cursor repeated")
|
||||
}
|
||||
seen[next] = struct{}{}
|
||||
cursor = &next
|
||||
}
|
||||
return nil, errors.New("connector MCP tools/list exceeds page limit")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fixedMCPCaller struct {
|
||||
result json.RawMessage
|
||||
}
|
||||
|
||||
func (caller fixedMCPCaller) Call(context.Context, string, any) (json.RawMessage, error) {
|
||||
return caller.result, nil
|
||||
}
|
||||
|
||||
func TestListModernMCPToolsAcceptsStandardResponseWithoutResultTypeExtension(t *testing.T) {
|
||||
tools, err := listModernMCPTools(context.Background(), fixedMCPCaller{result: json.RawMessage(
|
||||
`{"tools":[{"name":"search","description":"Search","inputSchema":{"type":"object"}}]}`,
|
||||
)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 1 || tools[0].Name != "search" {
|
||||
t.Fatalf("tools = %#v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListModernMCPToolsRejectsExplicitIncompleteResponse(t *testing.T) {
|
||||
_, err := listModernMCPTools(context.Background(), fixedMCPCaller{result: json.RawMessage(
|
||||
`{"resultType":"partial","tools":[{"name":"search","inputSchema":{"type":"object"}}]}`,
|
||||
)})
|
||||
if err == nil {
|
||||
t.Fatal("explicit incomplete tools/list response was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
)
|
||||
|
||||
// AuthorizationObserver receives runtime-side credential binding outcomes.
|
||||
// The owning product may project them upstream; this Host does not become the
|
||||
// account authorization truth source.
|
||||
type AuthorizationObserver interface {
|
||||
ObserveAuthorization(context.Context, AuthorizationObservation)
|
||||
}
|
||||
|
||||
type AuthorizationObservation struct {
|
||||
ConnectorKey string
|
||||
ConnectionID string
|
||||
State market.AuthorizationState
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
// RouteObserver receives unexpected long-lived runtime exits after the route
|
||||
// has been removed from publication. Intentional deactivate, replacement, and
|
||||
// Host shutdown do not emit an observation.
|
||||
type RouteObserver interface {
|
||||
ObserveRoute(context.Context, RouteObservation)
|
||||
}
|
||||
|
||||
type RouteObservation struct {
|
||||
ConnectorKey string
|
||||
ConnectionID string
|
||||
ReleaseDigest string
|
||||
Generation market.HostGeneration
|
||||
ObservedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
const maxCLIReadinessOutput = 64 << 10
|
||||
|
||||
func (host *Host) checkCLIReadiness(ctx context.Context, route *connectorRoute, probe *market.CLIReadinessProbe) error {
|
||||
if probe == nil {
|
||||
return nil
|
||||
}
|
||||
if host == nil || route == nil || route.cliLaunch == nil {
|
||||
return errors.New("connector CLI readiness target is unavailable")
|
||||
}
|
||||
launch := route.cliLaunch
|
||||
probeContext, cancel := context.WithTimeout(ctx, time.Duration(probe.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
arguments := append(append([]string(nil), launch.arguments...), probe.Arguments...)
|
||||
spec := connectorruntime.ConnectorProcessSpec(route.connectionID, route.connectorKey, launch.language,
|
||||
launch.executable, launch.cwd, arguments, launch.stateDir, route.userHome, launch.artifactTrees)
|
||||
connection, processID, err := host.startProcess(probeContext, route, spec, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = route.releaseProcess(processID, connection) }()
|
||||
if graceful, ok := connection.(agentruntime.GracefulProcessConnection); ok {
|
||||
_ = graceful.CloseInput()
|
||||
}
|
||||
return waitCLIReadiness(probeContext, connection)
|
||||
}
|
||||
|
||||
func waitCLIReadiness(ctx context.Context, connection agentruntime.ProcessConnection) error {
|
||||
outputBytes := 0
|
||||
for {
|
||||
var frame agentruntime.ProcessFrame
|
||||
var err error
|
||||
if contextual, ok := connection.(agentruntime.ContextProcessConnection); ok {
|
||||
frame, err = contextual.RecvContext(ctx)
|
||||
} else {
|
||||
frame, err = connection.Recv()
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return errors.New("CLI readiness probe exited without an exit code")
|
||||
}
|
||||
return err
|
||||
}
|
||||
outputBytes += len(frame.Stdout) + len(frame.Stderr)
|
||||
if outputBytes > maxCLIReadinessOutput {
|
||||
if graceful, ok := connection.(agentruntime.GracefulProcessConnection); ok {
|
||||
_ = graceful.Kill()
|
||||
}
|
||||
return errors.New("CLI readiness probe output exceeded its limit")
|
||||
}
|
||||
if frame.ExitCode == nil {
|
||||
continue
|
||||
}
|
||||
if *frame.ExitCode != 0 {
|
||||
return fmt.Errorf("CLI readiness probe exited with code %d", *frame.ExitCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
"github.com/tutti-os/tutti/packages/connector/runtime/mcp"
|
||||
)
|
||||
|
||||
type remoteMCPFactoryRecorder struct {
|
||||
request RemoteMCPClientRequest
|
||||
client RemoteMCPClient
|
||||
}
|
||||
|
||||
func (factory *remoteMCPFactoryRecorder) NewRemoteMCPClient(_ context.Context, request RemoteMCPClientRequest) (RemoteMCPClient, error) {
|
||||
factory.request = request
|
||||
return factory.client, nil
|
||||
}
|
||||
|
||||
type remoteMCPClientStub struct {
|
||||
calls []string
|
||||
registered []string
|
||||
closed bool
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (client *remoteMCPClientStub) Call(_ context.Context, method string, _ any) (json.RawMessage, error) {
|
||||
client.calls = append(client.calls, method)
|
||||
if method == "tools/list" {
|
||||
if client.listErr != nil {
|
||||
return nil, client.listErr
|
||||
}
|
||||
return json.RawMessage(`{"resultType":"complete","tools":[{"name":"search","description":"Search","inputSchema":{"type":"object"}}]}`), nil
|
||||
}
|
||||
return json.RawMessage(`{"resultType":"complete"}`), nil
|
||||
}
|
||||
|
||||
func (client *remoteMCPClientStub) RegisterTool(name string, _ map[string]any) error {
|
||||
client.registered = append(client.registered, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*remoteMCPClientStub) ReplaceTools(map[string]map[string]any) error { return nil }
|
||||
|
||||
func (client *remoteMCPClientStub) Close(context.Context) error {
|
||||
client.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBuildRemoteRouteUsesProductClientFactoryWithLifecycleIdentity(t *testing.T) {
|
||||
client := &remoteMCPClientStub{}
|
||||
factory := &remoteMCPFactoryRecorder{client: client}
|
||||
host := &Host{remoteMCPClientFactory: factory}
|
||||
implementation := market.RemoteStreamableHTTPImplementation{
|
||||
ProtocolVersion: mcp.ModernProtocolVersion, BindingRef: "documents.primary", ContractVersion: 1,
|
||||
BindingContractHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}
|
||||
generation := market.HostGeneration{BootEpoch: "boot-1", Generation: 7}
|
||||
request := market.RuntimeReconcileRequest{
|
||||
OperationID: "operation-1", ConnectionID: "connection-1", Scope: market.OperationScope{AccountID: "account-1"},
|
||||
Connector: market.Connector{Key: "documents", Release: market.Release{
|
||||
Version: "1.2.3", ReleaseDigest: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
Manifest: market.Manifest{Implementation: market.Implementation{
|
||||
Kind: market.ImplementationKindRemoteStreamableHTTP, RemoteStreamableHTTP: &implementation,
|
||||
}},
|
||||
}},
|
||||
Generation: generation,
|
||||
}
|
||||
route, err := host.buildRemoteRoute(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := RemoteMCPClientRequest{
|
||||
OperationID: "operation-1", ConnectionID: "connection-1", ConnectorKey: "documents", AccountID: "account-1",
|
||||
ReleaseDigest: request.Connector.Release.ReleaseDigest, Version: "1.2.3", Generation: generation, Implementation: implementation,
|
||||
}
|
||||
if !reflect.DeepEqual(factory.request, want) {
|
||||
t.Fatalf("factory request = %#v, want %#v", factory.request, want)
|
||||
}
|
||||
if route.connectorKey != "documents" || route.connectorVersion != "1.2.3" || route.remoteMCP != client ||
|
||||
!reflect.DeepEqual(client.calls, []string{"server/discover", "tools/list"}) ||
|
||||
!reflect.DeepEqual(client.registered, []string{"search"}) {
|
||||
t.Fatalf("remote client bootstrap = provenance:%s@%s route:%v calls:%#v registered:%#v",
|
||||
route.connectorKey, route.connectorVersion, route.remoteMCP == client, client.calls, client.registered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRemoteRouteReusesInProcessToolsListForSameAuthorizationIdentity(t *testing.T) {
|
||||
client := &remoteMCPClientStub{}
|
||||
host := &Host{
|
||||
remoteMCPClientFactory: &remoteMCPFactoryRecorder{client: client},
|
||||
remoteMCPTools: newRemoteMCPToolCache(),
|
||||
}
|
||||
request := testRemoteMCPRouteRequest("documents", 3, 12)
|
||||
if _, err := host.buildRemoteRoute(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Generation.Generation++
|
||||
if _, err := host.buildRemoteRoute(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := countRemoteMCPCalls(client.calls, "tools/list"); got != 1 {
|
||||
t.Fatalf("tools/list calls = %d, want 1; calls=%#v", got, client.calls)
|
||||
}
|
||||
if got := countRemoteMCPCalls(client.calls, "server/discover"); got != 2 {
|
||||
t.Fatalf("server/discover calls = %d, want 2; calls=%#v", got, client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRemoteRouteReloadsToolsListWhenAuthorizationIdentityChanges(t *testing.T) {
|
||||
client := &remoteMCPClientStub{}
|
||||
host := &Host{
|
||||
remoteMCPClientFactory: &remoteMCPFactoryRecorder{client: client},
|
||||
remoteMCPTools: newRemoteMCPToolCache(),
|
||||
}
|
||||
request := testRemoteMCPRouteRequest("documents", 3, 12)
|
||||
if _, err := host.buildRemoteRoute(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.ConnectionVersion = 4
|
||||
if _, err := host.buildRemoteRoute(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := countRemoteMCPCalls(client.calls, "tools/list"); got != 2 {
|
||||
t.Fatalf("tools/list calls = %d, want 2; calls=%#v", got, client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRemoteRouteDoesNotCacheAuthorizationRequiredToolsList(t *testing.T) {
|
||||
client := &remoteMCPClientStub{listErr: &mcp.ModernHTTPError{
|
||||
StatusCode: http.StatusPreconditionRequired,
|
||||
Cause: &mcp.RPCError{Code: -33001, Message: "authorization required"},
|
||||
}}
|
||||
host := &Host{
|
||||
remoteMCPClientFactory: &remoteMCPFactoryRecorder{client: client},
|
||||
remoteMCPTools: newRemoteMCPToolCache(),
|
||||
}
|
||||
request := testRemoteMCPRouteRequest("gmail", 3, 12)
|
||||
if _, err := host.buildRemoteRoute(context.Background(), request); err == nil {
|
||||
t.Fatal("expected authorization-required tools/list to fail")
|
||||
}
|
||||
client.listErr = nil
|
||||
if _, err := host.buildRemoteRoute(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := countRemoteMCPCalls(client.calls, "tools/list"); got != 2 {
|
||||
t.Fatalf("failed tools/list was cached: calls=%#v", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRemoteRouteMapsAuthorizationRequired(t *testing.T) {
|
||||
client := &remoteMCPClientStub{listErr: &mcp.ModernHTTPError{
|
||||
StatusCode: http.StatusPreconditionRequired,
|
||||
Cause: &mcp.RPCError{Code: -33001, Message: "authorization required"},
|
||||
}}
|
||||
host := &Host{remoteMCPClientFactory: &remoteMCPFactoryRecorder{client: client}}
|
||||
implementation := market.RemoteStreamableHTTPImplementation{
|
||||
ProtocolVersion: mcp.ModernProtocolVersion, BindingRef: "gmail.primary", ContractVersion: 1,
|
||||
BindingContractHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}
|
||||
_, err := host.buildRemoteRoute(context.Background(), market.RuntimeReconcileRequest{
|
||||
OperationID: "operation-1", ConnectionID: "connection-1", Scope: market.OperationScope{AccountID: "account-1"},
|
||||
Connector: market.Connector{Key: "gmail", Release: market.Release{
|
||||
Version: "0.1.3", ReleaseDigest: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
Manifest: market.Manifest{Implementation: market.Implementation{
|
||||
Kind: market.ImplementationKindRemoteStreamableHTTP, RemoteStreamableHTTP: &implementation,
|
||||
}},
|
||||
}},
|
||||
Generation: market.HostGeneration{BootEpoch: "boot-1", Generation: 1},
|
||||
})
|
||||
var domain *market.DomainError
|
||||
if !errors.As(err, &domain) || domain.Code != market.ErrorCodeAuthorizationFailed || domain.Retryable {
|
||||
t.Fatalf("buildRemoteRoute error = %#v", err)
|
||||
}
|
||||
if !client.closed {
|
||||
t.Fatal("authorization-required discover/list left the remote MCP client open")
|
||||
}
|
||||
}
|
||||
|
||||
func testRemoteMCPRouteRequest(connectorKey string, connectionVersion, serverRevision uint64) market.RuntimeReconcileRequest {
|
||||
implementation := market.RemoteStreamableHTTPImplementation{
|
||||
ProtocolVersion: mcp.ModernProtocolVersion, BindingRef: connectorKey + ".primary", ContractVersion: 1,
|
||||
BindingContractHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}
|
||||
return market.RuntimeReconcileRequest{
|
||||
OperationID: "operation-1", ConnectionID: "connection-1", Scope: market.OperationScope{AccountID: "account-1"},
|
||||
Connector: market.Connector{Key: connectorKey, Release: market.Release{
|
||||
Version: "1.2.3", ReleaseDigest: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
Manifest: market.Manifest{Implementation: market.Implementation{
|
||||
Kind: market.ImplementationKindRemoteStreamableHTTP, RemoteStreamableHTTP: &implementation,
|
||||
}},
|
||||
}},
|
||||
Generation: market.HostGeneration{BootEpoch: "boot-1", Generation: 1},
|
||||
ConnectionVersion: connectionVersion,
|
||||
ServerRevision: serverRevision,
|
||||
}
|
||||
}
|
||||
|
||||
func countRemoteMCPCalls(calls []string, method string) int {
|
||||
count := 0
|
||||
for _, call := range calls {
|
||||
if call == method {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
)
|
||||
|
||||
// remoteMCPToolCache keeps successful route-activation tools/list results in
|
||||
// process memory. It is not Agent-visible truth and has no TTL; identity
|
||||
// changes miss, and the Host drops the table on Close.
|
||||
type remoteMCPToolCache struct {
|
||||
mu sync.RWMutex
|
||||
tools map[remoteMCPToolCacheKey][]mcpTool
|
||||
}
|
||||
|
||||
type remoteMCPToolCacheKey struct {
|
||||
accountID string
|
||||
connectorKey string
|
||||
releaseDigest string
|
||||
connectionID string
|
||||
connectionVersion uint64
|
||||
serverRevision uint64
|
||||
}
|
||||
|
||||
func newRemoteMCPToolCache() *remoteMCPToolCache {
|
||||
return &remoteMCPToolCache{tools: make(map[remoteMCPToolCacheKey][]mcpTool)}
|
||||
}
|
||||
|
||||
func remoteMCPToolCacheKeyFrom(request market.RuntimeReconcileRequest) remoteMCPToolCacheKey {
|
||||
return remoteMCPToolCacheKey{
|
||||
accountID: strings.TrimSpace(request.Scope.AccountID),
|
||||
connectorKey: strings.TrimSpace(request.Connector.Key),
|
||||
releaseDigest: strings.TrimSpace(request.Connector.Release.ReleaseDigest),
|
||||
connectionID: strings.TrimSpace(request.ConnectionID),
|
||||
connectionVersion: request.ConnectionVersion,
|
||||
serverRevision: request.ServerRevision,
|
||||
}
|
||||
}
|
||||
|
||||
func (key remoteMCPToolCacheKey) complete() bool {
|
||||
return key.accountID != "" && key.connectorKey != "" && key.releaseDigest != "" && key.connectionID != ""
|
||||
}
|
||||
|
||||
func (cache *remoteMCPToolCache) lookup(key remoteMCPToolCacheKey) ([]mcpTool, bool) {
|
||||
if cache == nil || !key.complete() {
|
||||
return nil, false
|
||||
}
|
||||
cache.mu.RLock()
|
||||
defer cache.mu.RUnlock()
|
||||
tools, ok := cache.tools[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return cloneMCPTools(tools), true
|
||||
}
|
||||
|
||||
func (cache *remoteMCPToolCache) store(key remoteMCPToolCacheKey, tools []mcpTool) {
|
||||
if cache == nil || !key.complete() || len(tools) == 0 {
|
||||
return
|
||||
}
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
cache.tools[key] = cloneMCPTools(tools)
|
||||
}
|
||||
|
||||
func (cache *remoteMCPToolCache) clear() {
|
||||
if cache == nil {
|
||||
return
|
||||
}
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
cache.tools = make(map[remoteMCPToolCacheKey][]mcpTool)
|
||||
}
|
||||
|
||||
func cloneMCPTools(tools []mcpTool) []mcpTool {
|
||||
cloned := make([]mcpTool, len(tools))
|
||||
for index, tool := range tools {
|
||||
cloned[index] = mcpTool{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
InputSchema: cloneJSONMap(tool.InputSchema),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentruntime "github.com/tutti-os/tutti/packages/agent/daemon/runtime"
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
"github.com/tutti-os/tutti/packages/connector/runtime/mcp"
|
||||
)
|
||||
|
||||
const routeObserverTestDigest = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
|
||||
type routeObservationRecorder struct {
|
||||
mu sync.Mutex
|
||||
observations []RouteObservation
|
||||
}
|
||||
|
||||
func (recorder *routeObservationRecorder) ObserveRoute(_ context.Context, observation RouteObservation) {
|
||||
recorder.mu.Lock()
|
||||
recorder.observations = append(recorder.observations, observation)
|
||||
recorder.mu.Unlock()
|
||||
}
|
||||
|
||||
type exitedMCPConnection struct{ delivered bool }
|
||||
|
||||
func (*exitedMCPConnection) Send([]byte) error { return nil }
|
||||
func (*exitedMCPConnection) Close() error { return nil }
|
||||
func (*exitedMCPConnection) CloseInput() error { return nil }
|
||||
func (*exitedMCPConnection) Terminate() error { return nil }
|
||||
func (*exitedMCPConnection) Kill() error { return nil }
|
||||
func (connection *exitedMCPConnection) Recv() (agentruntime.ProcessFrame, error) {
|
||||
if connection.delivered {
|
||||
return agentruntime.ProcessFrame{}, io.EOF
|
||||
}
|
||||
connection.delivered = true
|
||||
exitCode := 17
|
||||
return agentruntime.ProcessFrame{ExitCode: &exitCode}, nil
|
||||
}
|
||||
|
||||
func TestMonitorMCPRouteObservesOnlyUnexpectedCurrentRouteExit(t *testing.T) {
|
||||
recorder := &routeObservationRecorder{}
|
||||
routes := connectorruntime.NewRouteTable()
|
||||
host := &Host{routes: routes, routeObserver: recorder}
|
||||
generation := market.HostGeneration{BootEpoch: "boot-1", Generation: 4}
|
||||
route := &connectorRoute{id: connectorRouteKey("connection-1", "calendar"), connectionID: "connection-1",
|
||||
connectorKey: "calendar", releaseDigest: routeObserverTestDigest, generation: generation,
|
||||
processes: connectorruntime.NewProcessGroup()}
|
||||
if err := routes.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := mcp.NewStdioClient(mcp.StdioClientConfig{Connection: &exitedMCPConnection{}, ProcessName: "calendar"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host.monitorMCPRoute(route, client)
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
if len(recorder.observations) != 1 {
|
||||
t.Fatalf("route observations = %+v", recorder.observations)
|
||||
}
|
||||
observation := recorder.observations[0]
|
||||
if observation.ConnectorKey != "calendar" || observation.ConnectionID != "connection-1" ||
|
||||
observation.ReleaseDigest != routeObserverTestDigest || observation.Generation != generation ||
|
||||
observation.ObservedAt.IsZero() {
|
||||
t.Fatalf("route observation = %+v", observation)
|
||||
}
|
||||
if routes.IsCurrent(route) {
|
||||
t.Fatal("exited MCP route remained current")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorMCPRouteDoesNotObserveIntentionalRemoval(t *testing.T) {
|
||||
recorder := &routeObservationRecorder{}
|
||||
routes := connectorruntime.NewRouteTable()
|
||||
host := &Host{routes: routes, routeObserver: recorder}
|
||||
generation := market.HostGeneration{BootEpoch: "boot-1", Generation: 1}
|
||||
route := &connectorRoute{id: connectorRouteKey("connection-1", "calendar"), connectionID: "connection-1",
|
||||
connectorKey: "calendar", releaseDigest: routeObserverTestDigest, generation: generation,
|
||||
processes: connectorruntime.NewProcessGroup()}
|
||||
if err := routes.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := routes.Remove(route.id, generation, routeObserverTestDigest, time.Now().Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client, err := mcp.NewStdioClient(mcp.StdioClientConfig{Connection: &exitedMCPConnection{}, ProcessName: "calendar"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host.monitorMCPRoute(route, client)
|
||||
recorder.mu.Lock()
|
||||
defer recorder.mu.Unlock()
|
||||
if len(recorder.observations) != 0 {
|
||||
t.Fatalf("intentional removal observations = %+v", recorder.observations)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package implementationhost
|
||||
|
||||
import market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
|
||||
type SkillSummary = market.ConnectorSkillSummary
|
||||
type ConnectorSummary = market.ConnectorSummary
|
||||
type ConnectorInterfaceSummary = market.ConnectorInterfaceSummary
|
||||
|
||||
// ConnectorRoutingHint is a bounded, non-secret projection of one active
|
||||
// route for Agent runtime preparation.
|
||||
type ConnectorRoutingHint struct {
|
||||
Key string
|
||||
DisplayName string
|
||||
Aliases []string
|
||||
SkillRoot string
|
||||
}
|
||||
|
||||
// ConnectorSummaries returns immutable discovery metadata already validated
|
||||
// before each route was committed. It never rescans installed artifacts.
|
||||
func (registry *RouteRegistry) ConnectorSummaries() []ConnectorSummary {
|
||||
routes := registry.Routes()
|
||||
connectors := make([]ConnectorSummary, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
connectors = append(connectors, connectorSummaryFromDescriptor(route))
|
||||
}
|
||||
return connectors
|
||||
}
|
||||
|
||||
func connectorSummaryFromDescriptor(route RouteDescriptor) ConnectorSummary {
|
||||
interfaces := make([]ConnectorInterfaceSummary, 0, 2)
|
||||
if route.HasMCP {
|
||||
interfaces = append(interfaces, ConnectorInterfaceSummary{Kind: "mcp", ServerName: "connector",
|
||||
ToolPrefix: route.ConnectorKey + "_", Status: string(route.InterfaceState("mcp"))})
|
||||
}
|
||||
if route.CLIInvocationCommand != "" {
|
||||
interfaces = append(interfaces, ConnectorInterfaceSummary{Kind: "cli", Command: route.CLIInvocationCommand,
|
||||
Status: string(route.InterfaceState("cli"))})
|
||||
}
|
||||
return ConnectorSummary{Key: route.ConnectorKey, Version: route.ConnectorVersion, Name: route.DisplayName, Description: route.Description,
|
||||
Skills: append([]SkillSummary(nil), route.Skills...), Interfaces: interfaces}
|
||||
}
|
||||
|
||||
// RoutingHints returns a detached snapshot for Agent runtime preparation.
|
||||
func (registry *RouteRegistry) RoutingHints() []ConnectorRoutingHint {
|
||||
routes := registry.Routes()
|
||||
hints := make([]ConnectorRoutingHint, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
hints = append(hints, ConnectorRoutingHint{
|
||||
Key: route.ConnectorKey, DisplayName: route.DisplayName,
|
||||
Aliases: append([]string(nil), route.RoutingAliases...), SkillRoot: route.SkillRoot,
|
||||
})
|
||||
}
|
||||
return hints
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
connectorartifact "github.com/tutti-os/tutti/packages/connector/runtime/artifact"
|
||||
)
|
||||
|
||||
func TestRouteRegistryProjectsDetachedConnectorMetadata(t *testing.T) {
|
||||
route := &connectorRoute{
|
||||
id: "account-1\x00calendar", connectionID: "account-1", connectorVersion: "1.2.3", releaseDigest: "digest",
|
||||
generation: market.HostGeneration{BootEpoch: "boot-1", Generation: 1},
|
||||
connectorKey: "calendar", displayName: "Calendar", description: "Manage meetings",
|
||||
routingAliases: []string{"日历"}, skillRoot: "/verified/skills",
|
||||
skills: []connectorartifact.SkillSummary{{Name: "standup", Title: "Standup", Description: "Prepare a standup"}},
|
||||
mcpTools: map[string]registeredMCPTool{"calendar_list": {}}, cliCommand: "tutti-connector-calendar", cliInvocationCommand: "calendar-cli",
|
||||
cliContractHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
cliCommands: []market.CLICommand{{Name: "events", Arguments: []string{"events"},
|
||||
InputSchema: map[string]any{"type": "object"}, TimeoutMS: 10_000}},
|
||||
processes: connectorruntime.NewProcessGroup(),
|
||||
}
|
||||
table := connectorruntime.NewRouteTable()
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := NewRouteRegistry()
|
||||
registry.attach(table)
|
||||
|
||||
summaries := registry.ConnectorSummaries()
|
||||
if len(summaries) != 1 || summaries[0].Key != "calendar" || summaries[0].Version != "1.2.3" || len(summaries[0].Skills) != 1 ||
|
||||
len(summaries[0].Interfaces) != 2 || summaries[0].Interfaces[0].Kind != "mcp" || summaries[0].Interfaces[1].Kind != "cli" {
|
||||
t.Fatalf("summaries = %#v", summaries)
|
||||
}
|
||||
if summaries[0].Interfaces[1].Command != "calendar-cli" {
|
||||
t.Fatalf("CLI command = %q", summaries[0].Interfaces[1].Command)
|
||||
}
|
||||
hints := registry.RoutingHints()
|
||||
if len(hints) != 1 || hints[0].SkillRoot != "/verified/skills" || !slices.Equal(hints[0].Aliases, []string{"日历"}) {
|
||||
t.Fatalf("hints = %#v", hints)
|
||||
}
|
||||
routes := registry.Routes()
|
||||
if len(routes) != 1 || routes[0].ConnectionID != "account-1" || routes[0].ConnectorVersion != "1.2.3" ||
|
||||
routes[0].ReleaseDigest != "digest" || routes[0].CLIContractHash == "" || len(routes[0].CLICommands) != 1 {
|
||||
t.Fatalf("routes = %#v", routes)
|
||||
}
|
||||
summaries[0].Skills[0].Name = "mutated"
|
||||
hints[0].Aliases[0] = "mutated"
|
||||
routes[0].CLICommands[0].Arguments[0] = "mutated"
|
||||
routes[0].CLICommands[0].InputSchema["type"] = "mutated"
|
||||
refetchedRoute := registry.Routes()[0]
|
||||
if registry.ConnectorSummaries()[0].Skills[0].Name != "standup" || registry.RoutingHints()[0].Aliases[0] != "日历" ||
|
||||
refetchedRoute.CLICommands[0].Arguments[0] != "events" || refetchedRoute.CLICommands[0].InputSchema["type"] != "object" {
|
||||
t.Fatal("route projection leaked mutable state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedRouteSummaryDoesNotDependOnCapabilityPublication(t *testing.T) {
|
||||
route := &connectorRoute{
|
||||
id: "account-1\x00calendar", releaseDigest: "digest",
|
||||
generation: market.HostGeneration{BootEpoch: "boot-1", Generation: 1},
|
||||
connectorKey: "calendar", displayName: "Calendar", description: "Manage meetings",
|
||||
skills: []connectorartifact.SkillSummary{{Name: "standup", Title: "Standup", Description: "Prepare a standup"}},
|
||||
mcpTools: map[string]registeredMCPTool{"calendar_list": {}}, processes: connectorruntime.NewProcessGroup(),
|
||||
readiness: market.RuntimeReadiness{State: market.RuntimeReadinessReady,
|
||||
Interfaces: []market.InterfaceReadiness{{Kind: "mcp", State: market.RuntimeReadinessReady}}},
|
||||
}
|
||||
table := connectorruntime.NewRouteTable()
|
||||
if err := table.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
table.SetPublished(false)
|
||||
registry := NewRouteRegistry()
|
||||
registry.attach(table)
|
||||
if summaries := registry.ConnectorSummaries(); len(summaries) != 0 {
|
||||
t.Fatalf("published summaries = %#v", summaries)
|
||||
}
|
||||
summary := connectorSummaryFromDescriptor(routeDescriptor(route))
|
||||
if summary.Key != "calendar" || len(summary.Skills) != 1 || len(summary.Interfaces) != 1 {
|
||||
t.Fatalf("committed summary = %#v", summary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
connectorartifact "github.com/tutti-os/tutti/packages/connector/runtime/artifact"
|
||||
)
|
||||
|
||||
type RouteRegistry struct {
|
||||
mu sync.RWMutex
|
||||
routes *connectorruntime.RouteTable
|
||||
revision atomic.Uint64
|
||||
}
|
||||
|
||||
type RouteDescriptor struct {
|
||||
ConnectionID string
|
||||
ConnectorKey string
|
||||
ConnectorVersion string
|
||||
ReleaseDigest string
|
||||
Generation market.HostGeneration
|
||||
DisplayName string
|
||||
Description string
|
||||
RoutingAliases []string
|
||||
SkillRoot string
|
||||
Skills []connectorartifact.SkillSummary
|
||||
HasMCP bool
|
||||
CLICommand string
|
||||
CLIInvocationCommand string
|
||||
CLIContractHash string
|
||||
CLICommands []market.CLICommand
|
||||
Readiness market.RuntimeReadiness
|
||||
}
|
||||
|
||||
func (descriptor RouteDescriptor) InterfaceState(kind string) market.RuntimeReadinessState {
|
||||
for _, readiness := range descriptor.Readiness.Interfaces {
|
||||
if readiness.Kind == kind {
|
||||
return readiness.State
|
||||
}
|
||||
}
|
||||
return market.RuntimeReadinessFailed
|
||||
}
|
||||
|
||||
func NewRouteRegistry() *RouteRegistry { return &RouteRegistry{} }
|
||||
|
||||
func (registry *RouteRegistry) Revision() uint64 {
|
||||
if registry == nil {
|
||||
return 0
|
||||
}
|
||||
return registry.revision.Load()
|
||||
}
|
||||
|
||||
func (registry *RouteRegistry) notifyChanged() {
|
||||
if registry != nil {
|
||||
registry.revision.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (registry *RouteRegistry) attach(routes *connectorruntime.RouteTable) {
|
||||
registry.mu.Lock()
|
||||
registry.routes = routes
|
||||
registry.mu.Unlock()
|
||||
}
|
||||
|
||||
func (registry *RouteRegistry) activeRoutes() []*connectorRoute {
|
||||
registry.mu.RLock()
|
||||
table := registry.routes
|
||||
registry.mu.RUnlock()
|
||||
if table == nil {
|
||||
return nil
|
||||
}
|
||||
portable := table.PublishedRoutes()
|
||||
routes := make([]*connectorRoute, 0, len(portable))
|
||||
for _, candidate := range portable {
|
||||
if route, ok := candidate.(*connectorRoute); ok {
|
||||
routes = append(routes, route)
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func (registry *RouteRegistry) Routes() []RouteDescriptor {
|
||||
routes := registry.activeRoutes()
|
||||
result := make([]RouteDescriptor, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
result = append(result, routeDescriptor(route))
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool {
|
||||
if result[left].ConnectorKey != result[right].ConnectorKey {
|
||||
return result[left].ConnectorKey < result[right].ConnectorKey
|
||||
}
|
||||
return result[left].ConnectionID < result[right].ConnectionID
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func routeDescriptor(route *connectorRoute) RouteDescriptor {
|
||||
return RouteDescriptor{ConnectionID: route.connectionID, ConnectorKey: route.connectorKey,
|
||||
ConnectorVersion: route.connectorVersion, ReleaseDigest: route.releaseDigest, Generation: route.generation,
|
||||
DisplayName: route.displayName,
|
||||
Description: route.description, RoutingAliases: append([]string(nil), route.routingAliases...),
|
||||
SkillRoot: route.skillRoot, Skills: append([]connectorartifact.SkillSummary(nil), route.skills...),
|
||||
HasMCP: len(route.mcpTools) > 0, CLICommand: route.cliCommand, CLIInvocationCommand: route.cliInvocationCommand,
|
||||
CLIContractHash: route.cliContractHash,
|
||||
CLICommands: cloneCLICommands(route.cliCommands),
|
||||
Readiness: cloneRuntimeReadiness(route.readiness)}
|
||||
}
|
||||
|
||||
func cloneCLICommands(commands []market.CLICommand) []market.CLICommand {
|
||||
result := make([]market.CLICommand, 0, len(commands))
|
||||
for _, command := range commands {
|
||||
cloned := command
|
||||
cloned.Arguments = append([]string(nil), command.Arguments...)
|
||||
cloned.InputSchema = cloneJSONMap(command.InputSchema)
|
||||
result = append(result, cloned)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
func (host *Host) deactivateConnector(request market.RuntimeDeactivationRequest) error {
|
||||
match := func(route connectorruntime.ManagedRoute) bool {
|
||||
candidate, ok := route.(*connectorRoute)
|
||||
return ok && candidate.connectorKey == request.ConnectorKey
|
||||
}
|
||||
routeErr := host.routes.RemoveMatching(match, request.Generation, request.Deadline)
|
||||
|
||||
host.authorizationMu.Lock()
|
||||
authorizationRoutes := make([]*connectorRoute, 0)
|
||||
for key, route := range host.authorizationRoutes {
|
||||
if route.connectorKey != request.ConnectorKey {
|
||||
continue
|
||||
}
|
||||
delete(host.authorizationRoutes, key)
|
||||
authorizationRoutes = append(authorizationRoutes, route)
|
||||
}
|
||||
host.authorizationMu.Unlock()
|
||||
var authorizationErrs []error
|
||||
for _, route := range authorizationRoutes {
|
||||
host.authorizationProvider.cancelAuthorizationSessionByRoute(route.id)
|
||||
route.Fence()
|
||||
if err := route.Close(request.Deadline); err != nil {
|
||||
// Keep a fenced route reachable so an idempotent uninstall retry can
|
||||
// finish snapshot/process cleanup after a transient close failure.
|
||||
host.authorizationMu.Lock()
|
||||
if host.authorizationRoutes[route.id] == nil {
|
||||
host.authorizationRoutes[route.id] = route
|
||||
}
|
||||
host.authorizationMu.Unlock()
|
||||
authorizationErrs = append(authorizationErrs, err)
|
||||
}
|
||||
}
|
||||
shimErr := host.removeOrphanedConnectorCLIShim(request.ConnectorKey)
|
||||
host.notifyRouteChanged()
|
||||
return errors.Join(routeErr, errors.Join(authorizationErrs...), shimErr)
|
||||
}
|
||||
|
||||
func (host *Host) removeOrphanedConnectorCLIShim(connectorKey string) error {
|
||||
if !hostIdentityPattern.MatchString(connectorKey) {
|
||||
return errors.New("connector CLI shim removal identity is invalid")
|
||||
}
|
||||
if !filepath.IsAbs(host.binDir) {
|
||||
return nil
|
||||
}
|
||||
command := "tutti-connector-" + connectorKey
|
||||
if runtime.GOOS == "windows" {
|
||||
command += ".cmd"
|
||||
}
|
||||
path := filepath.Join(host.binDir, command)
|
||||
content, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read connector CLI shim before removal: %w", err)
|
||||
}
|
||||
managedMarker := "export TUTTI_CONNECTOR_KEY=" + shellQuote(connectorKey) + "\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
managedMarker = "set \"TUTTI_CONNECTOR_KEY=" + connectorKey + "\"\r\n"
|
||||
}
|
||||
if !strings.Contains(string(content), managedMarker) {
|
||||
return errors.New("connector CLI shim removal target is not Tutti-managed")
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove connector CLI shim: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package implementationhost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
market "github.com/tutti-os/tutti/packages/connector/daemon/core"
|
||||
connectorruntime "github.com/tutti-os/tutti/packages/connector/runtime"
|
||||
)
|
||||
|
||||
func TestDeactivateRuntimeAllConnectionsRemovesRotatedConnectorRoutes(t *testing.T) {
|
||||
generation := market.HostGeneration{BootEpoch: "boot-1", Generation: 7}
|
||||
host := &Host{
|
||||
routes: connectorruntime.NewRouteTable(),
|
||||
authorizationRoutes: make(map[string]*connectorRoute),
|
||||
authorizationProvider: newManagedCredentialAuthorizationProvider(nil),
|
||||
registry: NewRouteRegistry(),
|
||||
mcpRegistry: NewMCPRegistry(),
|
||||
}
|
||||
host.authorizationProvider.host = host
|
||||
shimPath := filepath.Join(t.TempDir(), "tutti-connector-github")
|
||||
routes := []*connectorRoute{
|
||||
testUninstallRoute("connection-before-rotation", "github", "superseded-release", generation),
|
||||
testUninstallRoute("connection-after-rotation", "github", "release-1", generation),
|
||||
}
|
||||
routes[0].cliShimPath = shimPath
|
||||
routes[0].cliShimContent = []byte("shim")
|
||||
if err := os.WriteFile(shimPath, routes[0].cliShimContent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, route := range routes {
|
||||
if err := host.routes.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
unrelated := testUninstallRoute("connection-other", "notion", "release-2", generation)
|
||||
if err := host.routes.Commit(unrelated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := host.DeactivateRuntime(context.Background(), market.RuntimeDeactivationRequest{
|
||||
ConnectionID: "connection-after-rotation", ConnectorKey: "github", ReleaseDigest: "release-1",
|
||||
AllConnections: true, Generation: generation, Deadline: time.Now().Add(time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, route := range routes {
|
||||
if host.routes.Route(route.id) != nil || !route.processes.IsFenced() {
|
||||
t.Fatalf("route %q survived all-connection deactivation", route.id)
|
||||
}
|
||||
}
|
||||
if host.routes.Route(unrelated.id) != unrelated {
|
||||
t.Fatal("unrelated connector route was removed")
|
||||
}
|
||||
if _, err := os.Stat(shimPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI shim still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledReconcileRemovesRotatedConnectorRoutes(t *testing.T) {
|
||||
generation := market.HostGeneration{BootEpoch: "boot-1", Generation: 7}
|
||||
host := &Host{
|
||||
routes: connectorruntime.NewRouteTable(),
|
||||
authorizationRoutes: make(map[string]*connectorRoute),
|
||||
authorizationProvider: newManagedCredentialAuthorizationProvider(nil),
|
||||
registry: NewRouteRegistry(),
|
||||
mcpRegistry: NewMCPRegistry(),
|
||||
}
|
||||
host.authorizationProvider.host = host
|
||||
routes := []*connectorRoute{
|
||||
testUninstallRoute("connection-before-rotation", "github", "superseded-release", generation),
|
||||
testUninstallRoute("connection-after-rotation", "github", "release-1", generation),
|
||||
}
|
||||
for _, route := range routes {
|
||||
if err := host.routes.Commit(route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
receipt, err := host.Reconcile(context.Background(), ReconcileRequest{Runtime: market.RuntimeReconcileRequest{
|
||||
OperationID: "disable-github", ConnectionID: "connection-after-rotation",
|
||||
Connector: market.Connector{Key: "github", Installation: market.Installation{
|
||||
InstalledReleaseDigest: "release-1",
|
||||
}},
|
||||
Enabled: false, Generation: generation,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, route := range routes {
|
||||
if host.routes.Route(route.id) != nil || !route.processes.IsFenced() {
|
||||
t.Fatalf("route %q survived disabled convergence", route.id)
|
||||
}
|
||||
}
|
||||
if receipt.Readiness.State != market.RuntimeReadinessBlocked ||
|
||||
receipt.Readiness.ReasonCode != market.RuntimeReadinessReasonRuntimeDisabled {
|
||||
t.Fatalf("disabled receipt = %#v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledCandidateReconcileReturnsRequestedReleaseDigest(t *testing.T) {
|
||||
host := &Host{
|
||||
routes: connectorruntime.NewRouteTable(),
|
||||
authorizationRoutes: make(map[string]*connectorRoute),
|
||||
authorizationProvider: newManagedCredentialAuthorizationProvider(nil),
|
||||
registry: NewRouteRegistry(),
|
||||
mcpRegistry: NewMCPRegistry(),
|
||||
}
|
||||
host.authorizationProvider.host = host
|
||||
const releaseDigest = "candidate-release"
|
||||
receipt, err := host.Reconcile(context.Background(), ReconcileRequest{Runtime: market.RuntimeReconcileRequest{
|
||||
OperationID: "disable-unauthed-linear", ConnectionID: "account-linear",
|
||||
Connector: market.Connector{
|
||||
Key: "linear",
|
||||
Release: market.Release{ReleaseDigest: releaseDigest},
|
||||
Installation: market.Installation{
|
||||
State: market.InstallationStateInstalling,
|
||||
CandidateReleaseDigest: releaseDigest,
|
||||
},
|
||||
},
|
||||
Enabled: false, Generation: market.HostGeneration{BootEpoch: "boot-1", Generation: 8},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt.ReleaseDigest != releaseDigest {
|
||||
t.Fatalf("receipt release digest = %q, want %q", receipt.ReleaseDigest, releaseDigest)
|
||||
}
|
||||
if receipt.Readiness.State != market.RuntimeReadinessBlocked ||
|
||||
receipt.Readiness.ReasonCode != market.RuntimeReadinessReasonRuntimeDisabled {
|
||||
t.Fatalf("disabled candidate receipt = %#v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateRuntimeAllConnectionsCancelsPendingAuthorizationRoute(t *testing.T) {
|
||||
host := &Host{
|
||||
routes: connectorruntime.NewRouteTable(),
|
||||
authorizationRoutes: make(map[string]*connectorRoute),
|
||||
authorizationProvider: newManagedCredentialAuthorizationProvider(nil),
|
||||
registry: NewRouteRegistry(),
|
||||
mcpRegistry: NewMCPRegistry(),
|
||||
}
|
||||
host.authorizationProvider.host = host
|
||||
generation := market.HostGeneration{BootEpoch: "boot-1", Generation: 3}
|
||||
route := testUninstallRoute("account-pending", "github", "superseded-release", market.HostGeneration{BootEpoch: "authorization", Generation: 2})
|
||||
host.authorizationRoutes[route.id] = route
|
||||
canceled := make(chan struct{})
|
||||
const operationID = "authorization-pending"
|
||||
host.authorizationProvider.sessions[operationID] = &credentialBrokerSession{
|
||||
operationID: operationID, route: route,
|
||||
cancel: func() { close(canceled) }, changed: make(chan struct{}), state: market.AuthorizationStatePending,
|
||||
}
|
||||
host.authorizationProvider.activeByRoute[route.id] = operationID
|
||||
|
||||
if err := host.DeactivateRuntime(context.Background(), market.RuntimeDeactivationRequest{
|
||||
ConnectionID: "current-connection", ConnectorKey: "github", ReleaseDigest: "release-1",
|
||||
AllConnections: true, Generation: generation, Deadline: time.Now().Add(time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-canceled:
|
||||
default:
|
||||
t.Fatal("pending credential broker session was not canceled")
|
||||
}
|
||||
if host.authorizationRoutes[route.id] != nil || host.authorizationProvider.sessions[operationID] != nil || !route.processes.IsFenced() {
|
||||
t.Fatal("pending authorization route survived uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeactivateRuntimeAllConnectionsRemovesOrphanedManagedCLIShim(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
host := &Host{
|
||||
binDir: binDir,
|
||||
routes: connectorruntime.NewRouteTable(),
|
||||
authorizationRoutes: make(map[string]*connectorRoute),
|
||||
authorizationProvider: newManagedCredentialAuthorizationProvider(nil),
|
||||
registry: NewRouteRegistry(),
|
||||
mcpRegistry: NewMCPRegistry(),
|
||||
}
|
||||
host.authorizationProvider.host = host
|
||||
command := "tutti-connector-github"
|
||||
content := "#!/bin/sh\nexport TUTTI_CONNECTOR_KEY='github'\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
command += ".cmd"
|
||||
content = "@echo off\r\nset \"TUTTI_CONNECTOR_KEY=github\"\r\n"
|
||||
}
|
||||
shimPath := filepath.Join(binDir, command)
|
||||
if err := os.WriteFile(shimPath, []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := host.DeactivateRuntime(context.Background(), market.RuntimeDeactivationRequest{
|
||||
ConnectionID: "missing", ConnectorKey: "github", ReleaseDigest: "release-1", AllConnections: true,
|
||||
Generation: market.HostGeneration{BootEpoch: "boot-1", Generation: 1}, Deadline: time.Now().Add(time.Second),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(shimPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("orphaned CLI shim still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testUninstallRoute(connectionID, connectorKey, releaseDigest string, generation market.HostGeneration) *connectorRoute {
|
||||
return &connectorRoute{
|
||||
id: connectorRouteKey(connectionID, connectorKey), connectionID: connectionID,
|
||||
connectorKey: connectorKey, releaseDigest: releaseDigest, generation: generation,
|
||||
mcpTools: make(map[string]registeredMCPTool), processes: connectorruntime.NewProcessGroup(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user