mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Slice 3 of 4 for runtime experiment rules: evaluate stored rules per user in discovery, the frontend experiments query, and the pilot `mcp-tool-search` gate in chatd. There is still no way to edit rules through the product; that comes in slice 4. With no rules stored and a healthy database, backend decisions match today. This PR deliberately changes three things: 1. **Fail closed on rule reads.** If the rules read fails, `mcp-tool-search` is off, even when it is enabled at startup. 2. **Frontend freshness.** The experiments query now refetches. 3. **Caching.** `/api/v2/experiments` and rendered HTML are sent with `Cache-Control: no-store`. Static assets keep their long-lived caching. ## What's in it - **coderd** builds `api.ExperimentEvaluator` from `experiments.NewDBStore` plus the startup `--experiments` list. - **Discovery:** `GET /api/v2/experiments` and the `<meta property="experiments">` tag both use `EnabledExperiments(ctx, caller)`. - **chatd:** `mcp-tool-search` is now user-scoped. `chatd.Config.ExperimentEvaluator` is required, so `chatd.New` returns an error without it. `prepareGeneration` decides once per turn for the chat owner (`chat.OwnerID`); child chats inherit the owner. - **Ruleguard** (`userScopedExperimentStaticCheck` in `scripts/rules.go`): rejects static `Experiments.Enabled(...)` and `httpmw.RequireExperiment*(...)` checks of user-scoped experiments in server packages. A codersdk test keeps its list equal to `codersdk.ExperimentsUserScoped`. - **Frontend:** the experiments query is keyed by user ID and seeded from the meta tag only when the embedded user matches. It is stale 60 s after page load and refetches on mount, window focus and reconnect. Logout also clears the experiments metadata. The dashboard keeps the last loaded list when a background refetch fails, instead of replacing the page with an error. - **Once per turn:** chatd decides `mcp-tool-search` on a turn's first step and reuses that decision for the turn's later steps, so a rule change applies from the next turn and never withdraws a `find_tools` call the model already made. The decision lives on the chat runner, keyed by the turn's prompt row, like the stop-nudge tracker. A runner handoff in the middle of a turn evaluates again. - **Reference audit:** the only non-test server check of `mcp-tool-search` is in `generation_preparer.go`, and it now goes through the evaluator. ## Testing - **Go:** per-user results and `no-store` on `/api/v2/experiments`; the per-user meta tag and the HTML cache headers in `site`; group and org conditions end to end in enterprise; chatd owner eligibility, on → off → on across turns including a child chat, and a failed rules read. - **Vitest:** user-keyed seeding, staleness from page load after a remount, refetch on focus, logout clearing, and the dashboard surviving a failed refetch. Remote dogfood UAT through Coder Agents passed on a licensed test deployment: - per-user discovery in both the API and the meta tag; - widening a rule, and turning it off, with no restart; - `off` beating a startup default, and `inherit` restoring it; - fail closed on malformed JSON and on an unknown mode, with no condition text in the logs; - the `no-store` headers; - real-model chatd enforcement: `find_tools` offered on turn 1, gone after `off`, back on turn 3. That round tested `b57207d4ee`. The later commits change only tests, and a follow-up round will cover the final head. **For the author:** `coderd/x/chatd/ARCHITECTURE.md`'s list of the runner's local state doesn't mention the per-turn trackers (debug turn, session start, stop nudges, and now this experiment decision). I did not edit that document. **Known behavior:** a broken rule logs one error line per evaluation (per request or turn), with only the experiment, revision, category and position. ## Stack 1. #29869: evaluator library 2. #29898: storage and audit types 3. **This PR:** enforcement and discovery 4. Control plane (experimental API, `coder exp experiment-rules` CLI, docs) > [!NOTE] > This PR was written by an AI agent (Xum). --- _Generated with [`xum`](https://github.com/coder/xum) • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ --------- Signed-off-by: Thomas Kosiewski <tk@coder.com>
191 lines
6.3 KiB
Go
191 lines
6.3 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
experimentrules "github.com/coder/coder/v2/coderd/experiments"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func Test_Experiments(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := coderdtest.DeploymentValues(t)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: cfg,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
experiments, err := client.Experiments(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, experiments)
|
|
require.Empty(t, experiments)
|
|
require.False(t, experiments.Enabled("foo"))
|
|
})
|
|
|
|
t.Run("multiple features", func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := coderdtest.DeploymentValues(t)
|
|
cfg.Experiments = []string{"foo", "BAR"}
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: cfg,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
experiments, err := client.Experiments(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, experiments)
|
|
// Should be lower-cased.
|
|
require.ElementsMatch(t, []codersdk.Experiment{"foo", "bar"}, experiments)
|
|
require.True(t, experiments.Enabled("foo"))
|
|
require.True(t, experiments.Enabled("bar"))
|
|
require.False(t, experiments.Enabled("baz"))
|
|
})
|
|
|
|
t.Run("wildcard", func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := coderdtest.DeploymentValues(t)
|
|
cfg.Experiments = []string{"*"}
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: cfg,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
experiments, err := client.Experiments(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, experiments)
|
|
require.ElementsMatch(t, codersdk.ExperimentsSafe, experiments)
|
|
for _, ex := range codersdk.ExperimentsSafe {
|
|
require.True(t, experiments.Enabled(ex))
|
|
}
|
|
require.False(t, experiments.Enabled("danger"))
|
|
})
|
|
|
|
t.Run("alternate wildcard with manual opt-in", func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := coderdtest.DeploymentValues(t)
|
|
cfg.Experiments = []string{"*", "dAnGeR"}
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: cfg,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
experiments, err := client.Experiments(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, experiments)
|
|
require.ElementsMatch(t, append(codersdk.ExperimentsSafe, "danger"), experiments)
|
|
for _, ex := range codersdk.ExperimentsSafe {
|
|
require.True(t, experiments.Enabled(ex))
|
|
}
|
|
require.True(t, experiments.Enabled("danger"))
|
|
require.False(t, experiments.Enabled("herebedragons"))
|
|
})
|
|
|
|
t.Run("Unauthorized", func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := coderdtest.DeploymentValues(t)
|
|
cfg.Experiments = []string{"*"}
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: cfg,
|
|
})
|
|
// Explicitly omit creating a user so we're unauthorized.
|
|
// _ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
_, err := client.Experiments(ctx)
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, httpmw.SignedOutErrorMessage)
|
|
})
|
|
|
|
t.Run("rules per user", func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := coderdtest.DeploymentValues(t)
|
|
cfg.Experiments = []string{"foo", string(codersdk.ExperimentMCPToolSearch)}
|
|
ownerClient, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
|
DeploymentValues: cfg,
|
|
})
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
revisions := map[codersdk.Experiment]int64{}
|
|
writeRule := func(ex codersdk.Experiment, rule experimentrules.Rule) {
|
|
t.Helper()
|
|
_, stored, _, err := experimentrules.WriteRule(dbauthz.AsSystemRestricted(ctx), db, owner.UserID, ex, rule, revisions[ex])
|
|
require.NoError(t, err)
|
|
revisions[ex] = stored.Revision
|
|
}
|
|
requireExperiments := func(client *codersdk.Client, want ...codersdk.Experiment) {
|
|
t.Helper()
|
|
got, err := client.Experiments(ctx)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, want, got)
|
|
}
|
|
|
|
// A condition enables a user-scoped experiment only for matching
|
|
// users; a kill switch disables a statically enabled one.
|
|
writeRule(codersdk.ExperimentExample, experimentrules.Rule{
|
|
Mode: experimentrules.ModeCondition,
|
|
Condition: fmt.Sprintf("user.username == %q", member.Username),
|
|
})
|
|
writeRule(codersdk.ExperimentMCPToolSearch, experimentrules.Rule{Mode: experimentrules.ModeOff})
|
|
requireExperiments(memberClient, "foo", codersdk.ExperimentExample)
|
|
requireExperiments(ownerClient, "foo")
|
|
|
|
// Inherit restores the startup default.
|
|
writeRule(codersdk.ExperimentMCPToolSearch, experimentrules.Rule{Mode: experimentrules.ModeInherit})
|
|
requireExperiments(memberClient, "foo", codersdk.ExperimentMCPToolSearch, codersdk.ExperimentExample)
|
|
requireExperiments(ownerClient, "foo", codersdk.ExperimentMCPToolSearch)
|
|
|
|
// The personalized result must not be cached.
|
|
res, err := memberClient.Request(ctx, http.MethodGet, "/api/v2/experiments", nil)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
require.Equal(t, "no-store", res.Header.Get("Cache-Control"))
|
|
})
|
|
|
|
t.Run("available experiments", func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := coderdtest.DeploymentValues(t)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: cfg,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
experiments, err := client.SafeExperiments(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, experiments)
|
|
require.ElementsMatch(t, codersdk.ExperimentsSafe, experiments.Safe)
|
|
})
|
|
}
|