From e351118f85f654de3ce39f3c707b1eacc90f7a50 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 4 Nov 2026 00:09:37 +0000 Subject: [PATCH] fix(addons): deny a stale generation before the credential check A read of an add-on that declares credentials looked up its capabilities before it compared the generation. A stale generation made that lookup fail, and the failure was reported as an outage of the owner (503) instead of a denial (403). Classify the lookup error like the tool check does. Assisted-by: Claude:claude-sonnet-5-5 go-test --- core/services/addons/gateway/credentials.go | 5 +++-- core/services/addons/gateway/credentials_test.go | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/core/services/addons/gateway/credentials.go b/core/services/addons/gateway/credentials.go index fe21e24bda..b9d1d530f7 100644 --- a/core/services/addons/gateway/credentials.go +++ b/core/services/addons/gateway/credentials.go @@ -114,8 +114,9 @@ func (g *Gateway) credentialize(ctx context.Context, a contract.Actor, r *contra caps, err := src.DeclaredCapabilities(ctx, r.AddonID, r.Generation) if err != nil { // Not knowing whether the add-on declares credentials is not a reason - // to run its tool without the check. - return ErrUnavailable + // to run its tool without the check. A stale generation is a denial, + // not an outage of the owner. + return toolSourceError(err) } if len(caps.Credentials) == 0 { return nil diff --git a/core/services/addons/gateway/credentials_test.go b/core/services/addons/gateway/credentials_test.go index bd1b3c32e6..c423fd600f 100644 --- a/core/services/addons/gateway/credentials_test.go +++ b/core/services/addons/gateway/credentials_test.go @@ -136,6 +136,15 @@ var _ = Describe("a read of an add-on that declares credentials", Label("tools") Expect(err).To(MatchError(ErrUnavailable)) }) + It("denies a stale generation as a denial, never as an outage of the owner", func() { + g.Resolver = resolved{} + _, err := g.Dispatch(ctx, contract.Actor{UserID: "alice", CredentialID: "c", Mode: "named-user"}, + contract.Request{AddonID: "sample", ResourceID: "r-0123456789", Generation: 7, OperationID: "op", Action: "read", Arguments: json.RawMessage(`{"q":"plain"}`)}) + Expect(err).To(MatchError(ErrForbidden)) + Expect(err).NotTo(MatchError(ErrUnavailable)) + Expect(client.reads.Load()).To(Equal(int32(0))) + }) + It("does not touch an add-on that declares no credentials", func() { plain := capSet{toolSet: toolSet{gen: 1, tools: map[string][]string{"sample": {"whoami"}}}, defs: defs[1:]} g.Tools = plain