Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 40 additions & 15 deletions docs/architecture/a2a-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,21 +186,46 @@ Codex and Claude reserve their native session while approval/input is pending.
Their local executor interceptor rejects input for another task before SDK
execution. Other harnesses retain their existing policy for parked tasks. All harnesses permit at most one active execution.

## Runtime authority and deployment prerequisite

TaskStore currently uses a temporary unsigned
`x-kagent-insecure-runtime-identity` header carrying the projected atespace, actor
name, and UID. Go and Python reread those files on every call so restored actors
use their own identity. The API checks the requested session, atespace, and
recorded actor UID; user and share credentials do not grant private persistence
access. Public authentication remains unchanged.

This is the single prerelease path, with no configuration switch. The header can
be forged and does not establish verified actor identity. These builds are for
isolated deployments until [Substrate #1660](https://github.com/agent-substrate/substrate/issues/1660)
supplies actor JWT injection. That implementation will replace the header path
outright, including trusted issuer/JWKS, signature, audience, and expiry validation;
there will be no fallback to unsigned headers.
## Runtime authority

Actor calls use the controller's runtime listener (`server.runtimeBindAddress`,
`:8084` by default). Every permitted RPC requires a Substrate-issued Actor JWT in
`Authorization`. Go and Python controller clients send `Bearer placeholder`;
the exact control-plane origin's egress rule replaces it with a five-minute JWT
bound to the `kagent-runtime` audience. Other destinations receive no runtime JWT,
and this rule does not grant network access to sandboxes that cannot already
reach the control plane.

The runtime method allowlist contains TaskStore and Memory only. User APIs,
A2A, MCP, reflection, and unconfigured methods are not available through that
listener. Actors do not inherit their session owner's general permissions.
The user listener retains configured authentication and rejects Actor JWTs as
user credentials. Insecure user authentication remains insecure by design;
protect access to that listener according to the selected user authentication mode.

The controller trusts `substrate.actorJwtIssuer` (default
`https://idp.ate-system.svc`). It fetches OIDC discovery and JWKS over TLS using
`substrate.caFile`, checks the exact issuer, signature, audience, expiration and
not-before time, and requires an `actor.ate.dev` identity consistent with the
subject. Worker-for-actor tokens are not accepted. Signing keys refresh every
minute; publish new verification keys at least a minute before using them.

The service checks the verified actor name against the requested session and
matches its atespace and recorded Actor UID. Memory derives its agent namespace
and user from that persisted session binding, ignoring caller-supplied identity
fields. Users can also list and delete their own memories through the user
listener. These calls use the requested agent key and the authenticated user ID,
ignoring the request's user ID, and do not require a live actor or session.
Public and share credentials cannot grant runtime access. The unsigned identity
header is not accepted.

Substrate supplies the calling Actor's current identity after restore and fork;
no token is persisted in a runtime snapshot. Only the projected actor name is
read locally, to choose the session being addressed. Controller and runtime
images must be upgraded together; snapshots of older runtime images still send
the removed header and must be recreated. Memory keys now use the stored
`namespace/name` agent reference; existing memories keyed by runtime display
names are not migrated.

## Push notifications

Expand Down
7 changes: 4 additions & 3 deletions docs/architecture/oidc-proxy-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,10 @@ claim. Headers alone do not authenticate an agent. This path does not perform a
Kubernetes TokenReview or otherwise validate a service-account token.

The [gRPC interceptors](../../go/core/internal/grpcserver/interceptors.go) pass
incoming authentication metadata to the configured provider. Private runtime
methods use their separate runtime authenticator; the proxy's agent-header path
does not define authentication for those methods. See the
incoming authentication metadata to the configured provider. Actor calls use the separate
runtime listener, which always verifies Actor JWTs and allows only TaskStore and
Memory. The user listener rejects Actor JWTs; the proxy's agent-header path
does not define authentication for runtime methods. See the
[A2A gateway](a2a-gateway.md) for the runtime boundary.

