Skip to content
Draft
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
58 changes: 56 additions & 2 deletions docs/architecture/credential-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ overrides of these variables are rejected.
| Gemini API key | `x-goog-api-key: <key>` |
| Bedrock bearer token | `authorization: Bearer <token>` |
| RemoteMCPServer Secret-backed header | Configured header; Secret contains its full value |
| AgentTemplate `skills[].source.git.authorizationFrom` | `authorization`; Secret contains its full value |

Provider endpoint overrides determine the allowed HTTP(S) origin. Egress rules
match its scheme, DNS name, and port; credential bindings remain scoped to the
hostname and header. Different credentials for the same hostname and header
are rejected, including conflicts between models, memory embeddings, and MCP
servers. Use distinct DNS names for
are rejected, including conflicts between models, memory embeddings, MCP
servers, and Git skill sources. Use distinct DNS names for
origins requiring different credentials. IP-address destinations are unsupported
by Substrate egress policies.

Expand All @@ -76,6 +77,59 @@ Caller-token passthrough retains its existing behavior. A passthrough model
cannot share a hostname with static gateway credentials, which would override
the caller's authentication.

## Private Git skill sources

A standalone skill may come from a private HTTPS Git repository. The Secret,
in the AgentTemplate's namespace, holds the complete `Authorization` header
value for the repository host:

```yaml
apiVersion: v1
kind: Secret
metadata:
name: skills-auth
stringData:
# GitHub: base64 of "x-access-token:<token>". Other forges may accept "Bearer <token>".
authorization: Basic eC1hY2Nlc3MtdG9rZW46PHRva2VuPg==
---
apiVersion: api.kagent.dev/v1alpha3
kind: AgentTemplate
metadata:
name: reviewer
spec:
skills:
- name: review
source:
git:
url: https://github.com/acme/private-skills
commit: 0123456789abcdef0123456789abcdef01234567
authorizationFrom:
name: skills-auth
key: authorization
```

The compiler allows the repository origin and binds the Secret key to its
hostname. The runtime fetches with a placeholder `Authorization` header, which
the gateway replaces; it never holds the token. The binding covers every
request the Actor makes to that hostname, including requests made by its tools,
so use a read-only token scoped to the skill repositories. `authorizationFrom`
requires an `https` URL and is rejected on `plugins[]`, which are still
fetched while the Actor starts.

### When skills are fetched

Actor networking starts only after the runtime is ready, and the golden Actor
that produces the template snapshot runs without an egress policy. Runtimes
therefore fetch standalone skills (Git, OCI, or S3, public or private) before
the first task of each session Actor, not at startup. Consequences:

- The golden snapshot contains neither a credential nor any standalone skill
content. Session snapshots contain the fetched files, never the credential.
- The first task of a session waits for the fetch. Later tasks reuse the files.
- A fetch failure fails that task with a `skills_unavailable: …` status message
and an error log line in the runtime; the next task retries the remaining
skills. It no longer prevents the golden snapshot.

## Deferred API fields

The source retains commented declarations for Harness and SandboxTemplate
Expand Down
6 changes: 4 additions & 2 deletions go/adk/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ func run(logger *slog.Logger, host, port, configDir string) error {
if err != nil {
return fmt.Errorf("load agent config from %s (model configuration is required): %w", configDir, err)
}
if err := config.MaterializeAgentPlugins(
ensureSkills, err := config.MaterializeAgentPlugins(
logging.IntoContext(context.Background(), logger), agentConfig,
config.AgentPluginPaths{
Packages: defaultPluginPackagesRoot,
Skills: defaultSkillsRoot,
Data: defaultPluginDataRoot,
},
); err != nil {
)
if err != nil {
return fmt.Errorf("materialize Agent Plugins: %w", err)
}
logger.Info("loaded agent config", "config_dir", configDir)
Expand Down Expand Up @@ -187,6 +188,7 @@ func run(logger *slog.Logger, host, port, configDir string) error {
Logger: logger,
Output: agentConfig.Output,
Flush: providers.ForceFlush,
EnsureSkills: ensureSkills,
})
if err != nil {
return fmt.Errorf("create A2A executor: %w", err)
Expand Down
17 changes: 17 additions & 0 deletions go/adk/pkg/a2a/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type KAgentExecutorConfig struct {
Logger *slog.Logger
Output *apiadk.OutputConfig
Flush func(context.Context) error
// EnsureSkills fetches deferred standalone skills before a task runs.
// A failure fails the task and is retried by the next one.
EnsureSkills func(context.Context) error
}

// KAgentExecutor keeps kagent's request/session glue around the upstream ADK
Expand All @@ -52,6 +55,7 @@ type KAgentExecutor struct {
logger *slog.Logger
structuredOutputEnabled bool
flush func(context.Context) error
ensureSkills func(context.Context) error
}

type structuredOutput struct {
Expand Down Expand Up @@ -109,6 +113,7 @@ func NewKAgentExecutor(cfg KAgentExecutorConfig) (*KAgentExecutor, error) {
logger: cfg.Logger.With("component", "kagent-executor"),
structuredOutputEnabled: output != nil,
flush: cfg.Flush,
ensureSkills: cfg.EnsureSkills,
}, nil
}

Expand Down Expand Up @@ -264,6 +269,18 @@ func (e *KAgentExecutor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorCon
"user_id", userID,
)

// Standalone skills need Actor egress, which only exists once a session
// Actor runs, so they are fetched here instead of at startup.
if e.ensureSkills != nil {
if err := e.ensureSkills(ctx); err != nil {
e.logger.ErrorContext(ctx, "standalone skills are unavailable", "task_id", reqCtx.TaskID, "error", err)
message := a2atype.NewMessageForTask(a2atype.MessageRoleAgent, reqCtx, a2atype.NewTextPart("skills_unavailable: "+err.Error()))
apia2a.SetTimelinePosition(message, time.Now())
yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, message), nil)
return
}
}

