feat(enterprise/coderd): add soft warning for AI Bridge GA transition (#21675)

## Summary

AI Bridge is moving to General Availability in v2.30 and will require
the AI Governance Add-On license in future versions. This adds a soft
warning for deployments using AI Bridge via Premium/Enterprise
FeatureSet without an explicit AI Bridge add-on license.

Relates to: https://github.com/coder/internal/issues/1226

## Changes

- Track whether AI Bridge was explicitly granted via license Features
(add-on) vs inherited from FeatureSet
- Show soft warning when AI Bridge is enabled and entitled via
FeatureSet but not via explicit add-on
- Changed AI Bridge enablement from hardcoded `true` to check
`CODER_AIBRIDGE_ENABLED` deployment config

## Behavior Change

AI Bridge is now only marked as "enabled" in entitlements when
`CODER_AIBRIDGE_ENABLED=true` is set in the deployment config.
Previously, it was always enabled for Premium/Enterprise licenses
regardless of the config setting.

This change ensures that users who do not use AI Bridge will not see the
soft warning about the upcoming license requirement.

## Warning Message

> AI Bridge is now Generally Available in v2.30. In a future Coder
version, your deployment will require the AI Governance Add-On to
continue using this feature. Please reach out to your account team or
sales@coder.com to learn more.

## Behavior

| Condition | Warning Shown |
|-----------|---------------|
| AI Bridge disabled |  No |
| AI Bridge enabled + explicit add-on license |  No |
| AI Bridge enabled + Premium/Enterprise FeatureSet (no add-on) |  Yes
|

## Screenshots

### 1. No license
<img width="1708" height="577" alt="image"
src="https://github.com/user-attachments/assets/cbdbfd4d-55de-4d70-8abf-2665f458e96f"
/>

### 2. No license + CODER_AIBRIDGE_ENABLED=true
<img width="1716" height="513" alt="image"
src="https://github.com/user-attachments/assets/344aae76-7703-485f-b568-1f13a1efa48f"
/>

### 3. Premium license + CODER_AIBRIDGE_ENABLED=false
<img width="1687" height="389" alt="image"
src="https://github.com/user-attachments/assets/c2be12b0-1c0f-438d-a293-f9ec9fe6a736"
/>

### 4. Premium license + CODER_AIBRIDGE_ENABLED=true
<img width="1707" height="525" alt="image"
src="https://github.com/user-attachments/assets/1a4640e1-e656-4f9b-bed0-9390cb5d6a84"
/>

## Notes

- TODO comments added to mark code that should be removed when AI Bridge
enforcement is added
- Feature continues to work - this is just a transitional warning (soft
enforcement)
This commit is contained in:
Kacper Sawicki
2026-01-26 10:46:45 +01:00
committed by GitHub
parent 0d21365825
commit 78bc5861e0
6 changed files with 226 additions and 1 deletions
+3
View File
@@ -27,6 +27,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = true
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -77,6 +78,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = true
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -162,6 +164,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = true
client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
+11
View File
@@ -20,6 +20,7 @@ import (
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestAIBridgeListInterceptions(t *testing.T) {
@@ -51,6 +52,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Run("EmptyDB", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -71,6 +73,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Run("OK", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -189,6 +192,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -304,6 +308,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Run("InflightInterceptions", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -337,6 +342,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Run("Authorized", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
adminClient, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -381,6 +387,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Run("Filter", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, db, firstUser := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -561,6 +568,7 @@ func TestAIBridgeListInterceptions(t *testing.T) {
t.Run("FilterErrors", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -643,6 +651,7 @@ func TestAIBridgeRouting(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -703,6 +712,7 @@ func TestAIBridgeRateLimiting(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
// Set a low rate limit for testing.
dv.AI.BridgeConfig.RateLimit = 2
@@ -758,6 +768,7 @@ func TestAIBridgeConcurrencyLimiting(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
// Set a low concurrency limit for testing.
dv.AI.BridgeConfig.MaxConcurrency = 1
+4
View File
@@ -11,6 +11,7 @@ import (
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestAIBridgeProxyCertificateRetrieval(t *testing.T) {
@@ -20,6 +21,7 @@ func TestAIBridgeProxyCertificateRetrieval(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
// Proxy is disabled by default, so we don't need to set it explicitly.
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
@@ -50,6 +52,7 @@ func TestAIBridgeProxyCertificateRetrieval(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
@@ -78,6 +81,7 @@ func TestAIBridgeProxyCertificateRetrieval(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.AI.BridgeConfig.Enabled = serpent.Bool(true)
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
+1 -1
View File
@@ -769,7 +769,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
codersdk.FeatureUserRoleManagement: true,
codersdk.FeatureAccessControl: true,
codersdk.FeatureControlSharedPorts: true,
codersdk.FeatureAIBridge: true,
codersdk.FeatureAIBridge: api.DeploymentValues.AI.BridgeConfig.Enabled.Value(),
})
if err != nil {
return codersdk.Entitlements{}, err
+23
View File
@@ -167,6 +167,12 @@ func LicensesEntitlements(
keys map[string]ed25519.PublicKey,
featureArguments FeatureArguments,
) (codersdk.Entitlements, error) {
// TODO: Remove this tracking once AI Bridge is enforced as an add-on license.
// Track if AI Bridge was explicitly granted via license Features (add-on)
// vs inherited from FeatureSet (Premium). Only explicit grants should
// suppress the soft warning for AI Bridge GA.
hasExplicitAIBridgeEntitlement := false
// Default all entitlements to be disabled.
entitlements := codersdk.Entitlements{
Features: map[codersdk.FeatureName]codersdk.Feature{
@@ -335,6 +341,12 @@ func LicensesEntitlements(
continue
}
// TODO: Remove this tracking once AI Bridge is enforced as an add-on license.
// Track explicit AI Bridge entitlement (add-on license).
if featureName == codersdk.FeatureAIBridge && featureValue > 0 {
hasExplicitAIBridgeEntitlement = true
}
// Special handling for grouped (e.g. usage period) features.
if grouping, ok := featureGrouping[featureName]; ok {
ul := uncommittedUsageFeatures[grouping.sdkFeature]
@@ -583,6 +595,17 @@ func LicensesEntitlements(
default:
}
}
// TODO: Remove this soft warning block once AI Bridge is enforced as an add-on license.
// AI Bridge soft warning: Show warning when AI Bridge is enabled and
// entitled via Premium FeatureSet but not via explicit add-on license.
// This is a transitional warning as AI Bridge moves to GA and will
// require a separate add-on license in future versions.
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
if aiBridgeFeature.Enabled && aiBridgeFeature.Entitlement.Entitled() && !hasExplicitAIBridgeEntitlement {
entitlements.Warnings = append(entitlements.Warnings,
"AI Bridge is now Generally Available in v2.30. In a future Coder version, your deployment will require the AI Governance Add-On to continue using this feature. Please reach out to your account team or sales@coder.com to learn more.")
}
}
// Wrap up by disabling all features that are not entitled.
+184
View File
@@ -188,9 +188,11 @@ func TestEntitlements(t *testing.T) {
graceDate := dbtime.Now().AddDate(0, 0, 1)
_, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
// TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license.
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning
},
FeatureSet: codersdk.FeatureSetPremium,
@@ -214,9 +216,11 @@ func TestEntitlements(t *testing.T) {
// license expires.
_, err = db.InsertLicense(context.Background(), database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
// TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license.
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning
},
FeatureSet: codersdk.FeatureSetPremium,
@@ -245,9 +249,11 @@ func TestEntitlements(t *testing.T) {
graceDate := dbtime.Now().AddDate(0, 0, 1)
_, err := db.InsertLicense(context.Background(), database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
// TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license.
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning
},
FeatureSet: codersdk.FeatureSetPremium,
@@ -271,9 +277,11 @@ func TestEntitlements(t *testing.T) {
// license expires (e.g. there's a gap)
_, err = db.InsertLicense(context.Background(), database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
// TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license.
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureAuditLog: 1,
codersdk.FeatureAIBridge: 1, // Explicit AI Bridge to avoid soft warning
},
FeatureSet: codersdk.FeatureSetPremium,
@@ -806,12 +814,17 @@ func TestEntitlements(t *testing.T) {
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
// TODO: Remove explicit FeatureAIBridge once AI Bridge is enforced as an add-on license.
licenseOpts := (&coderdenttest.LicenseOptions{
FeatureSet: codersdk.FeatureSetPremium,
IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
// Explicit AI Bridge to avoid soft warning in tests
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
}).
UserLimit(100).
ManagedAgentLimit(100, 200)
@@ -895,6 +908,7 @@ func TestLicenseEntitlements(t *testing.T) {
codersdk.FeatureAIBridge: true,
}
// TODO: Remove explicit FeatureAIBridge from these license helpers once AI Bridge is enforced as an add-on license.
legacyLicense := func() *coderdenttest.LicenseOptions {
return (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
@@ -902,6 +916,10 @@ func TestLicenseEntitlements(t *testing.T) {
Trial: false,
// Use the legacy boolean
AllFeatures: true,
// Explicit AI Bridge to avoid soft warning in tests
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
}).Valid(time.Now())
}
@@ -913,6 +931,10 @@ func TestLicenseEntitlements(t *testing.T) {
Trial: false,
FeatureSet: codersdk.FeatureSetEnterprise,
AllFeatures: true,
// Explicit AI Bridge to avoid soft warning in tests
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
}).Valid(time.Now())
}
@@ -924,6 +946,10 @@ func TestLicenseEntitlements(t *testing.T) {
Trial: false,
FeatureSet: codersdk.FeatureSetPremium,
AllFeatures: true,
// Explicit AI Bridge to avoid soft warning in tests
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
}).Valid(time.Now())
}
@@ -1285,6 +1311,164 @@ func TestLicenseEntitlements(t *testing.T) {
}
}
func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
aiBridgeEnabledEnablements := map[codersdk.FeatureName]bool{
codersdk.FeatureAIBridge: true,
}
aiBridgeDisabledEnablements := map[codersdk.FeatureName]bool{
codersdk.FeatureAIBridge: false,
}
aiBridgeWarningMessage := "AI Bridge is now Generally Available in v2.30. In a future Coder version, your deployment will require the AI Governance Add-On to continue using this feature. Please reach out to your account team or sales@coder.com to learn more."
t.Run("PremiumLicenseWithAIBridgeEnabled", func(t *testing.T) {
t.Parallel()
// Premium license with AI Bridge enabled should show soft warning
// because AI Bridge is not explicitly granted via add-on.
lo := (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
AccountID: "test",
FeatureSet: codersdk.FeatureSetPremium,
}).Valid(time.Now())
generatedLicenses := []database.License{
{
ID: 1,
UploadedAt: time.Now().Add(time.Hour * -1),
JWT: lo.Generate(t),
Exp: lo.GraceAt,
UUID: uuid.New(),
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
// AI Bridge should be enabled and entitled.
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
assert.True(t, aiBridgeFeature.Enabled)
assert.Equal(t, codersdk.EntitlementEntitled, aiBridgeFeature.Entitlement)
// Should have the soft warning.
require.Contains(t, entitlements.Warnings, aiBridgeWarningMessage)
})
t.Run("ExplicitAIBridgeLicense", func(t *testing.T) {
t.Parallel()
// License with explicit AI Bridge feature (add-on) should NOT show warning.
lo := (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
AccountID: "test",
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
}).Valid(time.Now())
generatedLicenses := []database.License{
{
ID: 1,
UploadedAt: time.Now().Add(time.Hour * -1),
JWT: lo.Generate(t),
Exp: lo.GraceAt,
UUID: uuid.New(),
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
// AI Bridge should be enabled and entitled.
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
assert.True(t, aiBridgeFeature.Enabled)
assert.Equal(t, codersdk.EntitlementEntitled, aiBridgeFeature.Entitlement)
// Should NOT have the soft warning.
require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage)
})
t.Run("PremiumLicenseWithAIBridgeDisabled", func(t *testing.T) {
t.Parallel()
// Premium license with AI Bridge NOT enabled should NOT show warning.
lo := (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
AccountID: "test",
FeatureSet: codersdk.FeatureSetPremium,
}).Valid(time.Now())
generatedLicenses := []database.License{
{
ID: 1,
UploadedAt: time.Now().Add(time.Hour * -1),
JWT: lo.Generate(t),
Exp: lo.GraceAt,
UUID: uuid.New(),
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
// AI Bridge should NOT be enabled.
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
assert.False(t, aiBridgeFeature.Enabled)
// Should NOT have the soft warning.
require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage)
})
t.Run("PremiumPlusExplicitAIBridge", func(t *testing.T) {
t.Parallel()
// Premium license PLUS explicit AI Bridge add-on should NOT show warning.
lo := (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
AccountID: "test",
FeatureSet: codersdk.FeatureSetPremium,
Features: license.Features{
codersdk.FeatureAIBridge: 1,
},
}).Valid(time.Now())
generatedLicenses := []database.License{
{
ID: 1,
UploadedAt: time.Now().Add(time.Hour * -1),
JWT: lo.Generate(t),
Exp: lo.GraceAt,
UUID: uuid.New(),
},
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
// AI Bridge should be enabled and entitled.
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
assert.True(t, aiBridgeFeature.Enabled)
assert.Equal(t, codersdk.EntitlementEntitled, aiBridgeFeature.Entitlement)
// Should NOT have the soft warning.
require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage)
})
t.Run("NoLicenseWithAIBridgeEnabled", func(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
// AI Bridge should NOT be entitled.
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
assert.Equal(t, codersdk.EntitlementNotEntitled, aiBridgeFeature.Entitlement)
// Should NOT have the soft warning (the feature is not entitled so it won't be enabled).
require.NotContains(t, entitlements.Warnings, aiBridgeWarningMessage)
})
}
func TestUsageLimitFeatures(t *testing.T) {
t.Parallel()