Commit Graph
1538 Commits
Author SHA1 Message Date
Susana Ferreira d989ae36cd docs: clarify who can set AI model prices and how they are keyed (#28401)
The docs named the `ai_model_price:update` permission but never said
which role holds it, and nothing anywhere explained that a price is
keyed by provider type and model, so every configured provider of that
type shares it. This names the Owner role, documents the keying and why
`openai-compat` cannot be priced, and repeats both in the CLI's
`--provider` descriptions and `update` help.

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by
@ssncferreira
2026-08-20 17:35:30 +01:00
Susana Ferreira ab6601086a fix: gate group settings page and fix unauthorized group patch (#28314)
## Problem

A user without `group:update` (e.g. the `Coder Agents User` role) could
open the group settings page by direct URL and edit the budget. Saving
then failed with a misleading error:

> Failed to add or remove non-existent group member — execute
transaction: update group by ID: unauthorized: rbac: forbidden.

The authorization failure was misclassified as a missing group member,
and the settings page was not gated on the required permission.

## Changes

- Return the standard vague 404 for authorization failures in the group
PATCH handler, so the response is accurate and does not leak resource
existence.
- Use a generic message for the remaining 400 branch, since it covers
various transaction failures rather than only member add/remove.
- Gate the group settings page on `group:update`. The settings tab is
already hidden without the permission, but the page was reachable by
direct URL and rendered an editable form that always failed on save. It
now shows the standard permission dialog.

Fixes
[AIGOV-603](https://linear.app/codercom/issue/AIGOV-603/hide-group-budget-controls-without-permission).

> [!NOTE]
> Generated by Coder Agents, reviewed by @ssncferreira.
2026-08-20 14:51:27 +01:00
Susana Ferreira 50ecd6959f feat: add a source filter to the AI model price list (#28329)
## Description

With prices from the price book and prices set through the API both in the table, `list` showed only the price in effect and gave no way to tell where it came from. There was also no way to see the price book's entry for a model that had been overridden, since the listing resolves to the custom price.

This adds a `source` column to the output and a `--source` filter for narrowing to one source, or to every row with `all`.

Note: `--source` selects rows rather than models. A model carrying both a price book price and a custom one appears under either value, so the two filtered listings do not sum to the unfiltered one. `--source all` reports both rows at once.

## Changes

- Add `Source` to `codersdk.AIModelPrice` and `AIModelPricesFilter`, and map it in `db2sdk`, which previously dropped it.
- Filter `GetAIModelPrices` by source before resolution, so the price book's row for an overridden model stays reachable.
- Accept `all` as a source, which reports every price a model holds instead of resolving to one.
- Accept a `source` query parameter on the list endpoint, rejected with a 400 when outside the enum.
- Add `--source` to the list command and a `source` column to its output.
- Document the column and the filter, and lead the price section with `coder exp ai-model-prices list`.
- Distinguish a deployment's default prices from the price book that ships with each release.

Closes https://linear.app/codercom/issue/AIGOV-593

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by @ssncferreira
2026-08-20 11:56:58 +01:00
Susana Ferreira 19d1ee9fc8 feat: support custom prices for models in the price book (#28328)
## Description

The experimental CLI could only price models the price book does not cover, so an operator with a negotiated rate for a model Coder already prices had no way to record it. The restriction existed because a single row served both the price book and prices set through the API, and the startup seeder would overwrite an override on the next restart.

Each source now keeps its own row (https://github.com/coder/coder/pull/28204), so that no longer applies. This removes the restriction: a price set through the CLI takes effect over the price book for any model, and the book stays underneath it.

## Changes

- Remove the validation that rejected models the price book covers.
- Delete `prices.IsDefaultPriced` and the index behind it, which existed only for that check.
- Generalize the `coder exp ai-model-prices update` help.
- Document that a custom price overrides the price book and does not follow its updates.
- Rework the spend approximation callout around what a custom price can and cannot correct.

Closes https://linear.app/codercom/issue/AIGOV-594

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by @ssncferreira
2026-08-20 11:46:38 +01:00
Susana Ferreira 9390e51422 feat: distinguish default and custom AI model prices (#28204)
## Description

Prices in `ai_model_prices` come from two places: the price book embedded in each Coder release, and prices set through the experimental CLI (https://github.com/coder/coder/pull/27926). Until now, they were indistinguishable, so the startup seeder re-applied the book over every row, and a price someone set would silently revert on the next restart.

This adds a `source` column with values `default` and `custom`, and keeps a row for each. Readers resolve a model to its custom price when one exists and fall back to the book, including the cost path that prices an interception.

Keeping both rows means the price book stays current underneath an override, so a future delete can restore it.

Note: the endpoint still only accepts models the price book does not cover. Overriding a price the book already carries is handled by the follow-up issue https://linear.app/codercom/issue/AIGOV-594.

## Changes

- Add a migration for the `ai_model_price_source` enum and a `source` column, backfilled to `default` and then `NOT NULL`. `source` joins the primary key, so a model can hold one row per source.
- Take a `source` parameter on `UpsertAIModelPrices`. Each source keeps its own row, so the price book and a custom price never overwrite each other.
- Resolve to the custom price in `GetAIModelPriceByProviderModel` and `GetAIModelPrices`, so both the interception cost snapshot and the list endpoint use it.
- Seed the price book as `default`, and write prices set through the endpoint as `custom`.

Closes https://linear.app/codercom/issue/AIGOV-592

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by @ssncferreira
2026-08-20 11:36:58 +01:00
Asher 7ba2e6e22a fix: add distributed protection for external auth refresh (#27479)
Before refreshing, the caller has to put a lease on the row to prevent
any other replicas from trying to refresh using the same token. Callers
unable to get a lease enter a holding pattern waiting for the results.

Also use this same lease query to re-read the link. This more or less
functions the same as the re-read we used to have after a refresh
failure, except it also allows us to avoid making the failed request in
the first place. The downside is that we have to refetch the link every
time even if the caller just fetched it, but since we have to make a
database call to set the lease anyway...
2026-08-19 13:00:31 -08:00
Michael Suchacz 24b83dc727 feat: add organization picker to MCP server settings pages (#28150)
Adds an organization picker to the MCP server settings pages so
multi-org deployments can manage every organization's MCP servers
through the UI, not just the default organization's. Closes CODAGT-714
(scoped to MCP servers only; model configs stay deployment-wide until
CODAGT-709 lands).

## Stack Context

Top of the CODAGT-711/717/712/806 stack: MCP server configs are
org-scoped with RBAC, audit logging, per-config ACLs, and owner-scoped
user tokens. This layer makes the admin UI org-aware, resolving the
"default org only" limitation documented in the bottom PR.

## Summary

- The MCP servers list and add pages resolve the working organization
from an `org` search param (falling back to the default org), and render
an organization picker built on the existing `OrganizationAutocomplete`.
The picker only appears when more than one organization is visible, so
single-org deployments see no change.
- Selecting an organization rewrites the search param, refetches the
list for that org, and threads through to the add page so creation posts
to the selected organization.
- The update page now loads the server by ID (new organization-scoped
`GET /api/experimental/organizations/{organization}/mcp-servers/{id}`
frontend binding) instead of listing the default org and searching
client-side, so servers from any organization can be edited. Post-save
navigation returns to the owning organization's list via the server's
`organization_id`.
- Removes the now-unused `getDefaultOrganizationId` helper.
- Review hardening: organization display labels are disambiguated with
the unique org name when effective display names collide (picker
options, trigger text, and accessible name). Access is no longer
restricted to deployment admins: the pages, sidebar item, and nav entry
accept org-scoped `mcp_server_config` permissions, the picker lists only
organizations the user can view, content and affordances (add, edit,
delete) are gated on the selected organization's permissions, and
deployment admins keep full access everywhere.

## Testing

Storybook interaction stories cover: picker hidden with one org,
switching orgs refetches and swaps list contents, add posting to the
URL-selected org, add posting after an in-form org switch (asserting
typed form state survives the switch), and update loading by ID.
Red-green verified by breaking the search-param resolution and observing
the new stories fail.

> Mux acted on Mike's behalf for this PR.
2026-08-19 19:46:23 +00:00
david-fraley 8203b2ebaf feat!: hide Coder Tasks behind the enable-ai-tasks flag (#28008) 2026-08-19 14:46:10 -05:00
Michael Suchacz a3eb1ee930 fix: gate MCP user tokens on owner-scoped personal RBAC (#27945)
Re-gates every `mcp_server_user_tokens` dbauthz method from
`rbac.ResourceDeploymentConfig` onto owner-scoped personal actions,
mirroring the `external_auth_links` prior art. These are per-user
credential rows, not deployment config, and the old gate forced every
call site to elevate with `dbauthz.AsSystemRestricted`.

## Summary

- Reads gate on `ActionReadPersonal` and writes on
`ActionUpdatePersonal` against the token owner
(`MCPServerUserToken.RBACObject()` derives from `user_id`). The id-keyed
refresh and refresh-failure mutations resolve the owner via a
`GetMCPServerUserTokenByID` prefetch inside dbauthz; the mutation
queries cannot retarget a row's owner, so there is no fetch-authorize
race.
- All seven token-path `AsSystemRestricted` elevations in
`coderd/mcp.go` and all four in chatd are removed along with their
`//nolint:gocritic` markers; handlers use the request actor.
- The daemon-wide chatd subject keeps only `ActionReadPersonal` on
`ResourceUser`. Background token refresh and failure persistence use a
new per-user `AsChatdTokenOwner` subject holding only user-level
`ActionUpdatePersonal` scoped to the token owner (both mutations resolve
the row via the underlying store, so the subject needs no read grant),
and chatd never holds site-wide personal-write access. `TestAsChatd`
pins the complete `ResourceUser` action set and
`TestMCPServerUserTokensAuth` exercises both subjects against the real
authorizer.
- New strict-authorizer test (`rbac.NewAuthorizer` against real rows):
owner passes get/upsert/delete, a stranger fails, and an org admin of
another org fails.

No user-visible behavior change. Stacked on #27944. Completes the MCP
org-separation stack.

Closes https://linear.app/codercom/issue/CODAGT-806

UAT: regression pass on a dogfood instance: OAuth2 connect redirect,
member list/status with redaction, self-scoped disconnect (cannot touch
another user's token), end-to-end chat MCP tool invocation, and
lower-stack audit/ACL smoke checks.

> Mux (AI agent) authored this PR on Mike's behalf.

<!-- mux-attribution: model=claude-fable-5 thinking=high -->
2026-08-19 19:36:59 +00:00
Michael Suchacz 7ca7c30f40 feat: add group and user ACLs to MCP server configs (#27944)
Adds group and user ACLs to org-scoped MCP server configs so
organizations can restrict specific MCP servers to subsets of members,
mirroring the template ACL pattern.

## Summary

- Migration adds `group_acl`/`user_acl` JSONB columns (nested `{"<id>":
{"permissions": [...]}}` shape) and seeds every existing config with its
organization's Everyone group read entry (the Everyone group ID equals
the org ID), so member access is unchanged by default. Creation seeds
the same entry.
- The blanket org-member read grant from the base PR is replaced by ACL
evaluation: Rego requires org membership for every ACL grant, and
`GetAuthorizedMCPServerConfigs` compiles ACL-aware SQL filters.
- New `ActionShare` (org admins) gates `GET|PATCH
/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl`
(nested under the organization like the rest of the config surface).
PATCH validates principals against the config's organization, merges
sparse updates under a row lock, stamps `updated_by`/`updated_at`, and
is audited as a Write with `Old` captured before authorization. A config
deleted concurrently between the middleware fetch and the locked
re-fetch is concealed as 404, matching the update and delete handlers.
ACL columns are tracked in the audit table.
- ACL management is available in all editions (no enterprise
entitlement), documented in the MCP servers page. Revoking an ACL does
not retro-strip already-selected configs from existing chats; new
selection is blocked at chat create.
- Force On respects the ACL: the forced set is loaded as the chat owner,
so a `force_on` server whose ACL denies the owner never attaches at
create, send, or generation time.
- No rolling-upgrade machinery: upgrades run in scheduled maintenance
downtime, so the migration only backfills existing rows; the API sets
the Everyone read grant explicitly on every insert.

Stacked on #27943. Part of the MCP org-separation stack.

Closes https://linear.app/codercom/issue/CODAGT-712

UAT: verified on a dogfood instance with two members and a custom group:
Everyone-seed default visibility, group grant with Everyone removal
(non-member loses list/fetch/selection), foreign-principal rejection,
non-admin share denial (audited 403 / concealed 404), user_acl restore,
and audited ACL diffs.

> Mux (AI agent) authored this PR on Mike's behalf.

<!-- mux-attribution: model=claude-fable-5 thinking=high -->
2026-08-19 19:25:18 +00:00
Michael Suchacz 299e72ad30 feat: audit MCP server config changes (#27943)
Adds enterprise audit logging for MCP server config create, update, and
delete, with strict secret redaction. MCP configs hold credentials
(OAuth2 client secrets, API keys, custom headers), so admin changes to
them need an audit trail.

## Summary

- `enterprise/audit/table.go` gains an `MCPServerConfig` entry
enumerating every column: `oauth2_client_secret`, `api_key_value`, and
`custom_headers` are `ActionSecret` (never appear in diffs); dbcrypt
`*_key_id` bookkeeping, IDs, and timestamps are ignored; the remaining
config fields, including the endpoint URL fields, are tracked so
auditors can see which endpoints a config points at.
- Type registration in `coderd/audit` (diff, request, resource target
with org attribution), `codersdk/audit.go`, and a `resource_type` enum
migration.
- Handlers wire `audit.InitRequest`: create records `New`; update and
delete record `Old` from the param middleware before the
write-authorization check, so a readable-but-not-writable caller
produces an audited 403 while read-denied callers stay concealed as
unaudited 404s.
- Tests: create/update/delete audit entries, write-denied and
delete-denied 403 auditing, cross-org concealment producing zero
entries, and a serializer-level regression test proving none of the
three secret classes can reach a serialized diff.
- Review round: MCP config audit entries link to
`/ai/settings/mcp-servers/{id}`, audit table comments are trimmed per
review, and a fault-injection test pins that a config row surviving a
failed post-discovery credential update still gets its creation audit
entry.

Stacked on #27942 (org-scoped MCP configs). Part of the MCP
org-separation stack.

Closes https://linear.app/codercom/issue/CODAGT-717

UAT: verified on a trial-licensed dogfood instance: audit entries for
the full CRUD lifecycle with correct actor/org/target, redacted secrets
in the update and OAuth2 create diffs, and a full plaintext scan of the
audit dump finding zero secret leaks.

> Mux (AI agent) authored this PR on Mike's behalf.

<!-- mux-attribution: model=claude-fable-5 thinking=high -->
2026-08-19 19:11:52 +00:00
Michael Suchacz f7a0de7a11 fix: harden org-scoped MCP config chat gating, updates, and visibility (#28065)
Hardens the org-scoped MCP server config surface from #27942 with fixes
and regression pins that are independent of the core cutover:

- Keep chats sendable after a selected MCP server is disabled (persisted
selections are exempt from message-time rejection).
- Keep cached MCP selections usable after a background refetch error
(gate the composer on missing data, not `isSuccess`).
- Merge PATCH updates onto the current row so unset fields are not
clobbered.
- Give auditors the full management view of MCP configs their audit logs
reference (site and org auditor roles).
- Regression pins: frozen OAuth2 callback path, cross-org concealment
for item routes, OAuth callback token binding, and disconnect responses
indistinguishable from nonexistent config IDs.
- Storybook: semantic textbox queries in MCP loading stories; docs note
that the MCP settings page needs deployment access.

## Stack context

Part of the MCP org-separation stack (CODAGT-711 org scope -> apidocs ->
hardening -> CODAGT-717 audit -> CODAGT-712 ACLs -> CODAGT-806 token
RBAC). Split out of #27942 to keep the core cutover reviewable; each
change here builds on the org-scoped routes and chat gating introduced
below it.

> Mux (AI agent) authored this PR on Mike's behalf.

<!-- mux-attribution: model=claude-fable-5 thinking=high -->
2026-08-19 18:50:31 +00:00
TJ dd43574990 refactor: remove AI add-on badge, seat column, and add-on wording leftovers (#28004)
Removes all "AI add-on" labeling from the UI, plus two leftover "AI
Governance add-on" wording items from the licensing repackaging (AI
Governance is now included with Premium).

- Remove the **AI add-on** badge from the group settings AI budget
section and the AI Governance add-on card on the Licenses page.
- Remove the **AI add-on** column (check icon showing AI seat
consumption) from the Users and Organization Members tables, along with
its help popover, the `AISeatCell` component, and the
`shouldShowAISeatColumn` entitlement helper.
- Update related Storybook stories.
- Drop "add-on" from the AI cost control route comments in
`enterprise/coderd/coderd.go` and the `coder exp ai-model-prices`
prerequisite in `docs/ai-coder/ai-gateway/cost-controls.md` (leftovers
not covered by #28075/#28077/#28268).

The `has_ai_seat` API field is left intact.

<details>
<summary>Removal scope</summary>

Badge usages removed:

- `site/src/pages/GroupsPage/GroupSettingsPageView.tsx`
-
`site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard.tsx`

AI seat column plumbing removed:

- `site/src/pages/UsersPage/UsersTable.tsx` and `UsersPage.tsx`
- `site/src/pages/OrganizationSettingsPage/OrganizationMembersTable.tsx`
and `OrganizationMembersPage.tsx`
- `site/src/modules/users/AISeatCell.tsx` (deleted)
- `AiAddonHelpPopover` in `site/src/modules/users/UserHelpPopovers.tsx`
- `shouldShowAISeatColumn` in
`site/src/modules/dashboard/entitlements.ts`

Add-on wording leftovers:

- `enterprise/coderd/coderd.go`: 7 route comments (comments only, no
behavior change)
- `docs/ai-coder/ai-gateway/cost-controls.md`: model prices CLI
prerequisite

</details>

---

🤖 This pull request was generated by Coder Agents on behalf of
@tracyjohnsonux.
2026-08-19 11:43:27 -07:00
Michael SuchaczandMathias Fredriksson 443e3b9b80 feat!: org-scope MCP server configs with RBAC (#27942)
Moves `mcp_server_configs` from deployment scope to organization scope
so each organization fully controls the MCP servers its members can use
with Coder Agents.

## Summary

- Migration: adds `organization_id` (NOT NULL, FK) and keeps existing
rows as the default organization's originals with credentials intact.
Other organizations start with no MCP servers and configure their own;
nothing is copied across organizations. Chats outside the default
organization keep any now-cross-organization `mcp_server_ids` entries;
the runtime already ignores IDs that do not resolve in the chat's
organization, so no data rewrite is needed. Slug uniqueness becomes
`(organization_id, slug)`.
- RBAC: new org-scoped `ResourceMCPServerConfig` with regosql converter
and `GetAuthorizedMCPServerConfigs`; org admins get in-org CRUD, org
members get read (replaced by ACL evaluation in the follow-up ACL PR in
this stack).
- API: all config routes nest under the organization, matching
templates: `POST|GET
/api/experimental/organizations/{organization}/mcp-servers` and
`GET|PATCH|DELETE .../mcp-servers/{mcpserverconfig}` (plus
`oauth2/connect`), resolved by a read-only param middleware that
conceals read-denied and cross-organization access as 404. Two routes
stay on the frozen `/api/experimental/mcp/servers/{mcpServer}` block:
the OAuth2 callback (the redirect URI baked into existing AS-side client
registrations) and `oauth2/disconnect`, which must remain reachable by
users removed from the organization so they can still revoke their token
grant.
- Chat runtime: selection validation and generation resolve configs
strictly by IDs, enabled state, and the chat's organization in SQL;
requested duplicates are normalized; invalid or cross-org IDs are
rejected with the precise ID list. IDs already persisted on a chat are
exempt from message-time rejection so disabling a selected server never
blocks sends; generation skips servers that are no longer usable.
- Frontend: API layer and admin settings pages target the new endpoints.
The admin page manages the default organization's servers; the org
picker is tracked separately (CODAGT-714).

- Security hardening from review: OAuth user grants are additionally
bound to `oauth2_revocation_url` (changing it invalidates grants, and a
racing OAuth callback gets 409 instead of recreating a grant).
Stack-wide SSRF protection for MCP config-directed traffic was split
into its own PR at the top of this stack (#28242) to keep this diff
reviewable; this PR keeps main's existing discovery IP-range guard.
OAuth2 auto-discovery now completes before the config row is inserted: a
failed discovery persists nothing, and there is no provisional row that
concurrent updates could race against.

Two follow-up PRs in this stack were split out to keep this diff
reviewable: #28064 completes the swagger annotations for the moved
routes (main already ships these experimental MCP handlers unannotated),
and #28065 carries hardening fixes and regression pins on top of the
cutover.

- Force On enforcement (landed on main mid-review) is org-scoped: the
forced set is read per chat organization
(`GetForcedMCPServerConfigsByOrganization`), so another organization's
`force_on` server never attaches to a chat.

## Breaking changes (experimental API)

The MCP server config endpoints move from the deployment-scoped
`/api/experimental/mcp/servers` block to organization-nested paths:
`POST|GET /api/experimental/organizations/{organization}/mcp-servers`
and `GET|PATCH|DELETE
/api/experimental/organizations/{organization}/mcp-servers/{mcpserverconfig}`
(plus `oauth2/connect`). The old paths are removed, so API consumers
must supply an organization. Two routes intentionally stay on the frozen
`/api/experimental/mcp/servers/{mcpServer}` block: the OAuth2 callback
(its redirect URI is baked into existing AS-side client registrations)
and `oauth2/disconnect` (must remain reachable by users removed from the
organization). These endpoints are under `/api/experimental`, so no
deprecation window is provided.

## Rolling upgrades

During a rolling deploy, an old replica creating an MCP config can fail
the new `NOT NULL organization_id` constraint until it is upgraded
(reads are unaffected: old binaries' generated queries select their own
column lists). This matches the repo's existing precedent for additive
NOT NULL migrations (000562) and affects only the admin config-create
path in the upgrade window.

Upgrades are expected to run in scheduled maintenance downtime with the
database locked during migration, so the migration ships no
rolling-upgrade compatibility machinery. The down migration deletes
organization-created configs (their chat references are cleaned by the
000510 delete trigger) and restores deployment-wide slug uniqueness.

Part of the MCP org-separation stack (CODAGT-711 -> CODAGT-717 audit ->
CODAGT-712 ACLs -> CODAGT-806 token RBAC).

Closes https://linear.app/codercom/issue/CODAGT-711

UAT: validated end to end on a two-org dogfood deployment, including a
real pre-migration to post-migration upgrade, cross-org isolation
(404s), same-slug-two-orgs, chat selection gating, and a live MCP tool
call through the org-scoped generation path. The migration was later
revised to keep existing rows in the default organization only (no
per-organization copies); that revision is covered by the migration test
suite.

> Mux (AI agent) authored this PR on Mike's behalf.

<!-- mux-attribution: model=claude-fable-5 thinking=high -->

---------

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2026-08-19 18:04:13 +00:00
Jiachen Jiang 6107cfe918 fix(enterprise/coderd/license): remove AI Governance required banner (#28268)
## Summary

The AI Governance add-on is now included in all Premium licenses, so the
transitional license banner warning that AI Bridge / AI Gateway requires
the AI Governance add-on is no longer accurate. This removes that
warning throughout Coder.

The banner text this removes:

> The AI Governance add-on is required to use AI Gateway. Please reach
out to your account team or sales@coder.com to learn more.

## Changes

- Remove the AI Bridge soft warning block in `LicensesEntitlements`.
- Remove the now-unused `hasExplicitAIBridgeEntitlement` tracking that
only fed that warning.
- Remove `TestAIBridgeSoftWarning` and the vestigial warning
assertions/comments that pinned the removed behavior.

The license banner surfaces backend entitlement warnings verbatim, so
removing the warning here removes it from the dashboard banner. No
frontend changes are needed; the warning string existed only in the
backend.

## Testing

- `go build ./enterprise/coderd/license/...`
- `go vet ./enterprise/coderd/license/...`
- `go test ./enterprise/coderd/license/` (license entitlement + AI
Governance addon suites)

---

*This PR was created by Coder Agents on behalf of @jcjiang.*
2026-08-19 08:50:17 -07:00
Paweł Banaszewski a441b03d70 feat: add yaml config option to standalone AI gateway (#28258)
`coder ai-gateway start` now accepts a `--config` / `-c` flag (and
`CODER_CONFIG_PATH`) to load configuration from a YAML file.
2026-08-19 10:07:22 +00:00
Jon Ayers 821d91fabd fix: log tailnet tunnel authorization decisions (#27819) 2026-08-18 18:21:50 -05:00
Mathias FredrikssonandMichael Suchacz d3f08b1983 feat: audit chat system instructions changes (#27668)
Adds an audit record for administrative events on the deployment-wide
chat instruction settings (system prompt, the include-default toggle,
and the plan-mode instructions), per CODAGT-719 and operator decision
D5. Each endpoint records under a stable identity: resource type
`chat_instruction_settings`, a fixed resource ID and a human-readable
target ("System prompt", "Plan mode instructions"), so two changes to
one setting share an ID and history-by-setting works. A real change
exports a Write entry with the old-to-new text visible; a
value-identical PUT still upserts and still returns 204 but records
nothing.

Attempts are recorded, not only transitions. Identity is assigned before
the authorization check, so a denied PUT exports a 403 row with an empty
diff (no request content reaches it), a validation failure exports a 400
row, and a write failure exports a 500 row, each with an empty diff; an
operator can tell "nothing changed" from "something changed and capture
degraded" by the status code.

The write path stays authoritative. The advisory lock and, on plan-mode,
the transaction exist only to serve change-detection; if any of that
machinery fails (lock, begin, commit, rollback), the handler runs main's
idempotent write path directly and derives the response from it, so a
member-visible failure of audit-only infrastructure can never replace
main's successful response. Accepted consequence: when the lock cannot
be taken, two concurrent identical writes can produce two rows instead
of one. That is audit degradation, which is allowed; changing a member's
response is not. Write failures keep the exact response the endpoint
produced before this wiring (transaction error for the system prompt,
which was always transactional; the raw write error for plan mode, which
was not), and the full transaction error is logged so rollback failures
cannot vanish.

<details>
<summary>CODAGT-66 plan entry: S1 (verbatim)</summary>

**S1 `feat: audit chat system instructions changes`** (CODAGT-719; base:
main)

- Struct: `database.ChatSystemPromptSettings{ID uuid.UUID; SystemPrompt
string; IncludeDefaultSystemPrompt bool; PlanModeInstructions string}`
in `coderd/database/types.go` (ticket-sketched shape; one struct, both
endpoints).
- Registration: union entry (diff.go), table.go entry (`id`
ActionIgnore, other three ActionTrack), `AuditActionMap` Write-only;
four request.go cases (`ResourceTarget` "", `ResourceID` from struct,
`ResourceType` new enum value `chat_system_prompt_settings`,
`ResourceRequiresOrgID` false with the "Artificial ID / deployment
singleton" comment convention).
- Migration: `ALTER TYPE resource_type ADD VALUE IF NOT EXISTS
'chat_system_prompt_settings';` comment-only no-op down (000558 shape);
number picked at push per the numbering constraint.
- codersdk: constant + prose `FriendlyString` ("chat system prompt
settings"); `TestAuditDBEnumsCovered` forces both. `coderd/audit.go`
presentation switches: rely on safe defaults (no link, generic
description); no FE changes (filter label falls back to capitalized
value; acceptable per precedent).
- Wiring `putChatSystemPrompt` and `putChatPlanModeInstructions`:
InitRequest with Action Write; artificial `ID: uuid.New()` on `New` only
when a change is detected; no-op suppression by leaving both aReq sides
unset (nil resource IDs skip the log, request.go skip rule); the write
path itself stays byte-identical (upserts still run unconditionally).
- `putChatSystemPrompt` (writes two keys conditionally in one existing
tx): inside that tx, read the pair via `GetChatSystemPromptConfig` for
`Old`, perform the conditional writes exactly as today, then RE-READ the
pair for `New`. The re-read is load-bearing:
`include_default_system_prompt` is computed from the toggle row AND the
prompt, so a prompt-only write can flip the effective value without the
request carrying the pointer. `PlanModeInstructions` stays zero on both
sides.
- `putChatPlanModeInstructions` (no tx exists today): wrap its
read-upsert in `InTx` (behavior-preserving: same single write);
`Old`/`New` populate only `PlanModeInstructions`; the two system-prompt
fields stay zero on both sides; no cross-key reads.
- Change detection compares the populated payload fields only (never the
artificial ID).
- Tests: handler-level coderdtest with `audit.NewMock()` asserting Write
entry on change and NO entry on a value-identical PUT, for both
endpoints (this also exercises `ResourceRequiresOrgID` end to end); the
fallback-flip case (no explicit include-default row, nonempty prompt set
to empty, effective boolean flips: entry emitted with the boolean diff);
diff assertions (old->new prompt text tracked, not secret) in
`enterprise/audit/diff_internal_test.go`; `TestAuditableResources`
passes by construction.
- Bookkeeping at PR open: correct CODAGT-719's no-op premise ("matches
the existing 204-on-unchanged behavior" does not exist on main;
suppression is new, write path unchanged).
- Review focus: Old capture and the New re-read inside the tx (three of
four existing singletons never set Old; do not copy them; and the
computed include-default value makes a naive New construction wrong);
the skip-on-no-op mechanism; prompt text deliberately visible in diffs.

</details>

Note: the plan excerpt above predates operator decision D5 (2026-07-30),
which this PR implements: the resource type is
`chat_instruction_settings` (not `chat_system_prompt_settings`), each
setting carries a stable ID and a display-name target (not a per-write
artificial ID and an empty target), no-op suppression runs through
`InitRequestWithCancel` (not the nil-ID skip), and attempts (denied,
failed, capture-degraded) record rows with real statuses and empty
diffs. Ticket bookkeeping for CODAGT-719 was corrected on Linear at
kickoff: the ticket's "matches the existing 204-on-unchanged behavior"
premise does not exist on main; suppression is new, and the write path
is unchanged.

> 🤖 This PR was created with the help of Coder Agents, and _will be_
reviewed by a human. 🏂🏻

---------

Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
2026-08-18 20:03:06 +02:00
Michael Suchacz 119f2b1dd9 feat: limit concurrent chat agents with pooled admission (#27902)
Limits concurrent chat generation on capped deployments to 5 root chats
and 10 delegated subagent chats. The pools are deployment-wide and
independent, so delegated work can continue while root capacity is full.

The default caps live in AGPL code. Enterprise contributes only a
licensing unlock, so unlicensed deployments stay capped and cannot fail
open. Licensed deployments are uncapped while Agent Hours usage stays
below an explicit hard limit. Deployments without a hard limit remain
uncapped, and reaching the Agent Hours allocation only triggers
warnings.

Admission happens before a worker takes chat ownership. Capped
deployments serialize admission across replicas with a
transaction-scoped advisory lock and derive active and queued state from
current ownership plus fresh runner heartbeats, rather than persisted
queue markers or per-replica state. The acquisition query returns a
bounded, pool-interleaved candidate set instead of ranking the whole
backlog; a migration replaces the acquisition index with a pool-aware
one. Refused chats stay running but unowned, and interrupt requests
bypass admission so users can stop queued or over-cap chats.

The single-chat API derives `queued_for_capacity` from live pool state;
list endpoints do not report it. The UI polls that value every 5 seconds
while a chat is running and shows a callout when the chat is waiting for
capacity.

Updates the administrator documentation and deployment-wide Prometheus
gauges for active and queued agents. Replica-level values must be
aggregated with `max`, not `sum`.

> Mux updated this PR on Mike's behalf.
2026-08-18 16:55:43 +02:00
Paweł Banaszewski 0a34a37314 test(enterprise/cli): add standalone AI Gateway tests against a live coderd (#27863)
Stacked on #27860.

Adds four connection tests that run the real `ai-gateway start` against
`coderdenttest` and assert only what an operator or LLM client can
observe. A `chaosProxy` between the gateway and coderd simulates outages
by answering 503 and closing the connections it accepted, the latter
because the DRPC websocket is hijacked and so out of reach of
`httptest.Server`.

- `RevokedKey`: revoking an in-use key closes the session, and the 401
on redial terminates the command.
- `ReconnectAfterDisconnect`: LLM traffic and interception recording
resume after a coderd outage, with no intervention.
- `RequestWhileDisconnected`: a request arriving while disconnected is
parked until the connection returns, not failed. The pre-flight DRPC
calls block with the caller's context as the only bound, which is
intentional: a caller willing to wait is served on reconnect, and
`/readyz` has already withdrawn the replica. The RFC's "fails with 503
if pre-flight DRPC calls cannot complete" does not describe this and
needs correcting.
- `InFlightRequestSurvivesDisconnect`: a stream whose first chunk
already reached the caller completes after the DRPC connection drops.

No production code is changed.

Refs https://linear.app/codercom/issue/AIGOV-320/write-connection-tests

---

Generated with Coder Agents.
2026-08-18 14:03:41 +00:00
Jaayden Halko fa8ffe4eda feat: report agent runtime hours usage in entitlements (#27985)
Populate `FeatureAgentRuntimeHours.Actual` on every entitlements refresh
for licenses that grant the feature. A new
`GetTotalUsageHBAgentRuntimeV1` query sums `runtime_ms` over the
license's usage period, reading `usage_events` directly:
`hb_agent_runtime_v1` is exactly one row per hourly bucket
deployment-wide with `created_at` at the bucket start, enforced by the
unique partial index introduced in #27983.

The measurement reuses the shared `measureUsage` policy from #27984
through a new `AgentRuntimeMsFn` closure (usage publisher subject):
failures publish the stable
`LicenseAgentRuntimeUsageUnavailableErrorText` and log the cause. Usage
is floored to whole hours, matching the unit of the
`agent_runtime_hours_*` claims, and at most one warning is emitted per
refresh: reaching the allocation supersedes the advisory soft limit. The
dashboard renders the soft-limit advisory muted without a sales link and
treats the runtime usage-unavailable text as a diagnostic.

**Precise usage.** `Feature.ActualMs` (JSON `actual_ms`), set only for
`agent_runtime_hours`, carries the exact stored milliseconds backing the
floored `Actual` so clients can render fractional hours (e.g. `10.3`).
It has the same freshness as `Actual`; the whole-hour warning thresholds
are unchanged.

**Unlimited licenses.** A license minted with the unlimited (`-1`)
allocation decodes to an enabled feature with a nil `Limit` (#27984), so
the warning write-back now guards the allocation dereference: no
thresholds can exist for an unlimited license, so no runtime hours
warning is ever emitted, while `Actual` is still measured and published.
`Feature.Compare` is unchanged; for usage-period features the
issued-at/end dates decide first, so a metered feature outranks an
unlimited one only on an exact timestamp tie, an edge pinned by a
`TestFeatureComparison` case and documented on
`decodeAgentRuntimeHours`.

**Grandfathered premium licenses.** Premium licenses without
`agent_runtime_hours_*` claims are now granted the feature disabled with
a zero limit over the license term, identical to an explicit
`allocation: 0`: usage is measured and published for every Premium
deployment, and chatd's pooled admission (#27902) caps concurrent
agentic chats until a license with a positive allocation is added. The
default carries a fixed early `UsagePeriod.IssuedAt` (2026-08-01, the
same mechanism as the managed-agents default) so any license actually
carrying the claims outranks it in the `AddFeature` merge regardless of
the licenses' relative issue dates; the constant must stay earlier than
the earliest legitimately issued claim-bearing license. Zero allocations
(explicit or grandfathered) emit no deployment-wide warning banner:
those deployments are steered by the in-page upgrade CTA and the
concurrency cap. Enterprise licenses are unchanged.

Part 3 of a 3-PR stack splitting up #27796 (see there for review
history). Stack: #27983 → #27984 → this PR.

Closes CODAGT-852.
2026-08-18 12:40:33 +07:00
Jaayden Halko d15800b494 feat: tolerate unusable runtime hours claims and decode -1 allocation as unlimited (#27984)
Two coupled changes to the license/entitlements layer, preparing for
runtime-hours usage reporting.

**Tolerate unusable runtime hour claims.** Unusable
`agent_runtime_hours_*` claim combinations no longer reject the whole
license: rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed. `decodeAgentRuntimeHours` drops
the unusable claims, surfaces the stable
`LicenseAgentRuntimeHoursClaimsIgnoredWarningText` (deduplicated across
licenses), and logs the affected license and claims through the new
`FeatureArguments.Logger`; `validateAgentRuntimeHours` and its
license-invalidating errors are removed. The dashboard recognizes the
stable diagnostic text and renders it muted, with a "License notices"
heading instead of the exceedance heading and without a sales link.

**Unlimited allocation.** An `agent_runtime_hours_allocation` claim of
exactly `-1` (`AgentRuntimeHoursUnlimitedAllocation`, mirrored in
coder/license) is reserved to mean unlimited: it decodes to an enabled
feature with no `limit` in `/api/v2/entitlements`, the shape the UI
already renders as "Unlimited". Threshold claims alongside it have
nothing to threshold against, so they are dropped with the
claims-ignored warning, and any other negative allocation remains
unusable. The issuer-side counterpart (refusing to mint `-1` together
with threshold claims) is coder/license#49.

The managed agent measurement path is intentionally untouched: managed
agents are deprecated and slated for removal, so the shared
usage-measurement failure policy (`measureUsage`) now lands in #27985
next to its runtime-hours consumer instead of converting a doomed call
site here.

Part 2 of a 3-PR stack splitting up #27796 (see there for review
history). Stack: #27983 → this PR → #27985.
2026-08-18 12:07:22 +07:00
Paweł Banaszewski 94f487b890 test(enterprise/cli): add standalone AI Gateway connection tests (#27860)
Adds two tests that run the real `ai-gateway start` command against a
real coderd over the production websocket dialer.
`TestAIGatewayStartE2E`: the gateway completes the handshake, loads
providers over DRPC, proxies an OpenAI chat completion on its own
listener, and the interception is recorded in coderd.
`TestAIGatewayStartE2E_InvalidKey`: a key rejected by the handshake is
fatal rather than retried, and the command reports it.

Also tidies the existing tests: `TestAIGatewayStart_HealthBeforeReady`
moves to the external package and reuses the new helpers, the two fake
reloaders collapse into one `mockReloader`.

---

Generated with Coder Agents.
2026-08-17 20:48:09 +00:00
Jaayden Halko 20c376a575 fix: enforce uniqueness and hour alignment for agent runtime usage events (#27983)
The usage generator writes `hb_agent_runtime_v1` rows with `created_at`
at the UTC hourly bucket start and exactly one row per bucket, but
nothing in the schema enforced either invariant. A duplicate bucket row
under a different id would be double-counted by any consumer summing
`runtime_ms`, and a misaligned `created_at` would skew which usage
period a bucket is attributed to.

This replaces the non-unique partial index
`idx_usage_events_agent_runtime` (from migration 000561) with a unique
index of the same shape and adds an hour-alignment `CHECK` constraint.
Both statements validate existing rows: every supported writer has
always produced conforming data, so a pre-existing violator is anomalous
and failing the migration loudly beats silently rewriting usage rows.
`generateBucket` treats a unique violation on the bucket index as
another replica having won the race, mirroring the existing `ON CONFLICT
(id)` no-op for committed rows.

The `coderd/notifications` sync commit and its revert cancel out (the
drift they addressed was fixed on main by #27979); the PR's net diff is
only the usage-event changes.

Part 1 of a 3-PR stack splitting up #27796 (see there for review
history). Stack: this PR → #27984 → #27985.
2026-08-17 16:23:45 +07:00
Wyatt FryandEthan Dickson a005e5cd22 feat: add username and email user search filters (#27922)
## Summary

User search can now resolve exact `email:` and `username:` terms through
`GET /api/v2/users` instead of only supporting fuzzy free-text matches.
The database query already had exact email and username filters; this
wires the public search parser and API handler to those filters so
clients can ask for a single user by email without fetching every user
or depending on substring matching.

This is the API half of coder/terraform-provider-coderd#403: that
provider PR adds `data.coderd_user.email`, and this PR gives it an
efficient exact lookup path.

## Testing

- `go test ./coderd/searchquery -run '^TestSearchUsers$' -count=1`
- `go test ./coderd -run '^TestGetUsersFilter$' -count=1`
- Live API test:
  - Built local enterprise Coder from this branch.
- Started Coder on `http://127.0.0.1:39991` against a clean Postgres
database.
  - Created `lookup-target@example.com`.
- Verified `GET /api/v2/users?q=email:LOOKUP-TARGET@EXAMPLE.COM&limit=2`
returned exactly one user:

```json
{
  "count": 1,
  "users": [
    {
      "id": "efc6f909-ce0a-4731-bd2f-6e4df417aaa7",
      "username": "lookup-target",
      "email": "lookup-target@example.com"
    }
  ]
}
```

---

![flow.ai](https://img.shields.io/badge/Built_with-flow.ai-6366f1)
![Codex](https://img.shields.io/badge/GPT--5-000000)

---------

Co-authored-by: Ethan Dickson <ethanndickson@gmail.com>
2026-08-16 18:18:02 +05:00
Susana Ferreira 2d9b6eda8f feat: add experimental CLI to price unpriced AI models (#27926)
## Description

AI Gateway computes the cost of an interception from `ai_model_prices`,
which is seeded on every server start from a price book embedded in the
binary. A model the price book does not cover records a NULL cost, so
its spend is invisible to cost reporting and is not enforced against
budgets. The only fix was to wait for a Coder release that added the
model.

This adds an experimental CLI, backed by an experimental HTTP endpoint,
for pricing those models. Models the price book already covers are
rejected, because the seeder re-applies the book on every start and
would overwrite an operator price. Support for custom pricing will be
handled in
https://linear.app/codercom/issue/AIGOV-589/extend-experimental-cli-command-to-set-custom-ai-model-prices.

## Commands

```
coder exp ai-model-prices list [--provider] [--model]
coder exp ai-model-prices update [file|-] [--provider] [--model] [--input-price] [--output-price] [--cache-read-price] [--cache-write-price] [--yes]
```

## Changes

- Add `GET` and `POST /api/experimental/ai/model-prices`, gated behind
the AI Bridge entitlement and the existing `ai_model_price` RBAC
resource.
- Add a `GetAIModelPrices` query with optional `provider` and `model`
filters applied in SQL.
- Validate the whole request before writing anything, so one bad entry
cannot leave the table half updated, and report every problem at once.
- Reject prices for models the embedded price book already covers,
through a new `prices.IsDefaultPriced`.
- Add the `coder exp ai-model-prices` command with `list` and `update`.
`update` accepts a JSON document or the single-model flags and prints a
plan, asking to confirm unless the document is piped in or `--yes` is
passed.
- Consolidate the supported provider list into
`coderd/aibridge/prices/providers` so the price generator and the server
share one definition.
- Add `codersdk` types and client methods for both endpoints, and bound
the request body at 1 MiB.
- Document the command in the AI Gateway cost controls page.

Closes
https://linear.app/codercom/issue/AIGOV-567/experimental-cli-command-to-set-prices-for-unpriced-ai-models

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by
@ssncferreira
2026-08-13 15:00:36 +01:00
Michael Suchacz c8e8b21a88 feat: migrate aibridge injected-MCP proxy to official MCP Go SDK (#28060)
## Stack Context

PR 5 of 6 in a stack that migrates every Coder MCP surface from the
archived `github.com/mark3labs/mcp-go` library to the official
`github.com/modelcontextprotocol/go-sdk` v1.7.0.

Stack: #28056 -> #28057 -> #28058 -> #28059 -> #28060 -> #28061

## Why

The aibridge injected-MCP proxy now owns an official `*mcp.Client`,
`*mcp.StreamableClientTransport`, and `*mcp.ClientSession`.

- The proxy constructor accepts an optional `*http.Client` instead of
mark3labs options; the header-injecting wrapper shallow-copies a
supplied client so its Timeout, Jar, and redirect policy survive.
- Manual protocol version negotiation and the mark3labs five-second
close workaround are removed; the SDK negotiates during `Connect` and
fails when no mutually supported version exists.
- Repeated `Init` closes the previous session, and a failed tool fetch
closes the just-created session so transports do not leak.
- Tool and intercept types use the official pointer content types;
embedded resource blobs are re-encoded to base64 for model-facing text
because the SDK decodes them into raw bytes.
- `aibridge/mcpmock` is regenerated, and its stale `go:generate` source
path is corrected.

> Mux created this PR on Mike's behalf.
2026-08-13 10:38:14 +00:00
Steven Masley f0c17291b3 feat: unhide --oidc-redirect-url server option (#28072)
Unhides the `--oidc-redirect-url` / `CODER_OIDC_REDIRECT_URL` server
option so it appears in `coder server --help` and the deployment
configuration docs.

- Removed `Hidden: true` from the option in `codersdk/deployment.go`
- Regenerated CLI golden files and docs via `make gen`

---

> Generated with Coder Agents on behalf of @Emyrk
2026-08-12 16:02:15 -05:00
Cian Johnston e02d9adc11 chore: add NewUnstartedHTTPServer helper to disable keep-alives on test servers (#28052)
Tests that proxy or pool connections to a bare `httptest.Server`
intermittently fail on Windows with a bare EOF when a stale pooled
connection is reused. net/http will not retry a non-replayable request
(e.g. a POST) on a closed pooled connection, so forcing a fresh
connection per request eliminates the failure class. This is the same
mechanism fixed in #28016 (AIGOV-430 / internal#1564), now expressed as
a reusable, behavior-preserving helper.

This PR adds `testutil.NewTestHTTPServer`, a known-good wrapper around
`httptest.NewServer` that applies some defaults. Currently the only
default is disabling keep-alives by default.

- `testutil/http_server.go`: `NewHTTPServer(t, handler, opts)` started,
with documented defaults, starts automatically, and handles `t.Cleanup`.
- `testutil/http_server_test.go`: unit test for defaults and overriding
defaults.
- `enterprise/aibridgeproxyd/reload_test.go`: refactor the harness's
hand-rolled server to the helper.

## Future Work

- Functional options are exposed but not explicitly defined. This can be
done later as required.
- No lint rule or broader migration. A forcing-function analyzer
covering more packages, plus wider adoption, belongs in a separate
follow-up.

## Verification

- `testutil` and `enterprise/aibridgeproxyd` suites pass under `-race`.
- `TestProxy_HotReloadRouting` and `TestProxy_StaleTunnel` pass 10x
under `-race`.
- New helper unit test passes under `-race`.

> Generated by a Coder agent.
2026-08-12 15:22:38 +01:00
J. Scott Miller 866e676320 feat: invalidate provisioner daemon sessions on key deletion (#26532)
## Summary

Closes PLAT-305.

When a provisioner key is deleted, the associated daemon kept operating
on its existing WebSocket connection, because authentication was only
checked at connection establishment and deletion was a bare `DELETE`
with no session invalidation.

This adds four layers of defense so a deleted key promptly stops doing
work:

1. **Publish on delete.** `deleteProvisionerKey` publishes to a new
per-key pubsub channel (`coderd/pubsub.ProvisionerKeyDeletedChannel`)
after a successful delete. Publish errors are logged but still return
`204`, since layer 3 is the durable backstop.
2. **Subscribe and tear down.** The daemon serve handler subscribes to
its key's channel and terminates the DRPC session on a deletion event.
Termination is deferred while a job claimed by the session is active:
the daemon may finish and report the in-flight job
(`UpdateJob`/`CompleteJob` have no key check), and the last active job's
completion performs the cancellation. Because Postgres `LISTEN`/`NOTIFY`
does not buffer for non-listeners, the handler also performs a
synchronous key-existence re-check immediately after subscribing to
close the race between auth and subscription. The subscription uses
`SubscribeWithErr` so that an `ErrDroppedMessages` signal (emitted when
the pubsub listener reconnects) triggers the same key re-check, closing
the listener-outage window in which a deletion notification could be
missed.
3. **Backstop on acquire.** `AcquireJob` and `AcquireJobWithCancel`
verify the key still exists before waiting for a job, and the `Acquirer`
claims jobs in a transaction that first locks the worker's deletable key
(`LockProvisionerKeyByIDForShare`, a `FOR KEY SHARE` row lock held until
commit) before running the `AcquireProvisionerJob` claim, so a claim
cannot commit after the key's deletion. This guards against a missed
pubsub message. A missing key row surfaces as its own result rather than
overloading the claim query's no-rows response: the acquire terminates
with `ErrProvisionerKeyDeleted` (terminating the session, with the same
active-job deferral) and hands the consumed wakeup to another waiting
daemon in the same domain, rather than silently re-parking and starving
peers of job postings.
4. **Heartbeat watchdog.** The per-session heartbeat loop (1m interval)
also re-checks the key, so even a session whose deletion notification
was silently lost terminates within one heartbeat interval instead of
living until the connection breaks (same active-job deferral as layer
2). Reserved keys skip the check.

A job that is claimed but never delivered (the session or connection
dies between the database claim and the stream send) is marked failed
immediately on a fresh context, instead of staying assigned to the
worker until the job reaper.

Reserved keys (built-in, user-auth, PSK) are exempt throughout, since
they are not deletable rows. The acquire-time lookup runs as
`dbauthz.AsSystemReadProvisionerDaemons`, because the provisionerd role
cannot read provisioner keys and a provisioner key's RBAC object is a
provisioner daemon.

A single key can back many daemons (and span HA replicas), so the
per-key channel fans out to invalidate all of them at once. Per-key
channels keep the `LISTEN` count proportional to distinct keys rather
than waking every daemon on unrelated deletions.

### Known limitations

- **`UpdateJob`/`CompleteJob` intentionally have no key check.** By the
time those RPCs arrive the work has already run; rejecting completion
would strand a build in "running" (until the job reaper fails it) with
real infrastructure left unreconciled. Session termination is deferred
while a job is active so the completion can be reported; the daemon may
not receive the final RPC response when the deferred termination fires,
but the job's outcome is already persisted.
- **After termination, the daemon process redials and receives 401s
until restarted.** The dial-time exit logic only triggers on 403, and
the auth middleware returns 401 for an invalid key; this dial behavior
predates this PR and is tracked as a follow-up in
[PLAT-452](https://linear.app/codercom/issue/PLAT-452) (return 403 for
invalid provisioner keys).

## Tests

- `coderd/provisionerdserver`: `TestAcquireJob_ProvisionerKeyDeleted`
(both RPC variants), `TestAcquireJob_ReservedProvisionerKey`,
`TestHeartbeat_ProvisionerKeyDeleted` (heartbeat watchdog cancels the
session after key deletion), `TestAcquirer_ProvisionerKeyDeleted` (a
dead-key acquiree exits terminally and its clearance is promoted to a
peer in the same domain), and `TestTerminateSession_Deferral`
(termination is immediate when idle and deferred until the last active
job finishes).
- `coderd/database`: `TestAcquireProvisionerJob/ProvisionerKeyLock`
covers the lock query against real Postgres: it returns the key ID while
the row exists and no rows once it is deleted. The lock-then-claim
composition is pinned by `TestAcquirer_ProvisionerKeyDeleted`.
- `enterprise/coderd`:
`TestProvisionerDaemonServe/KeyDeletionClosesSession` asserts an active
session closes after its key is deleted.
`KeyDeletedDuringSetupClosesSession` covers the post-subscribe re-check
when a key is deleted between auth and subscription, and
`DroppedMessageClosesSession` covers the `ErrDroppedMessages` re-check
when a deletion is missed during a listener outage.

## Validation

- `make` pre-commit (gen/fmt/lint/build) passed via git hooks.
- Targeted tests pass; existing acquire tests pass with no regression.
- Manual: brought up a dev deployment (coder-in-coder) with a Premium
license, created a deletable provisioner key, and started an external
daemon with `coder provisionerd start`. Confirmed it authenticated via
the key and connected, appearing as `idle` in both `coder provisioner
list` (with the key name) and the organization Provisioners UI.
- Manual, idle teardown: deleted the key while the daemon was idle. The
server logged `provisioner key deleted, terminating session`, the
daemon's session closed immediately, and it dropped from `coder
provisioner list` (then entered the known 401 redial loop, PLAT-452).
- Manual, deferred termination: ran a workspace build (tagged template,
`sleep 45` in `local-exec`) pinned to the external daemon and deleted
the key mid-build. The server logged `deferring session cancellation
until active jobs finish`; the heartbeat watchdog re-checked mid-build
and re-deferred rather than force-killing. The build ran to completion
(`Apply complete`, workspace `Started`) and only then did `canceling
session after job completion` fire. The documented caveat reproduced:
the daemon lost the final `CompleteJob` ack, and the build outcome was
still persisted correctly.

<details>
<summary>Implementation plan and design decisions</summary>

### Design

- **Per-key vs global channel:** chose per-key
(`provisioner_key_deleted:<keyID>`) so daemons do not wake on unrelated
deletions. The cost is one `LISTEN` per distinct key per replica on the
shared listener connection, which is negligible against Coder's existing
channels.
- **Missing-key behavior on acquire:** returns an error that tears down
the acquire rather than silently returning an empty job.
- **Subscribe-startup race:** ordering is `authorize ->
UpsertProvisionerDaemon -> Subscribe -> GetProvisionerKeyByID`. The
post-subscribe re-check handles a deletion that committed before the
`LISTEN` registered (Postgres does not buffer notifications for
non-listeners; the in-process buffer only smooths bursts and drops on
overflow).
- **`NewServer` change:** `KeyID` was added to
`provisionerdserver.Options` to avoid a positional signature change
across call sites. The in-memory (built-in) daemon leaves it unset and
is therefore exempt.

### Files

- `coderd/pubsub/provisionerkeydeleted.go` (new) — channel helper.
- `enterprise/coderd/provisionerkeys.go` — publish on delete.
- `enterprise/coderd/provisionerdaemons.go` — subscribe, re-check,
cancel session; pass `KeyID`.
- `coderd/provisionerdserver/provisionerdserver.go` — `KeyID` option and
acquire-time existence check.

</details>

---

This pull request was created by Coder Agents on behalf of
@jscottmiller.
2026-08-11 11:04:51 -05:00
Cian Johnston 5febde0ebb fix: disable keep-alives on mock aibridged to prevent stale-conn EOF (#28016)
Fixes the Windows-only `TestProxy_HotReloadRouting` EOF flake (AIGOV-430
/ [internal#1564](https://github.com/coder/internal/issues/1564)).

## Root cause

The proxy forwards MITM'd requests to aibridged over a keep-alive-pooled
`http.Transport`. net/http will not retry a POST on a reused-but-closed
pooled connection (a POST isn't replayable), so a stale reuse of a conn
the mock aibridged has closed surfaces as a bare `EOF` on the Windows
runner. goproxy turns that upstream round-trip error into a closed MITM
connection, so the test client sees `Post
"https://alpha.invalid/v1/messages": EOF`.

This is a stale pooled-connection reuse failure, not a routing or
hot-reload bug: `Reload()` is a synchronous atomic router swap and never
touches the upstream transport.

## Fix

`bridged.Config.SetKeepAlivesEnabled(false)` on the mock aibridged
backend forces a fresh proxy→aibridged connection per request,
eliminating the stale-reuse failure by construction. No production
changes.

## Verification

- `go test -race` of the reload family passes repeatedly (`-count=10`,
earlier `-count=20` and full package `-race`).
- Cannot reproduce the Windows closed-socket semantics on a Linux
runner, so this removes the failure class rather than proving it;
Windows CI is the confirmation.

Closes https://linear.app/codercom/issue/AIGOV-430

---

Generated by a Coder agent.
2026-08-11 15:48:23 +01:00
Atif Ali d7953bd046 fix(coderd): use service account wording in account notifications (#27536) 2026-08-11 13:16:25 +00:00
Andrew Aquino 6e07e2610f feat: add paginated API endpoint for groups (#27603)
backend-only changes from #27271; see that PR for summary of changes +
implementation details
2026-08-10 13:23:14 -07:00
Ethan b3485d9b3a chore: add agents_allowed to templates (#27284)
Relates to CODAGT-713

This adds `templates.agents_allowed` as a default-true, auditable template attribute, along with nullable database filtering. Migration `000562` translates the effective legacy `agents_template_allowlist` state for existing templates: a valid nonempty list allows matching templates and blocks the rest, missing or empty values leave templates allowed, whilst corrupt values fail closed by blocking all existing templates. As per the linear issue, new templates deliberately default to allowed under the per-template model.

This is the database-only first PR in the stack. #27285 makes the field authoritative in the API and chatd whilst temporarily retaining the compatibility routes needed by the shipped frontend. Later PRs migrate the UI, remove the legacy storage, routes, SDK types, and utility, then add CLI flags.
2026-08-06 14:04:23 +10:00
david-fraley d458fe4941 fix(coderd/database): match group name case-insensitively in search (#27894) 2026-08-05 14:21:49 -05:00
Nick Vigilante 9dcb75cd56 chore: add docs inline-HTML linter and backtick generated placeholders (#27399)
## What

Adds CI enforcement that fails when docs Markdown contains invalid
inline HTML
the docs site silently drops or mangles, and fixes the remaining
generated-doc
placeholders at their source.

This is the tooling half of the docs-HTML audit. The hand-written fixes
it
guards landed in #27298 (kept small and separate so it reviewed fast);
this PR
carries everything that touches code, CI, or generated output.

## Changes

**Linter (`scripts/docshtmlcheck`), wired into `make lint` via
`lint/docs-html`.**
Markdown-aware: parses each file with goldmark and inspects only
raw-HTML nodes,
so angle brackets in fenced code blocks, inline code, HTML comments, and
`<https://…>` / `<user@host>` autolinks are ignored. Flags swallowed
placeholders (`<region>`), void-element end tags (`</br>`), unregistered
or
incorrectly capitalized component tags (`<Image>`), and unclosed
container tags (a
`<div class="tabs">` that leaks its wrapper). The one intentional
renderer
component, `<children>`, is allowed but still balance-checked.

**Generator-source placeholder fixes (regenerated via `make gen`).**
- `codersdk/chats.go`: backtick `<server>__` in the
`ChatContextTool.Name` doc
  comment (it becomes the Swagger description, so it was swallowed in
  `reference/api/{chats,schemas}.md`).
- `codersdk/deployment.go`: backtick `<region>` in the AWS Bedrock
region flag
help (swallowed in `reference/cli/server.md`); also updates `coder
server
  --help` output and the golden files.

**Temporary allowlist.** `docs/reference/cli/agent-firewall.md`'s
`<host>` /
`<glob>` come from the external `github.com/coder/boundary` CLI help
(still
`v0.10.0` on `main`), so they are suppressed on that one file. The
suppression
is self-clearing: if an allowlisted tag stops appearing on a scanned
file, the
linter reports `stale-allowlist-entry` and fails until the dead entry is
removed, so a dead entry cannot silently mask a later regression of that
tag on
that page. (An entry whose file is deleted outright is never rescanned,
but a
missing file yields no findings, so nothing hides behind it either.)

## Review feedback addressed

This tool + generator work was reviewed by Coder Agents Review while it
was
bundled into #27298. Addressed here:

- **P1:** tokenize each raw-HTML node as a whole instead of per source
line, so
a tag whose attributes wrap across lines is no longer torn in half. This
fixes
both the missed multi-line unclosed `<div>` (a leaked wrapper that
passed with
exit 0) and the spurious `stray-end-tag` on valid multi-line tags. Each
token
  maps back to its own source line.
- Normalize allowlist lookup/report paths to a canonical repo-relative
form, so
the escape hatch no longer silently misses under absolute / `./` paths.
- Route generated-page findings to the generator source.
- Add `<search>` to the allowed set; reword the unknown-element message
to note
  that a real element can be added to `allowedElements`.
- Self-clearing allowlist guard (above); rename `optionalEndTag(s)` and
`kindUnclosed(Tag)`; adopt `slices`/`maps` idioms; move the lint banner
to the
  Makefile recipe; stop aliasing the input slice in `filterAllowed`.
- New tests: multi-line tokenization (both classes), interleaved
nesting, a
  pinned line number, `collectMarkdown`, and the stale-allowlist guard.

### Round 2 (Coder Agents Review on this PR)

A second `/coder-agents-review` pass on this PR raised 16 findings;
addressed in
`fix(docshtmlcheck): catch self-closing containers and capitalized
tags`:

- **P2:** self-closing container tags (`<div class="tabs"/>`) were
ignored by
the HTML5 parser and leaked their wrapper like the open spelling; the
balance
  check now tracks self-closing tokens too (CRF-1).
- **P2:** a capitalized component tag whose lowercase name is a real
element
(`<Table>`, `<Section>`) slipped through on the `allowedElements`
lookup. The
tokenizer lowercases tag names, so the check now reads the raw token and
  reports any capitalized name as a component reference (CRF-2).
- Narrowed the `:` / `@` autolink skip to a real URI scheme or a dotted
`local@domain`, so `<region:id>` and `<user@host>` stay checked (CRF-3).
- Stale-allowlist findings now report against the linter source with no
line,
and count separately from invalid-HTML issues in the footer (CRF-7,
CRF-11).
- Comment / README / Makefile wording synced to the honest
capitalized-tag
  behavior; added the deleted-file allowlist caveat and a note that
`allowedElements` is hand-maintained against the renderer (CRF-14,
CRF-17,
  CRF-9).
- Internal cleanups (`pop` -> `matchEndTag`, extracted
`unclosedFinding`) and
new tests: self-closing, capitalized open/close, colon/at placeholders,
a
non-first-token line assertion, `isGeneratedDoc`, and the stale message
  (CRF-12, CRF-13, CRF-1/2/3/4/5/16).

Two findings resolved without a code change:

- **CRF-8** (also wire `lint/docs-html` into `lint-light`): declined.
  `lint-light` is the Go-free fast path; `lint/docs-html` needs the Go
toolchain, so it stays in the full `make lint`, which CI runs. Adding it
would
  pull Go into the light path for no coverage gain.
- **CRF-9** (`allowedElements` <-> renderer coupling): documented with a
maintenance note in the `allowedElements` comment and tracked in
DOCS-597 for
  a cross-repo sync/check decision.

Deferred (note, no current trigger): raw-text element interiors
(`<script>` / `<style>`) are not scanned for nested tags. No docs page
relies
on this today; noted for follow-up.

## Merge order

#27298 (the hand-written fixes this PR guards) has merged, and this
branch is
rebased on `main`, so `make lint/docs-html` now reports 0 findings and
the
`lint` check passes. The two PRs are independent (disjoint files, no
stacking).

## Verification

- `go test ./scripts/docshtmlcheck/`, `go vet`, `gofmt -l`,
`golangci-lint run`: clean.
- `make lint/docs-html` (branch rebased on `main`): 0 findings.

## Linear

- DOCS-584:
https://linear.app/codercom/issue/DOCS-584/add-ci-check-that-fails-on-invalid-inline-html-in-docs
- DOCS-551:
https://linear.app/codercom/issue/DOCS-551/backtick-placeholder-syntax-in-generated-reference-docs-cli-help
- DOCS-597 (follow-up, from CRF-9):
https://linear.app/codercom/issue/DOCS-597/track-docshtmlcheck-allowedelements-drift-vs-docs-renderer-component

> This PR was created with AI assistance (Coder Agents).
2026-08-05 14:45:45 -04:00
Bobby Ho 8a510314df fix(enterprise/coderd): deflake TestPrebuildsAutobuild prebuild waits (#27601)
## Summary

Each of the five `TestPrebuildsAutobuild` subtests spent about 30
seconds of its 60 second context budget waiting for a prebuilt workspace
whose build job had already been created and queued. On a quiet machine
the remaining budget is enough and the test passes; under
`test-go-race-pg` it is not, and the subtest fails at `found 0 running
prebuilds so far, want 1`.

Worth being precise about the shape, because it changes the fix: this is
not a data race. The 30 second stall is deterministic and every run pays
it in full. Only the *failure* is intermittent, because it depends on
whether the leftover budget covers the rest of the test.

Refs: https://github.com/coder/internal/issues/1578

## Problem

`StoreReconciler` publishes `provisioner_job_posted` to pubsub so that
provisionerd wakes up and acquires a newly created job. That publish
does not happen inline. `publishProvisionerJob` performs a non-blocking
send onto an internal buffered channel, and the goroutine that drains
that channel and calls `provisionerjobs.PostJob` is created inside
`StoreReconciler.Run`:

```go
// enterprise/coderd/prebuilds/reconcile.go, inside Run()
wg.Add(1)
go func() {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			return
		case job := <-c.provisionNotifyCh:
			err := provisionerjobs.PostJob(c.pubsub, job)
			...
		}
	}
}()
```

These tests drive the reconciler directly through `SnapshotState` /
`CalculateActions` / `ReconcilePreset` and never start `Run`. The
notification therefore lands in a cap-10 channel with no reader, the
non-blocking send succeeds silently, and provisionerd does not learn
about the job until the Acquirer's 30 second backup poll fires.

Laid out as a relay across goroutines, the hand-off is severed at the
first hop:

```mermaid
flowchart LR
    subgraph G1["goroutine: test body"]
        T1["ReconcilePreset()"]
        T2["testutil.Eventually<br/>1s poll of the DB"]
    end

    subgraph G2["goroutine: Run() drain worker"]
        D["case job := &lt;-provisionNotifyCh:<br/>PostJob(pubsub, job)"]
    end

    subgraph G3["goroutine: pubsub listener"]
        H["Acquirer.jobPosted<br/>-> clearOrPend(domain)"]
    end

    subgraph G4["goroutine: domain.poll"]
        P["ticker 30s, REAL clock<br/>-> clearOrPend(domain)"]
    end

    subgraph G5["goroutine: provisionerd AcquireJob"]
        A["select { &lt;-ctx.Done() ; &lt;-clearance }"]
    end

    CH1[["provisionNotifyCh<br/>chan ProvisionerJob, cap 10"]]
    CH2[["clearance<br/>chan struct{}, cap 1"]]
    DB[("Postgres")]

    T1 -- "non-blocking send" --> CH1
    CH1 -. "NO READER:<br/>Run() never started" .-> D
    D -. "never reached" .-> H
    H -. "never fires" .-> CH2
    P -- "every 30s:<br/>the only live writer" --> CH2
    CH2 --> A
    A -- "AcquireProvisionerJob" --> DB
    T2 -- "GetRunningPrebuiltWorkspaces" --> DB

    style G2 fill:#f2f2f2,stroke-dasharray: 5 5
    style CH1 fill:#ffe5e5,stroke:#cc0000,stroke-width:2px
```

Two properties turn this into a quiet latency bug rather than an obvious
failure:

- The channel is **buffered**, so a send with no reader succeeds instead
of blocking or panicking. The writer never learns that nobody is
listening.
- `domain.poll` ticks on the **real** clock, so the test's mock clock
cannot skip it. That is the entire 30 seconds.

From the CI job that filed the ticket, every job created through the
HTTP API is picked up in about a millisecond, and only the
reconciler-created prebuild job is not:

```text
19:11:40.760  pubsub: publish  event=provisioner_job_posted      <- template import job
19:11:40.761  acquirer: got job posting                          <- picked up in 1ms
...
19:11:40.903  prebuild job scheduled  job_id=ac196e6e-...
              (no "pubsub: publish", no "acquirer: got job posting")
19:11:41 .. 19:12:10   30 x "found 0 running prebuilds so far, want 1"
19:12:10.803  acquirer: successfully acquired job  ac196e6e-...   <- 29.899s later, via backup poll
```

Corroboration from the existing test suite: `FailureTTLOnlyAfterClaimed`
had already run into this. It builds its Acquirer on a mock clock and
calls `acquirerClock.Advance(30 * time.Second)` right after reconciling,
with a comment about the backup-poll ticker. A previous author found the
same dependency and worked around it by making the poll fire instantly
rather than by restoring the notification.

### Where that lands in the test

Each subtest is three helpers called in order. They never call each
other; they communicate through Postgres plus one returned value.
`runReconciliationLoop` performs no writes itself, they all happen
inside `ReconcilePreset`, whose transaction has committed by the time it
returns.

```mermaid
sequenceDiagram
    autonumber
    participant T as test body
    participant H1 as runReconciliationLoop
    participant H2 as getRunningPrebuilds
    participant H3 as claimPrebuild
    participant R as StoreReconciler
    participant DB as Postgres
    participant PD as provisionerd

    T->>H1: (t, ctx, db, reconciler, presets)
    H1->>R: ReconcilePreset
    R->>DB: InsertWorkspace(owner=prebuilds)
    R->>DB: builder.Build -> build(start) + job(pending)
    R->>DB: COMMIT
    R->>R: publishProvisionerJob -> provisionNotifyCh<br/>non-blocking send, no reader, DROPPED
    R-->>H1: nil
    Note over H1,PD: nothing publishes provisioner_job_posted
    H1-->>T: void

    T->>H2: (t, ctx, db, want=1)
    loop 30 polls, 1s apart
        H2->>DB: GetRunningPrebuiltWorkspaces
        DB-->>H2: 0 rows (job still pending)
    end
    PD->>DB: acquire, via the 30s backup poll
    PD->>DB: CompleteJob, IsPrebuild so deadline stays zero
    H2->>DB: GetRunningPrebuiltWorkspaces
    DB-->>H2: 1 row (succeeded)
    H2->>DB: UPDATE agents SET lifecycle_state='ready'
    H2-->>T: rows, test captures prebuild.ID

    T->>H3: (client, userClient, user, version, presetID)
    H3->>DB: CreateUserWorkspace(presetID)<br/>-> ClaimPrebuiltWorkspace, requires ready
    DB-->>H3: same workspace, new owner
    H3-->>T: workspace
    Note over T: require.Equal(prebuild.ID, workspace.ID)<br/>~30s of the 60s budget already gone
```

The defect is in `runReconciliationLoop`, but the waiting, and therefore
the failing log line, is in `getRunningPrebuilds`. Note also that
`claimPrebuild` was never affected: it builds through the HTTP API,
which publishes on the normal `wsbuilder` path, so its job was always
acquired in about a millisecond. The bug was never "prebuild jobs are
slow", it was "jobs created by the reconciler, driven directly, are
never announced".

## Fix

Publish the pending provisioner jobs on the reconciler's behalf, in the
test helper, immediately after reconciling. No production code changes.

```mermaid
flowchart LR
    subgraph G1["goroutine: test body"]
        T1["ReconcilePreset()"]
        T3["NEW: post pending jobs<br/>provisionerjobs.PostJob(pb, job)"]
    end

    subgraph G3["goroutine: pubsub listener"]
        H["Acquirer.jobPosted<br/>-> clearOrPend(domain)"]
    end

    subgraph G5["goroutine: provisionerd AcquireJob"]
        A["unblocks on &lt;-clearance"]
    end

    CH2[["clearance<br/>chan struct{}, cap 1"]]
    DB[("Postgres")]

    T1 --> T3
    T3 -- "publish provisioner_job_posted" --> H
    H -- "send" --> CH2
    CH2 --> A
    A -- "AcquireProvisionerJob, ~1ms" --> DB

    style T3 fill:#e5ffe5,stroke:#007700,stroke-width:2px
```

This works because provisionerd is already subscribed and already parked
in `select { <-ctx.Done(); <-clearance }`. It needs exactly one write to
`clearance`, and today the only live writer is the 30 second poll
ticker. Publishing to pubsub gives `jobPosted` a reason to fire, and
`clearOrPendLocked` performs that write immediately.

Posting every still-`pending` job, rather than trying to identify the
one just created, keeps the helper idempotent and avoids coupling to
whichever clock stamped `created_at`. Re-posting a job that was already
acquired is harmless: the Acquirer re-queries and finds nothing.

The same three helpers after the change. `getRunningPrebuilds` collapses
to a single poll, and nothing else about the test moves:

```mermaid
sequenceDiagram
    autonumber
    participant T as test body
    participant H1 as runReconciliationLoop
    participant H2 as getRunningPrebuilds
    participant H3 as claimPrebuild
    participant R as StoreReconciler
    participant PS as Pubsub
    participant DB as Postgres
    participant PD as provisionerd

    T->>H1: (t, ctx, db, pb, reconciler, presets)
    H1->>R: ReconcilePreset
    R->>DB: InsertWorkspace + build(start) + job(pending), COMMIT
    R->>R: publishProvisionerJob still dropped<br/>(production path, unchanged)
    R-->>H1: nil
    Note over H1,DB: job row is committed and visible,<br/>which is why the query below finds it
    H1->>DB: GetProvisionerJobsCreatedAfter(zero time)
    DB-->>H1: all jobs, filtered in Go to status=pending
    H1->>PS: PostJob -> provisioner_job_posted
    PS->>PD: acquirer wakes, clearance write
    H1-->>T: void
    PD->>DB: acquire in ~1ms, then CompleteJob

    T->>H2: (t, ctx, db, want=1)
    H2->>DB: GetRunningPrebuiltWorkspaces
    DB-->>H2: 1 row (succeeded), queued_for ~3ms
    H2->>DB: UPDATE agents SET lifecycle_state='ready'
    H2-->>T: rows, test captures prebuild.ID

    T->>H3: (client, userClient, user, version, presetID)
    H3->>DB: CreateUserWorkspace(presetID) -> claim
    DB-->>H3: same workspace, new owner
    H3-->>T: workspace
    Note over T: same assertions, ~55s of budget still unspent
```

`getRunningPrebuilds` still polls, still forces agents ready,
`claimPrebuild` still claims, and every assertion is unchanged. Its
floor is now one `testutil.IntervalSlow` tick, about a second, because
`testutil.Eventually` fires on a ticker rather than checking
immediately.

Note that `publishProvisionerJob` at `reconcile.go:940` is still
dropped. That call site is correct; it simply has no drain worker behind
it when `Run` is not started. The `PostJob` added here is a manual redo
of what it already intended.

Starting `reconciler.Run(ctx)` instead would be closer to production,
but `Run` also starts a reconciliation ticker on the **mock** clock, and
these tests jump that clock by hours. Each jump would fire an
unscheduled `ReconcileAll` that rebuilds a replacement prebuild
mid-assertion, which is the opposite of what a deflake should introduce.

## Measurements

Single subtest with `-race` against Postgres, the closest local
approximation of `test-go-race-pg`, three iterations:

| Run | before | after  |
|-----|--------|--------|
| 1   | 58.85s | 30.68s |
| 2   | 55.42s | 35.64s |
| 3   | 57.47s | 31.30s |

The baseline passed all three, at 1.1s to 4.6s of margin against the 60
second context. That is the flake caught in the act: locally green, one
scheduling hiccup from red. After the change the margin is 24s to 29s.

All five subtests against real Postgres go from roughly 35s each to
6.76s each, and `queued_for` on the prebuild job drops from 29.975s to
single-digit milliseconds.

## Also in this change

Two smaller items in the same helper, both aimed at the next person to
see this symptom.

Diagnostics while waiting for prebuilds: poll count, elapsed time, and
`queued_for` (`started_at - created_at` on the provisioner job), plus a
warning naming this defect if the wait exceeds 10 seconds. `queued_for`
is the field that discriminates: about 0 means the notification arrived
and any slowness is elsewhere, about 30 seconds means it was lost and
the backup poll took over.

There is deliberately no duration computed against `completed_at`. A
single `provisioner_jobs` row mixes time bases in these tests:
`created_at` and `started_at` come from the real clock, while
`completed_at` is stamped by `CompleteJob` from the injected mock clock.
My first version of the logging did subtract them and printed
`ran_for=-22543h3m21s`.

`getRunningPrebuilds` also now resets its accumulator each poll. It
appended rows on every iteration without clearing, so an iteration that
appended and then returned early on a transient error would double count
and leave the expected count permanently unreachable, producing this
same `found N running prebuilds` symptom for an unrelated reason.
2026-08-04 08:26:10 -07:00
Paweł Banaszewski 8f5f15a92f fix: remove unbound Client() method from aibridged.Server (#27845)
Adds client context to `Client()` method in `aibridged.Server`,
effectivly renaming `ClientContext()` method as `Client()`.
Similarly `aibridged.ClientFuncWithContext` became
`aibridged.ClientFunc`.

`aibridged.Server.Client()` acquired a DRPC client with
`context.Background()`, callers in theory could wait indefinitely for
the daemon to connect to coderd.

Every call site already had a context except the recorder callback.
`aibridge.NewRecorder` takes a `func(context.Context) (Recorder, error)`
and acquires against the record call's context.
2026-08-04 16:57:16 +02:00
Paweł BanaszewskiandCian Johnston 0b8b48913f fix: fix port assignment race from aigatewaystart_internal_test (#27801)
The AI Gateway tests reserved a port with `testutil.RandomPort`, which
binds and closes `127.0.0.1:0`, then bound it later in `serve`.
If something took the port in between, `serve` failed at `net.Listen`
and the error went unread, leaving only a 10s `Eventually` timeout.

The gateway now exposes its bound address via `httpAddr` and a
`listenerReady` latch, so tests listen on port 0 and read the address
afterwards. `requireListening` reports `serve`'s error where an address
must still be fixed upfront.

---
Investigated and authored with Coder Agents.

---------

Co-authored-by: Cian Johnston <cian@coder.com>
2026-08-04 09:51:14 +00:00
Michael Suchacz f0e6ac64b3 feat: remove native chat usage limits in favor of AI Gateway budgets (#27329)
## Stack Context

This stack makes AI Gateway data and budgets the source of truth for AI
spend controls.

1. Re-back the per-chat cost endpoint with AI Gateway data (#27328,
merged).
2. **This PR:** remove native chat usage limits.
3. Remove native chat cost tracking and its dedicated admin UI (#27330).

## Summary

Removes the native usage-limit API, SDK types, SQL, and chat enforcement
for deployment, user, and group chat limits. Compact AI Gateway budget
indicators remain in the Agents sidebar, user menu, and group settings.
Gateway budget rejections and provider quota failures continue to
classify as usage-limit errors, including a 409 response for synchronous
title generation.

Budget-period labels now use the API's UTC boundaries, so users see the
same dates in every browser timezone. The documentation explains the AI
Gateway replacement, its licensing requirements, and the differences
from native limits.

No schema is dropped in this release. The usage-limit table, index, user
and group columns, constraints, audit mappings, and generated scan
fields remain for mixed-version rolling upgrades. #27600 tracks their
removal after the compatibility window.

## Breaking change

Native day, week, and month chat spend limits are removed and are not
migrated. AI Gateway budgets are month-based, group-scoped with per-user
overrides, and require the AI Gateway entitlement. Deployments without
that entitlement no longer have chat spend enforcement.

> Mux prepared this PR on Mike's behalf.
2026-08-04 11:36:49 +02:00
Paweł Banaszewski a2287d6739 chore: fix port availability check flake from aigatewaystart_internal… (#27784)
Extends state tracking in `standaloneGateway` which is used in tests to
simplify checks.
Flaky `requireListenerAvailable` was removed.
2026-08-04 11:33:34 +02:00
Steven Masley 52423eb87b feat: promote MinimumImplicitMember experiment to GA (#27472)
Promotes the `minimum-implicit-member` experiment to GA and removes it.

## What changes

- The `minimum-implicit-member` experiment constant, its
`RoleOptions.MinimumImplicitMember` toggle, and the global
`rbac.MinimumImplicitMember()` accessor are deleted. The minimal-member
behavior is now the only behavior: `organization-member` and
`organization-service-account` carry only the floor (read-self records,
notifications, and similar) and grant **no workspace permissions**.
Workspace access lives exclusively on the
`organization-workspace-access` role.
- The experiment gate on customizing `default_org_member_roles` (`PATCH
/organizations/{org}`) is removed; the built-in-roles-only validation
remains.
- The dashboard's Default Roles section and the implied-roles display on
the members page are no longer experiment-gated.
- Admin docs: new "Default member roles" section in
`docs/admin/users/organizations.md`, cross-linked from
`groups-roles.md`.

## Why this is safe for existing deployments

Migration `000516` (shipped earlier) backfilled
`default_org_member_roles` with `['organization-workspace-access']` on
every organization. Members therefore keep exactly the effective
permissions they had with the experiment off; the workspace elevation
flows through the default role instead of being baked into
`organization-member`.

**Rollback caveat:** rolling back past this release restores the bundled
elevation, silently re-granting workspace access to members of
organizations that cleared their default roles.

## Review

Deep-review R1 findings are addressed in `chore: address deep-review
findings` (copy fixes, read-only Default Roles for viewers, removable
overlapping explicit grants, RBAC prose restoration, test
de-tautologizing, docs). Point-by-point disposition is in the PR
comments.

---

Generated by Coder Agents on behalf of @Emyrk.
2026-08-03 15:56:56 -05:00
Sas SwartandClaude Opus 4.8 8886a5749a feat: add network calls list to AI session threads API (#27425)
The AI session threads API returned only a network call *summary*
(total/blocked counts + top domains). This adds the per-call list so the
session detail can render individual Agent Firewall network calls.

`ListAIBridgeSessionNetworkCalls` reuses the same sequence-number
windowing as the existing summary and includes all protocols. The list
is exposed as `network_call_logs` on the threads response and is capped
server-side at 100 rows. The summary (`network_calls.total`/`blocked`)
remains authoritative for whole-session totals: the list length and its
blocked count equal the summary only when a session has at most 100
calls, and are truncated beyond that.

### PR map (merge strictly bottom-up)

This change is a 4-PR stack. Each PR depends on all the ones below it,
so merge in this exact order:

1. #27417 — backend network summary
2. #27418 — frontend summary rows
3. #27425 — backend per-call list `network_call_logs`
4. #27426 — frontend network-calls panel

Refs AIGOV-464

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 11:34:27 +02:00
841a1765f7 feat: add network calls summary to AI session threads API (#27417)
Backend for the AI session network summary. Exposes total/blocked
network calls and top destination domains on the session threads
endpoint (`GET /api/v2/ai-gateway/sessions/{id}`).

Total and blocked reuse the existing Agent Firewall aggregation from the
sessions list query, so the numbers match the sessions table. Top
domains are a new server-side aggregation
(`GetAIBridgeSessionTopDomains`) over boundary logs, using the same
interception-window correlation. There is no network-error state,
matching the current data model.

Frontend consuming these fields is in a separate stacked PR.

### PR map (merge strictly bottom-up)

This change is a 4-PR stack. Each PR depends on all the ones below it,
so merge in this exact order:

1. #27417 — backend network summary (base `main`)
2. #27418 — frontend summary rows (base #27417)
3. #27425 — backend per-call list `network_call_logs` (base #27418)
4. #27426 — frontend network-calls panel (base #27425)

Refs AIGOV-463

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cian Johnston <cian@coder.com>
2026-07-30 13:09:46 +02:00
Jaayden Halko 54d5eb7ec2 feat: add hourly hb_agent_runtime_v1 usage events for Coder Agent runtime (#27312)
closes CODAGT-839
closes CODAGT-843
closes CODAGT-773

## Summary

Adds a new heartbeat usage event type, `hb_agent_runtime_v1`, measuring
the total agent-loop runtime of Coder Agents (chats) per UTC hour, plus
a reconciler that generates one event per hour with self-healing
backfill over a trailing 7-day window. Events flow to Tallyman through
the existing publisher unchanged. This measures the new Coder Agents
(the `chats` tables), not the deprecated Tasks counted by
`dc_managed_agents_v1`.

Independent of #27508, which fixes the dead ai-seats cron registration.
Both PRs carry the identical `usage_event` create permission hunk for
the usage-publisher subject (this feature's generator and the ai-seats
cron each need it for heartbeat inserts), so they can land in either
order and the overlap merges cleanly.

> [!WARNING]
> **Do not include this in a release until Tallyman accepts
`hb_agent_runtime_v1`.** The publisher marks permanently rejected events
as done-forever, and the generator then sees those buckets as complete
locally, so their usage would be silently and permanently lost.

## Details

Each event's payload is `{"runtime_ms": N}`: the sum of
`chat_messages.runtime_ms` for messages created in the hour bucket `[H,
H+1)`, across all chats (sub-agents, API-created, archived, and
soft-deleted messages included). Events use deterministic IDs
(`hb_agent_runtime_v1:<bucket start>`) with `created_at` set to the
bucket start, so concurrent replicas race safely via `ON CONFLICT (id)
DO NOTHING` without locking, and daily rollups attribute backfilled
hours to the correct day. Idle hours produce zero-valued events. A
bucket becomes eligible 5 minutes after it closes; hours missing for
longer than the 7-day window are forfeited, which can only undercount.

Note that this makes `usage_events.created_at` explicitly the *event
occurrence time* rather than the row insertion time; the two only
diverge for backfilled events. It already behaved as the occurrence
timestamp (it drives the daily rollup day and is shipped to
Tallyman/Metronome as the event timestamp), and the migration now
documents this with a `COMMENT ON COLUMN`, which also surfaces as a Go
doc comment on `UsageEvent.CreatedAt`.

The new `usage.Generator` runs unconditionally in enterprise builds; the
`publish_usage_data` license flag continues to gate egress only, so
air-gapped deployments still fill their local ledger. The
`aggregate_usage_event()` trigger sums `runtime_ms` per day into
`usage_events_daily` (unlike `hb_ai_seats_v1`, which takes the daily
max).

`InsertHeartbeatUsageEvent` now takes an explicit `createdAt` so
generators can backfill historical buckets; the cron passes
`clock.Now()` to preserve its existing behavior.

## Tallyman follow-up

<details>
<summary>Prompt for the Tallyman-repo agent</summary>

> **Task**: Add support for the new Coder usage event type
`hb_agent_runtime_v1` so Tallyman accepts, validates, and forwards it to
Metronome.
>
> **Background**: coder/coder PR (this PR) adds hourly heartbeat events
measuring Coder Agent runtime. Events arrive via the existing
`/api/v1/events/ingest` endpoint with: `event_type:
"hb_agent_runtime_v1"`, `event_data: {"runtime_ms": <int64 >= 0>}`,
deterministic `id` of the form `hb_agent_runtime_v1:2026-07-15_14:00:00`
(UTC hour bucket start), and `created_at` set to the bucket start (may
be up to ~8 days in the past due to backfill; within Metronome's 34-day
dedup window). Zero-value events are normal (idle hours).
>
> **Work**:
> 1. Update Tallyman's vendored/imported `coderd/usage/usagetypes` (or
equivalent) to the coder/coder commit that adds
`UsageEventTypeHBAgentRuntimeV1` and `HBAgentRuntime`.
> 2. Ensure ingestion validation accepts the type (`Valid()` switches)
and rejects negative `runtime_ms`.
> 3. Ensure Metronome forwarding maps the event with transaction ID
derived from the event `id` as for existing types, passing `runtime_ms`
through as the property for a SUM-aggregated billable metric ("Coder
Agent Hours" = `SUM(runtime_ms) / 3,600,000`).
> 4. Do NOT permanently reject unknown-but-well-formed future `hb_*`
types if avoidable; at minimum confirm current behavior for unknown
types (temporary vs permanent rejection) and report it.
> 5. Tests: ingest accept/validate, dedup by ID, Metronome payload
mapping.
>
> **Constraint**: this must be deployed to tallyman-prod **before** any
coder/coder release containing the event generator; coderd treats
permanent rejections as terminal per event.

</details>
2026-07-30 08:37:45 +01:00
Susana Ferreira 3deecb481e chore: remove ai-gateway-cost-control experiment flag (#27579)
## Description

Closes
[AIGOV-443](https://linear.app/codercom/issue/AIGOV-443/remove-ai-gateway-cost-control-experiment-flag-once-feature-is-stable).

The AI Gateway cost control feature is planned for GA on the upcoming
release, so this removes the `ExperimentAIGatewayCostControl` experiment
and all of its gating. The cost control API endpoints remain gated by
the `FeatureAIBridge` license feature (the AI Governance add-on), so
this only drops the experiment layer.

## Changes

- **`codersdk/deployment.go`**: remove the
`ExperimentAIGatewayCostControl` const, its `DisplayName()` case, and
its `ExperimentsKnown` entry.
- **`enterprise/coderd/coderd.go`**: remove the
`httpmw.RequireExperiment(...)` gating from the AI cost control routes.
They keep `RequireFeatureMW(codersdk.FeatureAIBridge)`. Affected
endpoints:
  - `GET /organizations/{organization}/groups/ai/spend`
- `GET
/organizations/{organization}/groups/{groupName}/members/ai/spend`
  - `GET /organizations/{organization}/ai/spend/export`
  - `GET /groups/{group}/members/ai/spend`
  - `GET /groups/{group}/ai/spend`
- `GET/PUT/DELETE /users/{user}/ai/budget/override` and `GET
/users/{user}/ai/spend`
- **`enterprise/coderd/aibridge_test.go`**: drop the experiment from
test setup and remove the now-obsolete `RequiresExperiment`
negative-path tests.
- **Frontend (`site/src/...`)**: remove the `ai-gateway-cost-control`
experiment checks from the cost control UI (Groups pages, user dropdown)
and their stories/mocks. The feature is now driven solely by the
`aibridge` feature visibility.
- **Generated**: regenerated `coderd/apidoc/*`,
`docs/reference/api/schemas.md`, and `site/src/api/typesGenerated.ts`.

## Out of scope

The dogfood `CODER_EXPERIMENTS` config lives in a separate infra repo,
not `coder/coder`. Leaving `ai-gateway-cost-control` there is harmless:
unknown experiment values are logged as `"ignoring unknown experiment"`
at startup and otherwise ignored, so no ordering dependency or breakage.
That cleanup can be a follow-up.

<details>
<summary>Implementation notes</summary>

- Verified how unknown experiments are handled in `coderd/coderd.go`
`ReadExperiments`: unknown values produce a warning log and are inert,
so removing the definition before the dogfood config is updated is safe.
- Noticed the group `ai/budget` routes (`/groups/{group}/ai/budget`)
were already gated only by `FeatureAIBridge`, never by the experiment.
After this change all cost control routes are uniformly feature-gated,
resolving that inconsistency.
- Removed an obsolete `RequiresExperiment` subtest in
`TestUserAISpendStatus` that only asserted a 403 from the experiment
gate; with the gate gone it would no longer be blocked pre-RBAC.

</details>

---

_This PR was created by Coder Agents on behalf of @ssncferreira._
2026-07-29 14:59:58 +01:00
Susana Ferreira d6a5c8e9f8 refactor: make user AI budget and spend endpoints consistent (#27611)
## Description

Makes the user AI cost control endpoints consistent.

## Changes

- Replaces the flat `spend_limit_micros` and `limit_source` fields on
`GET /users/{user}/ai/spend` with a nested `effective_budget`, reusing
the type behind `group_budget`. The flat pair made it possible to encode
a limit without a source.
- Renames `AIGroupBudget` to `AIBudgetLimit`, since it also carries
`user_override` limits and is no longer group-specific. The type name is
not part of the wire format.
- Moves `/users/{user}/ai/budget` to `/users/{user}/ai/budget/override`.
The endpoint only ever managed the per-user override, which the type,
the handlers, and the operation IDs all already said; the path was the
only place that didn't.

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by
@ssncferreira
2026-07-29 14:01:32 +01:00
Susana Ferreira e71249a821 fix: ai cost control cap configurable AI spend limit (#27640)
## Problem

A configured AI spend limit was only validated as `gte=0`, with no upper
bound. The group spend query multiplies the per-member limit by the
number of attributed members, so a large enough limit overflows `bigint`
and fails the whole query, returning an error for every group in the
request rather than just the misconfigured one.

## Changes

- Add `MaxAISpendLimitMicros`, $1,000,000 per member per budget period.
- Reject group budgets and per-user overrides above the maximum with a
400 naming the limit.
- Bound both budget forms in the UI so they show the valid range before
submitting.

Follow-up
https://github.com/coder/coder/pull/27589#discussion_r3668956350
Depends on https://github.com/coder/coder/pull/27589

> [!NOTE]
> Initially generated by Claude Opus 5, modified and reviewed by
@ssncferreira
2026-07-29 14:00:13 +01:00
Susana Ferreira 0b4095085e fix: report combined member limit in group AI spend (#27589)
## Problem

The organization groups page showed each group's AI budget as the
group's per-member limit, so the total it displayed was effectively
group members × group budget. That ignores per-user budget overrides
charged to the group, so a group where one member has an override
reported a limit that doesn't match what its members can actually spend.

## Changes

- Add `total_spend_limit_micros` to the organization groups AI spend
payload, the combined budget of the members attributed to the group,
with each member's override replacing their share.
- Return `null` for the total when the group has no budget, since its
members spend without a cap.
- Both the organization groups and single group spend endpoints report
the new field, as they share the same query.
- Use the total as the denominator on the groups page AI budget column.

Depends on #27568
2026-07-29 09:16:37 +01:00
Jaayden Halko 06ceb4253d 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).
2026-07-29 07:58:30 +01:00