// Run our own session management before upstream executor runs its prepareSession function.
// This ensures that we create a session that contains metadata like x-kagent-source,
// and the upstream executor will find this session already exists and skip creation.
Expand Down
45 changes: 45 additions & 0 deletions go/adk/pkg/a2a/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package a2a

import (
"context"
"errors"
"iter"
"maps"
"slices"
Expand Down Expand Up @@ -249,6 +250,50 @@ func TestKAgentExecutor_StampsStructuredOutputFailureMessage(t *testing.T) {
}
}

func TestKAgentExecutor_FailsTaskWhenSkillsAreUnavailable(t *testing.T) {
reqCtx := &a2asrv.ExecutorContext{
TaskID: "task-1", ContextID: "ctx-1",
Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")),
}
builtin := &recordingExecutor{}
fetchErr := errors.New(`materialize skill "review": exit status 128`)
executor := &KAgentExecutor{
builtin: builtin, logger: slog.New(slog.DiscardHandler),
ensureSkills: func(context.Context) error { return fetchErr },
}
var events []a2atype.Event
for event, err := range executor.Execute(context.Background(), reqCtx) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
events = append(events, event)
}
if builtin.message != nil {
t.Fatal("the agent ran without its skills")
}
if len(events) != 1 {
t.Fatalf("events = %#v, want one failed status", events)
}
got, ok := events[0].(*a2atype.TaskStatusUpdateEvent)
if !ok || got.Status.State != a2atype.TaskStateFailed || got.Status.Message == nil {
t.Fatalf("status update = %#v, want failed status", events[0])
}
text, _ := got.Status.Message.Parts[0].Content.(a2atype.Text)
if want := "skills_unavailable: " + fetchErr.Error(); string(text) != want {
t.Fatalf("failure message = %q, want %q", text, want)
}

executor.ensureSkills = func(context.Context) error { return nil }
for _, err := range executor.Execute(context.Background(), reqCtx) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
}
if builtin.message == nil {
t.Fatal("the agent did not run once its skills were present")
}
}

