feat: add agent runtime hour license claims and entitlement feature (#27459)

Licenses can now carry three agent runtime hour claims:
`agent_runtime_hours_allocation`, `agent_runtime_hours_limit_soft`, and
`agent_runtime_hours_limit_hard` (unit: hours). They surface as the new
usage-period feature `agent_runtime_hours` in `GET
/api/v2/entitlements`, where `limit` carries the allocation and the new
optional `soft_limit` / `hard_limit` fields on `codersdk.Feature` carry
the thresholds.

Invalid combinations reject the entire license via `validateClaims`
(both at upload and when computing entitlements for stored licenses):
soft/hard without allocation, negative allocation, soft outside `0 <=
soft < allocation`, or `hard < allocation`.

Soft and hard limits are not comparison inputs in `Feature.Compare`;
they ride along with whichever license wins (newest `iat`, existing
behavior). None of the three claim names is a feature name, so old
servers ignore them via the existing unknown-claim tolerance, protecting
rollout of licenses minted with the new claims.

The claim name constants defined in `enterprise/coderd/license` are the
canonical contract for `github.com/coder/license` (X1).

Part of
[CODAGT-837](https://linear.app/codercom/issue/CODAGT-837/a1-agent-runtime-license-claims-and-entitlement-feature).
Blocks B4 (usage wiring + warnings), C1 (hard-limit admission gate), F1
(licenses page), A4 (managed-agent coexistence), X1 (licensor).

Out of scope, handled by follow-up issues: `Actual` usage wiring,
threshold warnings, admission gating, premium defaults, and FE surfacing
beyond regenerated types.

<details>
<summary>Implementation plan and decision log</summary>

## Decisions (confirmed by jaayden, 2026-07-23)

1. **Claim names / unit:**
   - `agent_runtime_hours_allocation` - allocation (unit: hours, int64)
   - `agent_runtime_hours_limit_soft` - soft limit
   - `agent_runtime_hours_limit_hard` - hard limit
- None of the three claim names is itself a `FeatureName`; all three map
to the single new usage-period feature `agent_runtime_hours`
(`FeatureAgentRuntimeHours`), mirroring how `managed_agent_limit_soft`
mapped onto `managed_agent_limit`. Old servers therefore ignore all
three claims via the `FeatureNamesMap` check.
2. **Reject-license.** Invalid claim combinations reject the whole
license via `validateClaims` (upload returns 400 via
`ParseClaimsIgnoreNbf`; already-stored licenses produce an `Invalid
license ... parsing claims` entitlements error and contribute nothing).

## Design notes

- `codersdk.Feature` had a `SoftLimit` field until 051ed34580 ("feat:
convert soft_limit to limit", #22048) collapsed managed-agent soft/hard
into a single `limit`. This reintroduces soft/hard as optional fields
without changing managed-agent behavior.
- Existing usage-period machinery populates `UsagePeriod` from
`nbf`/`exp` (`usagePeriodStart`/`usagePeriodEnd` in
`LicensesEntitlements`); reused unchanged, consistent with managed
agents.
- `Entitlements.AddFeature` replaces whole `Feature` structs (no
merging), so soft/hard automatically ride along with the winning
license. No `Feature.Compare` logic change; doc updates plus tests pin
that soft/hard are not comparison inputs.
- The feature name itself is not accepted as a claim; the allocation
must come from the dedicated claim so it is validated against soft/hard
(prevents a validation bypass where a direct feature-name claim could
win precedence with unvalidated thresholds).
- The generic "enabled but not entitled/expired" warning loop skips the
feature, mirroring `FeatureManagedAgentLimit`; usage-based warnings
arrive with B4.
- No premium default for this feature (unlike managed agents).

## Changes

1. `codersdk/deployment.go`: new `FeatureAgentRuntimeHours` (in
`FeatureNames`, `UsesLimit()`, `UsesUsagePeriod()`, keeping it out of
`FeatureSet` expansion); `Feature.SoftLimit`/`Feature.HardLimit`
(`soft_limit`/`hard_limit`, omitempty); doc updates for `UsagePeriod`
and `Compare`.
2. `enterprise/coderd/license/license.go`: canonical claim constants;
validation helper called from `validateClaims`; al-la-carte loop maps
the allocation claim to the feature and attaches soft/hard from the
companion claims; skips for the companion claims and the raw feature
name; generic warning loop skip.
3. `enterprise/coderd/coderdenttest`: `AgentRuntimeHours(allocation)`
builder.
4. Tests:
- `TestAgentRuntimeHoursLicenses`: entitled/grace round-trips (including
JSON field assertions), allocation-only, explicit zero,
`IssuedAtRanking` mirror, soft/hard ride-along with a newer
allocation-only license, direct feature-name claim ignored,
unknown-claims compatibility (old-server simulation).
- `TestAgentRuntimeHoursClaimValidation`: table of valid/invalid claim
combinations against `ParseClaims`, plus stored-license entitlements
error.
- `TestPostLicense`: API-level 400 rejection and a happy-path POST +
`GET /api/v2/entitlements` round-trip.
- `TestFeatureComparison`: soft/hard ignored in comparison; newest `iat`
wins over larger soft/hard.
5. `make gen`: regenerated `site/src/api/typesGenerated.ts`,
`coderd/apidoc/*`, `docs/reference/api/*`.

## Verification

- `go test ./enterprise/coderd/license/ ./codersdk/` and `go test
./enterprise/coderd/ -run 'TestPostLicense|TestEntitlements'` pass.
- `golangci-lint` clean on changed packages; `make lint/emdash` clean;
FE `tsc --noEmit` clean.
- Independent agent review of the diff found no blockers; its minor
findings (direct feature-name claim validation bypass, precedence test
gap, missing API happy-path test) were addressed.

</details>

> [!NOTE]
> Generated by Coder Agents on behalf of @jaaydenh (Linear CODAGT-837
agent session).
This commit is contained in:
Jaayden Halko
2026-07-29 07:58:30 +01:00
committed by GitHub
parent d072aa7bd0
commit 06ceb4253d
10 changed files with 842 additions and 10 deletions
+9 -1
View File
@@ -20568,11 +20568,19 @@ const docTemplate = `{
"entitlement": {
"$ref": "#/definitions/codersdk.Entitlement"
},
"hard_limit": {
"description": "HardLimit is the enforcement threshold that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.",
"type": "integer"
},
"limit": {
"type": "integer"
},
"soft_limit": {
"description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours",
"type": "integer"
},
"usage_period": {
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit",
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours",
"allOf": [
{
"$ref": "#/definitions/codersdk.UsagePeriod"
+9 -1
View File
@@ -18702,11 +18702,19 @@
"entitlement": {
"$ref": "#/definitions/codersdk.Entitlement"
},
"hard_limit": {
"description": "HardLimit is the enforcement threshold that accompanies Limit for\nfeatures whose license carries it. See SoftLimit for the set of\nfeatures that use these thresholds.",
"type": "integer"
},
"limit": {
"type": "integer"
},
"soft_limit": {
"description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours",
"type": "integer"
},
"usage_period": {
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit",
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours",
"allOf": [
{
"$ref": "#/definitions/codersdk.UsagePeriod"
+24 -1
View File
@@ -199,6 +199,12 @@ const (
FeatureBoundary FeatureName = "boundary"
FeatureServiceAccounts FeatureName = "service_accounts"
FeatureAIGovernanceUserLimit FeatureName = "ai_governance_user_limit"
// FeatureAgentRuntimeHours is a usage period feature. It is never a
// license claim itself. It is populated from the
// agent_runtime_hours_allocation, agent_runtime_hours_limit_soft and
// agent_runtime_hours_limit_hard claims. Refer to
// enterprise/coderd/license/license.go for the license format.
FeatureAgentRuntimeHours FeatureName = "agent_runtime_hours"
)
var (
@@ -231,6 +237,7 @@ var (
FeatureBoundary,
FeatureServiceAccounts,
FeatureAIGovernanceUserLimit,
FeatureAgentRuntimeHours,
}
// FeatureNamesMap is a map of all feature names for quick lookups.
@@ -300,6 +307,7 @@ func (n FeatureName) UsesLimit() bool {
FeatureUserLimit: true,
FeatureManagedAgentLimit: true,
FeatureAIGovernanceUserLimit: true,
FeatureAgentRuntimeHours: true,
}[n]
}
@@ -307,6 +315,7 @@ func (n FeatureName) UsesLimit() bool {
func (n FeatureName) UsesUsagePeriod() bool {
return map[FeatureName]bool{
FeatureManagedAgentLimit: true,
FeatureAgentRuntimeHours: true,
}[n]
}
@@ -372,7 +381,18 @@ type Feature struct {
Entitlement Entitlement `json:"entitlement"`
Enabled bool `json:"enabled"`
Limit *int64 `json:"limit,omitempty"`
Actual *int64 `json:"actual,omitempty"`
// SoftLimit is the advisory warning threshold that accompanies Limit for
// features whose license carries it. For these features, Limit carries
// the purchased allocation.
//
// Only certain features set this field:
// - FeatureAgentRuntimeHours
SoftLimit *int64 `json:"soft_limit,omitempty"`
// HardLimit is the enforcement threshold that accompanies Limit for
// features whose license carries it. See SoftLimit for the set of
// features that use these thresholds.
HardLimit *int64 `json:"hard_limit,omitempty"`
Actual *int64 `json:"actual,omitempty"`
// Below is only for features that use usage periods.
@@ -385,6 +405,7 @@ type Feature struct {
//
// Only certain features set these fields:
// - FeatureManagedAgentLimit
// - FeatureAgentRuntimeHours
UsagePeriod *UsagePeriod `json:"usage_period,omitempty"`
}
@@ -407,6 +428,8 @@ type UsagePeriod struct {
// 5. The limit is greater
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
// SoftLimit and HardLimit are not comparison inputs.
func (f Feature) Compare(b Feature) int {
// For features with usage period constraints only, check the issued at and
// end dates.
+32
View File
@@ -1015,6 +1015,38 @@ func TestFeatureComparison(t *testing.T) {
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: nil, Actual: nil},
Expected: 1,
},
{
Name: "SoftHardLimitsIgnored",
A: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100)), SoftLimit: ptr.Ref(int64(80)), HardLimit: ptr.Ref(int64(120))},
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100))},
Expected: 0,
},
{
Name: "NewerIssuedAtWinsOverSoftHardLimits",
A: codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Limit: ptr.Ref(int64(50)),
SoftLimit: ptr.Ref(int64(40)),
HardLimit: ptr.Ref(int64(60)),
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
Start: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
},
},
B: codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Limit: ptr.Ref(int64(100)),
SoftLimit: ptr.Ref(int64(80)),
HardLimit: ptr.Ref(int64(120)),
UsagePeriod: &codersdk.UsagePeriod{
IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
},
},
Expected: 1,
},
}
for _, tc := range testCases {
+4
View File
@@ -571,7 +571,9 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \
"actual": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
"limit": 0,
"soft_limit": 0,
"usage_period": {
"end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z",
@@ -582,7 +584,9 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \
"actual": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
"limit": 0,
"soft_limit": 0,
"usage_period": {
"end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z",
+16 -7
View File
@@ -7179,7 +7179,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"actual": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
"limit": 0,
"soft_limit": 0,
"usage_period": {
"end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z",
@@ -7190,7 +7192,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"actual": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
"limit": 0,
"soft_limit": 0,
"usage_period": {
"end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z",
@@ -7473,7 +7477,9 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
"actual": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
"limit": 0,
"soft_limit": 0,
"usage_period": {
"end": "2019-08-24T14:15:22Z",
"issued_at": "2019-08-24T14:15:22Z",
@@ -7484,15 +7490,18 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
### Properties
| Name | Type | Required | Restrictions | Description |
|---------------|----------------------------------------------|----------|--------------|-------------|
| `actual` | integer | false | | |
| `enabled` | boolean | false | | |
| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
| `limit` | integer | false | | |
| Name | Type | Required | Restrictions | Description |
|---------------|----------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `actual` | integer | false | | |
| `enabled` | boolean | false | | |
| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
| `limit` | integer | false | | |
|`soft_limit`|integer|false||Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation.
Only certain features set this field: - FeatureAgentRuntimeHours|
|`usage_period`|[codersdk.UsagePeriod](#codersdkusageperiod)|false||Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license).
These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go.
Only certain features set these fields: - FeatureManagedAgentLimit|
Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgentRuntimeHours|
## codersdk.FriendlyDiagnostic
+126
View File
@@ -508,6 +508,16 @@ func LicensesEntitlements(
continue
}
// Agent runtime hours are encoded as up to three claims and are
// decoded together after this loop, see
// decodeAgentRuntimeHours. The feature name itself is never a
// valid claim. The allocation must come from the dedicated claim
// so it is validated against the soft and hard limits.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
}
if featureValue < 0 {
// We currently don't use negative values for features.
continue
@@ -567,6 +577,16 @@ func LicensesEntitlements(
}
}
// The loop above skips Agent runtime hours because the
// three claims that encode them decode into a single feature.
if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
}); ok {
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
licenseHasAIGovernanceAddon := false
@@ -790,6 +810,11 @@ func LicensesEntitlements(
if featureName == codersdk.FeatureManagedAgentLimit {
continue
}
// Agent runtime hours is a usage period feature and does not
// generate generic entitlement warnings.
if featureName == codersdk.FeatureAgentRuntimeHours {
continue
}
feature := entitlements.Features[featureName]
if !feature.Enabled {
@@ -860,6 +885,26 @@ const (
VersionClaim = "version"
)
// Agent runtime hour license claims. These are the canonical claim names
// minted by github.com/coder/license. All three claims map to the single
// codersdk.FeatureAgentRuntimeHours feature and are validated together when
// the license is parsed, see validateClaims.
//
// The unit for all three claims is hours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
// allocation for the license term. It becomes the feature's Limit.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// must satisfy 0 <= soft < allocation, so it may only be set when the
// allocation is greater than 0. It becomes the feature's SoftLimit.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
// absent or >= allocation, and may only be set when the allocation is
// greater than 0. It becomes the feature's HardLimit.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
var (
ValidMethods = []string{"EdDSA"}
@@ -872,10 +917,88 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
// claims are decoded together, see decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
ClaimAgentRuntimeHoursLimitSoft,
ClaimAgentRuntimeHoursLimitHard:
return true
default:
return false
}
}
// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
// from the claims that encode it. It reports false when the license carries no
// allocation claim, in which case the license does not grant the feature.
//
// The claim combination is validated when the license is parsed, see
// Features.validateAgentRuntimeHours. The allocation is never negative here
// and the soft and hard limits are only present alongside a positive
// allocation.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
if !ok {
return codersdk.Feature{}, false
}
feature := codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
feature.SoftLimit = &soft
}
if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
feature.HardLimit = &hard
}
return feature, true
}
// validateAgentRuntimeHours validates the relationship between the agent
// runtime hour claims. Invalid combinations reject the entire license.
func (f Features) validateAgentRuntimeHours() error {
allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
if !hasAllocation {
if hasSoft || hasHard {
return ErrMissingAgentRuntimeHoursAllocation
}
return nil
}
if allocation < 0 {
return ErrInvalidAgentRuntimeHoursAllocation
}
// A zero allocation disables the feature.
// A zero hard limit is not permitted.
if allocation == 0 && (hasSoft || hasHard) {
return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
if hasSoft && (soft < 0 || soft >= allocation) {
return ErrInvalidAgentRuntimeHoursSoftLimit
}
if hasHard && hard < allocation {
return ErrInvalidAgentRuntimeHoursHardLimit
}
return nil
}
// Claims is the full set of claims in a license.
type Claims struct {
jwt.RegisteredClaims
@@ -966,6 +1089,9 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
if err := claims.Features.validateAgentRuntimeHours(); err != nil {
return nil, err
}
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
+559
View File
@@ -2,6 +2,7 @@ package license_test
import (
"context"
"encoding/json"
"fmt"
"slices"
"testing"
@@ -87,6 +88,10 @@ func TestEntitlements(t *testing.T) {
f[codersdk.FeatureManagedAgentLimit] = 100
continue
}
if name == codersdk.FeatureAgentRuntimeHours {
f[license.ClaimAgentRuntimeHoursAllocation] = 100
continue
}
f[name] = 1
}
return f
@@ -381,6 +386,7 @@ func TestEntitlements(t *testing.T) {
featureName == codersdk.FeatureHighAvailability ||
featureName == codersdk.FeatureMultipleExternalAuth ||
featureName == codersdk.FeatureManagedAgentLimit ||
featureName == codersdk.FeatureAgentRuntimeHours ||
featureName == codersdk.FeatureAIGovernanceUserLimit ||
featureName == codersdk.FeatureBoundary {
// These fields don't generate warnings when not entitled unless
@@ -394,6 +400,12 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[featureName].Entitlement)
require.Contains(t, entitlements.Warnings, fmt.Sprintf("%s is enabled but your license is not entitled to this feature.", niceName))
}
// Agent runtime hours is enabled by `all` and not granted by this
// license, which is exactly the state the warning suppression covers.
require.NotContains(t, entitlements.Warnings, fmt.Sprintf(
"%s is enabled but your license is not entitled to this feature.",
codersdk.FeatureAgentRuntimeHours.Humanize(),
))
})
t.Run("TooManyUsers", func(t *testing.T) {
t.Parallel()
@@ -2146,6 +2158,553 @@ func TestManagedAgentLimitDefault(t *testing.T) {
})
}
// TestAgentRuntimeHoursLicenses ensures licenses carrying the agent runtime
// hour claims (allocation, soft limit, hard limit) surface as the single
// agent_runtime_hours feature.
func TestAgentRuntimeHoursLicenses(t *testing.T) {
t.Parallel()
t.Run("AllClaims", func(t *testing.T) {
t.Parallel()
licIat := time.Now().Add(-time.Minute)
licNbf := licIat.Add(-time.Minute)
licExp := licIat.Add(time.Hour)
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: licExp,
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: licIat,
NotBefore: licNbf,
ExpiresAt: licExp,
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 80, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.Nil(t, feature.Actual)
require.NotNil(t, feature.UsagePeriod)
require.WithinDuration(t, licIat, feature.UsagePeriod.IssuedAt, 2*time.Second)
require.WithinDuration(t, licNbf, feature.UsagePeriod.Start, 2*time.Second)
require.WithinDuration(t, licExp, feature.UsagePeriod.End, 2*time.Second)
// The feature round-trips into the entitlements JSON served by
// GET /api/v2/entitlements with all four fields.
data, err := json.Marshal(entitlements)
require.NoError(t, err)
var raw struct {
Features map[codersdk.FeatureName]map[string]any `json:"features"`
}
require.NoError(t, json.Unmarshal(data, &raw))
rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
require.EqualValues(t, 100, rawFeature["limit"])
require.EqualValues(t, 80, rawFeature["soft_limit"])
require.EqualValues(t, 120, rawFeature["hard_limit"])
require.Contains(t, rawFeature, "usage_period")
})
t.Run("GracePeriod", func(t *testing.T) {
t.Parallel()
now := time.Now()
opts := coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}
opts.GracePeriod(now)
lic := database.License{
ID: 1,
UploadedAt: now,
Exp: now.Add(time.Hour * 24),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, opts),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), now, []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 80, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
})
t.Run("AllocationOnly", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.Nil(t, feature.SoftLimit)
require.Nil(t, feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
})
// A license with an explicit zero allocation is entitled but disabled,
// mirroring the managed agent limit behavior.
t.Run("ExplicitZero", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.False(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 0, *feature.Limit)
require.NotNil(t, feature.UsagePeriod)
})
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
// Mirrors TestUsageLimitFeatures/IssuedAtRanking.
t.Run("IssuedAtRanking", func(t *testing.T) {
t.Parallel()
lic1 := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: time.Now().Add(-time.Minute * 2),
NotBefore: time.Now().Add(-time.Minute * 2),
ExpiresAt: time.Now().Add(time.Hour * 2),
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}),
}
lic2Iat := time.Now().Add(-time.Minute * 1)
lic2Nbf := lic2Iat.Add(-time.Minute)
lic2Exp := lic2Iat.Add(time.Hour)
lic2 := database.License{
ID: 2,
UploadedAt: time.Now(),
Exp: lic2Exp,
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: lic2Iat,
NotBefore: lic2Nbf,
ExpiresAt: lic2Exp,
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 50,
license.ClaimAgentRuntimeHoursLimitSoft: 40,
license.ClaimAgentRuntimeHoursLimitHard: 60,
},
}),
}
// Load the licenses in both orders to ensure the correct
// behavior is observed no matter the order.
for _, order := range [][]database.License{
{lic1, lic2},
{lic2, lic1},
} {
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 50, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 40, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 60, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
require.WithinDuration(t, lic2Iat, feature.UsagePeriod.IssuedAt, 2*time.Second)
require.WithinDuration(t, lic2Nbf, feature.UsagePeriod.Start, 2*time.Second)
require.WithinDuration(t, lic2Exp, feature.UsagePeriod.End, 2*time.Second)
}
})
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
t.Parallel()
lic1 := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: time.Now().Add(-time.Minute * 2),
NotBefore: time.Now().Add(-time.Minute * 2),
ExpiresAt: time.Now().Add(time.Hour * 2),
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
}),
}
lic2Iat := time.Now().Add(-time.Minute * 1)
lic2 := database.License{
ID: 2,
UploadedAt: time.Now(),
Exp: lic2Iat.Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
IssuedAt: lic2Iat,
NotBefore: lic2Iat.Add(-time.Minute),
ExpiresAt: lic2Iat.Add(time.Hour),
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 50,
},
}),
}
for _, order := range [][]database.License{
{lic1, lic2},
{lic2, lic1},
} {
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 50, *feature.Limit)
require.Nil(t, feature.SoftLimit)
require.Nil(t, feature.HardLimit)
}
})
// The feature name itself is not a valid claim; the allocation must come
// from the dedicated claim.
t.Run("DirectFeatureNameClaimIgnored", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAgentRuntimeHours: 100,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
require.Nil(t, feature.Limit)
})
// The rollout guarantee for old deployments is that none of the three
// claim names is itself a feature name, so an old server ignores them as
// unknown claims. Pin the invariant so a future feature registration
// cannot break it silently.
t.Run("ClaimNamesAreNotFeatureNames", func(t *testing.T) {
t.Parallel()
for _, claim := range []string{
license.ClaimAgentRuntimeHoursAllocation,
license.ClaimAgentRuntimeHoursLimitSoft,
license.ClaimAgentRuntimeHoursLimitHard,
} {
require.NotContains(t, codersdk.FeatureNamesMap, codersdk.FeatureName(claim))
}
})
// Ensures licenses carrying claims for features this server version does
// not know about do not break entitlement computation. This is exactly
// what old deployments see when a license carries the agent runtime hour
// claims, since none of the three claim names is a feature name.
t.Run("UnknownClaimsCompatibility", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureUserLimit: 100,
codersdk.FeatureName("future_feature_allocation"): 100,
codersdk.FeatureName("future_feature_limit_soft"): 80,
codersdk.FeatureName("future_feature_limit_hard"): 120,
codersdk.FeatureName("future_boolean_feature"): 1,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
require.True(t, entitlements.HasLicense)
// The unknown claims are silently ignored and the known claim is
// still entitled.
require.NotContains(t, entitlements.Features, codersdk.FeatureName("future_feature_allocation"))
require.NotContains(t, entitlements.Features, codersdk.FeatureName("future_boolean_feature"))
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
require.NotNil(t, userLimit.Limit)
require.EqualValues(t, 100, *userLimit.Limit)
})
}
// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
// agent runtime hour claims reject the entire license.
func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
features license.Features
expectedErr error
}{
{
name: "AllClaims",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
},
{
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
},
{
name: "HardEqualsAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitHard: 100,
},
},
{
name: "ZeroAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
},
},
{
name: "ZeroAllocationWithZeroHard",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
license.ClaimAgentRuntimeHoursLimitHard: 0,
},
expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
name: "ZeroAllocationWithPositiveHard",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
license.ClaimAgentRuntimeHoursLimitHard: 1000,
},
expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
name: "SoftWithoutAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
name: "HardWithoutAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
name: "NegativeAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: -1,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
},
{
name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: -1,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
},
{
name: "SoftEqualsAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
},
{
name: "SoftAboveAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
},
{
name: "SoftWithZeroAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 0,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
name: "HardBelowAllocation",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitHard: 99,
},
expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: tc.features,
})
_, err := license.ParseClaims(jwt, coderdenttest.Keys)
if tc.expectedErr == nil {
require.NoError(t, err)
return
}
require.ErrorIs(t, err, tc.expectedErr)
})
}
// An invalid license already stored in the database is rejected entirely
// and produces an entitlements error.
t.Run("EntitlementsError", func(t *testing.T) {
t.Parallel()
lic := database.License{
ID: 1,
UploadedAt: time.Now(),
Exp: time.Now().Add(time.Hour),
UUID: uuid.New(),
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
}),
}
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
)
require.NoError(t, err)
require.Len(t, entitlements.Errors, 1)
require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
require.False(t, entitlements.HasLicense)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
})
}
func TestAIGovernanceAddon(t *testing.T) {
t.Parallel()
+45
View File
@@ -105,6 +105,51 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
// A soft limit claim without an allocation claim rejects the whole
// license.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
errResp := &codersdk.Error{}
require.ErrorAs(t, err, &errResp)
require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
require.Contains(t, errResp.Message, "Invalid license")
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
})
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
require.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
require.True(t, feature.Enabled)
require.NotNil(t, feature.Limit)
require.EqualValues(t, 100, *feature.Limit)
require.NotNil(t, feature.SoftLimit)
require.EqualValues(t, 80, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
})
t.Run("Unauthorized", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
+18
View File
@@ -5247,6 +5247,21 @@ export interface Feature {
readonly entitlement: Entitlement;
readonly enabled: boolean;
readonly limit?: number;
/**
* SoftLimit is the advisory warning threshold that accompanies Limit for
* features whose license carries it. For these features, Limit carries
* the purchased allocation.
*
* Only certain features set this field:
* - FeatureAgentRuntimeHours
*/
readonly soft_limit?: number;
/**
* HardLimit is the enforcement threshold that accompanies Limit for
* features whose license carries it. See SoftLimit for the set of
* features that use these thresholds.
*/
readonly hard_limit?: number;
readonly actual?: number;
/**
* UsagePeriod denotes that the usage is a counter that accumulates over
@@ -5258,6 +5273,7 @@ export interface Feature {
*
* Only certain features set these fields:
* - FeatureManagedAgentLimit
* - FeatureAgentRuntimeHours
*/
readonly usage_period?: UsagePeriod;
}
@@ -5268,6 +5284,7 @@ export type FeatureName =
| "ai_governance_user_limit"
| "access_control"
| "advanced_template_scheduling"
| "agent_runtime_hours"
| "appearance"
| "audit_log"
| "boundary"
@@ -5297,6 +5314,7 @@ export const FeatureNames: FeatureName[] = [
"ai_governance_user_limit",
"access_control",
"advanced_template_scheduling",
"agent_runtime_hours",
"appearance",
"audit_log",
"boundary",