mirror of
https://github.com/coder/coder.git
synced 2026-09-23 05:43:53 +08:00
efd5ce366808643b49bf4a263f02be515e3491d4
1677
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6e07e2610f |
feat: add paginated API endpoint for groups (#27603)
backend-only changes from #27271; see that PR for summary of changes + implementation details |
||
|
|
053b38944d |
fix(coderd): render collected_at as UTC RFC3339 in the agent metadata aggregate (#27991)
Follow-up to #27934; this fix was pushed to the branch after the squash-merge and missed it. `jsonb_build_object` renders timestamptz in the session `TimeZone`, which Coder never pins, and `collected_at` defaults to year 1 until the agent's first report. On a non-UTC Postgres session a registered-but-never-collected item renders with an LMT second-offset (even `BC`, e.g. `0001-12-31T19:03:58-04:56:02 BC`), which Go's RFC3339 parsing rejects - a 500 for the entire list page whenever `include_agent_metadata` is used. - `to_char(... AT TIME ZONE 'UTC', ...)` pins the rendering; never-collected items round-trip as Go's zero time. - The test now runs against a named-zone database (`dbtestutil.WithTimezone("America/Caracas")`) and requests a registered but never-collected key; it reproduces the 500 without the fix. Also contains the failure mode Go-side: an unparsable aggregate now degrades to missing metadata for that workspace (with a warning log) instead of failing the entire page. The SQL fix prevents the known cause; the containment covers any future one. The test still catches regressions because it asserts the metadata values, not just a 200. --- Authored by Coder Agents on behalf of @Emyrk. |
||
|
|
16c58770f8 |
feat: constrain the OAuth2 client type column (#27931)
Extracted from #27873 so the schema change can be reviewed for migration safety on its own. #27873 will rebase onto this. `client_type` decides whether the token endpoint validates a client secret at all, and the column accepts any text: nullable, no `CHECK`, no enum. No Go path can write a bad value today, and `IsPublic` fails closed on anything unrecognized, so the read side is safe. What the schema still permits is the problem: a future migration writing `'public'` onto a row that holds a secret turns off client authentication for that app with nothing to catch it, no constraint, no log, no audit entry, no test. `000565` adds `CHECK (client_type IN ('confidential', 'public'))` and `NOT NULL`. The `UPDATE` ahead of it should touch zero rows, since migration `000344` added the column with a default of `'confidential'` and backfilled with `COALESCE`; it is there so `SET NOT NULL` cannot fail on an unexpected row. Both `ALTER`s take `ACCESS EXCLUSIVE` and scan a table holding one row per registered OAuth2 client, so the lock is brief. ## The second migration, and why it aligns the way it does Two columns describe the same fact and can currently contradict each other. `token_endpoint_auth_method` is the client's own declaration: registered client metadata under RFC 7591 §2, where `"none"` is defined to mean the client is public and has no secret. `client_type` is Coder's derived copy, and it is what the token endpoint enforces on. RFC 7591 defines no `client_type` metadata field; the column exists only as a denormalization. Registration used to persist the declaration verbatim while hardcoding `client_type` to `'confidential'`, so rows exist declaring `"none"` on a client stored confidential that was issued, and still requires, a real secret. A client that reads its own metadata and believes it is public will drop that secret and stop being able to exchange codes. `000566` aligns the declaration to what is enforced, not the reverse. Deriving enforcement from the declaration would reclassify every such client as public and stop requiring the secret it holds, which is a silent authentication downgrade. The down migration is deliberately empty: the previous values are not recorded, and restoring them would only reinstate metadata that tells a client to authenticate in a way the server rejects. ## Application changes `SET NOT NULL` changes the generated field from `sql.NullString` to `string`, so the three write sites are updated to match. That is the entire application diff and no behavior depends on it. Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5efa7abe7d |
fix: only write AI model prices that changed (#27923)
Previously, the AI Gateway price seeder rewrote every row of `ai_model_prices` on each server start, because `ON CONFLICT` fires on a key conflict rather than on a value difference. `updated_at` therefore recorded when the server last restarted rather than when a price last changed. Guard the `DO UPDATE` branch so a conflicting row is only rewritten when one of its four prices differs. The comparison uses `IS DISTINCT FROM` rather than `<>` because the price columns are nullable, and `<>` yields NULL when either side is NULL, which would skip the update and leave a stale price in place. Related to 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 |
||
|
|
9a57dfa642 |
feat: include agent metadata in workspace list responses (#27934)
Closes #27933. Related: #27897 (single-agent GET). Agent metadata is only readable via a per-agent watch stream, so reading it across N workspaces costs N+1 requests. This adds a batch read to the list endpoint: ```text GET /api/v2/workspaces?q=param:"pool=demo" include_agent_metadata:task_status ``` - New `include_agent_metadata` search key, repeatable and key-scoped. It expands the response, it does not filter workspaces. - `GetWorkspaces` aggregates the requested keys as JSON behind a `CASE`: without opt-in the response is unchanged and the subquery never runs. Runs only for the returned page, inside the same authorized query. - Agents in the response gain `metadata` (`[]codersdk.WorkspaceAgentMetadata`, `omitempty`), mapped by the `workspace_agent_id` each element carries. The collection script is omitted; it can be long. - `codersdk.WorkspaceFilter` gains `IncludeAgentMetadata []string`. - No wildcard, no schema change, no migration. --- Authored by Coder Agents on behalf of @Emyrk. |
||
|
|
51a9aa1bfc |
perf(coderd): batch authcheck permissions via rbac.Filter (#27309)
`POST /api/v2/authcheck` evaluated every check with a full policy evaluation in a serial loop. A subject in many organizations (100+) produced hundreds of full evaluations, taking seconds on a cold cache (DEVEX-608). Group the checks by `(action, resource type)` and authorize each group with the existing `rbac.Filter`, which amortizes a single partial evaluation across the group once it is large enough. Each check is wrapped in a small value struct that carries its response key, so `Filter`'s returned subset maps back to keys by reading a field rather than relying on element identity. `Filter` now takes an explicit `prepareThreshold`; existing callers pass the new `rbac.DefaultFilterThreshold` (10), and `checkAuthorization` passes 50, above the ~35-group crossover measured for this workload, so subjects with few objects of a given type keep the per-object path and cannot regress. ## Stacking This is stacked on top of #27244. `Filter` runs `Prepare` (partial evaluation), and those residuals are only compact once #27244's set-membership residuals land. On plain `main` the existing O(N) residual fanout means batching can regress at high org counts, so this change should land with or after #27244. <details> <summary>Decision log</summary> ### Bottleneck - `site/src/modules/permissions/organizations.ts` defines ~14 permission checks per org; `organizationsPermissions()` flattens them across all orgs into one `POST /api/v2/authcheck`. A 100-org request is ~1400 checks. - `checkAuthorization` looped serially, calling `Authorizer.Authorize` (full eval) once per check. - The endpoint's `maxFetch = 10` only caps checks that carry a `resource_id`, not total checks, so it does not bound this workload. ### Approach - Group checks by `(action, resource type)` and run each group through `rbac.Filter`, which does one partial evaluation (`Prepare`) and reuses it across the group. - Carry the response key as data in a small value struct implementing `RBACObject()`, so allowed results map back to keys without pointer identity: ```go type authorizeCheck struct { key string object rbac.Object } func (c authorizeCheck) RBACObject() rbac.Object { return c.object } ``` - `Filter` takes a required `prepareThreshold int` (no functional options). Generic callers pass `rbac.DefaultFilterThreshold = 10`; `/authcheck` passes 50 because the measured crossover for this workload is ~35 groups. ### Alternatives rejected - **Bounded `errgroup` parallelism**: reduced wall time at high org counts but not aggregate work (allocations flat). Discarded in favor of reducing work via partial evaluation. - **Symmetric-deny Rego simplification** (on the #27244 branch): replacing the known-org deny-fold with symmetric `org := -1` / `scope_org := -1` rules failed existing SQL-compile tests. A `-1` known-org vote gated by `not org = -1` produces a negated membership test over the unknown org id, which OPA emits as an unconvertible support rule. #27244's fold (`member_allow - org_deny`, a positive set-difference membership test) is therefore load-bearing, not incidental. </details> --- Authored with Coder Agents. --------- Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com> |
||
|
|
d2f9280138 |
chore: remove legacy chat template allowlist (#27515)
Relates to CODAGT-713 Depends on #27514 Removes the legacy deployment-wide allowlist now that the API and frontend use per-template `agents_allowed`: the experimental `/template-allowlist` routes, SDK methods and generated types, site config queries, frontend bindings, and the now-unused `xjson` utility. Migration `000563` deletes the obsolete `agents_template_allowlist` value. It's irreversible for deployments that configured an allowlist, which I think is fine, since `000562` already drops `agents_allowed` on the way down, and this release ships `000548` and `000555` with the same property. Two side effects of the model change worth writing down, both from #27514 rather than here. The value used to need `ActionRead` on `ResourceDeploymentConfig` to read and deployment config update to write. `AgentsAllowed` is now a plain field on the template response, readable by anyone who can read the template, and it's set with a template update, so org admins manage it themselves. That's the delegation we wanted, and it's tracked in the audit log. The rest of the stack adds `--agents-allowed` to the CLI and updates the platform controls docs. |
||
|
|
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. |
||
|
|
d458fe4941 | fix(coderd/database): match group name case-insensitively in search (#27894) | ||
|
|
d814dfad88 |
feat(coderd): support public OAuth2 client tokens at the schema layer (#27712)
Layer 1 of a multi-PR split of #27195 (public/secretless PKCE-only OAuth2 clients), broken up for easier review: **database schema (this PR)** → oauth2provider handler logic → API/e2e integration tests. ## Goal Coder's OAuth2 provider only works correctly for confidential clients today. Public clients — native apps that can't safely hold a shared secret, such as the CLI's browser-based login flow, IDE plugins (VS Code, JetBrains), desktop apps, and MCP clients — cannot complete a real OAuth2 flow against Coder, even though OAuth 2.1 §2.1 explicitly defines this client type and RFC 8252 §8.5 requires PKCE alone to be sufficient authentication for it. Every MCP client, CLI login flow, and IDE plugin is a public client by construction, and none of them can complete a secretless flow against Coder today: dynamic registration always classifies a client as confidential regardless of what it asks for, the token endpoint unconditionally requires a `client_secret`, and discovery metadata never advertises `"none"` as a supported auth method. Full write-up: [ENG-3029](https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client) ### Overall design (end state across the full PR stack) `[PR2]` marks handler-layer changes landing in the next PR in this stack. The green box is what this PR implements. ```mermaid sequenceDiagram autonumber participant C as Public Client (CLI/MCP/IDE plugin) participant S as coderd (chi router) participant H as oauth2provider handlers participant DB as PostgreSQL Note over C,S: Discovery C->>S: GET /.well-known/oauth-authorization-server S->>H: GetAuthorizationServerMetadata() Note over H: [PR2] add "none" to<br/>the returned auth methods list H-->>C: [PR2] 200 { token_endpoint_auth_methods_supported:<br/>[..., "none"] } Note over C,S: Dynamic Client Registration C->>S: POST /oauth2/register<br/>{redirect_uris, token_endpoint_auth_method: "none"} S->>H: CreateDynamicClientRegistration() Note over H: [PR2] client type now reads<br/>the request -> "public" Note over H: [PR2] skip secret generation<br/>for public clients H->>DB: [PR2] INSERT app row<br/>(client_type = 'public') DB-->>H: app row Note over H: [PR2] skip secret insert entirely H-->>C: [PR2] 201 { client_id }<br/>(no client_secret field) Note over C,S: Authorization Code + PKCE flow C->>S: GET /oauth2/authorize?client_id=...&code_challenge=... C->>S: POST /oauth2/tokens (grant_type=authorization_code)<br/>no client_secret S->>H: extractTokenRequest() Note over H: [PR2] client_secret no longer required<br/>for public clients H->>H: authorizationCodeGrant() Note over H: [PR2] skip secret lookup for public clients Note over H: PKCE verification — already mandatory, unchanged rect rgb(198, 239, 206) Note over H,DB: [THIS PR] oauth2_provider_app_tokens.app_id<br/>column added (NOT NULL, populated at insert<br/>time from app.ID) and app_secret_id loosened<br/>to nullable. Revocation now checks app_id<br/>directly. Confidential-client behavior is<br/>unchanged — no public client can be created yet. H->>DB: [PR2] INSERT refresh token row<br/>(no secret reference, for public clients) end DB-->>H: token row H-->>C: 200 { access_token, refresh_token } ``` ## This PR: database schema A public client has no `client_secret`, so it has nothing to put in `oauth2_provider_app_tokens.app_secret_id`, which was `NOT NULL`. This PR makes that column nullable and instead attributes a token to its owning app through a new, always-populated `app_id` column — so ownership checks (e.g. revocation) work identically for public and confidential clients without joining through a secret that may not exist. | Column | Before | After (this PR) | |---|---|---| | `app_secret_id` | `uuid NOT NULL` | **nullable** | | `app_id` | — | **new**: `uuid NOT NULL`, `FOREIGN KEY → oauth2_provider_apps(id) ON DELETE CASCADE`, backfilled for every existing row and populated on every new insert from that point on | This is a single, complete migration — not staged across multiple PRs. An earlier version of this branch deferred `app_secret_id`'s nullability and the insert-time population of `app_id` to a later PR, keeping this PR's diff limited to `coderd/database`. [Automated review](https://github.com/coder/coder/pull/27712#discussion_r3686851911) correctly flagged that as unsafe: the migration would backfill existing rows once, but nothing would populate `app_id` for rows written afterward, so the moment this PR merged, new tokens would start accumulating a permanently `NULL` app_id — and if a release happened to be cut before the follow-up PR landed, that gap could ship to customers and would need a second, later backfill to close. Doing the full migration now avoids that: `app_id` is correct from the first row written, and the promised `NOT NULL` constraint requires no data repair because it's already enforced. Closing that gap requires a few mechanical, non-branching touches outside `coderd/database`: - `revoke.go`'s two ownership checks now compare `dbToken.AppID` directly instead of looking up the app through `app_secret_id` — a genuine simplification (and slightly less code), not a temporary shim. - `tokens.go`'s two `InsertOAuth2ProviderAppToken` call sites supply the new `app_id` column and wrap `app_secret_id` as a `NullUUID`. - `oauth2_test.go`'s one direct-insert test fixture does the same. None of these introduce client-type branching or new capability — every client today is still confidential-only, still always presents a secret, and behavior is unchanged. The full repo builds, vets, and all existing tests pass unmodified in behavior. ## Coming next - **PR2 (handler layer)**: `codersdk`'s `DetermineClientType()` reading the requested `token_endpoint_auth_method`; `registration.go` skipping secret generation for public clients (and wrapping the app+secret insert in a single transaction, fixing a pre-existing orphan-row/visibility-race gap); `tokens.go` making the secret check conditional so PKCE alone authenticates a public client; `metadata.go` advertising `"none"` in discovery. No further migration is needed — the schema this PR ships is already final. - **PR3 (API/e2e layer)**: integration tests through the real HTTP API (`coderd/oauth2_test.go`), the MCP OAuth2 e2e flow (`coderd/mcp/mcp_e2e_test.go`), and the manual test script (`scripts/oauth2/test-mcp-oauth2.sh`). Depends on: #27195 (original combined PR, being superseded by this stack) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
6b8f820493 |
feat: remove native chat cost tracking in favor of AI Gateway cost data (#27330)
## 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. Remove native chat usage limits (#27329, merged). 3. **This PR, now based on `main`:** remove native chat cost tracking and its dedicated admin UI. ## Summary Removes native per-message price calculation, model pricing fields, cost persistence, aggregate cost queries, and admin cost API types. It also deletes the Analytics and Spend pages plus their legacy redirects. The AI Gateway-backed per-chat cost row and compact budget indicators remain. The spend documentation is renamed to `spend-management.md` and updated for the remaining surfaces, group budget APIs, CSV export, upgrade handling for native pricing and cost history, and the absence of a deployment-wide spend dashboard. The per-chat cost API documents that data follows AI Gateway retention and reports zero after all matching requests are purged. No schema is dropped in this release. `chat_messages.total_cost_micros` remains nullable and unwritten so replicas from the previous release can continue inserting messages during rolling upgrades. #27600 tracks removal after the compatibility window. > Mux prepared this PR on Mike's behalf. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
95a2c2ba02 |
feat: back the per-chat cost endpoint with AI Gateway data (#27328)
## Stack Context
This stack removes native chat cost tracking and native chat usage
limits, making the AI Gateway the single source of AI spend data and
budget enforcement.
1. **This PR:** re-back the per-chat cost endpoint with AI Gateway data.
2. Remove native chat usage limits end to end, rewiring the sidebar
indicator to gateway spend.
3. Remove native chat cost tracking end to end, deleting the
Analytics/Spend cost UI.
## What?
`GET /api/experimental/chats/{chat}/cost` summed
`chat_messages.total_cost_micros`, which native chat cost tracking
maintained. It now aggregates AI Gateway interception data instead, and
has no native fallback.
- New `GetAIBridgeChatCost` query, authorized through the root chat so
members can read their own chat's cost without gaining access to raw
interception rows.
- Response fields renamed: `priced_message_count` -> `request_count`,
`unpriced_messages_having_usage_count` -> `unpriced_request_count`.
- The chat summary sidebar keys its cost cache by root chat, and hides
the cost row where the AI Gateway is off or unlicensed. The root cost is
invalidated when a chat leaves an active status and when a generated
title lands, since title generation bills its own gateway request.
`GetChatModelUsageCostByChatID` and the rest of native cost tracking are
untouched here; PR 3 removes them.
## Why?
Native cost tracking duplicates what the AI Gateway already records, and
the two disagree. Repointing the endpoint first means the cost UI keeps
working while the native implementation is deleted later in the stack.
Two behaviour changes follow from gateway semantics and are intentional:
- **Requests, not messages.** The gateway records interceptions, so
counts are requests. Title-generation traffic now counts.
- **Whole-tree totals.** The gateway records the *spawning* chat's ID as
the interception session ID, so a subagent's requests are attributed to
its immediate parent, not always the root. Only a whole chat tree can be
summed, so the query resolves the root and aggregates the tree, and
every chat in a tree reports the same total. Native returned per-subtree
totals.
## Attribution and counting semantics
The aggregate groups token usage per interception before counting, so
the reported numbers are per request even though a request records one
usage row per provider response:
- `RequestCount` counts finished `Coder Agents` interceptions in the
tree, including unpriced ones.
- `UnpricedRequestCount` counts requests with at least one usage row the
gateway could not price. It is a subset of `RequestCount`.
- `TotalCostMicros` omits only unpriced usage, so a partially priced
request still contributes its priced portion. The sidebar therefore says
`Excludes unpriced usage from N request(s)` rather than claiming whole
requests were dropped.
A recorded cost of zero is a free request, not an unpriced one. Usage
without an effective group is excluded, matching what never reached
`ai_user_daily_spend`.
## Authorization
Reads go through `ExtractChatParam` plus `ResourceChat`, with no
cost-specific RBAC widening. `TestGetChatCost/MemberCanReadOwnChat`
covers a scoped `agents-access` member reading their own chat's cost,
and `MemberCannotReadOtherUsersChat` still asserts 404 for a non-owner.
Plain members without `agents-access` cannot create or read chats at
all, so they never reach this endpoint.
## Known limitation
AI Gateway data has its own retention period, 60 days by default and
configured independently of chat retention, so spend for requests older
than that is no longer reported. A chat whose gateway records have all
been purged reports zero cost, which is indistinguishable from genuinely
free usage under this contract. The endpoint documents the caveat;
#27330 documents it on the Spend Management page.
In-flight interceptions are excluded, since cost is only known once the
response is recorded. A chat's cost therefore lags the active turn by
one request.
## Rebase note
Rebased onto `main` after #27579 removed the `ai-gateway-cost-control`
experiment. The per-chat cost row is now gated on the `aibridge` feature
alone, matching how #27579 degated the other cost-control surfaces.
> Mux prepared this PR on Mike's behalf.
|
||
|
|
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> |
||
|
|
2b28515d9b | refactor: migrate story snapshot params to pixel (#26844) | ||
|
|
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
|
||
|
|
1c722ff969 |
fix(coderd/database): order the chat prompt query and its boundary by id (#27619)
## Stack context Follows #27495 (merged), which gives `chat_messages.id` an append-order guarantee and moves the history reads onto it. This PR applies the same fix to the query that builds the model prompt. ## Why? `GetChatMessagesForPromptByChatID` mixed two orderings. It selected the compaction boundary with `created_at DESC, id DESC`, then applied that boundary with an `id >` comparison, and returned rows with `created_at ASC, id ASC`. `created_at` is `now()`, so it is the transaction start time. Every row in one insert batch shares it, and concurrent transactions can commit in the opposite order to the one they started in. Two consequences, both reaching the provider: - **Malformed prompts.** A tool result could be ordered ahead of the assistant message that requested it. `chatprompt.injectMissingToolResults` does not repair this: it only handles tool rows already contiguous after an assistant row, and adds missing results. It never moves a tool row that precedes its assistant, and nothing re-sorts the rows in Go. - **Wrong compaction boundary.** The boundary is picked by timestamp but compared by id, so a stale compressed summary could be retained while the actual latest one was dropped. ## Changes Both the boundary CTE and the outer query order by `id`. The `id >` predicate is unchanged, which is the point: the ordering now matches the comparison that was always being made. **The boundary index was dead, so it is rebuilt to match.** `idx_chat_messages_compressed_summary_boundary` was created for exactly this lookup, but its predicate requires `role = 'system'` while compaction writes its summary with the user role (`message_conversion.go:334`, the only writer of `compressed = true`). It matched zero rows, and no other query can use it. Migration `000560` rebuilds it as `(chat_id, id DESC) WHERE compressed AND NOT deleted AND visibility = 'model'`, which also matches the new order key. Measured on PostgreSQL 13 with a 20k-message chat, 11 summaries, and 14 sibling chats so `chat_id` is selective: | boundary lookup | plan | buffers | |---|---|---| | old predicate | Index Scan `idx_chat_messages_chat`, 19,989 rows filtered | 267 | | rebuilt index | Index Only Scan | 2 | Not in scope: the outer `SELECT` still inspects every row of the chat, because its `role = 'system' AND compressed = FALSE` disjunct has no lower `id` bound. That predates this PR and needs a query rewrite rather than an index. ## Testing Two subtests, both verified red by reverting the `ORDER BY` and regenerating: - `OrdersByIDWhenTimestampsDisagree` returned `[4,3,2,1]` instead of `[1,2,3,4]`, placing the tool result before the assistant call. - `CompactionBoundaryUsesID` selected the stale summary and leaked the messages between the two summaries into the prompt. Existing subtests pass unchanged. Migration up/down tests pass, and the rebuilt index was verified red-green: restoring the old predicate returns the plan to a 267-buffer scan, and the old predicate matches 0 rows in the fixture. > Opened by Mux on behalf of Mike. |
||
|
|
c17bed25e0 |
feat: wire chat lifecycle hooks into chatd (#27429)
Wires chat lifecycle hooks into chatd, gated by the `agent-lifecycle-hooks` experiment. Part of the lifecycle hooks stack (#27401, #27428, #27430). See `docs/admin/setup/chat-lifecycle-hooks.md` for the consumer-facing contract. ## Summary When a hook URL is configured, chatd dispatches `session_start`, `user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `pre_compact`, `post_compact`, and `stop` events to the consumer and applies its responses. ## Design - **Stateless**: Coder stores no hook dispatch or decision state. Delivery is at least once; consumers deduplicate on stable payload identifiers (chat ID, event type, tool-use ID) and answer duplicates with the same decision. - **Admission-time prompt effects**: `user_prompt_submit` dispatches exactly once per submission (create, send, queue, edit, subagent spawn) and folds its effects into the stored prompt as typed message parts: original-or-overridden user parts, then model-only `hook-context`, then a user-visible `hook-notice`. Hook context is stripped from every client-facing conversion; hook notices are excluded from model prompts. The server rejects hook parts in client-submitted content. - **Tool gating**: `pre_tool_use` allow can override tool input; deny becomes a synthetic denied tool result, with any returned model context persisted as a model-only transcript row so it never reaches clients. The denial text identifies an external policy (the deployment's lifecycle hook) as the source and marks the decision as persistent, so the model explains the denial instead of retrying it or misreporting it as an infrastructure failure. - **Fail closed**: a dispatch failure rejects the triggering request or moves the chat to the error state in the same transaction as the affected step, so a runnable state is never published with unapproved content. - **Admission before persistence**: `pre_tool_use` is dispatched for the calls the model produced, before the assistant message is stored. See "Staged tool admission" below. - **Fresh dispatch per tool call**: every non-provider-executed tool call is decided by its own `pre_tool_use` dispatch; Coder never reuses an earlier decision on the consumer's behalf. Retries re-dispatch the same logical event. ## Structure All hook dispatch flows through one seam: entry points build a `chathooks.Chat` (chat identity) and a `chathooks.Message` (event details) and call `Trigger.Trigger`, the only component that talks to the dispatcher. The integration lives in the `coderd/x/chatd/chathooks` subpackage, split by responsibility: - `trigger.go`: the trigger seam; builds the wire envelope per event, normalizes deny into a typed error, and holds the package's single enabled-check. - `effects.go`: pure conversion of hook results into transcript rows and prompt parts. - `errors.go`: failure classification (dispatch error messages, denial mapping, tool-result dispatch-failure scanning). - `tooluse.go`: the tool-call gate (`pre_tool_use` preflight, `post_tool_use` payloads, applying admitted input to the step). Server-bound glue stays in `coderd/x/chatd/hook_server.go`: the chat-parking dispatch error handlers, the step-commit row insertion wrappers, and the dynamic post-tool-use state loader, which depends on chatd validation types. This PR adopts the `codersdk/x/agenthooks` and `coderd/x/agenthooks/dispatch` import paths introduced at the tip of #27401; intermediate commits still reference the pre-move paths and are not individually buildable. ## Staged tool admission `pre_tool_use` originally ran at tool execution time, which is after the assistant message carrying the tool call was already committed. An `input_override` therefore had to rewrite stored message content in place. @hugodutka pointed out that chatd treats message content as immutable, and that the rewrite was a shortcut rather than a requirement. It was also a correctness problem in its own right: the rewrite only updated the database, so the transcript could show one input while a different one had executed. The hook now runs before the step is persisted: ```text provider stream ends (tool calls complete, in memory) -> pre_tool_use dispatch per call -> ONE transaction: assistant row with admitted inputs, synthetic denials, hook rows -> execute ``` The step is inserted once, carrying the input the tool runs with. `UpdateChatMessageContentByID` and `Tx.UpdateMessageContent` are deleted from #27428, so message content stays immutable. Two consequences, both intentional: - **Clients converge rather than wait.** Tool-call parts still stream live, so a rewritten call briefly shows the model's proposed input before the committed message replaces it. The chat store already clears stream state when an assistant message arrives, so the stored input wins with no frontend change and no added latency before tool cards appear. - **A call already in history was already admitted.** Execution consumes the stored input instead of dispatching a second decision, which keeps one dispatch and one set of hook effects per call. A consumer policy change between admission and execution applies to later calls, not to calls already admitted. The per-chat debug endpoint still records the provider's original tool input. Its purpose is to report provider behavior, and it requires an explicit per-chat debug flag; the invariant here covers the transcript. ## Configuration Adds `chat-hook-url`, `chat-hook-secret`, `chat-hook-timeout`, and `chat-hook-enabled` deployment options with startup validation. The flags are hidden from `coder server --help` while the feature is experimental; the setup guide documents them. ## Tool input validation Built-in tool arguments reach a consumer as raw JSON with key spelling preserved, but the tools decode those bytes with Go, which matches struct fields case-insensitively and keeps the last match. A policy reading `path` could therefore authorize one value while the tool executed another, and a lone case variant such as `{"PATH":"/secret"}` was invisible to a policy checking for `path`. Coder now rejects a built-in tool call whose input repeats a key or spells a schema property with different capitalization, before the `pre_tool_use` dispatch, so a consumer is never asked to authorize bytes whose meaning depends on the reader. Rejected calls produce an error result the model can retry; unambiguous calls in the same batch still run. A consumer-authored `input_override` is rechecked after the dispatch and fails the turn closed, because the model cannot correct it. Dynamic and MCP inputs are excluded because the client and the workspace agent execute those calls rather than coderd. Two paths needed more than a schema check. Execution resolves a deprecated tool name to its canonical tool, so validation resolves aliases first. The `edit_files` decoder also reads `search` and `replace`, which its schema does not advertise, so those aliases are now matched exactly and their case variants ignored. A hook denial now returns a structured 403 carrying `kind: "hook_denied"`, mirroring the dispatch-failure response that already carries its own kind. Without it a client cannot tell a policy decision apart from a generic failure, and the chat UI titled a denial "Request failed". Adding a kind needs no migration: `ChatErrorKind` is persisted only inside the JSONB `chats.last_error` column, whose decoder accepts unknown kinds. The hook docs also correct the tool-input convergence window. A batch dispatches sequentially before the assistant row commits, so the original input stays visible for a span that scales with the number of tool calls in the step rather than a single hook timeout. > This PR was written by Mux, an AI coding agent, on Mike's behalf. |
||
|
|
91c7232d97 |
feat: add chat suffix messages, idle failure, and content update support (#27428)
Adds generic chat state and query capabilities that the lifecycle hooks integration (#27429) builds on. Part of the lifecycle hooks stack (#27401, #27429, #27430). - `chatstate`: `EditMessage` accepts caller-provided suffix messages inserted after the replacement in the same transaction, transitions can carry a typed error kind, and `FinishError` is also allowed from waiting chats so admission-time failures can park an idle chat in error. - `chatstate`: `ValidateToolResults` holds the submitted-tool-result rules (duplicate, invalid JSON, missing, unexpected) in one place, so `CompleteRequiresAction` and API-level prechecks reject the same payloads with the same typed causes. - `database`: `InsertChat` accepts an optional caller-provided ID. No hook-specific state or behavior is introduced here; these primitives are usable by any caller. An earlier revision added a message-content rewrite primitive so a `pre_tool_use` override could update an already-committed tool call. Message content is immutable by design, and @hugodutka pushed back on changing that. The rewrite is gone: #27429 now dispatches the hook before the assistant message is stored, so the stored input is the one that runs and nothing needs updating. > This PR was written by Mux, an AI coding agent, on Mike's behalf. |
||
|
|
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 |
||
|
|
e96d8646e2 |
fix(coderd): give chat message ids an append-order guarantee (#27495)
Chat message ordering was derived from `created_at`, which is `now()` and therefore the transaction start time. That makes it unusable as an append-order column for two independent reasons: every row in one `InsertChatMessages` batch shares a single timestamp, and two concurrent transactions can commit in the opposite order to the one they started in. This PR gives `chat_messages.id` a real append-order guarantee and moves the history reads onto it. ## Changes **`InsertChatMessages` had no input-order guarantee.** Callers index the returned slice by input position. That only worked because PostgreSQL happens to evaluate the `BIGSERIAL` default in row order. Ids are now allocated up front and the k-th smallest is assigned to input index k, so the pairing does not depend on where the column default is evaluated. Returned rows are explicitly `ORDER BY id`. **Three history reads now order by `id`.** | Query | Was | Now | |---|---|---| | `GetChatMessagesByChatID` | `created_at ASC` | `id ASC` | | `GetChatMessagesByRevisionForStream` | `created_at ASC, id ASC` | `id ASC` | | `GetLastChatMessageByRole` | `created_at DESC, id DESC` | `id DESC` | `GetChatMessagesByChatID` paginated by `id` while ordering by `created_at`, which is incoherent on its own terms. The other two matter because of who consumes them. The stream query supplies incremental updates on the same socket that emits a full `GetChatMessagesByChatID` snapshot on history reset, so once that snapshot moved to `id` the two disagreed under timestamp skew. `GetLastChatMessageByRole` returns an id that is then used as an id cursor, both as `AfterID` when synthesizing tool cancellations and as `chats.last_read_message_id`, where a stale anchor leaves later assistant messages permanently unread. A tie-breaker would not have fixed either one. It only resolves equal timestamps; leading with `created_at` is the actual defect. **`GetLastChatMessageByRole` loses its index, so this adds one.** `ORDER BY created_at DESC, id DESC` could take an ordered scan of `idx_chat_messages_chat_created`. Nothing in the schema can supply `ORDER BY id DESC LIMIT 1` for a given `chat_id` and `role`, so the planner switches to a backward scan of the primary key and filters every newer row in the table, scanning all of it when the chat has no message in that role, which is the routine case for a fresh chat. Migration `000559` adds `(chat_id, role, id DESC) WHERE deleted = false`, the same shape as the existing `idx_chat_messages_user_prompts`. This matters because the query is hot: it runs on every stream connect and disconnect, and once per turn when synthesizing tool cancellations. `GetChatMessagesForPromptByChatID` has the same defect and is fixed in the stacked PR, because its compaction boundary change is semantic and deserves a separate review. Auto-archive stays timestamp-based deliberately: it measures activity, not order. Wrapping the insert in a CTE (needed because `INSERT` cannot take `ORDER BY`) makes sqlc synthesize `InsertChatMessagesRow`. It is structurally identical to `ChatMessage`, so the call sites use a direct struct conversion that stops compiling if the two ever diverge. ## Testing Behavior tests write `created_at` values inverted against id order, so a reader that leads with `created_at` returns the batch backwards. All three queries were verified red by reverting the `ORDER BY` and regenerating: the stream query returned `[3,2,1]` for `[1,2,3]`, and `GetLastChatMessageByRole` picked id 1 instead of id 3. `TestInsertChatMessagesOrderContract` asserts against the generated SQL, covering what a behavior test cannot: PostgreSQL evaluates the id default in row order anyway, so a batch still looks ordered once the guarantee is removed. `TestChatMessagesSequenceCacheIsOne` guards the cross-batch half of the invariant. Ids follow chat row lock order only while the sequence hands out one value at a time; sequence cache blocks are per session, so with a cache above one a backend holding stale cached values can lock second and still commit lower ids. Bumping a sequence cache is an ordinary throughput tweak, and it would silently corrupt history order. The index was checked on a 200k row fixture. Without it, the zero-match lookup filters all 200,000 rows over 2763 buffers; with it, the plan is an index scan with both `chat_id` and `role` in the index condition, no sort node, and 3 buffers. Note that the within-batch mapping does not depend on the cache size. It is established by `ROW_NUMBER() OVER (ORDER BY id)` over the allocated ids, so it holds regardless of `nextval` evaluation order. ## Note on the deleted subagent hand-sort The subagent history reader's hand-sort stays deleted, but calling it redundant was imprecise. It sorted by `created_at` then `id`, so it is only equivalent to `id` ordering when the two agree. When they disagree the old code selected a different "latest assistant". This is a deliberate behavior change to match the new invariant, not dead-code removal. > Opened by Mux on behalf of Mike. |
||
|
|
fbac602456 |
feat!: add admin-controlled dynamic client registration toggle (#27316)
`POST /oauth2/register` (RFC 7591 Dynamic Client Registration) has exactly one gate today: `ExperimentOAuth2`, a static, process-lifetime flag that wraps the entire `/oauth2/*` route tree as an all-or-nothing switch. That flag is scheduled for removal at GA, which would leave DCR with zero admin control at all once it is gone. Add a persistent, DCR-specific `oauth2_dcr_enabled` deployment setting, independent of the experiment system, so admin control over DCR survives GA. `POST /oauth2/register` checks the flag and rejects new registrations with an RFC 7591-shaped `403` when disabled; discovery metadata (`GET /.well-known/oauth-authorization-server`) conditionally omits `registration_endpoint`. A new audited `GET`/`PUT /api/v2/oauth2-provider/settings` endpoint lets an owner toggle it live, no restart required. The setting defaults to disabled, matching the canonical design proposal; disabling only stops new self-registrations, clients that already registered continue to authorize and exchange tokens normally. Address issue described in [ENG-3056](https://linear.app/codercom/issue/ENG-3056/oauth2-dcr-admin-configurable-enabledisable). ## Where this sits in the request path ```mermaid sequenceDiagram autonumber participant A as Admin participant S as coderd participant DB as site_configs<br/>(oauth2_dcr_enabled) participant C as OAuth2/MCP Client Note over A,S: Admin toggles DCR (new) A->>S: PUT /api/v2/oauth2-provider/settings<br/>{dynamic_client_registration_enabled: false} S->>S: authorizeContext(ActionUpdate, ResourceDeploymentConfig) S->>DB: UPSERT oauth2_dcr_enabled = false S-->>A: 200 OK (audited) Note over C,S: Client discovery + registration afterward C->>S: GET /.well-known/oauth-authorization-server S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache) DB-->>S: false S-->>C: 200 metadata, registration_endpoint omitted C->>S: POST /oauth2/register S->>DB: GetOAuth2DCREnabled (system ctx, every request, no cache) DB-->>S: false S-->>C: 403 invalid_request,<br/>"Dynamic client registration is disabled" Note over C,S: A client that registered before the change is unaffected C->>S: GET /oauth2/authorize?client_id=... Note over S: no DCR-enabled check on this path S-->>C: 200 (proceeds normally) C->>S: PUT/DELETE /oauth2/clients/{client_id} (RFC 7592 self-management) Note over S: no DCR-enabled check on this path either S-->>C: 200 (proceeds normally) ``` ## Files changed: manual vs. generated Reviewers should focus on the **manual** files. The **generated** ones are `make gen` output that follows mechanically from the manual changes and don't need direct review. <details> <summary><b>Manual files (26)</b> — click to expand, grouped the same way as "Suggested review order" below</summary> **1. Database** | File | What changed | |---|---| | `coderd/database/queries/siteconfig.sql` | New `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled` query pair on the existing generic `site_configs` table. No schema change. | | `coderd/database/dbauthz/dbauthz.go` | RBAC check (`rbac.ResourceDeploymentConfig`) on the two new query methods; extends the `subjectSystemOAuth2` system-actor role with read-only `ResourceDeploymentConfig` access, needed so the public discovery/registration endpoints can read the flag via `dbauthz.AsSystemOAuth2`. | | `coderd/database/dbauthz/dbauthz_test.go` | RBAC assertion coverage for `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled` in the method-coverage test suite. | **2. Request gating (the actual feature)** | File | What changed | |---|---| | `coderd/oauth2provider/registration.go` | The actual gate: `CreateDynamicClientRegistration` reads the flag first and returns an RFC 7591-shaped `403` when disabled (defaults disabled if never configured). | | `coderd/oauth2provider/registration_test.go` | New unit test, `TestCreateDynamicClientRegistration_DCREnabled`: calls the handler directly (no HTTP server), covering enabled / explicitly disabled / never-configured. | | `coderd/oauth2provider/metadata.go` | `GetAuthorizationServerMetadata` conditionally omits `registration_endpoint` from discovery metadata when DCR is disabled. | | `coderd/oauth2provider/metadata_test.go` | New unit test, `TestGetAuthorizationServerMetadata_DCREnabled`: same three states, for the discovery handler. | **3. Admin settings endpoint** | File | What changed | |---|---| | `codersdk/oauth2.go` | New `OAuth2ProviderSettings` SDK type plus `Client.OAuth2ProviderSettings`/`PutOAuth2ProviderSettings` methods. | | `coderd/oauth2.go` | New `oauth2ProviderSettings`/`putOAuth2ProviderSettings` admin handlers (audited via `audit.InitRequest`); updates the `GetAuthorizationServerMetadata` call site to pass `api.Database`. | | `coderd/coderd.go` | Registers `GET`/`PUT /api/v2/oauth2-provider/settings`. | | `coderd/oauth2_provider_settings_test.go` | New test file: admin `GET`/`PUT` round-trip, default-disabled-before-any-`PUT`, and `403` for a non-owner on both `GET` and `PUT`. | **4. Audit wiring** | File | What changed | |---|---| | `coderd/database/types.go` | New `database.OAuth2ProviderSettings` audit-only struct (mirrors `NotificationsSettings`). | | `coderd/audit/diff.go` | Adds the new struct to the `Auditable` type union. | | `coderd/audit/request.go` | Adds the new struct to all four dispatch switches (`ResourceTarget`, `ResourceID`, `ResourceType`, `ResourceRequiresOrgID`). | | `codersdk/audit.go` | New API-facing `ResourceTypeOAuth2ProviderSettings` constant and its `FriendlyString` case. | | `enterprise/audit/table.go` | Field-level audit action map (`ActionTrack`/`ActionIgnore`) for the new struct. | | `coderd/database/migrations/000546_audit_oauth2_provider_settings.up.sql` | Adds `oauth2_provider_settings` to the `resource_type` Postgres enum, required for the audit wiring above (`resource_type` is a real enum, not a Go-only value). | | `coderd/database/migrations/000546_audit_oauth2_provider_settings.down.sql` | No-op (`ALTER TYPE ... ADD VALUE` can't be reverted). | **5. Test-suite ripple from the disabled-by-default flip** | File | What changed | |---|---| | `coderd/oauth2provider/oauth2providertest/helpers.go` | New shared test helper, `EnableDCR`, since DCR now defaults to disabled and many pre-existing tests need it turned on to register a client. | | `coderd/oauth2_test.go` | Adds `TestOAuth2DynamicClientRegistrationDisabled` (registers a client, disables DCR, verifies new registration is rejected while the existing client's self-management, authorize, and token exchange all keep working); calls `EnableDCR` in every pre-existing test that registers a client. | | `coderd/oauth2_error_compliance_test.go` | Calls `EnableDCR` in every test that registers a client, so RFC-error-format assertions aren't masked by the new disabled-by-default gate. | | `coderd/oauth2_metadata_validation_test.go` | Same: `EnableDCR` added to every registration-dependent test. | | `coderd/oauth2_security_test.go` | Same. | | `coderd/oauth2provider/validation_test.go` | Same (near-duplicate of `oauth2_metadata_validation_test.go` in a different package). | | `coderd/oauth2provider/provider_test.go` | Same. | | `coderd/mcp/mcp_e2e_test.go` | Same, for the MCP end-to-end dynamic-registration flow test. | </details> <details> <summary><b>Generated files (12)</b> — from <code>make gen</code>, no need to review directly</summary> `coderd/apidoc/docs.go`, `coderd/apidoc/swagger.json`, `coderd/database/dbmetrics/querymetrics.go`, `coderd/database/dbmock/dbmock.go`, `coderd/database/dump.sql`, `coderd/database/models.go`, `coderd/database/querier.go`, `coderd/database/queries.sql.go`, `docs/admin/security/audit-logs.md`, `docs/reference/api/enterprise.md`, `docs/reference/api/schemas.md`, `site/src/api/typesGenerated.ts`. </details> ## Suggested review order ### 1. Database Establishes the persisted setting and its RBAC rule; everything else builds on `GetOAuth2DCREnabled`/`UpsertOAuth2DCREnabled`. 1. `coderd/database/queries/siteconfig.sql` — the two new queries. Same boolean-encoding pattern as the existing `oauth2_github_default_eligible` key right above them in the same file. 2. `coderd/database/dbauthz/dbauthz.go` — the RBAC wrapper for those two queries, plus the `subjectSystemOAuth2` role extension (search this file for `ResourceDeploymentConfig`, it appears in both spots). 3. `coderd/database/dbauthz/dbauthz_test.go` — asserts the RBAC checks from (2) actually fire. ### 2. Request gating (the actual feature) Where `POST /oauth2/register` and discovery metadata change behavior. 1. `coderd/oauth2provider/registration.go` — the primary gate. Read this first; it's the feature. 2. `coderd/oauth2provider/registration_test.go` — its new unit test, exercising the gate's three states directly against the handler. 3. `coderd/oauth2provider/metadata.go` — the same gating pattern applied to the discovery `GET` endpoint. 4. `coderd/oauth2provider/metadata_test.go` — its new unit test. ### 3. Admin settings endpoint How an owner flips the setting live. 1. `codersdk/oauth2.go` — the `OAuth2ProviderSettings` SDK type and `Client` methods first; this is the public contract everything below implements against. 2. `coderd/oauth2.go` — the `GET`/`PUT` handlers themselves. 3. `coderd/coderd.go` — route registration, to see where those handlers get wired in. 4. `coderd/oauth2_provider_settings_test.go` — round-trip and permission tests. ### 4. Audit wiring Plumbing required so step 3's `PUT` is auditable; mechanical except for (3). 1. `coderd/database/types.go` — the audit-only struct; everything else in this layer exists to plumb it through. 2. `coderd/audit/diff.go` — adds it to the `Auditable` type union (the compiler enforces this one). 3. `coderd/audit/request.go` — the four dispatch switches; the one part of this layer worth reading closely. 4. `codersdk/audit.go` — the API-facing resource type constant. 5. `enterprise/audit/table.go` — the field-action map. 6. `coderd/database/migrations/000546_audit_oauth2_provider_settings.{up,down}.sql` — read last; a consequence of needing a new `resource_type` enum value for (1)-(5), not a design decision of its own. ### 5. Test-suite ripple from the disabled-by-default flip 1. `coderd/oauth2provider/oauth2providertest/helpers.go` — the new `EnableDCR` helper. Read first to understand the fix pattern before seeing it applied repeatedly. 2. `coderd/oauth2_test.go` — next, since it also contains the new `TestOAuth2DynamicClientRegistrationDisabled`, not just `EnableDCR` call sites. 3. The rest, in any order, they're mechanical repeats of the same one-line addition: `coderd/oauth2_error_compliance_test.go`, `coderd/oauth2_metadata_validation_test.go`, `coderd/oauth2_security_test.go`, `coderd/oauth2provider/validation_test.go`, `coderd/oauth2provider/provider_test.go`, `coderd/mcp/mcp_e2e_test.go`. ## Explicitly out of scope Per the design proposal: rate limiting on `POST /oauth2/register` (tracked separately), retroactively affecting already-registered clients when DCR is disabled (this only gates new self-registration), and an Initial Access Token requirement (a separate, follow-up ticket). |
||
|
|
1a6a8be96c |
feat: log tailnet tunnels to the connection log (#27423)
Co-authored-by: Chris DiGiamo <cd@anthropic.com> Co-authored-by: Chris DiGiamo <cdigiamo@anthropic.com> |
||
|
|
8cc7f2bb0e |
fix(coderd): reject workspace proxy hostname prefixes (#27544)
A workspace proxy hostname prefix could be accepted as a valid proxy access URL. An authenticated user could then be redirected to an attacker-controlled domain with an application-connect API key in the URL. Require proxy access URL matches to have a hostname boundary after the candidate hostname, allowing only the end of the URL, a port, or a path. Add regression coverage for proxy access URL and wildcard hostname prefixes. Refs: https://linear.app/codercom/issue/PLAT-384 --------- Co-authored-by: Bobby Ho <bobbidinho@gmail.com> |
||
|
|
09a69e624a |
feat: search users by display name (#27398)
Free-text member search previously matched only username and email, so typing a person's display name returned no results even though the UI shows the display name as the primary label. This broadens the free-text `@search` filter to also match `users.name`. The change is in three queries: `GetUsers`, `PaginatedOrganizationMembers`, and `GetGroupMembersByGroupIDPaginated`. This covers every server-filtered surface: the Users page, the Organization Members page, the Group Members page, and the `UserAutocomplete` / `WorkspaceUserAutocomplete` pickers (which query `GetUsers` with `q`). The org member picker (`MemberAutocomplete`) filters client-side via cmdk, so display name is added to its `keywords`. Explicit filters (`name:`, `username`/`email`) and pagination counts are unchanged; the group members count still comes from the filtered `COUNT(*) OVER()` in the same query. Refs DEVEX-484 Refs DEVEX-565 <details> <summary>Implementation plan</summary> ## Problem Member search (both the global Users page and the Organization Members page) matches only on `username` and `email`. It does not match on the user's display name (`users.name`), even though the Organization Members table shows `name` as the primary title. So typing a person's full name in the search box returns nothing. Today a bare search term (`alice`) is routed to the SQL `@search` filter, which only checks `email`/`username`. Display name is only matched if the user explicitly types `name:alice`, which is undiscoverable. ## Design decision Include `name` in the free-text `@search` condition in the affected SQL queries. A bare term then matches `email OR username OR name`, using the same case-insensitive substring `ILIKE` already in place. This keeps the existing explicit `name:` filter working. Tradeoff: this broadens the meaning of free-text `search` globally (anything using these queries now also matches display name). This is the intended behavior, confirmed against DEVEX-565 (display name search in the user picker). ## Affected files Backend: - `coderd/database/queries/users.sql` (`GetUsers`) - `coderd/database/queries/organizationmembers.sql` (`PaginatedOrganizationMembers`) - `coderd/database/queries/groupmembers.sql` (`GetGroupMembersByGroupIDPaginated`) - `coderd/database/queries.sql.go` regenerated via `make gen` Frontend: - `site/src/components/UserAutocomplete/UserAutocomplete.tsx` (add `name` to client-side cmdk keywords) Tests: - `coderd/coderdtest/users.go` (shared `UsersFilter` helper): added a `DisplayNameSearch` case and extended search-based expectations to include `name`. Exercised by `TestGetUsersFilter`, `TestGetOrgMembersFilter`, and `TestGetGroupMembersFilter`. Docs: - `docs/admin/users/index.md`: documented that free-text search matches username, email, and display name. ## Frontend surface coverage | Surface | Sends | Backend | Query | |---|---|---|---| | Users page | `q` | `GET /users` | `GetUsers` | | Organization Members page | `q` | paginated members | `PaginatedOrganizationMembers` | | Group Members page | `q` | `groupMembers` | `GetGroupMembersByGroupIDPaginated` | | User pickers (server-filtered) | `q` | `GET /users` | `GetUsers` | | Org member picker (client-filtered) | local cmdk | n/a | keyword change | ## Out of scope - Trigram/similarity (fuzzy) matching; keeps `ILIKE` substring semantics. - Sort/pagination ordering (still `LOWER(username)`). </details> --- _Created by Coder Agents on behalf of @aqandrew._ |
||
|
|
85984ff142 |
feat: add enable/disable support for user secrets (#27537)
Users can now disable a secret to stop it from being injected into workspaces without deleting it, and re-enable it later. Disabled secrets stay visible and editable everywhere they already appear. An enabled secret must have at least one injection target; a secret with no target can be stored only while disabled. Existing target-less secrets are migrated to disabled to preserve current behavior. Support spans the REST API, SDK, CLI, dashboard, and audit log. |
||
|
|
bd5d640f1e |
fix(coderd/database/migrations): resolve duplicate 000554 migration collision (#27581)
> 🤖 This PR was written by Coder Agents on behalf of Jake Howell. ## Problem `main` currently has **two migrations sharing version `000554`**: - `000554_aibridge_token_usage_spend_export_index.{up,down}.sql` - `000554_legacy_none_login_to_password.{up,down}.sql` (from #26851) Both merged around the same time. #26851 was renumbered to `000554` when `000553` was the latest, but `aibridge_token_usage_spend_export_index` claimed `000554` and merged too, leaving a duplicate version number on `main`. Duplicate migration versions break the migration sequence. ## Fix Renumber the legacy none login migration to the next free slot, `000555`, leaving the aibridge migration at `000554`: - `000554_legacy_none_login_to_password.{up,down}.sql` -> `000555_legacy_none_login_to_password.{up,down}.sql` - `migrate_test.go`: `TestMigration000554...` -> `TestMigration000555...`, `priorMigrationVersion` `553` -> `554`, and the `os.ReadFile` filename. The migration is data-only and unchanged; only its version number moves. `TestMigration000555LegacyNoneLoginToPassword` passes locally. The same collision exists on `release/2.36` via the backport (#27578), which has been renumbered to `000555` to match. Resolves [DEVEX-226] follow-up. [DEVEX-226]: https://linear.app/issue/DEVEX-226 |
||
|
|
0e104f38e0 |
fix!: deprecate login_type=none, convert existing users to password login (#26851)
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. Deprecates `login_type=none` (legacy passwordless machine users) in favour of premium **service accounts**, and migrates existing accounts off the deprecated path while preserving their identity. Resolves [DEVEX-226]. ## What this does - **Creation is gated** — `POST /users` and `coder users create` reject `login_type=none` (and the deprecated `--disable-login`) unless a service account is requested. - **Existing users are converted** — migration `000554_legacy_none_login_to_password` rewrites legacy non-system, non–service-account `login_type='none'` accounts to `login_type='password'`. Email addresses are **preserved** and existing API tokens remain valid. Admins can set a password if interactive login is desired. ## Why convert to `password` and not `is_service_account`? Migration `000433_add_is_service_account_to_users` adds two CHECK constraints: - `users_email_not_empty`: `(is_service_account = true) = (email = '')` - `users_service_account_login_type`: `is_service_account = false OR login_type = 'none'` Turning a real, email-bearing `login_type=none` user into a service account would require **blanking their email**. Converting to `password` instead preserves the account and its email. > ⚠️ **Breaking / one-way.** The `down` migration cannot restore which users originally had `login_type='none'`. Decision log - **Goal:** move existing `login_type=none` users off the deprecated path while preserving their identity/email. - **Constraint discovered:** the `is_service_account` CHECK constraints (migration `000433`) make a literal `none → service account` conversion require blanking emails, so this PR converts to `password` instead to keep emails intact. - **Implementation:** creation-gating in `cli/usercreate.go` and `coderd/users.go`, matching test updates, plus the `000554_legacy_none_login_to_password.{up,down}.sql` migration. - **CI fix:** the branch was behind `main` and its migration originally numbered `000534`, which collided with main's `000534_drop_chat_model_configs_provider`. Merged `main` and renumbered to `000554` (next free after main's `000553`). `make gen` produces no drift (the migration is data-only). > The service-account conversion alternative (#27182, which blanked emails) was closed in favour of this password-preserving approach. > > Docs follow-up: #27333. [DEVEX-226]: https://linear.app/issue/DEVEX-226 --------- Co-authored-by: Sushant P <zenithwolf1000@users.noreply.github.com> |
||
|
|
c3895ff9c0 |
feat: add CSV export for AI spend data (#27491)
## Description
Adds `GET /api/v2/organizations/{organization}/ai/spend/export`,
returning `text/csv` with per-user, per-group, per-model, per-provider
aggregated AI spend. The data is built from the raw AI Gateway token
usage tables rather than the `ai_user_daily_spend` rollup, but stays
consistent with it: spend is attributed through the token usage's
effective group and bucketed by the token usage `created_at`, the same
values the daily rollup derives from.
The period defaults to the current UTC month, narrowed to the configured
AI Gateway retention window when the month begins before retained data
does. Explicit `period_start`/`period_end` params must be provided
together, are interpreted as UTC, and may span at most 31 days. Unlike
the default period, an explicit period that begins before the retention
window is rejected rather than narrowed. Every row echoes the applied
bounds, so a narrowed window is visible in the export.
The endpoint requires organization-level admin permissions.
## Changes
- Add the `ExportOrganizationAISpend` query aggregating
`aibridge_token_usages` joined to `aibridge_interceptions`, scoped to
the organization via the effective group, resolving the username, group
name, and organization name alongside their IDs.
- Add the `exportOrganizationAISpend` handler and route, gated by the
`aigateway-cost-control` experiment and the `AIBridge` feature,
returning the CSV in a single response.
- Add the `ExportOrganizationAISpend` codersdk client method.
- Require organization-wide `ResourceGroupMember` read, since the export
aggregates every user in the organization. The per-row filter stays in
`dbauthz` as defence in depth.
- Escape leading formula characters in the free-text columns, so a model
or provider name recorded from an intercepted request cannot be
evaluated when the CSV is opened in a spreadsheet.
- Add an index on `aibridge_token_usages (effective_group_id,
created_at)`, which the period and group predicates otherwise cannot
use.
Closes
https://linear.app/codercom/issue/AIGOV-293/add-csv-export-for-ai-spend-data
> [!NOTE]
> Generated by Coder Agents on behalf of @ssncferreira
|
||
|
|
c351280a37 |
feat: add Prometheus metrics for AI Governance cost control (#27490)
## Description Adds Prometheus metrics for AI budget cost control, emitted by the aibridged server under the `cost_control` subsystem (full names are prefixed `coder_ai_gateway_`). - `blocked_requests_total` (counter) — labels: `group_id` - `blocked_users` (gauge) — labels: `group_id` - `unpriced_requests_total` (counter) — labels: `provider`, `model` - `enforcement_duration_seconds` (histogram) — labels: `outcome` ## Changes - Add `GetOverBudgetUsersPerGroup` query (plus dbauthz/dbmetrics/dbmock wiring) to count over-budget users per effective group. - Add a background collector that refreshes the `blocked_users` gauge on an interval, started only when Prometheus is enabled. - Wire `Metrics` through the aibridged server, coderd API, `cli/server.go`, and the enterprise AI gateway handler; recording is nil-safe when metrics are unset. Closes https://linear.app/codercom/issue/AIGOV-296/add-prometheus-metrics-for-cost-control > [!NOTE] > Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira |
||
|
|
6c102cc3f3 |
feat: count only workspace-capable users toward license seats (#27279)
Adds permission-based license seat counting behind the
`workspace-capable-licensing` experiment. When the experiment is enabled
and a valid license carries the AI Governance add-on, the `user_limit`
feature counts only active users the RBAC engine authorizes to create a
workspace, instead of every active user. Users without workspace-create
capability ("gateway accounts", e.g. AI-Gateway-only users) no longer
consume seats.
## How it works
- A new `GetActiveUsersAuthorizationRoles` bulk query returns effective
roles (implied member roles, org default member roles) and group
memberships for every seat-eligible user (active, not deleted, not
system, not a service account), matching `GetActiveUserCount` semantics.
- `license.CountWorkspaceCapableUsers` evaluates `workspace.create`
against the any-organization object form, which covers site-wide grants,
membership grants, and org-scoped bans in one check. Evaluation is
deduplicated on a sha256 of each user's canonical subject JSON (a fixed
sentinel user ID, sorted deduplicated roles and groups), so cost scales
with unique subjects rather than user count, and every subject field
participates in both the evaluation and the key.
- The AI Governance add-on is only known after license claims are
parsed, so `Entitlements()` passes a lazy `WorkspaceCapableUserCountFn`
(following the `ManagedAgentCountFn` precedent) and
`LicensesEntitlements` resolves it when a validated add-on is present.
Each license's `user_limit` claim becomes a candidate pair of limit and
counting mode, the most favorable pair is selected (see Behavior notes),
and the selected pair's limit, entitlement, and count become the
`user_limit` feature's terms; the warnings read the same values.
`license.Entitlements` gains `logger`, `authorizer`, and `experiments`
parameters.
- All custom roles are prefetched in a single query before evaluation
(new exported `rolestore.PrefetchCustomRoles`), and each count emits one
Info log line (capable count, eligible active users, unique subjects,
elapsed) whose presence identifies the counting mode. The count is
bounded by a 60s timeout.
## Behavior notes
- Without the experiment or without the add-on, the legacy
`GetActiveUserCount` path is unchanged.
- When the mode is active, the over-limit and expired-limit warnings say
"workspace-capable users" instead of "active users", since that is what
was counted.
- With multiple licenses, each license's `user_limit` claim forms a
candidate pair of limit and counting mode (workspace-capable for add-on
licenses, all active users otherwise), and the most favorable pair is
enforced: a pair satisfied by its own count wins over any unsatisfied
one, then higher entitlement, then higher limit. One license's limit is
never combined with another license's counting mode, so a small add-on
license can neither borrow a bigger non-add-on limit nor suppress it.
- Licenses in their grace period still gate the count; it reverts to the
legacy count only on hard expiry. While the add-on exists only on
grace-period licenses, a warning tells admins the counting mode will
revert and states the legacy active-user count they will then be
measured by.
- Count errors (database failures, timeout) abort the entitlements
computation, matching the legacy count's error semantics: the refresh
fails and the caller keeps the previous entitlements rather than a
silently different count. One exception: a stored role string that fails
to parse is logged and treated as not workspace-capable instead of
failing the refresh, since authorization fails closed on such roles
anyway.
- The experiment is deliberately not in `ExperimentsSafe`.
Part of the gateway-accounts feature; no behavior changes for
deployments without the experiment.
## Stack
Part 1 of the gateway-accounts stack. Each PR builds on the previous:
1. **#27279 (this PR)**: permission-based license seat counting. Behind
the `workspace-capable-licensing` experiment and gated on the AI
Governance add-on, `user_limit` counts only users the RBAC engine
authorizes to create workspaces.
2. **#27280**: adds the `organization-ai-gateway-access` org role
carrying the AI Bridge interception permissions (extracted from the
member floors, backfilled into org default roles by migration) and
enforces it at AI Gateway authentication; bridge usage stops claiming AI
Governance seats under the experiment.
3. ~~**#27281**: gates workspace ACL grants on matching member-level
capability (each granted action only takes effect while the recipient
holds that action in the org), so workspace sharing is ineffective for
(and rejected toward) users without workspace capabilities, evaluated
live on every authorization.~~ Tabled — excluded from the
gateway-accounts MVP.
Related but independent: **#27278** hides the Workspaces page create
CTAs for users without workspace-create permission.
## Benchmarks
`BenchmarkCountWorkspaceCapableUsers` (in `usercount_bench_test.go`, run
manually with `go test ./enterprise/coderd/license/ -bench
BenchmarkCountWorkspaceCapableUsers -benchtime 5x -run '^$'` — never
executed by CI) measures the count across user-scale and role-diversity
shapes:
| Scenario | Users | ~Unique subjects | per count |
|---|---|---|---|
| Uniform | 1k | 4 | 8.5ms |
| Uniform | 10k | 4 | 71ms |
| Uniform | 50k | 4 | 344ms |
| ManyOrgs (100 orgs) | 10k | ~200 | 112ms |
| CustomRoles (1000 org-scoped roles) | 10k | ~1000 | 168ms |
| UniquePairs (every user a distinct subject) | 10k | ~10,000 | 2.66s |
Summary:
- **Row-side cost is ~7µs per user, linear** (role parsing, subject
canonicalization, and sha256 per row). The bulk query + subject dedupe
handles 50k users in ~350ms; extrapolated 100k ≈ 0.7s. A non-issue at
the 10-minute refresh cadence.
- **Unique subjects are the dominant axis at ~0.26ms each** (role
expansion + one any-organization rego evaluation per subject). The
worst-case scenario — every user a distinct subject — costs ~2.7s at 10k
users, extrapolating to ~13s at 50k.
- **Realistic deployments sit near the cheap rows.** Subject diversity
tracks orgs × role/group combinations, not user count; only per-user
custom roles or per-user org-membership patterns approach the worst
case.
- Caveat encountered while building the harness: the roles query's plan
depends on accurate table statistics. With stale stats (e.g. right after
a bulk user import, before autovacuum ANALYZEs), the planner picks a
nested-loop plan that re-runs the aggregation per user row — a ~300×
regression (1.08s for 1k users). Fresh statistics restore the hash-join
plan; the harness ANALYZEs after seeding, so the numbers above reflect
the healthy plan.
|
||
|
|
2574e6b785 |
feat: notify admins when a user crosses an AI budget threshold (#27415)
Implements: https://linear.app/codercom/issue/AIGOV-289/notify-users-and-admins-on-budget-warning-and-limit-reached Notify admins when a user crosses an AI budget threshold, complementing the user-facing notifications from https://github.com/coder/coder/pull/27346 When a priced interception pushes a user's period spend across the warning (85%) or limit (100%) threshold, the Owners and User Admins now receive an admin notification naming the affected user, alongside the user's own notification. The affected user is excluded from the admin recipients since they already get the user-facing copy. Delivery is best-effort: a failure to enqueue is logged and never blocks recording the interception. The admin templates always show the effective group the spend is attributed to, and note when the limit comes from a per-user override rather than the group budget. Depends on https://github.com/coder/coder/pull/27346 ## Screenshots: <img width="1101" height="440" alt="image" src="https://github.com/user-attachments/assets/eb731088-05c8-47bd-9d06-fc9d07f63a08" /> <img width="468" height="391" alt="image" src="https://github.com/user-attachments/assets/b89b76a6-3fa8-4735-99a2-43e119a7a7e3" /> |
||
|
|
ce4ee923c2 |
feat: notify users when AI spend crosses the budget threshold (#27346)
Implements: https://linear.app/codercom/issue/AIGOV-289/notify-users-and-admins-on-budget-warning-and-limit-reached Notify users when their AI spend crosses a budget threshold for their effective group. Two thresholds are covered: a warning at 85%, and a limit-reached notification at 100%. Detection runs on the post-response path, right after the interception's cost is added to the user's daily spend. It reads the user's AI spend on the same transaction where token usage is recorded and AI daily spend is incremented, and derives the pre-interception total by subtracting this interception's cost. In case of `oldSpend < threshold && newSpend >= threshold` - notification is sent. A single interception that crosses both thresholds enqueues both notifications. Detection and delivery are best-effort: a failure is logged and never fails usage recording. The payload uses only stable values (the threshold percentage and the spend limit, not the exact spend), so duplicate enqueues are deduplicated by the notification system. The two templates are added via migration and appear in each user's notification settings under the "AI Budget" group. Admin notifications (owners and user admins) are a follow-up: #27415. ## Screenshots: <img width="1102" height="252" alt="image" src="https://github.com/user-attachments/assets/62291510-09ca-4cdf-a1f5-4bdc11a1db4b" /> <img width="466" height="384" alt="image" src="https://github.com/user-attachments/assets/030460ff-6fe2-4d59-b247-3550c543ef30" /> --------- Co-authored-by: Cian Johnston <cian@coder.com> |
||
|
|
6f2011af88 |
feat: add chat summary tab in the right sidebar and per-chat cost endpoint (#26649)
Stacked on #26657 (the persisted whole-chat summary backend). Base branch is `chat-summary-62j9`; review/merge that first. Adds a reusable `ChatSummary` component. The summary text is the persisted whole-chat summary (`chat.summary`) introduced by #26657. It is generated asynchronously and may be `null` until the first summary is produced, in which case the popover renders a muted empty state. Live updates arrive via that PR's `chat_summary_change` watch event, which is already merged into the chat caches. Cost is served by a new per-chat endpoint, `GET /api/experimental/chats/{chat}/cost`, which rolls up assistant-message cost across a chat's root and child (subagent) chats and is authorized like the other `{chat}` routes (read on the chat, 404 otherwise). Visual and interaction coverage lives in `ChatSummary.stories.tsx` and `ChatSummaryPopover.stories.tsx` (including populated-summary, empty-state, and cost-loading cases). --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
3cf97ff8e7 | fix: show selected owner's external auth when creating a workspace (#26653) | ||
|
|
3c7a1d33e3 |
feat: add persisted whole-chat summary with background generation (#26657)
Adds a persisted whole-chat summary that backs the chat summary popover. A new nullable `chats.summary` column is populated in the background after a successful root-chat turn and pushed to clients via a new `chat_summary_change` watch event (distinct from `summary_change`, which is bound to `last_turn_summary`), so the popover reads `chat.summary` straight off the loaded `Chat` with no extra query. This is the data source for the popover and per-chat cost UI built in #26649; the popover can consume `chat.summary` once this lands (the field is nullable, so merge order does not matter). ## How it works - **Generation** runs in the existing successful-turn finalize hook, detached from the request so the user's turn is never blocked. A cadence gate generates the first summary after one completed turn, then regenerates every three turns, using the `chats.summary_generated_at` freshness marker. Generation reads compaction-aware history, renders it to a bounded plain-text transcript (short transcripts are skipped), and asks for a 1-3 sentence summary via structured output. Failures never clear an existing summary. - **Staleness** is guarded by `history_version` (mirroring `last_turn_summary`), so a background write racing a newer turn loses while worker lifecycle transitions cannot reject a fresh write. - **Model selection** uses the chat's configured model. ## Deferred to follow-ups - **Cost accounting**: the `chat_messages.cost_source` discriminator and summary/title usage recording were removed from this PR so summary persistence is not blocked by hidden accounting rows advancing `history_version`. Title usage recording stays on main's `InsertChatMessages` path. - **Model override**: deployment-wide summary generation model selection is split into #26803; the base feature always uses the chat model. ## Notes - Migration `000540` adds `chats.summary` and `chats.summary_generated_at`, and recreates `chats_expanded` to expose the new columns. - Root chats only; shared viewers pick up the summary on their next refetch (live watch events are owner-only). Refs #26649 --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
b9fad66214 |
refactor: authorize AI budget reads against the user resource directly (#27443)
Replaces the `GetUserByID` read used as an authz check in the AI budget-resolution queries with a targeted `authorizeContext` against the user resource. Same RBAC decision, one fewer db query per resolution step. Follow-up to https://github.com/coder/coder/pull/27364#discussion_r3632577802. > [!NOTE] > Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira |
||
|
|
c23f2c0223 |
feat: fall back to the Everyone group for AI spend attribution (#27364)
## Description Previously, a user with no per-user override and no membership in a budgeted group had no effective group, so their AI spend was attributed nowhere and was, therefore, untracked. This change falls back to the organization's Everyone group when no override or group budget applies. Since every user in an organization is implicitly a member of that org's Everyone group, spend is now attributed and tracked for any user with organization membership. A user with no organization membership resolves to no group, so their daily spend is not incremented and a warning is logged. The fallback is unlimited, so enforcement is unaffected: only override and group budgets can block requests. For users in multiple organizations, an existing budget on any Everyone group is still chosen by the "highest" policy; when none is budgeted, the fallback prefers the default org, then orders by organization name. ## Changes - Add `ResolveUserEffectiveGroup` and the `GetUserEveryoneFallbackGroup` query: resolve override → group budget → Everyone group fallback. - Attribute token-usage spend and the user AI spend endpoint via the fallback, so unbudgeted users resolve to their Everyone group instead of null. - Update `GetGroupMembersAISpend` to surface the Everyone fallback as the effective group. - Update `GetHighestGroupAIBudgetByUser` to break ties by organization name then group name, keeping multi-org resolution deterministic and consistent with the fallback. - For multi-org users with no budget anywhere, the fallback picks the Everyone group deterministically: prefer the default org, then order by organization name. Closes https://linear.app/codercom/issue/AIGOV-509/fall-back-to-the-everyone-group-for-spend-attribution > [!NOTE] > Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira |
||
|
|
a9a1dcc65d |
feat: add network calls column to AI sessions table (#27269)
Add a "Total/blocked network calls" column to the AIBridge sessions table. Update `ListAIBridgeSessions` query to calculate network called made and blocked per session. See query plan [here](https://explain.dalibo.com/plan/54355c90b165ggb4). --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3227cac217 |
feat: add manual chat compaction via /compact (#27081)
Adds a user-triggered `/compact` action for Coder Agents chats: typing
`/compact` in the composer (or picking it from the `/` trigger menu)
summarizes the conversation so far to free up context window space.
## How it works
- New `POST /api/experimental/chats/{chat}/compact` endpoint
(owner-only, RBAC `ActionUpdate`, excluded from the public API reference
via `x-apidocgen skip`). It marks the chat with a durable one-shot
`chats.compaction_requested_at` signal and moves it `waiting -> running`
via a new `RequestCompaction` state transition; no message row is
inserted. AI Gateway attribution needs no per-request key: generation
preparation resolves the owner's synthetic API key (#27170) like any
other turn.
- `RequestCompaction` hands off chat ownership (clears
`worker_id`/`runner_id`) so a worker acquisition hint is published;
since the transition changes no history, the previous runner could
otherwise miss the request under reordered pubsub delivery.
- The background chat worker picks the chat up like any other turn. A
pending manual request takes precedence over turn completion in the
generation decision, and forces compaction even below the automatic
threshold (and when compaction is disabled via threshold=100). The
commit step consumes the request marker in the same transaction; any
transition that ends the turn clears stale markers.
- The summary triplet reuses the automatic-compaction path, now tagged
with a `source` (`automatic` | `manual`) that is plumbed through
streamed progress parts, persisted tool JSON, and the UI label
("Summarized (manual)").
- Validation order: busy chats reject with 409 (state-machine conflict),
empty/already-compacted chats with 409 "nothing to compact", archived
chats with 400; the owner usage-limit check runs last so no-op requests
surface the specific conflict instead of a limit error.
- Web UI: the `/` trigger menu now has a built-in "Commands" group
listing `/compact`; submit intercepts exactly `/compact` and calls the
endpoint instead of sending a message. A personal or workspace skill
named `compact` takes precedence over the built-in command; while skill
collisions are still resolving, an exact `/compact` submission is
blocked with a retryable hint instead of leaking as message text.
History and queued-message edits are never intercepted. After
compaction, the context usage indicator resets to its unknown state
until the next assistant response reports fresh usage, instead of
showing the stale pre-compaction number.
- codersdk: `ExperimentalClient.CompactChat`.
Worker-path execution (rather than compacting synchronously in the
handler) reuses the existing lock fencing, live "Summarizing..."
streaming, retry accounting, restart resilience, and debug-run
observability. Rationale documented in `coderd/x/chatd/ARCHITECTURE.md`.
## Testing
- State machine: transition-matrix coverage for `RequestCompaction`,
marker lifecycle tests (carried by lease renewals/queue appends, cleared
by terminal transitions, consumed by commit), ownership handoff +
acquisition hint assertions.
- Worker: decision-ordering and forced-compaction unit tests;
active-server end-to-end test (manual compact below threshold produces a
`source=manual` summary, returns to `waiting`, no assistant follow-up;
busy chat rejected).
- API: success, archived, non-owner, RBAC-denied, empty-chat, no-daemon
cases; usage-limit ordering (at-limit owners still get
state/nothing-to-compact conflicts for no-op requests, with marker
rollback).
- Frontend: Storybook play tests for the Commands menu group, submit
intercept, skill-name collision, queued-edit passthrough, and
manual/automatic tool rendering; unit tests for command availability
resolution and the post-compaction context usage reset.
> This PR was created by Mux, an AI coding agent, working on Mike's
behalf.
|
||
|
|
4ed6fcced7 |
refactor(coderd): stop storing chat gateway key IDs and drop the columns (#27171)
> Mux is working on behalf of Mike. ## Summary Stop reading and writing the legacy `api_key_id` columns on chat messages and queued messages, and drop the columns in the same PR. Runtime AI Gateway attribution continues to use the per-user synthetic key introduced by #27170. With the columns gone, `sqlc` generates `database.ChatMessage` and `database.ChatQueuedMessage` without `api_key_id`, so no transitional query scaffolding is needed. Migration `000548` drops the `api_key_id` columns. #27170 already removed their foreign keys, so the down migration re-adds nullable text columns without constraints. Previous column values cannot be restored. Also moves the model config validation in `CreateChat` above the message-building work so a disabled or invalid model fails fast. On main this mattered more: the old ordering minted a synthetic API key before rejecting the request. Deploy note: replicas still running the previous release write `api_key_id` on insert, so chat message inserts on old replicas fail during the rolling window after the column drop. This was previously split across two PRs to avoid that window; per review feedback the split added more churn than it was worth for an experimental surface. Depends on #27170 (merged). |
||
|
|
a9fdf87a2f |
feat: add GET /groups/{group}/members/ai/spend (#27130)
## Description
Adds `GET /api/v2/groups/{group}/members/ai/spend?user_ids=...` (also available org-scoped at `/api/v2/organizations/{org}/groups/{groupName}/members/ai/spend`) to return per-member AI spend attributed to a group, along with each member's effective budget group and the applied spend limit when the queried group is their effective budget source.
In the UI, this endpoint is used alongside the existing `/api/v2/groups/{group}/members` endpoint. AI spend data is kept separate from that endpoint so that:
- Different concepts stay on different endpoints: identity (group members) vs. cost control (spend). Cost control is an additional feature layered on top of groups/orgs.
- Callers that don't need spend information don't pay for its computation.
UI flow:
1. Request `/api/v2/groups/{group}/members` → returns the group's members.
2. Request `/api/v2/groups/{group}/members/ai/spend?user_ids=...` with the IDs from step 1.
**Note:** Only current members of the queried group are returned. `spend_limit_micros` and `limit_source` are populated only when the queried group is the member's effective budget source (its own limit or a user override). `effective_group_id` is null when the member's budget resolves to a group in another organization, since an organization is treated as a tenant boundary.
<img width="2880" height="1904" alt="image" src="https://github.com/user-attachments/assets/33ed395d-d1a3-4b46-bb04-c8d3f41c8886" />
## Changes
- Add `codersdk.GroupMembersAISpend` and `GroupMemberAISpend` types, reusing the shared `AISpendPeriodWindow`.
- Add `GetGroupMembersAISpend` SQL query with a dbauthz per-row filter that mirrors `GET /api/v2/groups/{group}/members`.
- Add handler and routes under `/groups/{group}/members/ai/spend` (and the org-scoped alias) with a required `user_ids` query param (cap 100). Callers with more than 100 members are expected to batch across multiple requests.
- Add codersdk client method.
- Tests: dbauthz, raw SQL, endpoint, and role-access.
Closes https://linear.app/codercom/issue/AIGOV-471/backend-group-members-endpoint-with-members-spend
> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
|
||
|
|
2adc8f5272 |
feat: add GET /organizations/{org}/groups/ai/spend (#27123)
## Description
Adds `GET /api/v2/organizations/{org}/groups/ai/spend?group_ids=...` to return per-group AI spend and configured limits for a set of groups in an organization.
In the UI, this endpoint is used alongside the existing `/api/v2/organizations/{org}/groups` endpoint. AI spend data is kept separate from that endpoint so that:
- Different concepts stay on different endpoints: identity (groups) vs. cost control (spend). Cost control is an additional feature layered on top of groups/orgs.
- Callers that don't need spend information don't pay for its computation.
UI flow:
1. Request `/api/v2/organizations/{org}/groups` → returns the organization's groups.
2. Request `/api/v2/organizations/{org}/groups/ai/spend?group_ids=...` with the IDs from step 1.
The groups endpoint from 1) is currently not paginated, but if pagination is added later, this design keeps the two responses in sync. This spend endpoint intentionally takes `group_ids` rather than paginating on its own, since it depends on the group set from step 1. Pagination could be added in the future, especially for Cost Control-focused pages.
<img width="2880" height="1460" alt="image" src="https://github.com/user-attachments/assets/ea83b74d-6a4f-45a6-af2f-1024e019da07" />
## Changes
- Add `codersdk.OrganizationGroupsAISpend` and `OrganizationGroupAISpend` types, plus a shared `AISpendPeriodWindow` embedded in the spend response.
- Add `GetOrganizationGroupsAISpend` SQL query with a dbauthz per-row filter that mirrors `GET /organizations/{org}/groups`.
- Add handler and route under `/organizations/{organization}/groups/ai/spend` with a required `group_ids` query param (cap 100). Callers with more than 100 groups are expected to batch across multiple requests.
- Add codersdk client method.
- Tests: dbauthz, raw SQL, endpoint, and role-access.
Closes https://linear.app/codercom/issue/AIGOV-466/backend-organization-groups-endpoint-with-groups-spend
> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira
|
||
|
|
9f4ddea571 |
feat: revoke MCP server OAuth grants at the provider on disconnect (#27300)
Closes [CODAGT-805](https://linear.app/codercom/issue/CODAGT-805/revoke-oauth-grants-at-the-source-for-mcp-servers). The experimental MCP server OAuth2 disconnect endpoint previously deleted only the stored token row, leaving the grant active at the OAuth provider. This PR adds provider-side token revocation while keeping local disconnect independent of provider availability. ## Changes - Add `mcp_server_configs.oauth2_revocation_url` in migration `000547`. The value can be configured manually, discovered from RFC 8414 metadata, and managed through the MCP server settings UI. Non-admin responses redact it with the other OAuth2 fields. - Revoke the refresh token first through the RFC 7009 endpoint, then fall back to the access token only for `unsupported_token_type`. Public clients send `client_id`; confidential clients use `client_secret_basic`. - Delete the local token transactionally before best-effort provider revocation. Callers without a token receive the same response for hidden and nonexistent config IDs, and provider failures return a generic warning without exposing provider response bodies. - Require HTTPS revocation endpoints except for HTTP loopback URLs. Redirects must preserve the POST and remain on the configured origin. Redirect errors omit provider-controlled paths and query strings so reflected token material cannot enter logs. - Treat `200 OK` and `204 No Content` as completed revocations. `202 Accepted` remains a failure because it does not confirm completion. - Prevent an in-flight refresh from recreating a token deleted by disconnect. Refresh persistence now uses an optimistic update keyed by token ID and `updated_at`; only the OAuth callback can create a token row. Refresh conflicts reload the current row or clear in-memory auth when disconnect deleted it. - Return `{token_revoked, token_revocation_error}` from disconnect, while retaining SDK compatibility with the legacy `204` response. The UI surfaces provider revocation failures as warning toasts. - Document revocation endpoint discovery, HTTPS requirements, and best-effort disconnect behavior. No token or no configured revocation URL returns `token_revoked: false` without an error, so disconnect remains idempotent. > Updated by Mux, an AI coding agent, on Mike's behalf. |
||
|
|
46d1823c0a |
feat: add workspace skills to agent chat slash menu (#25600)
> This Pull Request was updated by Mux working on behalf of Mike. Adds workspace skills to the agent chat slash menu, sourced entirely from the chat's pinned context resources (the single-chat GET response the page already fetches), the same inventory `read_skill` resolves from. No new API endpoint is introduced. Personal entries insert `/name`, or `/personal/name` when the name collides with a workspace skill or the chat's pinned context has not resolved yet; workspace entries insert `/workspace/name`. Qualified aliases stay searchable even when the displayed trigger is bare. Before a chat binds a workspace (new chat form, or a selected but unbound workspace), the menu lists personal skills only. Sending a message invalidates the chat detail query, and chatd broadcasts a context watch event when a first-turn bind pins the chat, so the menu picks up newly pinned context without a reload. Makes `UpdateChatWorkspaceBinding` a no-op when the requested workspace/build/agent binding is unchanged, preserving `updated_at` so chat list ordering and watch events stay stable. Includes regression coverage for the no-op binding guard, pinned-context skill mapping, collision qualification, and skills menu behavior. Refs [CODAGT-474](https://linear.app/codercom/issue/CODAGT-474/ux-improvements-for-coder-agents) (skills autocompleting in the editor). |
||
|
|
997b5d0843 |
feat: add synthetic gateway keys (#27170)
> Mux is working on behalf of Mike. ## Summary Add a per-user synthetic API key for chatd AI Gateway attribution. Chatd resolves the key from the chat owner, extends it before expiry, and discards the generated bearer token so the key is never a usable credential. There is no mapping table. The key is resolved from `api_keys` by a deterministic token name (`chatd_<owner_id>_session_token`), mirroring the provisionerd session token model, with three deltas that chatd needs: - **Login type guard**: token names are unvalidated user input, so a user can create a bearer token with the colliding name. The lookup excludes `login_type = 'token'` rows, so chatd never picks up (or extends) a real user token. Synthetic keys are minted with the owner's login type, which is never `token`. - **In-place expiry extension instead of delete-and-reinsert**: chat generations have no stop boundary, and an in-flight generation may have already delegated the current key ID to aibridged. Extending `expires_at` keeps the key ID stable forever. - **Advisory-lock mint**: the unique index on token names is partial (`WHERE login_type = 'token'`), so nothing DB-enforces uniqueness for synthetic keys. A per-user advisory lock serializes concurrent mints. Keys carry a minimal scope (`api_key:read`) as defense in depth; the delegated gateway path never evaluates scopes and the secret is discarded at mint. Migration 000544 removes the foreign keys from the legacy message and queue `api_key_id` columns while chatd continues stamping them for rolling compatibility. Stale IDs are tolerated because routing uses `chats.owner_id`. Individual key deletion, delete-all, and password reset remove the key without changing chat history or queue versions, and the next lookup remints it. Suspension does not delete the key; delegated gateway authorization rejects inactive users at request time. This is the first PR in a three-PR rollout and must be fully deployed before #27171. Refs https://linear.app/codercom/issue/CODAGT-561/maintain-synthetic-api-key-per-user-per-chat |
||
|
|
f7481c5d08 |
feat: Add full text search over chat messages (#27126)
Closes CODAGT-721 Closes CODAGT-722 Closes CODAGT-723 Closes CODAGT-724 Closes CODAGT-725 This PR adds the database and API pieces necessary to support full-text chat message search. - Adds required chat schema for full-text search - Adds dbpurge job to populate search_tsv in the background - Adds `search` parameter to GetChats query - Adds `search` filter to `searchquery.Chats` - Wires chat search filter into chats API > Implemented by Coder Agents, reviewed and tested by a human. |
||
|
|
e489092154 |
feat: handle revoked OAuth grants for MCP servers gracefully (#27264)
Closes [CODAGT-792](https://linear.app/codercom/issue/CODAGT-792/handle-revoked-oauth-grants-for-mcp-servers-gracefully). When a user revokes an upstream OAuth grant for an MCP server used by Coder Agents, Coder kept treating the cached token as valid: `invalid_grant` refresh failures were logged and swallowed, the dead bearer token kept being attached, the list endpoints re-attempted the refresh on every call, and the UI kept showing the server as authenticated. ## Changes Backend, mirroring the `external_auth_links` prior art: - New migration adds `mcp_server_user_tokens.oauth_refresh_failure_reason`. `UpsertMCPServerUserToken` clears it, so completing the OAuth flow again recovers the row. - New `MarkMCPServerUserTokenRefreshFailure` query records the failure and clears all token material, guarded by an `updated_at` optimistic lock so a stale failure never clobbers a concurrently refreshed token (on a lock miss the winner's row is used). - `mcpclient.IsPermanentRefreshError` classifies `*oauth2.RetrieveError` codes: only `invalid_grant` and `bad_refresh_token` are permanent. Client/config errors (`invalid_client`, `unauthorized_client`, ...) stay transient for the user row since reconnecting cannot fix them. - chatd token refresh and the MCP list/get endpoints persist permanent failures, return cleared tokens for the in-flight request, and skip provider calls for already-failed rows. - `buildAuthHeaders` no longer attaches an Authorization header for failed tokens, so chat degrades by omitting that server's tools instead of sending a dead bearer. API and UI: - No new API surface. A permanently failed token simply reports `auth_connected: false`, so the existing "Auth" button and "Not authenticated" tooltip appear and the user re-runs the same OAuth flow to recover. An earlier revision added an `auth_status` enum (`connected` / `not_connected` / `reconnect_required`) with a dedicated "Reconnect" button; it was collapsed to keep the API minimal since both states lead to the identical re-auth action. Out of scope (follow-up): typed 401-on-connect detection and forced refresh. mcp-go exposes no stable typed 401 signal in the static-header path, so a revocation while the access token still looks valid locally stays undetected until expiry triggers a refresh. ## Testing - Unit and integration tests: classifier, chatd refresh paths (permanent/transient/race/persist-failure), API endpoints (revoked, transient, no-retry caching, re-auth recovery, stale-lock), dbauthz, dbcrypt, migrations. - Dogfood UAT against a dev instance with a mock IdP returning `invalid_grant`: revoked grant detected on refresh and persisted once (no repeated IdP calls), chat with the revoked server selected completes with the server's tools omitted, and re-auth restores the connected state. > This PR was authored by Mux, working on Mike's behalf. |