From c9d8398cf44253a4b29b2da4f3718a9f8429ec8d Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 5 May 2026 09:09:46 +0000 Subject: [PATCH 1/6] depend on v2.9 of the agent proto api --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index a5e4732..7b1bfde 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.25.9 require ( github.com/cenkalti/backoff/v5 v5.0.3 - github.com/coder/coder/v2 v2.33.0-rc.3.0.20260501075247-b3e1178358f5 - github.com/coder/serpent v0.14.0 + github.com/coder/coder/v2 v2.34.0-rc.0.0.20260505083626-1ba7139f2154 + github.com/coder/serpent v0.15.0 github.com/google/uuid v1.6.0 github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c github.com/miekg/dns v1.1.72 diff --git a/go.sum b/go.sum index 89d4da8..900f8a7 100644 --- a/go.sum +++ b/go.sum @@ -34,10 +34,14 @@ github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoK github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coder/coder/v2 v2.33.0-rc.3.0.20260501075247-b3e1178358f5 h1:7V2ZTceP3V8EqYA8kC+3xrRwaZ8SOtgw4b8cElm0rcA= github.com/coder/coder/v2 v2.33.0-rc.3.0.20260501075247-b3e1178358f5/go.mod h1:w3FcuW2hJS/QnmY7ilsvt0yVpUhlVYdnBozwTkmpkKE= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260505083626-1ba7139f2154 h1:f3/+Fbp1/SPq3g8sI/oTjYEc53Zs54Cbd5byw0TEkEE= +github.com/coder/coder/v2 v2.34.0-rc.0.0.20260505083626-1ba7139f2154/go.mod h1:W1EYsVyhiAfMsZSguJK6/g5uJL3UbDPva4zvND7kHwQ= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= github.com/coder/serpent v0.14.0 h1:g7vt2zBMp3nWyAvyhvQduaI53Ku65U3wITMi01+/8pU= github.com/coder/serpent v0.14.0/go.mod h1:7OIvFBYMd+OqarMy5einBl8AtRr8LliopVU7pyrwucY= +github.com/coder/serpent v0.15.0 h1:jobR7DnPsxzEMD0cRiailwlY+4v6HAPS/8emIgBpaIU= +github.com/coder/serpent v0.15.0/go.mod h1:7OIvFBYMd+OqarMy5einBl8AtRr8LliopVU7pyrwucY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= From 3ea7428d04af40de6c33033170a14b48663665bc Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 30 Apr 2026 09:55:30 +0000 Subject: [PATCH 2/6] feat(config): session correlation header injection configuration Add YAML and CLI configuration surface for session correlation header injection per the Bridge/Boundaries Correlation RFC (FR 2). New configuration options: - --enable-session-correlation / session_correlation_enabled: top-level toggle to disable injection entirely for deployments without AI Bridge in front. - --inject-session-id-on / session_id_inject_targets (YAML): repeatable list of inject targets in "domain= [path=]" format. - --session-id-header-name / session_id_header_name: configurable header name (default X-Coder-Agent-Firewall-Session-Id). - --sequence-number-header-name / sequence_number_header_name: configurable header name (default X-Coder-Agent-Firewall-Sequence-Number). Config validation ensures that when correlation is enabled at least one inject target is present and header names are non-empty. Parsing validates the domain=... path=... key-value format and rejects unknown keys. This commit adds config and validation only; runtime injection is wired in a follow-up PR. --- cli/cli.go | 37 ++++ config/config.go | 58 ++++++ config/session_correlation.go | 105 ++++++++++ config/session_correlation_test.go | 314 +++++++++++++++++++++++++++++ 4 files changed, 514 insertions(+) create mode 100644 config/session_correlation.go create mode 100644 config/session_correlation_test.go diff --git a/cli/cli.go b/cli/cli.go index 7d1567a..a3f2de8 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -169,6 +169,43 @@ func BaseCommand(version string) *serpent.Command { Value: &showVersion, YAML: "", // CLI only }, + // Session correlation header injection options. + { + Flag: "enable-session-correlation", + Env: "BOUNDARY_SESSION_CORRELATION_ENABLED", + Description: "Enable session correlation header injection. Disable for deployments without AI Bridge in front.", + Value: &cliConfig.SessionCorrelationEnabled, + YAML: "session_correlation_enabled", + }, + { + Flag: "inject-session-id-on", + Env: "BOUNDARY_INJECT_SESSION_ID_ON", + Description: `Inject target (repeatable). Requests matching these targets receive session correlation headers. Format: "domain= [path=]".`, + Value: &cliConfig.InjectSessionIDOn, + YAML: "", // CLI only, YAML uses session_id_inject_targets. + }, + { + Flag: "", // No CLI flag, YAML only. + Description: "Inject targets from config file (YAML only).", + Value: &cliConfig.InjectSessionIDOnYAML, + YAML: "session_id_inject_targets", + }, + { + Flag: "session-id-header-name", + Env: "BOUNDARY_SESSION_ID_HEADER_NAME", + Description: "HTTP header name for the boundary session ID.", + Default: config.DefaultSessionIDHeaderName, + Value: &cliConfig.SessionIDHeaderName, + YAML: "session_id_header_name", + }, + { + Flag: "sequence-number-header-name", + Env: "BOUNDARY_SEQUENCE_NUMBER_HEADER_NAME", + Description: "HTTP header name for the boundary sequence number.", + Default: config.DefaultSequenceNumberHeaderName, + Value: &cliConfig.SequenceNumberHeaderName, + YAML: "sequence_number_header_name", + }, }, Handler: func(inv *serpent.Invocation) error { // Handle --version flag early diff --git a/config/config.go b/config/config.go index 73cdb38..5ee1220 100644 --- a/config/config.go +++ b/config/config.go @@ -70,6 +70,13 @@ type CliConfig struct { NoUserNamespace serpent.Bool `yaml:"no_user_namespace"` DisableAuditLogs serpent.Bool `yaml:"disable_audit_logs"` LogProxySocketPath serpent.String `yaml:"log_proxy_socket_path"` + + // Session correlation header injection. + SessionCorrelationEnabled serpent.Bool `yaml:"session_correlation_enabled"` + InjectSessionIDOn AllowStringsArray `yaml:"inject_session_id_on"` + InjectSessionIDOnYAML serpent.StringArray `yaml:"session_id_inject_targets"` + SessionIDHeaderName serpent.String `yaml:"session_id_header_name"` + SequenceNumberHeaderName serpent.String `yaml:"sequence_number_header_name"` } type AppConfig struct { @@ -87,6 +94,10 @@ type AppConfig struct { DisableAuditLogs bool LogProxySocketPath string + // SessionCorrelation controls header injection for AI Bridge + // correlation. See SessionCorrelationConfig for details. + SessionCorrelation SessionCorrelationConfig + // SessionID is a UUIDv4 generated at process startup. It groups // all audit events produced by this boundary invocation into a // single session. Set by Run, not by configuration. @@ -108,6 +119,12 @@ func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string) (AppConfig, er userInfo := GetUserInfo() + // Build session correlation config from CLI and YAML sources. + sc, err := buildSessionCorrelation(cfg) + if err != nil { + return AppConfig{}, fmt.Errorf("session correlation config: %w", err) + } + return AppConfig{ AllowRules: allAllowStrings, LogLevel: cfg.LogLevel.Value(), @@ -122,5 +139,46 @@ func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string) (AppConfig, er UserInfo: userInfo, DisableAuditLogs: cfg.DisableAuditLogs.Value(), LogProxySocketPath: cfg.LogProxySocketPath.Value(), + SessionCorrelation: sc, }, nil } + +// buildSessionCorrelation merges CLI and YAML inject target sources, +// parses each target string, applies header name defaults, and +// validates the resulting configuration. +func buildSessionCorrelation(cfg CliConfig) (SessionCorrelationConfig, error) { + // Merge YAML targets with CLI targets. + rawTargets := append(cfg.InjectSessionIDOnYAML.Value(), cfg.InjectSessionIDOn.Value()...) + + var targets []InjectTarget + for _, raw := range rawTargets { + t, err := ParseInjectTarget(raw) + if err != nil { + return SessionCorrelationConfig{}, err + } + targets = append(targets, t) + } + + // Apply defaults for header names. + sessionIDHeader := cfg.SessionIDHeaderName.Value() + if sessionIDHeader == "" { + sessionIDHeader = DefaultSessionIDHeaderName + } + seqHeader := cfg.SequenceNumberHeaderName.Value() + if seqHeader == "" { + seqHeader = DefaultSequenceNumberHeaderName + } + + sc := SessionCorrelationConfig{ + Enabled: cfg.SessionCorrelationEnabled.Value(), + InjectTargets: targets, + SessionIDHeaderName: sessionIDHeader, + SequenceNumberHeaderName: seqHeader, + } + + if err := ValidateSessionCorrelation(sc); err != nil { + return SessionCorrelationConfig{}, err + } + + return sc, nil +} diff --git a/config/session_correlation.go b/config/session_correlation.go new file mode 100644 index 0000000..a67b02e --- /dev/null +++ b/config/session_correlation.go @@ -0,0 +1,105 @@ +package config + +import ( + "fmt" + "strings" +) + +// Default header names for session correlation. +const ( + DefaultSessionIDHeaderName = "X-Coder-Agent-Firewall-Session-Id" + DefaultSequenceNumberHeaderName = "X-Coder-Agent-Firewall-Sequence-Number" +) + +// InjectTarget represents a parsed target for session correlation header +// injection. Requests matching the domain (and optional path glob) will +// receive the session ID and sequence number headers. +type InjectTarget struct { + Domain string + Path string +} + +// SessionCorrelationConfig holds configuration for session correlation +// header injection. When enabled, boundary injects its session ID and +// sequence number as custom headers on matching outbound requests so +// that an upstream AI Bridge can correlate the request back to the +// boundary audit event stream. +type SessionCorrelationConfig struct { + // Enabled controls whether session correlation headers are injected. + // Deployments without AI Bridge in front should set this to false. + Enabled bool + + // InjectTargets is the list of domain/path patterns that should + // receive session correlation headers. + InjectTargets []InjectTarget + + // SessionIDHeaderName is the HTTP header name used to carry the + // boundary session ID. Defaults to DefaultSessionIDHeaderName. + SessionIDHeaderName string + + // SequenceNumberHeaderName is the HTTP header name used to carry + // the boundary sequence number. Defaults to + // DefaultSequenceNumberHeaderName. + SequenceNumberHeaderName string +} + +// ParseInjectTarget parses a string of the form "domain=... path=..." +// into an InjectTarget. The domain key is required; path is optional. +func ParseInjectTarget(raw string) (InjectTarget, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return InjectTarget{}, fmt.Errorf("inject target must not be empty") + } + + var target InjectTarget + for _, part := range strings.Fields(raw) { + key, value, ok := strings.Cut(part, "=") + if !ok { + return InjectTarget{}, fmt.Errorf( + "inject target: malformed key-value pair %q, expected key=value", part, + ) + } + switch key { + case "domain": + if value == "" { + return InjectTarget{}, fmt.Errorf("inject target: domain must not be empty") + } + target.Domain = value + case "path": + target.Path = value + default: + return InjectTarget{}, fmt.Errorf("inject target: unknown key %q", key) + } + } + + if target.Domain == "" { + return InjectTarget{}, fmt.Errorf("inject target: domain is required") + } + + return target, nil +} + +// ValidateSessionCorrelation checks that the session correlation config +// is internally consistent. It returns an error describing the first +// problem found, or nil if the config is valid. +func ValidateSessionCorrelation(cfg SessionCorrelationConfig) error { + if !cfg.Enabled { + return nil + } + + if len(cfg.InjectTargets) == 0 { + return fmt.Errorf( + "session correlation is enabled but no inject targets are configured", + ) + } + + if cfg.SessionIDHeaderName == "" { + return fmt.Errorf("session-id-header-name must not be empty when session correlation is enabled") + } + + if cfg.SequenceNumberHeaderName == "" { + return fmt.Errorf("sequence-number-header-name must not be empty when session correlation is enabled") + } + + return nil +} diff --git a/config/session_correlation_test.go b/config/session_correlation_test.go new file mode 100644 index 0000000..eb0605a --- /dev/null +++ b/config/session_correlation_test.go @@ -0,0 +1,314 @@ +package config + +import ( + "testing" +) + +func TestParseInjectTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want InjectTarget + wantErr bool + }{ + { + name: "domain only", + input: "domain=dev.coder.com", + want: InjectTarget{Domain: "dev.coder.com"}, + }, + { + name: "domain and path", + input: "domain=dev.coder.com path=/api/v2/aibridge/*", + want: InjectTarget{Domain: "dev.coder.com", Path: "/api/v2/aibridge/*"}, + }, + { + name: "leading and trailing whitespace", + input: " domain=dev.coder.com path=/api/* ", + want: InjectTarget{Domain: "dev.coder.com", Path: "/api/*"}, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + { + name: "whitespace only", + input: " ", + wantErr: true, + }, + { + name: "missing domain", + input: "path=/api/*", + wantErr: true, + }, + { + name: "empty domain value", + input: "domain=", + wantErr: true, + }, + { + name: "malformed pair no equals", + input: "domain", + wantErr: true, + }, + { + name: "unknown key", + input: "domain=example.com port=443", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseInjectTarget(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Domain != tc.want.Domain { + t.Errorf("Domain: got %q, want %q", got.Domain, tc.want.Domain) + } + if got.Path != tc.want.Path { + t.Errorf("Path: got %q, want %q", got.Path, tc.want.Path) + } + }) + } +} + +func TestValidateSessionCorrelation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg SessionCorrelationConfig + wantErr bool + }{ + { + name: "disabled is always valid", + cfg: SessionCorrelationConfig{ + Enabled: false, + }, + }, + { + name: "disabled with empty targets is valid", + cfg: SessionCorrelationConfig{ + Enabled: false, + InjectTargets: nil, + }, + }, + { + name: "enabled with targets and default headers", + cfg: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: []InjectTarget{{Domain: "dev.coder.com"}}, + SessionIDHeaderName: DefaultSessionIDHeaderName, + SequenceNumberHeaderName: DefaultSequenceNumberHeaderName, + }, + }, + { + name: "enabled with custom headers", + cfg: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: []InjectTarget{{Domain: "example.com", Path: "/api/*"}}, + SessionIDHeaderName: "X-Custom-Session", + SequenceNumberHeaderName: "X-Custom-Seq", + }, + }, + { + name: "enabled with no targets", + cfg: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: nil, + SessionIDHeaderName: DefaultSessionIDHeaderName, + SequenceNumberHeaderName: DefaultSequenceNumberHeaderName, + }, + wantErr: true, + }, + { + name: "enabled with empty targets slice", + cfg: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: []InjectTarget{}, + SessionIDHeaderName: DefaultSessionIDHeaderName, + SequenceNumberHeaderName: DefaultSequenceNumberHeaderName, + }, + wantErr: true, + }, + { + name: "enabled with empty session id header", + cfg: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: []InjectTarget{{Domain: "example.com"}}, + SessionIDHeaderName: "", + SequenceNumberHeaderName: DefaultSequenceNumberHeaderName, + }, + wantErr: true, + }, + { + name: "enabled with empty sequence number header", + cfg: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: []InjectTarget{{Domain: "example.com"}}, + SessionIDHeaderName: DefaultSessionIDHeaderName, + SequenceNumberHeaderName: "", + }, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := ValidateSessionCorrelation(tc.cfg) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cli CliConfig + want SessionCorrelationConfig + wantErr bool + }{ + { + name: "defaults when not configured", + cli: baseCliConfig(), + want: SessionCorrelationConfig{ + Enabled: false, + InjectTargets: nil, + SessionIDHeaderName: DefaultSessionIDHeaderName, + SequenceNumberHeaderName: DefaultSequenceNumberHeaderName, + }, + }, + { + name: "enabled with inject targets", + cli: func() CliConfig { + c := baseCliConfig() + c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDOn.Set("domain=dev.coder.com path=/api/v2/aibridge/*") + return c + }(), + want: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: []InjectTarget{ + {Domain: "dev.coder.com", Path: "/api/v2/aibridge/*"}, + }, + SessionIDHeaderName: DefaultSessionIDHeaderName, + SequenceNumberHeaderName: DefaultSequenceNumberHeaderName, + }, + }, + { + name: "custom header names", + cli: func() CliConfig { + c := baseCliConfig() + c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDOn.Set("domain=example.com") + c.SessionIDHeaderName.Set("X-My-Session") + c.SequenceNumberHeaderName.Set("X-My-Seq") + return c + }(), + want: SessionCorrelationConfig{ + Enabled: true, + InjectTargets: []InjectTarget{{Domain: "example.com"}}, + SessionIDHeaderName: "X-My-Session", + SequenceNumberHeaderName: "X-My-Seq", + }, + }, + { + name: "enabled with no targets fails validation", + cli: func() CliConfig { + c := baseCliConfig() + c.SessionCorrelationEnabled.Set("true") + return c + }(), + wantErr: true, + }, + { + name: "invalid inject target", + cli: func() CliConfig { + c := baseCliConfig() + c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDOn.Set("notakey") + return c + }(), + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := NewAppConfigFromCliConfig(tc.cli, []string{"echo", "hello"}) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + sc := got.SessionCorrelation + if sc.Enabled != tc.want.Enabled { + t.Errorf("Enabled: got %v, want %v", sc.Enabled, tc.want.Enabled) + } + if sc.SessionIDHeaderName != tc.want.SessionIDHeaderName { + t.Errorf("SessionIDHeaderName: got %q, want %q", + sc.SessionIDHeaderName, tc.want.SessionIDHeaderName) + } + if sc.SequenceNumberHeaderName != tc.want.SequenceNumberHeaderName { + t.Errorf("SequenceNumberHeaderName: got %q, want %q", + sc.SequenceNumberHeaderName, tc.want.SequenceNumberHeaderName) + } + if len(sc.InjectTargets) != len(tc.want.InjectTargets) { + t.Fatalf("InjectTargets len: got %d, want %d", + len(sc.InjectTargets), len(tc.want.InjectTargets)) + } + for i := range sc.InjectTargets { + if sc.InjectTargets[i].Domain != tc.want.InjectTargets[i].Domain { + t.Errorf("InjectTargets[%d].Domain: got %q, want %q", + i, sc.InjectTargets[i].Domain, tc.want.InjectTargets[i].Domain) + } + if sc.InjectTargets[i].Path != tc.want.InjectTargets[i].Path { + t.Errorf("InjectTargets[%d].Path: got %q, want %q", + i, sc.InjectTargets[i].Path, tc.want.InjectTargets[i].Path) + } + } + }) + } +} + +// baseCliConfig returns a CliConfig with valid defaults for fields that +// NewAppConfigFromCliConfig requires, so tests can focus on the session +// correlation fields without tripping over unrelated validation. +func baseCliConfig() CliConfig { + c := CliConfig{} + c.JailType.Set("nsjail") + c.SessionIDHeaderName.Set(DefaultSessionIDHeaderName) + c.SequenceNumberHeaderName.Set(DefaultSequenceNumberHeaderName) + return c +} From fa3d50e6b5cb6fceab6f68424b0b308f8e5e6cad Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 5 May 2026 09:22:18 +0000 Subject: [PATCH 3/6] make fmt --- config/config.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/config.go b/config/config.go index 5ee1220..d5359fb 100644 --- a/config/config.go +++ b/config/config.go @@ -72,11 +72,11 @@ type CliConfig struct { LogProxySocketPath serpent.String `yaml:"log_proxy_socket_path"` // Session correlation header injection. - SessionCorrelationEnabled serpent.Bool `yaml:"session_correlation_enabled"` - InjectSessionIDOn AllowStringsArray `yaml:"inject_session_id_on"` - InjectSessionIDOnYAML serpent.StringArray `yaml:"session_id_inject_targets"` - SessionIDHeaderName serpent.String `yaml:"session_id_header_name"` - SequenceNumberHeaderName serpent.String `yaml:"sequence_number_header_name"` + SessionCorrelationEnabled serpent.Bool `yaml:"session_correlation_enabled"` + InjectSessionIDOn AllowStringsArray `yaml:"inject_session_id_on"` + InjectSessionIDOnYAML serpent.StringArray `yaml:"session_id_inject_targets"` + SessionIDHeaderName serpent.String `yaml:"session_id_header_name"` + SequenceNumberHeaderName serpent.String `yaml:"sequence_number_header_name"` } type AppConfig struct { From 0a1fe419b24953249d933a86645bc23837d7a1ec Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 5 May 2026 10:27:21 +0000 Subject: [PATCH 4/6] feat: determine which domains to inject correlation headers for based on the environment --- cli/cli.go | 29 +++-- config/config.go | 21 +++- config/session_correlation.go | 44 +++++++- config/session_correlation_test.go | 175 +++++++++++++++++++++++++++-- 4 files changed, 243 insertions(+), 26 deletions(-) diff --git a/cli/cli.go b/cli/cli.go index a3f2de8..5d0057f 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -37,7 +37,22 @@ func NewCommand(version string) *serpent.Command { # Use allowlist from config file with additional CLI allow rules boundary --allow "domain=example.com" -- curl https://example.com - # Block everything by default (implicit)` + # Block everything by default (implicit) + + # Enable session correlation inside a Coder workspace (inject target auto-derived from CODER_AGENT_URL) + boundary --enable-session-correlation \ + --allow "domain=dev.coder.com" -- python train.py + + # Enable session correlation with an explicit inject target (e.g. outside a workspace or custom deployment) + boundary --enable-session-correlation \ + --session-id-inject-target "domain=mydeployment.coder.com path=/api/v2/aibridge/*" \ + --allow "domain=mydeployment.coder.com" -- python train.py + + # Enable session correlation with multiple inject targets (e.g. staging + prod) + boundary --enable-session-correlation \ + --session-id-inject-target "domain=staging.coder.com path=/api/v2/aibridge/*" \ + --session-id-inject-target "domain=prod.coder.com path=/api/v2/aibridge/*" \ + --allow "domain=staging.coder.com" --allow "domain=prod.coder.com" -- python train.py` return cmd } @@ -173,21 +188,21 @@ func BaseCommand(version string) *serpent.Command { { Flag: "enable-session-correlation", Env: "BOUNDARY_SESSION_CORRELATION_ENABLED", - Description: "Enable session correlation header injection. Disable for deployments without AI Bridge in front.", + Description: "Enable session correlation header injection. When no inject targets are configured, the target is auto-derived from CODER_AGENT_URL (set automatically inside Coder workspaces). Disable for deployments without Coder AI Gateway in front.", Value: &cliConfig.SessionCorrelationEnabled, YAML: "session_correlation_enabled", }, { - Flag: "inject-session-id-on", - Env: "BOUNDARY_INJECT_SESSION_ID_ON", - Description: `Inject target (repeatable). Requests matching these targets receive session correlation headers. Format: "domain= [path=]".`, - Value: &cliConfig.InjectSessionIDOn, + Flag: "session-id-inject-target", + Env: "BOUNDARY_SESSION_ID_INJECT_TARGET", + Description: `Inject target (repeatable via flag; env accepts one value). Requests matching these targets receive session correlation headers. For multiple targets use the YAML config. Format: "domain= [path=]".`, + Value: &cliConfig.InjectSessionIDTarget, YAML: "", // CLI only, YAML uses session_id_inject_targets. }, { Flag: "", // No CLI flag, YAML only. Description: "Inject targets from config file (YAML only).", - Value: &cliConfig.InjectSessionIDOnYAML, + Value: &cliConfig.InjectSessionIDTargets, YAML: "session_id_inject_targets", }, { diff --git a/config/config.go b/config/config.go index d5359fb..9eeee66 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "os" "strings" "github.com/coder/serpent" @@ -73,8 +74,8 @@ type CliConfig struct { // Session correlation header injection. SessionCorrelationEnabled serpent.Bool `yaml:"session_correlation_enabled"` - InjectSessionIDOn AllowStringsArray `yaml:"inject_session_id_on"` - InjectSessionIDOnYAML serpent.StringArray `yaml:"session_id_inject_targets"` + InjectSessionIDTarget AllowStringsArray `yaml:"-"` // From CLI flags only + InjectSessionIDTargets serpent.StringArray `yaml:"session_id_inject_targets"` // From config file SessionIDHeaderName serpent.String `yaml:"session_id_header_name"` SequenceNumberHeaderName serpent.String `yaml:"sequence_number_header_name"` } @@ -120,7 +121,7 @@ func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string) (AppConfig, er userInfo := GetUserInfo() // Build session correlation config from CLI and YAML sources. - sc, err := buildSessionCorrelation(cfg) + sc, err := buildSessionCorrelation(cfg, os.Environ()) if err != nil { return AppConfig{}, fmt.Errorf("session correlation config: %w", err) } @@ -145,10 +146,12 @@ func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string) (AppConfig, er // buildSessionCorrelation merges CLI and YAML inject target sources, // parses each target string, applies header name defaults, and -// validates the resulting configuration. -func buildSessionCorrelation(cfg CliConfig) (SessionCorrelationConfig, error) { +// validates the resulting configuration. environ is passed explicitly +// (rather than reading os.Environ inside) so that callers and tests +// can supply a controlled environment. +func buildSessionCorrelation(cfg CliConfig, environ []string) (SessionCorrelationConfig, error) { // Merge YAML targets with CLI targets. - rawTargets := append(cfg.InjectSessionIDOnYAML.Value(), cfg.InjectSessionIDOn.Value()...) + rawTargets := append(cfg.InjectSessionIDTargets.Value(), cfg.InjectSessionIDTarget.Value()...) var targets []InjectTarget for _, raw := range rawTargets { @@ -159,6 +162,12 @@ func buildSessionCorrelation(cfg CliConfig) (SessionCorrelationConfig, error) { targets = append(targets, t) } + if len(targets) == 0 && cfg.SessionCorrelationEnabled.Value() { + if t := DefaultInjectTargetFromEnv(environ); t != nil { + targets = []InjectTarget{*t} + } + } + // Apply defaults for header names. sessionIDHeader := cfg.SessionIDHeaderName.Value() if sessionIDHeader == "" { diff --git a/config/session_correlation.go b/config/session_correlation.go index a67b02e..a5589ea 100644 --- a/config/session_correlation.go +++ b/config/session_correlation.go @@ -2,13 +2,23 @@ package config import ( "fmt" + "net/url" "strings" ) -// Default header names for session correlation. +// Default header names and paths for session correlation. const ( DefaultSessionIDHeaderName = "X-Coder-Agent-Firewall-Session-Id" DefaultSequenceNumberHeaderName = "X-Coder-Agent-Firewall-Sequence-Number" + + // DefaultAIBridgePath is the path glob used when auto-deriving an inject + // target from CODER_AGENT_URL. + DefaultAIBridgePath = "/api/v2/aibridge/*" + + // CoderAgentURLEnv is the environment variable set by the Coder workspace + // agent that points to the control plane. Boundary uses it to derive a + // default inject target when none is explicitly configured. + CoderAgentURLEnv = "CODER_AGENT_URL" ) // InjectTarget represents a parsed target for session correlation header @@ -79,6 +89,38 @@ func ParseInjectTarget(raw string) (InjectTarget, error) { return target, nil } +// DefaultInjectTargetFromEnv derives an InjectTarget from the CODER_AGENT_URL +// variable in the provided environment slice. It returns nil if the variable is +// absent, empty, or not a valid URL with a host. The derived target uses +// DefaultAIBridgePath as the path glob so that all AI Bridge traffic on the +// control-plane host is matched. +// +// The environ parameter is accepted rather than reading os.Environ directly so +// that callers (and tests) can supply an arbitrary environment. +func DefaultInjectTargetFromEnv(environ []string) *InjectTarget { + var raw string + for _, e := range environ { + k, v, ok := strings.Cut(e, "=") + if ok && k == CoderAgentURLEnv { + raw = v + break + } + } + if raw == "" { + return nil + } + + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return nil + } + + return &InjectTarget{ + Domain: u.Hostname(), + Path: DefaultAIBridgePath, + } +} + // ValidateSessionCorrelation checks that the session correlation config // is internally consistent. It returns an error describing the first // problem found, or nil if the config is valid. diff --git a/config/session_correlation_test.go b/config/session_correlation_test.go index eb0605a..572363d 100644 --- a/config/session_correlation_test.go +++ b/config/session_correlation_test.go @@ -207,7 +207,7 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { cli: func() CliConfig { c := baseCliConfig() c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDOn.Set("domain=dev.coder.com path=/api/v2/aibridge/*") + _ = c.InjectSessionIDTarget.Set("domain=dev.coder.com path=/api/v2/aibridge/*") return c }(), want: SessionCorrelationConfig{ @@ -224,7 +224,7 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { cli: func() CliConfig { c := baseCliConfig() c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDOn.Set("domain=example.com") + _ = c.InjectSessionIDTarget.Set("domain=example.com") c.SessionIDHeaderName.Set("X-My-Session") c.SequenceNumberHeaderName.Set("X-My-Seq") return c @@ -236,21 +236,16 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { SequenceNumberHeaderName: "X-My-Seq", }, }, - { - name: "enabled with no targets fails validation", - cli: func() CliConfig { - c := baseCliConfig() - c.SessionCorrelationEnabled.Set("true") - return c - }(), - wantErr: true, - }, + // Note: "enabled with no targets" is tested in + // TestBuildSessionCorrelation_AgentURLFallback with a controlled + // environ so that CODER_AGENT_URL in the test runner's environment + // cannot interfere. { name: "invalid inject target", cli: func() CliConfig { c := baseCliConfig() c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDOn.Set("notakey") + _ = c.InjectSessionIDTarget.Set("notakey") return c }(), wantErr: true, @@ -302,6 +297,162 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { } } +func TestDefaultInjectTargetFromEnv(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + environ []string + want *InjectTarget + }{ + { + name: "valid URL with trailing slash", + environ: []string{"CODER_AGENT_URL=https://dev.coder.com/"}, + want: &InjectTarget{Domain: "dev.coder.com", Path: DefaultAIBridgePath}, + }, + { + name: "valid URL without trailing slash", + environ: []string{"CODER_AGENT_URL=https://dev.coder.com"}, + want: &InjectTarget{Domain: "dev.coder.com", Path: DefaultAIBridgePath}, + }, + { + name: "URL with port", // Ports are ignored in the rules engine, so we strip them here. + environ: []string{"CODER_AGENT_URL=https://dev.coder.com:8443/"}, + want: &InjectTarget{Domain: "dev.coder.com", Path: DefaultAIBridgePath}, + }, + { + name: "unset variable", + environ: []string{}, + want: nil, + }, + { + name: "empty value", + environ: []string{"CODER_AGENT_URL="}, + want: nil, + }, + { + name: "no host in URL", + environ: []string{"CODER_AGENT_URL=not-a-url"}, + want: nil, + }, + { + name: "other env vars present but not CODER_AGENT_URL", + environ: []string{"CODER_URL=https://dev.coder.com/", "HOME=/home/user"}, + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := DefaultInjectTargetFromEnv(tc.environ) + if tc.want == nil { + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatalf("expected %+v, got nil", tc.want) + } + if got.Domain != tc.want.Domain { + t.Errorf("Domain: got %q, want %q", got.Domain, tc.want.Domain) + } + if got.Path != tc.want.Path { + t.Errorf("Path: got %q, want %q", got.Path, tc.want.Path) + } + }) + } +} + +func TestBuildSessionCorrelation_AgentURLFallback(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg func() CliConfig + environ []string + wantTargets []InjectTarget + wantErr bool + }{ + { + name: "enabled, no explicit targets, CODER_AGENT_URL set → auto-derived", + cfg: func() CliConfig { + c := baseCliConfig() + c.SessionCorrelationEnabled.Set("true") + return c + }, + environ: []string{"CODER_AGENT_URL=https://dev.coder.com/"}, + wantTargets: []InjectTarget{ + {Domain: "dev.coder.com", Path: DefaultAIBridgePath}, + }, + }, + { + name: "enabled, no explicit targets, CODER_AGENT_URL absent → error", + cfg: func() CliConfig { + c := baseCliConfig() + c.SessionCorrelationEnabled.Set("true") + return c + }, + environ: []string{}, + wantErr: true, + }, + { + name: "enabled, explicit target wins over CODER_AGENT_URL", + cfg: func() CliConfig { + c := baseCliConfig() + c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDTarget.Set("domain=custom.example.com") + return c + }, + environ: []string{"CODER_AGENT_URL=https://dev.coder.com/"}, + wantTargets: []InjectTarget{ + {Domain: "custom.example.com", Path: ""}, + }, + }, + { + name: "disabled, CODER_AGENT_URL absent → valid (no targets needed)", + cfg: func() CliConfig { + return baseCliConfig() + }, + environ: []string{}, + wantTargets: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + sc, err := buildSessionCorrelation(tc.cfg(), tc.environ) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(sc.InjectTargets) != len(tc.wantTargets) { + t.Fatalf("InjectTargets len: got %d, want %d", + len(sc.InjectTargets), len(tc.wantTargets)) + } + for i := range sc.InjectTargets { + if sc.InjectTargets[i].Domain != tc.wantTargets[i].Domain { + t.Errorf("InjectTargets[%d].Domain: got %q, want %q", + i, sc.InjectTargets[i].Domain, tc.wantTargets[i].Domain) + } + if sc.InjectTargets[i].Path != tc.wantTargets[i].Path { + t.Errorf("InjectTargets[%d].Path: got %q, want %q", + i, sc.InjectTargets[i].Path, tc.wantTargets[i].Path) + } + } + }) + } +} + // baseCliConfig returns a CliConfig with valid defaults for fields that // NewAppConfigFromCliConfig requires, so tests can focus on the session // correlation fields without tripping over unrelated validation. From 46876588f638866b066de61749127751c8322791 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 5 May 2026 10:31:16 +0000 Subject: [PATCH 5/6] make lint --- config/session_correlation_test.go | 34 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/config/session_correlation_test.go b/config/session_correlation_test.go index 572363d..3602327 100644 --- a/config/session_correlation_test.go +++ b/config/session_correlation_test.go @@ -205,9 +205,9 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { { name: "enabled with inject targets", cli: func() CliConfig { - c := baseCliConfig() - c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDTarget.Set("domain=dev.coder.com path=/api/v2/aibridge/*") + c := baseCliConfig() + _ = c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDTarget.Set("domain=dev.coder.com path=/api/v2/aibridge/*") return c }(), want: SessionCorrelationConfig{ @@ -222,11 +222,11 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { { name: "custom header names", cli: func() CliConfig { - c := baseCliConfig() - c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDTarget.Set("domain=example.com") - c.SessionIDHeaderName.Set("X-My-Session") - c.SequenceNumberHeaderName.Set("X-My-Seq") + c := baseCliConfig() + _ = c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDTarget.Set("domain=example.com") + _ = c.SessionIDHeaderName.Set("X-My-Session") + _ = c.SequenceNumberHeaderName.Set("X-My-Seq") return c }(), want: SessionCorrelationConfig{ @@ -243,9 +243,9 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { { name: "invalid inject target", cli: func() CliConfig { - c := baseCliConfig() - c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDTarget.Set("notakey") + c := baseCliConfig() + _ = c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDTarget.Set("notakey") return c }(), wantErr: true, @@ -380,7 +380,7 @@ func TestBuildSessionCorrelation_AgentURLFallback(t *testing.T) { name: "enabled, no explicit targets, CODER_AGENT_URL set → auto-derived", cfg: func() CliConfig { c := baseCliConfig() - c.SessionCorrelationEnabled.Set("true") + _ = c.SessionCorrelationEnabled.Set("true") return c }, environ: []string{"CODER_AGENT_URL=https://dev.coder.com/"}, @@ -392,7 +392,7 @@ func TestBuildSessionCorrelation_AgentURLFallback(t *testing.T) { name: "enabled, no explicit targets, CODER_AGENT_URL absent → error", cfg: func() CliConfig { c := baseCliConfig() - c.SessionCorrelationEnabled.Set("true") + _ = c.SessionCorrelationEnabled.Set("true") return c }, environ: []string{}, @@ -402,7 +402,7 @@ func TestBuildSessionCorrelation_AgentURLFallback(t *testing.T) { name: "enabled, explicit target wins over CODER_AGENT_URL", cfg: func() CliConfig { c := baseCliConfig() - c.SessionCorrelationEnabled.Set("true") + _ = c.SessionCorrelationEnabled.Set("true") _ = c.InjectSessionIDTarget.Set("domain=custom.example.com") return c }, @@ -458,8 +458,8 @@ func TestBuildSessionCorrelation_AgentURLFallback(t *testing.T) { // correlation fields without tripping over unrelated validation. func baseCliConfig() CliConfig { c := CliConfig{} - c.JailType.Set("nsjail") - c.SessionIDHeaderName.Set(DefaultSessionIDHeaderName) - c.SequenceNumberHeaderName.Set(DefaultSequenceNumberHeaderName) + _ = c.JailType.Set("nsjail") + _ = c.SessionIDHeaderName.Set(DefaultSessionIDHeaderName) + _ = c.SequenceNumberHeaderName.Set(DefaultSequenceNumberHeaderName) return c } From 5ea1fc4b2d8fa4ece2895f3038bc709389c9a87a Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 5 May 2026 10:32:57 +0000 Subject: [PATCH 6/6] make fmt --- config/session_correlation_test.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/config/session_correlation_test.go b/config/session_correlation_test.go index 3602327..9a593ad 100644 --- a/config/session_correlation_test.go +++ b/config/session_correlation_test.go @@ -205,9 +205,9 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { { name: "enabled with inject targets", cli: func() CliConfig { - c := baseCliConfig() - _ = c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDTarget.Set("domain=dev.coder.com path=/api/v2/aibridge/*") + c := baseCliConfig() + _ = c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDTarget.Set("domain=dev.coder.com path=/api/v2/aibridge/*") return c }(), want: SessionCorrelationConfig{ @@ -222,11 +222,11 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { { name: "custom header names", cli: func() CliConfig { - c := baseCliConfig() - _ = c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDTarget.Set("domain=example.com") - _ = c.SessionIDHeaderName.Set("X-My-Session") - _ = c.SequenceNumberHeaderName.Set("X-My-Seq") + c := baseCliConfig() + _ = c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDTarget.Set("domain=example.com") + _ = c.SessionIDHeaderName.Set("X-My-Session") + _ = c.SequenceNumberHeaderName.Set("X-My-Seq") return c }(), want: SessionCorrelationConfig{ @@ -243,9 +243,9 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { { name: "invalid inject target", cli: func() CliConfig { - c := baseCliConfig() - _ = c.SessionCorrelationEnabled.Set("true") - _ = c.InjectSessionIDTarget.Set("notakey") + c := baseCliConfig() + _ = c.SessionCorrelationEnabled.Set("true") + _ = c.InjectSessionIDTarget.Set("notakey") return c }(), wantErr: true,