func TestKAgentExecutor_PreservesContentBearingLastChunk(t *testing.T) {
reqCtx := &a2asrv.ExecutorContext{TaskID: "task-1", ContextID: "ctx-1"}
reqCtx.Message = a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hi"))
Expand Down
5 changes: 4 additions & 1 deletion go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *adk.AgentConfig, age
if err != nil {
return nil, fmt.Errorf("failed to load skills: %w", err)
}
if len(skills) > 0 {
// Standalone skills are fetched before the first task, after the agent
// is built; the skill toolset lists the directory on every request.
deferredSkills := agentConfig.AgentPlugins != nil && len(agentConfig.AgentPlugins.Skills) > 0
if len(skills) > 0 || deferredSkills {
executionTools, err := tools.NewSkillExecutionTools(skillsDirectory)
if err != nil {
return nil, fmt.Errorf("failed to create skill execution tools: %w", err)
Expand Down
72 changes: 45 additions & 27 deletions go/adk/pkg/config/agentplugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,55 @@ type AgentPluginPaths struct {
}

// MaterializeAgentPlugins materializes plugins independently for each agent
// and adds their skills and MCP servers to the ADK runtime configuration.
func MaterializeAgentPlugins(ctx context.Context, agentConfig *adk.AgentConfig, paths AgentPluginPaths) error {
if agentConfig.AgentPlugins != nil {
materialization, err := agentplugins.Materialize(ctx, *agentConfig.AgentPlugins, agentplugins.Paths{
Packages: paths.Packages,
Skills: paths.Skills,
})
if err != nil {
return fmt.Errorf("materialize agent plugins: %w", err)
}
if len(materialization.ClaudeFormatPluginRoots()) > 0 {
return fmt.Errorf("plugin is Claude-format; only the Agent Plugins format (plugin.json at the plugin root) is supported here")
// and adds their skills and MCP servers to the ADK runtime configuration. The
// returned function fetches every agent's standalone skills; the caller runs it
// before each task because the fetch needs Actor egress, which startup lacks.
func MaterializeAgentPlugins(ctx context.Context, agentConfig *adk.AgentConfig, paths AgentPluginPaths) (func(context.Context) error, error) {
var materializations []agentplugins.Materialization
var visit func(*adk.AgentConfig, AgentPluginPaths) error
visit = func(agentConfig *adk.AgentConfig, paths AgentPluginPaths) error {
if agentConfig.AgentPlugins != nil {
materialization, err := agentplugins.Materialize(ctx, *agentConfig.AgentPlugins, agentplugins.Paths{
Packages: paths.Packages,
Skills: paths.Skills,
})
if err != nil {
return fmt.Errorf("materialize agent plugins: %w", err)
}
if len(materialization.ClaudeFormatPluginRoots()) > 0 {
return fmt.Errorf("plugin is Claude-format; only the Agent Plugins format (plugin.json at the plugin root) is supported here")
}
mcpConfig, err := agentplugins.LoadMCP(ctx, materialization, paths.Data)
if err != nil {
return fmt.Errorf("load agent plugin MCP configuration: %w", err)
}
addMCPConfig(agentConfig, mcpConfig)
agentConfig.SkillsDirectory = materialization.SkillsDirectory
materializations = append(materializations, materialization)
}
mcpConfig, err := agentplugins.LoadMCP(ctx, materialization, paths.Data)
if err != nil {
return fmt.Errorf("load agent plugin MCP configuration: %w", err)
for i, child := range agentConfig.SubAgents {
childRoot := filepath.Join("subagents", fmt.Sprintf("%d", i))
if err := visit(child, AgentPluginPaths{
Packages: filepath.Join(paths.Packages, childRoot),
Skills: filepath.Join(paths.Skills, childRoot),
Data: filepath.Join(paths.Data, childRoot),
}); err != nil {
return fmt.Errorf("materialize sub-agent %q: %w", child.Name, err)
}
}
addMCPConfig(agentConfig, mcpConfig)
agentConfig.SkillsDirectory = materialization.SkillsDirectory
return nil
}
for i, child := range agentConfig.SubAgents {
childRoot := filepath.Join("subagents", fmt.Sprintf("%d", i))
if err := MaterializeAgentPlugins(ctx, child, AgentPluginPaths{
Packages: filepath.Join(paths.Packages, childRoot),
Skills: filepath.Join(paths.Skills, childRoot),
Data: filepath.Join(paths.Data, childRoot),
}); err != nil {
return fmt.Errorf("materialize sub-agent %q: %w", child.Name, err)
}
if err := visit(agentConfig, paths); err != nil {
return nil, err
}
return nil
return func(ctx context.Context) error {
for _, materialization := range materializations {
if err := materialization.EnsureSkills(ctx); err != nil {
return err
}
}
return nil
}, nil
}

func addMCPConfig(agentConfig *adk.AgentConfig, mcpConfig agentplugins.MCPConfig) {
Expand Down
11 changes: 9 additions & 2 deletions go/adk/pkg/config/agentplugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ func TestMaterializeAgentPluginsIsolatesSubagentSkills(t *testing.T) {
t.Fatal(err)
}
}
if err := MaterializeAgentPlugins(context.Background(), agentConfig, paths); err != nil {
ensureSkills, err := MaterializeAgentPlugins(context.Background(), agentConfig, paths)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(agentConfig.SkillsDirectory, "root")); !os.IsNotExist(err) {
t.Fatalf("standalone skill materialized during startup: %v", err)
}
if err := ensureSkills(context.Background()); err != nil {
t.Fatal(err)
}
if agentConfig.SkillsDirectory == agentConfig.SubAgents[0].SkillsDirectory {
Expand Down Expand Up @@ -64,7 +71,7 @@ func TestMaterializeAgentPluginsRejectsClaudeFormatPlugin(t *testing.T) {
}
source := agentplugin.Source{Git: &agentplugin.GitSource{URL: "unused", Commit: strings.Repeat("a", 40)}}
agentConfig := &adk.AgentConfig{AgentPlugins: &agentplugin.Resources{Plugins: []agentplugin.Bundle{{Source: source}}}}
if err := MaterializeAgentPlugins(context.Background(), agentConfig, paths); err == nil {
if _, err := MaterializeAgentPlugins(context.Background(), agentConfig, paths); err == nil {
t.Fatal("MaterializeAgentPlugins() accepted a Claude-format plugin")
}
}
Expand Down
8 changes: 6 additions & 2 deletions go/api/agentplugin/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// standalone skills and Agent Plugin packages.
package agentplugin

// Resources describes the skill resources a Harness must materialize before
// starting an agent.
// Resources describes the skill resources a Harness materializes: plugins
// before starting an agent, standalone skills before its first task.
type Resources struct {
Skills []Skill `json:"skills,omitempty"`
Plugins []Bundle `json:"plugins,omitempty"`
Expand Down Expand Up @@ -33,6 +33,10 @@ type Source struct {
type GitSource struct {
URL string `json:"url"`
Commit string `json:"commit"`
// GatewayAuthorization reports that the egress gateway owns this host's
// Authorization header. The runtime sends a placeholder value for the
// gateway to replace; it never holds the credential.
GatewayAuthorization bool `json:"gateway_authorization,omitempty"`
}

// S3Source identifies one immutable S3 object version.
Expand Down
Loading
Loading