diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index b4da4053ae..f8225fd125 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -20,6 +20,18 @@ with `tools[].subAgent.templateRef` and compile under the parent Agent's Harness Each subagent requires `templateRef` and shares the parent's runtime and Harness. Dedicated `agentRef` bindings are deferred and are not part of the served API. +`Agent.spec.egress` lists HTTP(S) origins the Agent may reach besides the +destinations its revision compiles, such as `https://proxy.golang.org`. A host +may have `*` as its leftmost label (`https://*.githubusercontent.com` matches +one label), with at least two labels under it, so nothing opens a top-level +domain or every host. The field is on the Agent, not the +template or the Harness: the hosts are a property of the workload in the place +where it runs (an internal git host, a registry mirror), so a template stays +portable and a Harness stays about the runtime. The compiler adds the origins, +in canonical form, after the runtime compiler has run, so every runtime and +every Shared subagent treats them the same, and they are part of the revision +identity. + All three are `api.kagent.dev/v1alpha3` Kubernetes resources. Infrastructure-derived values such as runtime addresses and inferred egress do not belong in the public API. diff --git a/go/api/config/crd/bases/api.kagent.dev_agents.yaml b/go/api/config/crd/bases/api.kagent.dev_agents.yaml index 84023a3624..3a4845f7b5 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agents.yaml @@ -51,6 +51,21 @@ spec: AgentSpec pairs portable behavior with a runner. References are local to the Agent's namespace, including references nested in inline specs. properties: + egress: + description: |- + Egress lists HTTP(S) origins the Agent may reach besides the destinations + its revision compiles (the model, its MCP servers, its skill and plugin + sources, telemetry), such as "https://proxy.golang.org". The host may + have "*" as its leftmost label: "https://*.githubusercontent.com" matches + one label, such as raw.githubusercontent.com. A wildcard needs two labels + under it, so nothing opens a whole top-level domain or every host. + items: + maxLength: 270 + pattern: ^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set harness: description: HarnessSpec defines a reusable runtime and its infrastructure policy. diff --git a/go/api/v1alpha3/agent_types.go b/go/api/v1alpha3/agent_types.go index 95fd057d5a..20e9879e9c 100644 --- a/go/api/v1alpha3/agent_types.go +++ b/go/api/v1alpha3/agent_types.go @@ -37,6 +37,18 @@ type AgentSpec struct { // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="harnessRef.name must not be empty" // +optional HarnessRef *corev1.LocalObjectReference `json:"harnessRef,omitempty"` + // Egress lists HTTP(S) origins the Agent may reach besides the destinations + // its revision compiles (the model, its MCP servers, its skill and plugin + // sources, telemetry), such as "https://proxy.golang.org". The host may + // have "*" as its leftmost label: "https://*.githubusercontent.com" matches + // one label, such as raw.githubusercontent.com. A wildcard needs two labels + // under it, so nothing opens a whole top-level domain or every host. + // +optional + // +listType=set + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:items:MaxLength=270 + // +kubebuilder:validation:items:Pattern=`^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$` + Egress []string `json:"egress,omitempty"` } const ( diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index be23e3a308..204602b844 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -341,6 +341,21 @@ func TestConfigurationCRDValidation(t *testing.T) { } require.ErrorContains(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "empty-" + strings.ToLower(field)}, Spec: spec}), field+".name must not be empty") } + t.Run("Agent egress accepts HTTP(S) origins and leftmost-label wildcards", func(t *testing.T) { + spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, + Egress: []string{"https://proxy.golang.org", "http://mirror.internal:8080", "https://git.internal:65535", "https://*.githubusercontent.com"}} + require.NoError(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "egress"}, Spec: spec})) + }) + for i, origin := range []string{ + "proxy.golang.org", "ftp://proxy.golang.org", "https://*", "https://*.com", "https://a.*.example.com", "https://192.0.2.1", "https://[2001:db8::1]", + "https://proxy.golang.org/path", "https://Proxy.Golang.org", "https://user@proxy.golang.org", "https://proxy.golang.org?x=1", + "https://proxy.golang.org#f", "https://proxy.golang.org:0", "https://proxy.golang.org:70000", + } { + t.Run("Agent egress rejects "+origin, func(t *testing.T) { + spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, Egress: []string{origin}} + require.ErrorContains(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: fmt.Sprintf("egress-invalid-%d", i)}, Spec: spec}), "spec.egress[0]") + }) + } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 59a3d9ddf2..d5426ad471 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -109,6 +109,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.Egress != nil { + in, out := &in.Egress, &out.Egress + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. diff --git a/go/core/internal/egress/destination.go b/go/core/internal/egress/destination.go index ade6f203ed..6171155f25 100644 --- a/go/core/internal/egress/destination.go +++ b/go/core/internal/egress/destination.go @@ -1,9 +1,14 @@ package egress import ( + "fmt" "net" + "net/netip" "net/url" + "strconv" "strings" + + "k8s.io/apimachinery/pkg/util/validation" ) // Origin retains the protocol and destination port needed by Substrate's @@ -24,3 +29,40 @@ func Origin(u *url.URL) string { } return (&url.URL{Scheme: u.Scheme, Host: host}).String() } + +// ParseOrigin validates a declared HTTP(S) origin, such as +// "https://proxy.golang.org" or "https://*.githubusercontent.com", and returns +// its canonical form. Unlike Origin, it refuses anything beyond scheme, host +// and port, because a declared path or credential would silently widen to the +// whole origin. +func ParseOrigin(value string) (string, error) { + u, err := url.Parse(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Opaque != "" || u.User != nil || u.Hostname() == "" || + u.Path != "" || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("invalid egress origin %q: expected http(s)://host[:port]", value) + } + if !ValidHostPattern(strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")) { + return "", fmt.Errorf("invalid egress origin %q: the host must be a DNS name, optionally with \"*\" as its leftmost label", value) + } + if port := u.Port(); port != "" { + if number, err := strconv.ParseUint(port, 10, 16); err != nil || number == 0 { + return "", fmt.Errorf("invalid egress origin %q: invalid port", value) + } + } + return Origin(u), nil +} + +// ValidHostPattern reports whether a normalized host is a DNS name, or a DNS +// name with "*" as its leftmost label, which is what Substrate's hostname +// rules match. An IP address and "*" alone are not patterns, and a wildcard +// needs two labels under it: "*.com" would open a whole top-level domain. +func ValidHostPattern(host string) bool { + name, wildcard := strings.CutPrefix(host, "*.") + if _, err := netip.ParseAddr(name); err == nil { + return false + } + if wildcard && !strings.Contains(name, ".") { + return false + } + return len(validation.IsDNS1123Subdomain(name)) == 0 +} diff --git a/go/core/internal/egress/destination_test.go b/go/core/internal/egress/destination_test.go index dc9fa6d812..1bcebcd606 100644 --- a/go/core/internal/egress/destination_test.go +++ b/go/core/internal/egress/destination_test.go @@ -22,3 +22,31 @@ func TestOrigin(t *testing.T) { }) } } + +func TestParseOrigin(t *testing.T) { + for _, tt := range []struct{ raw, want string }{ + {"https://proxy.golang.org", "https://proxy.golang.org:443"}, + {"https://Proxy.Golang.org.", "https://proxy.golang.org:443"}, + {"http://mirror.internal", "http://mirror.internal:80"}, + {"https://git.internal:8443", "https://git.internal:8443"}, + {"https://*.githubusercontent.com", "https://*.githubusercontent.com:443"}, + } { + t.Run(tt.raw, func(t *testing.T) { + got, err := ParseOrigin(tt.raw) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } + for _, raw := range []string{ + "proxy.golang.org", "ftp://proxy.golang.org", "https://", "https://*", "https://*.", + "https://a.*.example.com", "https://*.*.example.com", "https://*github.com", "https://*.com", "https://-bad.example.com", + "https://192.0.2.1", "https://[2001:db8::1]", "https://proxy.golang.org/", "https://proxy.golang.org/path", + "https://proxy.golang.org?x=1", "https://proxy.golang.org?", "https://proxy.golang.org#f", + "https://user@proxy.golang.org", "https://proxy.golang.org:0", "https://proxy.golang.org:70000", + } { + t.Run(raw, func(t *testing.T) { + _, err := ParseOrigin(raw) + require.Error(t, err) + }) + } +} diff --git a/go/core/internal/service/session/workflow_test.go b/go/core/internal/service/session/workflow_test.go index 69e7cca446..21a8dbb2db 100644 --- a/go/core/internal/service/session/workflow_test.go +++ b/go/core/internal/service/session/workflow_test.go @@ -363,7 +363,11 @@ func TestActorEgressPolicy(t *testing.T) { policy, err = substrate.ActorEgressPolicy("team-a", nil, nil) require.NoError(t, err) require.Empty(t, policy.Rules, "no destinations must deny all egress") - for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value", "http://*.example.com"} { + policy, err = substrate.ActorEgressPolicy("team-a", []string{"https://*.example.com:443"}, nil) + require.NoError(t, err, "a leftmost-label wildcard is a Substrate hostname pattern") + require.Equal(t, []string{"*.example.com"}, policy.Rules[0].GetHttps().GetHostnames()) + for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value", + "https://*", "https://*.com", "https://a.*.example.com", "https://*.*.example.com", "https://*example.com"} { t.Run(destination, func(t *testing.T) { _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil) require.Error(t, err) diff --git a/go/core/internal/substrate/policy.go b/go/core/internal/substrate/policy.go index 64bcb848fa..67dd233f8d 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -11,7 +11,6 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/kagent-dev/kagent/go/core/internal/egress" - "k8s.io/apimachinery/pkg/util/validation" ) // ActorEgressPolicy compiles HTTP(S) origins into an actor's default allowlist. @@ -25,26 +24,20 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr protocols := map[string]string{} hosts := map[string]bool{} for _, destination := range destinations { - u, err := url.Parse(destination) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Hostname() == "" || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" || u.Path != "" { - return nil, fmt.Errorf("invalid egress destination %q: expected an HTTP(S) origin", destination) + if u, err := url.Parse(destination); err == nil { + if _, err := netip.ParseAddr(strings.TrimSuffix(u.Hostname(), ".")); err == nil { + return nil, fmt.Errorf("egress destination %q requires a DNS name: Substrate does not support IP allowlists", destination) + } } - host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") - if _, err := netip.ParseAddr(host); err == nil { - return nil, fmt.Errorf("egress destination %q requires a DNS name: Substrate does not support IP allowlists", destination) - } - if len(validation.IsDNS1123Subdomain(host)) != 0 { - return nil, fmt.Errorf("invalid egress destination %q", destination) - } - origin := egress.Origin(u) - u, err = url.Parse(origin) + origin, err := egress.ParseOrigin(destination) if err != nil { - return nil, fmt.Errorf("invalid egress destination %q: %w", destination, err) - } - port, err := strconv.ParseUint(u.Port(), 10, 16) - if err != nil || port == 0 { - return nil, fmt.Errorf("invalid egress port in %q", destination) + return nil, fmt.Errorf("invalid egress destination: %w", err) } + // ParseOrigin returns scheme://host:port with a valid port; the port is + // re-encoded so "0443" and "443" are one origin. + u, _ := url.Parse(origin) + host := u.Hostname() + port, _ := strconv.ParseUint(u.Port(), 10, 16) u.Host = net.JoinHostPort(host, strconv.FormatUint(port, 10)) if protocol, found := protocols[u.Host]; found && protocol != u.Scheme { return nil, fmt.Errorf("egress destination %q uses both HTTP and HTTPS on the same port", u.Host) @@ -72,8 +65,10 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr for _, origin := range names { target := origins[origin] ports := &ateapipb.Ports{Numbers: []int32{target.port}} - // Keep exactly one rule per host and port so an overlapping rule cannot - // bypass credential replacement. + // Keep exactly one rule per host pattern and port. A wildcard rule can + // overlap an exact one, but Substrate decides on the exact hostname + // first (EgressPolicy in proto/ateapi.proto), and credentials bind only + // to exact hostnames, so a wildcard cannot bypass credential replacement. if target.scheme == "https" { policy.Rules = append(policy.Rules, &ateapipb.EgressRule{Https: &ateapipb.HTTPSRule{Hostnames: []string{target.hostname}, Ports: ports, Effects: effects[target.hostname]}}) } else { diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index c64977cb6f..bb33788d98 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "maps" + "slices" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "istio.io/istio/pkg/kube/krt" "k8s.io/apimachinery/pkg/types" ) @@ -126,6 +128,10 @@ func (c *Compiler) CompileAgent(ctx context.Context, agent *v1alpha3.Agent) (*Co return nil, err } result.AgentUID = string(agent.UID) + result.EgressDestinations, err = withAgentEgress(result.EgressDestinations, agent.Spec.Egress) + if err != nil { + return nil, err + } result.Provenance, err = json.Marshal(struct { AgentName string `json:"agentName"` AgentUID string `json:"agentUID"` @@ -138,6 +144,26 @@ func (c *Compiler) CompileAgent(ctx context.Context, agent *v1alpha3.Agent) (*Co return result, nil } +// withAgentEgress adds the Agent's declared origins to the destinations its +// runtime compiled, in canonical form and once each. Every runtime gets the +// same treatment, so no runtime compiler reads the Agent's egress. +func withAgentEgress(compiled, declared []string) ([]string, error) { + if len(declared) == 0 { + return compiled, nil + } + destinations := slices.Clone(compiled) + for _, value := range declared { + origin, err := egress.ParseOrigin(value) + if err != nil { + return nil, NewValidationError("Agent egress: %v", err) + } + if !slices.Contains(destinations, origin) { + destinations = append(destinations, origin) + } + } + return destinations, nil +} + // compileConfiguration compiles resolved configuration for the named Agent. // The Agent name owns runtime identity; template and Harness names are provenance. func (c *Compiler) compileConfiguration(ctx context.Context, agentName string, harness *HarnessConfiguration, template *TemplateConfiguration) (*CompileResult, error) { diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 00469ab8e2..ff2d6e2906 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -978,3 +978,70 @@ func TestResolveModelConfigMistral(t *testing.T) { resolved = mockCollections(t, model).ResolvedModelConfigs.List()[0] require.Equal(t, "APIKeySecretNotFound", resolved.Failure().Reason) } + +func TestCompileAgentAddsTheAgentEgress(t *testing.T) { + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "go-coder", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "help"}, + } + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + }, + } + declared := []string{"https://proxy.golang.org", "https://*.githubusercontent.com", "https://Proxy.Golang.org.", "https://git.internal:8443"} + want := []string{"https://proxy.golang.org:443", "https://*.githubusercontent.com:443", "https://git.internal:8443"} + c := compiler(t, modelConfig(), template, harness) + + referenced := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Name: "coder", Namespace: "test"}, Spec: v1alpha3.AgentSpec{ + TemplateRef: &corev1.LocalObjectReference{Name: template.Name}, HarnessRef: &corev1.LocalObjectReference{Name: harness.Name}, Egress: declared, + }} + inline := inlineAgent(harness, template) + inline.Spec.Egress = declared + for name, agent := range map[string]*v1alpha3.Agent{"references": referenced, "inline": inline} { + t.Run(name, func(t *testing.T) { + result, err := c.CompileAgent(t.Context(), agent) + require.NoError(t, err) + require.Subset(t, result.EgressDestinations, want) + require.Equal(t, 1, countOf(result.EgressDestinations, "https://proxy.golang.org:443"), "an origin is listed once") + policy, err := substrate.ActorEgressPolicy("test", result.EgressDestinations, result.Credentials) + require.NoError(t, err) + var hostnames []string + for _, rule := range policy.Rules { + hostnames = append(hostnames, rule.GetHttps().GetHostnames()...) + } + require.Subset(t, hostnames, []string{"proxy.golang.org", "*.githubusercontent.com", "git.internal"}) + }) + } + + withEgress, err := c.CompileAgent(t.Context(), referenced) + require.NoError(t, err) + plain := referenced.DeepCopy() + plain.Spec.Egress = nil + withoutEgress, err := c.CompileAgent(t.Context(), plain) + require.NoError(t, err) + // The destinations are a digest input, so the revision identity follows them. + require.Subset(t, withEgress.EgressDestinations, want) + for _, origin := range want { + require.NotContains(t, withoutEgress.EgressDestinations, origin) + } + + invalid := referenced.DeepCopy() + invalid.Spec.Egress = []string{"https://proxy.golang.org/path"} + _, err = c.CompileAgent(t.Context(), invalid) + var validation *v2translator.ValidationError + require.ErrorAs(t, err, &validation) +} + +func countOf(values []string, want string) int { + n := 0 + for _, value := range values { + if value == want { + n++ + } + } + return n +} diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 84023a3624..3a4845f7b5 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -51,6 +51,21 @@ spec: AgentSpec pairs portable behavior with a runner. References are local to the Agent's namespace, including references nested in inline specs. properties: + egress: + description: |- + Egress lists HTTP(S) origins the Agent may reach besides the destinations + its revision compiles (the model, its MCP servers, its skill and plugin + sources, telemetry), such as "https://proxy.golang.org". The host may + have "*" as its leftmost label: "https://*.githubusercontent.com" matches + one label, such as raw.githubusercontent.com. A wildcard needs two labels + under it, so nothing opens a whole top-level domain or every host. + items: + maxLength: 270 + pattern: ^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set harness: description: HarnessSpec defines a reusable runtime and its infrastructure policy. diff --git a/ui/playwright/DEFERRED.md b/ui/playwright/DEFERRED.md index be664c4f12..53e148bc42 100644 --- a/ui/playwright/DEFERRED.md +++ b/ui/playwright/DEFERRED.md @@ -332,3 +332,12 @@ navigation had just reset. What actually removes the coupling is a second `test` same file — one recording for the lifecycle, one for the states, neither able to abort the other. That costs `conventions.test.ts` its "one spec, one test" rule, so it is an amendment to the convention rather than a reshuffle, and belongs in its own change. + +## An Agent edit keeps the egress the form does not show + +`AgentFormPage` does not author `spec.egress`, and the update sends the whole spec, so +the save carries the Agent's existing `egress` over. Nothing asserts it: no spec opens +an Agent that declares egress, saves it, and reads the request back. The case it guards +is silent — a save that dropped the line would widen nothing and narrow the Agent's +egress to what its revision compiles, with no message on the page. It belongs in the +agent lifecycle spec, with a fixture Agent whose `egress` matches the CRD shape. diff --git a/ui/src/api/domain/agents.ts b/ui/src/api/domain/agents.ts index 537ff3af5c..ceb5c7c8b6 100644 --- a/ui/src/api/domain/agents.ts +++ b/ui/src/api/domain/agents.ts @@ -5,7 +5,10 @@ import type { HarnessSpec } from "./harnesses"; /** Exactly one of each pair; refs resolve in the Agent's namespace. */ export type AgentSpec = ({ template: AgentTemplateSpec; templateRef?: never } | { templateRef: { name: string }; template?: never }) & - ({ harness: HarnessSpec; harnessRef?: never } | { harnessRef: { name: string }; harness?: never }); + ({ harness: HarnessSpec; harnessRef?: never } | { harnessRef: { name: string }; harness?: never }) & { + /** HTTP(S) origins the Agent may reach besides what its revision compiles. */ + egress?: string[]; + }; /** One condition the controller recorded for an Agent. */ export interface AgentCondition { type: string; diff --git a/ui/src/pages/agents/AgentFormPage.tsx b/ui/src/pages/agents/AgentFormPage.tsx index 75693eb0e3..d1d615229a 100644 --- a/ui/src/pages/agents/AgentFormPage.tsx +++ b/ui/src/pages/agents/AgentFormPage.tsx @@ -137,6 +137,8 @@ function AgentForm({ agent }: { agent?: Agent }) { ...(harnessSource === "reference" ? { harnessRef: { name: harnessRef! } } : { harness: harnessSpecFromDraft(harnessDraft, spec?.harness) }), + // The form does not author egress; an edit keeps what the Agent declares. + ...(spec?.egress ? { egress: spec.egress } : {}), } as AgentSpec; const input = { namespace,