diff --git a/.changeset/assistant-trigger-self-update-fix.md b/.changeset/assistant-trigger-self-update-fix.md new file mode 100644 index 0000000000..6968bd4ca5 --- /dev/null +++ b/.changeset/assistant-trigger-self-update-fix.md @@ -0,0 +1,5 @@ +--- +"server": patch +--- + +Assistants can now update their own triggers. Previously, calling `configure_trigger` on an existing trigger returned a generic internal error every time, even though the assistant could read its triggers fine — its scoped tool was being silently swapped for a stricter variant that demanded fields the assistant isn't allowed to send. As a side effect, an assistant's trigger list no longer leaks sibling assistants' triggers in the same project. diff --git a/server/internal/gateway/models.go b/server/internal/gateway/models.go index bc3757f3fa..e262ddbf3d 100644 --- a/server/internal/gateway/models.go +++ b/server/internal/gateway/models.go @@ -1,14 +1,25 @@ package gateway import ( + "context" "errors" + "io" "github.com/speakeasy-api/gram/server/internal/billing" "github.com/speakeasy-api/gram/server/internal/externalmcp" "github.com/speakeasy-api/gram/server/internal/functions" + "github.com/speakeasy-api/gram/server/internal/toolconfig" "github.com/speakeasy-api/gram/server/internal/urn" ) +// PlatformDirectExecutor lets a caller pin a specific executor to a plan so +// the runtime skips URN resolution. Used where the caller's matching context +// (e.g. a platform toolset slice) is more authoritative than the URN-keyed +// registry — multiple scoped variants of one tool can share a URN. +type PlatformDirectExecutor interface { + Call(ctx context.Context, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error +} + type FilterType string const ( @@ -135,6 +146,9 @@ type PlatformToolCallPlan struct { OwnerKind string `json:"owner_kind" yaml:"owner_kind"` OwnerID string `json:"owner_id" yaml:"owner_id"` InputSchema []byte `json:"input_schema" yaml:"input_schema"` + // Executor, when set, bypasses URN resolution in the platform runtime. + // In-process only; never serialized. + Executor PlatformDirectExecutor `json:"-" yaml:"-"` } // ExternalMCPToolCallPlan is an alias for externalmcp.ToolCallPlan. diff --git a/server/internal/mcp/serve_platform.go b/server/internal/mcp/serve_platform.go index 787c59ec0e..e26cceffc2 100644 --- a/server/internal/mcp/serve_platform.go +++ b/server/internal/mcp/serve_platform.go @@ -258,6 +258,9 @@ func (s *Service) callPlatformToolsetTool( OwnerKind: conv.PtrValOrEmpty(desc.OwnerKind, ""), OwnerID: conv.PtrValOrEmpty(desc.OwnerID, ""), InputSchema: desc.InputSchema, + // The toolset slice is authoritative; the runtime's URN registry can + // hold a differently scoped variant of the same tool. + Executor: matched.Executor, }) ctx, logger := o11y.EnrichToolCallContext(ctx, s.logger, descriptor.OrganizationSlug, descriptor.ProjectSlug) diff --git a/server/internal/platformtools/runtime/service.go b/server/internal/platformtools/runtime/service.go index 4d74bdb212..ef63e661b8 100644 --- a/server/internal/platformtools/runtime/service.go +++ b/server/internal/platformtools/runtime/service.go @@ -122,7 +122,7 @@ func TriggerExternalTools(db *pgxpool.Pool, app *bgtriggers.App, auditLogger *au } func (s *Service) ExecuteTool(ctx context.Context, plan *gateway.ToolCallPlan, env toolconfig.ToolCallEnv, requestBody io.Reader) (*gateway.PlatformResult, error) { - if plan == nil || plan.Kind != gateway.ToolKindPlatform || plan.Descriptor == nil { + if plan == nil || plan.Kind != gateway.ToolKindPlatform || plan.Descriptor == nil || plan.Platform == nil { return nil, fmt.Errorf("invalid platform tool plan") } authCtx, ok := contextvalues.GetAuthContext(ctx) @@ -141,6 +141,21 @@ func (s *Service) ExecuteTool(ctx context.Context, plan *gateway.ToolCallPlan, e } } + // A pinned executor wins over the URN registry: scoped variants of a + // platform tool share a URN, so the caller's match is more specific than + // what the registry would resolve. + if plan.Platform.Executor != nil { + var out bytes.Buffer + if err := plan.Platform.Executor.Call(ctx, env, requestBody, &out); err != nil { + return nil, fmt.Errorf("execute platform tool %s: %w", plan.Descriptor.URN, err) + } + return &gateway.PlatformResult{ + StatusCode: http.StatusOK, + ContentType: "application/json", + Body: out.Bytes(), + }, nil + } + executor, ok := s.executors[urnStr] if !ok { return nil, oops.E(oops.CodeNotFound, nil, "platform tool not found").Log(ctx, s.logger) diff --git a/server/internal/platformtools/runtime/service_test.go b/server/internal/platformtools/runtime/service_test.go index 3eb17ffb2a..2c9e18dfbc 100644 --- a/server/internal/platformtools/runtime/service_test.go +++ b/server/internal/platformtools/runtime/service_test.go @@ -2,6 +2,10 @@ package runtime import ( "context" + "errors" + "fmt" + "io" + "strings" "testing" "github.com/google/uuid" @@ -15,6 +19,31 @@ import ( "github.com/speakeasy-api/gram/server/internal/urn" ) +type stubDirectExecutor struct { + response string + err error + called bool + gotBody string +} + +func (s *stubDirectExecutor) Call(_ context.Context, _ toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error { + s.called = true + if payload != nil { + body, err := io.ReadAll(payload) + if err != nil { + return fmt.Errorf("read payload: %w", err) + } + s.gotBody = string(body) + } + if s.err != nil { + return s.err + } + if _, err := io.WriteString(wr, s.response); err != nil { + return fmt.Errorf("write response: %w", err) + } + return nil +} + func TestService_ExecuteTool_RequiresProjectAuthContext(t *testing.T) { t.Parallel() @@ -27,6 +56,7 @@ func TestService_ExecuteTool_RequiresProjectAuthContext(t *testing.T) { ProjectID: projectID.String(), URN: urn.NewTool(urn.ToolKindPlatform, "logs", "search_logs"), }, + Platform: &gateway.PlatformToolCallPlan{}, }, toolconfig.ToolCallEnv{ UserConfig: toolconfig.NewCaseInsensitiveEnv(), SystemEnv: toolconfig.NewCaseInsensitiveEnv(), @@ -53,6 +83,7 @@ func TestService_ExecuteTool_RejectsMismatchedProjectAuthContext(t *testing.T) { ProjectID: descriptorProjectID.String(), URN: urn.NewTool(urn.ToolKindPlatform, "logs", "search_logs"), }, + Platform: &gateway.PlatformToolCallPlan{}, }, toolconfig.ToolCallEnv{ UserConfig: toolconfig.NewCaseInsensitiveEnv(), SystemEnv: toolconfig.NewCaseInsensitiveEnv(), @@ -62,3 +93,60 @@ func TestService_ExecuteTool_RejectsMismatchedProjectAuthContext(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "does not match project") } + +// overridePlan builds a plan whose URN is unregistered, so any test that +// reaches the URN registry instead of the pinned executor would 404. +func overridePlan(projectID uuid.UUID, exec gateway.PlatformDirectExecutor) *gateway.ToolCallPlan { + return &gateway.ToolCallPlan{ + Kind: gateway.ToolKindPlatform, + Descriptor: &gateway.ToolDescriptor{ + ProjectID: projectID.String(), + URN: urn.NewTool(urn.ToolKindPlatform, "unregistered", "tool"), + }, + Platform: &gateway.PlatformToolCallPlan{Executor: exec}, + } +} + +func TestService_ExecuteTool_UsesPlanExecutorOverride(t *testing.T) { + t.Parallel() + + svc := NewService(testenv.NewLogger(t), nil, nil, audit.NewLogger()) + projectID := uuid.New() + ctx := contextvalues.SetAuthContext(context.Background(), &contextvalues.AuthContext{ + ActiveOrganizationID: "org-1", + ProjectID: &projectID, + }) + + exec := &stubDirectExecutor{response: `{"ok":true}`} + + result, err := svc.ExecuteTool(ctx, overridePlan(projectID, exec), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.NewCaseInsensitiveEnv(), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + }, strings.NewReader(`{"hello":"world"}`)) + require.NoError(t, err) + require.True(t, exec.called) + require.JSONEq(t, `{"hello":"world"}`, exec.gotBody) + require.NotNil(t, result) + require.JSONEq(t, `{"ok":true}`, string(result.Body)) +} + +func TestService_ExecuteTool_PlanExecutorOverrideSurfacesError(t *testing.T) { + t.Parallel() + + svc := NewService(testenv.NewLogger(t), nil, nil, audit.NewLogger()) + projectID := uuid.New() + ctx := contextvalues.SetAuthContext(context.Background(), &contextvalues.AuthContext{ + ActiveOrganizationID: "org-1", + ProjectID: &projectID, + }) + + exec := &stubDirectExecutor{err: errors.New("boom")} + + _, err := svc.ExecuteTool(ctx, overridePlan(projectID, exec), toolconfig.ToolCallEnv{ + UserConfig: toolconfig.NewCaseInsensitiveEnv(), + SystemEnv: toolconfig.NewCaseInsensitiveEnv(), + }, nil) + require.Error(t, err) + require.ErrorContains(t, err, "boom") + require.True(t, exec.called) +} diff --git a/server/internal/toolsets/shared.go b/server/internal/toolsets/shared.go index 66d6687ede..13135960e7 100644 --- a/server/internal/toolsets/shared.go +++ b/server/internal/toolsets/shared.go @@ -134,6 +134,7 @@ func (t *Toolsets) extractPlatformToolCallPlan(ctx context.Context, projectID uu OwnerKind: conv.PtrValOrEmpty(tool.OwnerKind, ""), OwnerID: conv.PtrValOrEmpty(tool.OwnerID, ""), InputSchema: tool.InputSchema, + Executor: nil, } return gateway.NewPlatformToolCallPlan(descriptor, plan), nil