## Deployment configuration
Expand Down
9 changes: 5 additions & 4 deletions docs/architecture/process-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ kubernetes:
leaderElection: true
server:
bindAddress: :8083
runtimeBindAddress: :8084
auth:
mode: insecure
database:
Expand All @@ -88,8 +89,8 @@ push:
sessions:
idleTtl: 604800s
runtimeDefaults:
controlPlaneUrl: http://kagent-controller.kagent.svc:8083
gatewayUrl: http://kagent-controller.kagent.svc:8083
controlPlaneUrl: http://kagent-controller.kagent.svc:8084
gatewayUrl: http://kagent-controller.kagent.svc:8084
logging:
level: info
```
Expand Down Expand Up @@ -168,8 +169,8 @@ compiles `runtimeDefaults` into these payloads independently of its own settings
```json
{
"runtime": {
"control_plane_url": "http://kagent-controller.kagent.svc:8083",
"gateway_url": "http://kagent-controller.kagent.svc:8083",
"control_plane_url": "http://kagent-controller.kagent.svc:8084",
"gateway_url": "http://kagent-controller.kagent.svc:8084",
"name": "researcher",
"namespace": "kagent",
"log_level": "info",
Expand Down
18 changes: 10 additions & 8 deletions go/adk/pkg/controllerclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,21 +130,23 @@ func (client *Client) CallContext(ctx context.Context, userID string) (context.C
if userID == "" {
userID = auth.UserIDFromContext(ctx)
}
metadataValues := make([]string, 0, 6)
metadataValues, _ := metadata.FromOutgoingContext(ctx)
metadataValues = metadataValues.Copy()
// Substrate replaces the placeholder at the controller's runtime origin.
token := "placeholder"
if client.tokenProvider != nil {
if token := client.tokenProvider.GetToken(); token != "" {
metadataValues = append(metadataValues, "authorization", "Bearer "+token)
if supplied := client.tokenProvider.GetToken(); supplied != "" {
token = supplied
}
}
metadataValues.Set("authorization", "Bearer "+token)
if client.agentName != "" {
metadataValues = append(metadataValues, "x-agent-name", client.agentName)
metadataValues.Set("x-agent-name", client.agentName)
}
if userID != "" {
metadataValues = append(metadataValues, "x-user-id", userID)
}
if len(metadataValues) > 0 {
ctx = metadata.AppendToOutgoingContext(ctx, metadataValues...)
metadataValues.Set("x-user-id", userID)
}
ctx = metadata.NewOutgoingContext(ctx, metadataValues)
if client.timeout <= 0 {
return ctx, func() {}
}
Expand Down
13 changes: 13 additions & 0 deletions go/adk/pkg/controllerclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,16 @@ func TestClientCanDisableDefaultDeadline(t *testing.T) {
_, hasDeadline := ctx.Deadline()
assert.False(t, hasDeadline)
}

func TestCallContextRequestsActorJWTWithoutDuplicateCredentials(t *testing.T) {
client, err := New(Config{APIURL: "http://controller.test:8084"})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, client.Close()) })
original := metadata.Pairs("authorization", "Bearer inherited", "x-trace-id", "trace")
ctx, cancel := client.CallContext(metadata.NewOutgoingContext(t.Context(), original), "")
defer cancel()
got, _ := metadata.FromOutgoingContext(ctx)
require.Equal(t, []string{"Bearer placeholder"}, got.Get("authorization"))
require.Equal(t, []string{"trace"}, got.Get("x-trace-id"))
require.Equal(t, []string{"Bearer inherited"}, original.Get("authorization"))
}
28 changes: 7 additions & 21 deletions go/adk/pkg/taskstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -19,7 +18,6 @@ import (
"google.golang.org/grpc/status"

"github.com/kagent-dev/kagent/go/adk/pkg/controllerclient"
apia2a "github.com/kagent-dev/kagent/go/api/a2a"
runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1"
)

Expand Down Expand Up @@ -49,27 +47,18 @@ func (s *Store) sessionID() (string, error) {
return id, nil
}

func (s *Store) callContext(ctx context.Context) (context.Context, context.CancelFunc, error) {
func (s *Store) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
ctx, cancel := s.client.CallContext(ctx, "")
md, _ := metadata.FromOutgoingContext(ctx)
md = md.Copy()
md.Delete("authorization")
md.Delete("x-user-id")
md.Delete("x-agent-name")
md.Delete("x-share-token")
// Temporary identity transport until Substrate injects actor credentials (#1660).
// Reread on every call because restore rebinds these files to the new actor.
var identity []string
for _, field := range []string{"atespace", "name", "uid"} {
value, err := os.ReadFile(filepath.Join(filepath.Dir(s.identityPath), field))
if err != nil {
cancel()
return nil, nil, fmt.Errorf("read runtime identity %s: %w", field, err)
}
identity = append(identity, strings.TrimSpace(string(value)))
}
md.Set(apia2a.InsecureRuntimeIdentityHeader, strings.Join(identity, "/"))
return metadata.NewOutgoingContext(ctx, md), cancel, nil
// Substrate replaces this placeholder with the calling Actor's JWT, including
// after a restore or fork. No credential is kept in the runtime snapshot.
md.Set("authorization", "Bearer placeholder")
return metadata.NewOutgoingContext(ctx, md), cancel
}

// Create persists the SDK's initial task and returns its committed version.
Expand Down Expand Up @@ -183,11 +172,8 @@ func (s *Store) List(ctx context.Context, request *a2a.ListTasksRequest) (*a2a.L
// retries conflicts, advances a version locally, or invokes the agent again.
func (s *Store) retry(ctx context.Context, call func(context.Context) error) error {
for attempt := range 4 {
rpcCtx, cancel, err := s.callContext(ctx)
if err != nil {
return err
}
err = call(rpcCtx)
rpcCtx, cancel := s.callContext(ctx)
err := call(rpcCtx)
cancel()
if (status.Code(err) != codes.Unavailable && status.Code(err) != codes.DeadlineExceeded) || attempt == 3 {
return err
Expand Down
6 changes: 1 addition & 5 deletions go/api/a2a/runtime.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
package a2a

// RuntimeIdentityPath is projected by Substrate and rebound on restore. It is
// routing metadata. Temporary TaskStore headers use it until Substrate #1660.
// routing metadata, not an authentication credential.
const RuntimeIdentityPath = "/run/kagent/identity/name"

// InsecureRuntimeIdentityHeader temporarily carries atespace/actor-name/actor-UID
// until Substrate supplies verified actor credentials (#1660).
const InsecureRuntimeIdentityHeader = "x-kagent-insecure-runtime-identity"

// DispatchHeader carries the gateway's attempt fence to the runtime TaskStore.
const DispatchHeader = "x-kagent-dispatch-id"
38 changes: 30 additions & 8 deletions go/api/gen/kagent/config/v1/config.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 4 additions & 6 deletions go/core/internal/grpcserver/interceptors.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,17 @@ import (
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"

apia2a "github.com/kagent-dev/kagent/go/api/a2a"
"github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors"
sessionsvc "github.com/kagent-dev/kagent/go/core/internal/service/session"
"github.com/kagent-dev/kagent/go/core/pkg/auth"
"github.com/kagent-dev/kagent/go/pkg/logging"
)

var forwardedMetadataKeys = map[string]string{
apia2a.InsecureRuntimeIdentityHeader: apia2a.InsecureRuntimeIdentityHeader,
"authorization": "Authorization",
"x-user-id": "X-User-Id",
"x-agent-name": "X-Agent-Name",
"x-share-token": "X-Share-Token",
"authorization": "Authorization",
"x-user-id": "X-User-Id",
"x-agent-name": "X-Agent-Name",
"x-share-token": "X-Share-Token",
}

func authenticationUnaryInterceptor(authenticator, runtimeAuthenticator auth.AuthProvider, shareStore sessionsvc.ShareStore, policies MethodPolicies) grpc.UnaryServerInterceptor {
Expand Down
11 changes: 5 additions & 6 deletions go/core/internal/grpcserver/interceptors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,9 @@ import (
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"

apia2a "github.com/kagent-dev/kagent/go/api/a2a"
apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1"
"github.com/kagent-dev/kagent/go/core/internal/database"
"github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors"
"github.com/kagent-dev/kagent/go/core/internal/service/taskstore"
pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth"
)

Expand Down Expand Up @@ -218,22 +216,23 @@ func TestA2AShareAuthorizationIsDelegatedToGateway(t *testing.T) {
}
}

func TestInsecureRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) {
func TestRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) {
const runtimeMethod = "/test.TaskStore/Get"
policies := MethodPolicies{runtimeMethod: pkgauth.AccessRuntime, readMethod: pkgauth.AccessRead}
ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs(apia2a.InsecureRuntimeIdentityHeader, "team-a/session-"+uuid.NewString()+"/actor-uid"))
ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", "Bearer signed-actor-token"))
public := &testAuthenticator{err: errors.New("public credentials required")}
for _, method := range []string{runtimeMethod, readMethod} {
_, err := authenticate(ctx, method, public, nil, nil, policies)
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("default %s: %v", method, err)
}
}
_, err := authenticate(ctx, readMethod, public, &taskstore.Authenticator{}, nil, policies)
runtime := &testAuthenticator{session: &testSession{principal: pkgauth.Principal{Agent: pkgauth.Agent{ID: "actor"}}}}
_, err := authenticate(ctx, readMethod, public, runtime, nil, policies)
if status.Code(err) != codes.Unauthenticated {
t.Fatalf("runtime test mode authorized public API: %v", err)
}
_, err = authenticate(ctx, runtimeMethod, public, &taskstore.Authenticator{}, nil, policies)
_, err = authenticate(ctx, runtimeMethod, public, runtime, nil, policies)
if err != nil {
t.Fatalf("explicit runtime test mode rejected identity: %v", err)
}
Expand Down
Loading
Loading