From bb11946bd4c193a81450036b7d409ef3055aa0f2 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 28 May 2026 15:34:36 -0500 Subject: [PATCH 01/63] fix: require update permission to recreate devcontainers (#25812) - The httpmw upstream from this endpoint only checks for read perms to the workspace agent. Recreating a dev container should require `update` perms since it mutates state. This also matches the behavior of the `DELETE` endpoint --- coderd/workspaceagents.go | 5 ++++ coderd/workspaceagents_test.go | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 097d4ff41b..074ca687b1 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1096,6 +1096,11 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht ctx := r.Context() waws := httpmw.WorkspaceAgentAndWorkspaceParam(r) + if !api.Authorize(r, policy.ActionUpdate, waws.WorkspaceTable) { + httpapi.Forbidden(rw) + return + } + devcontainer := chi.URLParam(r, "devcontainer") if devcontainer == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 0fff131f5a..9b36d11c27 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1876,6 +1876,51 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }) } +func TestWorkspaceAgentRecreateDevcontainerAuthorization(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + role func(uuid.UUID) rbac.RoleIdentifier + }{ + { + name: "TemplateAdmin", + role: func(uuid.UUID) rbac.RoleIdentifier { + return rbac.RoleTemplateAdmin() + }, + }, + { + name: "OrgTemplateAdmin", + role: rbac.ScopedRoleOrgTemplateAdmin, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + client, db = coderdtest.NewWithDatabase(t, nil) + admin = coderdtest.CreateFirstUser(t, client) + _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + templateAdminClient, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, tc.role(admin.OrganizationID)) + workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: admin.OrganizationID, + OwnerID: workspaceOwner.ID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + return agents + }).Do() + ) + + _, err := templateAdminClient.WorkspaceAgentRecreateDevcontainer(ctx, workspace.Agents[0].ID, uuid.NewString()) + require.Error(t, err) + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + } +} + func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) { t.Parallel() From 9d90b32d92c1c27208845fed605333101a30f49f Mon Sep 17 00:00:00 2001 From: George K Date: Thu, 28 May 2026 14:02:23 -0700 Subject: [PATCH 02/63] fix(flake.nix): stop forcing musl biome binary in dev shell (#25815) The nix dev shell was forcing BIOME_BINARY to the musl package, but pnpm install on glibc hosts only installs the glibc biome package by default. That caused pnpm exec biome to fail in nix-shell even though the default biome binary worked when BIOME_BINARY was unset. --- flake.nix | 8 -------- 1 file changed, 8 deletions(-) diff --git a/flake.nix b/flake.nix index d22dbcd72c..e47b078777 100644 --- a/flake.nix +++ b/flake.nix @@ -295,14 +295,6 @@ lib.optionalDrvAttr stdenv.isLinux "${glibcLocales}/lib/locale/locale-archive"; NODE_OPTIONS = "--max-old-space-size=8192"; - BIOME_BINARY = - if pkgs.stdenv.isLinux then - if pkgs.stdenv.hostPlatform.isAarch64 then - "@biomejs/cli-linux-arm64-musl/biome" - else - "@biomejs/cli-linux-x64-musl/biome" - else - ""; GOPRIVATE = "coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder"; }; }; From dcb107684ea301b984c6948916a3f71e36b322a8 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Thu, 28 May 2026 17:11:33 -0400 Subject: [PATCH 03/63] docs: fix stale redirect links in four docs pages (#25738) Four pages contained absolute `coder.com/docs` links that issued 308 redirects, creating unnecessary extra hops for readers. These were identified via a SiteOne Crawler redirect-chain audit (DOCS-216). | File | Old link | Final destination | | -- | -- | -- | | `admin/security/0001_user_apikeys_invalidation.md` | `/docs/admin/audit-logs` | `/docs/admin/security/audit-logs` | | `admin/templates/extending-templates/web-ides.md` | `/docs/code-server/` (trailing slash) | `/docs/code-server` | | `user-guides/workspace-access/index.md` | `/docs/code-server/latest` | `/docs/code-server` | | `install/cloud/azure-vm.md` | `/docs/coder-oss/latest/install` | `/docs/install` | Also quotes the `[install.sh]` bash associative array key in `scripts/release/check_commit_metadata.sh` to fix a pre-existing shfmt parse warning (shfmt misreads `.sh` inside unquoted `[...]` as a floating-point expression). --------- Co-authored-by: Claude Sonnet 4.6 --- docs/admin/templates/extending-templates/web-ides.md | 2 +- docs/install/cloud/azure-vm.md | 2 +- docs/user-guides/workspace-access/index.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/admin/templates/extending-templates/web-ides.md b/docs/admin/templates/extending-templates/web-ides.md index 4240dfe552..dae3fc593b 100644 --- a/docs/admin/templates/extending-templates/web-ides.md +++ b/docs/admin/templates/extending-templates/web-ides.md @@ -55,7 +55,7 @@ resource "coder_agent" "main" { For advanced use, we recommend installing code-server in your VM snapshot or container image. Here's a Dockerfile which leverages some special -[code-server features](https://coder.com/docs/code-server/): +[code-server features](https://coder.com/docs/code-server): ```Dockerfile FROM codercom/enterprise-base:ubuntu diff --git a/docs/install/cloud/azure-vm.md b/docs/install/cloud/azure-vm.md index 2ab41bc53a..6cc2163105 100644 --- a/docs/install/cloud/azure-vm.md +++ b/docs/install/cloud/azure-vm.md @@ -56,7 +56,7 @@ as a system service. For this instance, we will run Coder as a system service, however you can run Coder a multitude of different ways. You can learn more about those -[here](https://coder.com/docs/coder-oss/latest/install). +[here](https://coder.com/docs/install). In the Azure VM instance, run the following command to install Coder diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index da72459cbb..ee1bd9aa5c 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -132,7 +132,7 @@ on connecting your JetBrains IDEs. [code-server](https://github.com/coder/code-server) is our supported method of running VS Code in the web browser. Learn more about [what makes code-server different from VS Code web](./code-server.md) or visit the -[documentation for code-server](https://coder.com/docs/code-server/latest). +[documentation for code-server](https://coder.com/docs/code-server). ![code-server in a workspace](../../images/code-server-ide.png) From a16de96611dede8a32263c39f4e09a8c676cec16 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 28 May 2026 17:38:09 -0400 Subject: [PATCH 04/63] chore: extract Expecter into its own package (#25806) Relates to https://github.com/coder/internal/issues/1400 Extracts the code that matches command output from the code that sets up a PTY, so it can be used independently. Subsequent PRs will actually refactor the tests to use this directly over an inmemory pipe. --- pty/ptytest/ptytest.go | 474 +----------------- testutil/expecter/expecter.go | 346 +++++++++++++ testutil/expecter/stdbuf.go | 119 +++++ .../expecter/stdbuf_internal_test.go | 2 +- 4 files changed, 488 insertions(+), 453 deletions(-) create mode 100644 testutil/expecter/expecter.go create mode 100644 testutil/expecter/stdbuf.go rename pty/ptytest/ptytest_internal_test.go => testutil/expecter/stdbuf_internal_test.go (97%) diff --git a/pty/ptytest/ptytest.go b/pty/ptytest/ptytest.go index 7aaac5b2dc..43fff7c5d7 100644 --- a/pty/ptytest/ptytest.go +++ b/pty/ptytest/ptytest.go @@ -1,27 +1,14 @@ package ptytest import ( - "bufio" - "bytes" - "context" - "fmt" - "io" - "regexp" "runtime" - "slices" - "strings" "sync" "testing" - "time" - "unicode/utf8" - "github.com/acarl005/stripansi" "github.com/stretchr/testify/require" - "go.uber.org/atomic" - "golang.org/x/xerrors" "github.com/coder/coder/v2/pty" - "github.com/coder/coder/v2/testutil" + "github.com/coder/coder/v2/testutil/expecter" "github.com/coder/serpent" ) @@ -31,10 +18,11 @@ func New(t *testing.T, opts ...pty.Option) *PTY { ptty, err := newTestPTY(opts...) require.NoError(t, err) - e := newExpecter(t, ptty.Output(), "cmd") + e := expecter.New(t, ptty.Output(), "cmd") r := &PTY{ - outExpecter: e, - PTY: ptty, + t: t, + Expecter: *e, + PTY: ptty, } // Ensure pty is cleaned up at the end of test. t.Cleanup(func() { @@ -54,11 +42,12 @@ func Start(t *testing.T, cmd *pty.Cmd, opts ...pty.StartOption) (*PTYCmd, pty.Pr _ = ps.Kill() _ = ps.Wait() }) - ex := newExpecter(t, ptty.OutputReader(), cmd.Args[0]) + ex := expecter.New(t, ptty.OutputReader(), cmd.Args[0]) r := &PTYCmd{ - outExpecter: ex, - PTYCmd: ptty, + Expecter: *ex, + PTYCmd: ptty, + t: t, } t.Cleanup(func() { _ = r.Close() @@ -66,322 +55,12 @@ func Start(t *testing.T, cmd *pty.Cmd, opts ...pty.StartOption) (*PTYCmd, pty.Pr return r, ps } -func newExpecter(t *testing.T, r io.Reader, name string) outExpecter { - // Use pipe for logging. - logDone := make(chan struct{}) - logr, logw := io.Pipe() - - // Write to log and output buffer. - copyDone := make(chan struct{}) - out := newStdbuf() - w := io.MultiWriter(logw, out) - - ex := outExpecter{ - t: t, - out: out, - name: atomic.NewString(name), - - runeReader: bufio.NewReaderSize(out, utf8.UTFMax), - } - - logClose := func(name string, c io.Closer) { - ex.logf("closing %s", name) - err := c.Close() - ex.logf("closed %s: %v", name, err) - } - // Set the actual close function for the outExpecter. - ex.close = func(reason string) error { - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - - ex.logf("closing expecter: %s", reason) - - // Caller needs to have closed the PTY so that copying can complete - select { - case <-ctx.Done(): - ex.fatalf("close", "copy did not close in time") - case <-copyDone: - } - - logClose("logw", logw) - logClose("logr", logr) - select { - case <-ctx.Done(): - ex.fatalf("close", "log pipe did not close in time") - case <-logDone: - } - - ex.logf("closed expecter") - - return nil - } - - go func() { - defer close(copyDone) - _, err := io.Copy(w, r) - ex.logf("copy done: %v", err) - ex.logf("closing out") - err = out.closeErr(err) - ex.logf("closed out: %v", err) - }() - - // Log all output as part of test for easier debugging on errors. - go func() { - defer close(logDone) - s := bufio.NewScanner(logr) - for s.Scan() { - ex.logf("%q", stripansi.Strip(s.Text())) - } - // Surface non-EOF scanner errors; otherwise they're invisible. - if err := s.Err(); err != nil { - ex.logf("log scanner stopped: %v", err) - } - }() - - return ex -} - -type outExpecter struct { - t *testing.T - close func(reason string) error - out *stdbuf - name *atomic.String - - runeReader *bufio.Reader -} - -// Deprecated: use ExpectMatchContext instead. -// This uses a background context, so will not respect the test's context. -func (e *outExpecter) ExpectMatch(str string) string { - return e.expectMatchContextFunc(str, e.ExpectMatchContext) -} - -func (e *outExpecter) ExpectRegexMatch(str string) string { - return e.expectMatchContextFunc(str, e.ExpectRegexMatchContext) -} - -func (e *outExpecter) expectMatchContextFunc(str string, fn func(ctx context.Context, str string) string) string { - e.t.Helper() - - timeout, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) - defer cancel() - - return fn(timeout, str) -} - -// TODO(mafredri): Rename this to ExpectMatch when refactoring. -func (e *outExpecter) ExpectMatchContext(ctx context.Context, str string) string { - return e.expectMatcherFunc(ctx, str, strings.Contains) -} - -func (e *outExpecter) ExpectRegexMatchContext(ctx context.Context, str string) string { - return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool { - return regexp.MustCompile(pattern).MatchString(src) - }) -} - -func (e *outExpecter) expectMatcherFunc(ctx context.Context, str string, fn func(src, pattern string) bool) string { - e.t.Helper() - - var buffer bytes.Buffer - err := e.doMatchWithDeadline(ctx, "ExpectMatchContext", func(rd *bufio.Reader) error { - for { - r, _, err := rd.ReadRune() - if err != nil { - return err - } - _, err = buffer.WriteRune(r) - if err != nil { - return err - } - if fn(buffer.String(), str) { - return nil - } - } - }) - if err != nil { - e.fatalf("read error", "%v (wanted %q; got %q)", err, str, buffer.String()) - return "" - } - e.logf("matched %q = %q", str, buffer.String()) - return buffer.String() -} - -// ExpectNoMatchBefore validates that `match` does not occur before `before`. -func (e *outExpecter) ExpectNoMatchBefore(ctx context.Context, match, before string) string { - e.t.Helper() - - var buffer bytes.Buffer - err := e.doMatchWithDeadline(ctx, "ExpectNoMatchBefore", func(rd *bufio.Reader) error { - for { - r, _, err := rd.ReadRune() - if err != nil { - return err - } - _, err = buffer.WriteRune(r) - if err != nil { - return err - } - - if strings.Contains(buffer.String(), match) { - return xerrors.Errorf("found %q before %q", match, before) - } - - if strings.Contains(buffer.String(), before) { - return nil - } - } - }) - if err != nil { - e.fatalf("read error", "%v (wanted no %q before %q; got %q)", err, match, before, buffer.String()) - return "" - } - e.logf("matched %q = %q", before, stripansi.Strip(buffer.String())) - return buffer.String() -} - -func (e *outExpecter) Peek(ctx context.Context, n int) []byte { - e.t.Helper() - - var out []byte - err := e.doMatchWithDeadline(ctx, "Peek", func(rd *bufio.Reader) error { - var err error - out, err = rd.Peek(n) - return err - }) - if err != nil { - e.fatalf("read error", "%v (wanted %d bytes; got %d: %q)", err, n, len(out), out) - return nil - } - e.logf("peeked %d/%d bytes = %q", len(out), n, out) - return slices.Clone(out) -} - //nolint:govet // We don't care about conforming to ReadRune() (rune, int, error). -func (e *outExpecter) ReadRune(ctx context.Context) rune { - e.t.Helper() - - var r rune - err := e.doMatchWithDeadline(ctx, "ReadRune", func(rd *bufio.Reader) error { - var err error - r, _, err = rd.ReadRune() - return err - }) - if err != nil { - e.fatalf("read error", "%v (wanted rune; got %q)", err, r) - return 0 - } - e.logf("matched rune = %q", r) - return r -} - -func (e *outExpecter) ReadLine(ctx context.Context) string { - e.t.Helper() - - var buffer bytes.Buffer - err := e.doMatchWithDeadline(ctx, "ReadLine", func(rd *bufio.Reader) error { - for { - r, _, err := rd.ReadRune() - if err != nil { - return err - } - if r == '\n' { - return nil - } - if r == '\r' { - // Peek the next rune to see if it's an LF and then consume - // it. - - // Unicode code points can be up to 4 bytes, but the - // ones we're looking for are only 1 byte. - b, _ := rd.Peek(1) - if len(b) == 0 { - return nil - } - - r, _ = utf8.DecodeRune(b) - if r == '\n' { - _, _, err = rd.ReadRune() - if err != nil { - return err - } - } - - return nil - } - - _, err = buffer.WriteRune(r) - if err != nil { - return err - } - } - }) - if err != nil { - e.fatalf("read error", "%v (wanted newline; got %q)", err, buffer.String()) - return "" - } - e.logf("matched newline = %q", buffer.String()) - return buffer.String() -} - -func (e *outExpecter) ReadAll() []byte { - e.t.Helper() - return e.out.ReadAll() -} - -func (e *outExpecter) doMatchWithDeadline(ctx context.Context, name string, fn func(*bufio.Reader) error) error { - e.t.Helper() - - // A timeout is mandatory, caller can decide by passing a context - // that times out. - if _, ok := ctx.Deadline(); !ok { - timeout := testutil.WaitMedium - e.logf("%s ctx has no deadline, using %s", name, timeout) - var cancel context.CancelFunc - //nolint:gocritic // Rule guard doesn't detect that we're using testutil.Wait*. - ctx, cancel = context.WithTimeout(ctx, timeout) - defer cancel() - } - - match := make(chan error, 1) - go func() { - defer close(match) - match <- fn(e.runeReader) - }() - select { - case err := <-match: - return err - case <-ctx.Done(): - // Ensure goroutine is cleaned up before test exit, do not call - // (*outExpecter).close here to let the caller decide. - _ = e.out.Close() - <-match - - return xerrors.Errorf("match deadline exceeded: %w", ctx.Err()) - } -} - -func (e *outExpecter) logf(format string, args ...interface{}) { - e.t.Helper() - - // Match regular logger timestamp format, we seem to be logging in - // UTC in other places as well, so match here. - e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name.Load(), fmt.Sprintf(format, args...)) -} - -func (e *outExpecter) fatalf(reason string, format string, args ...interface{}) { - e.t.Helper() - - // Ensure the message is part of the normal log stream before - // failing the test. - e.logf("%s: %s", reason, fmt.Sprintf(format, args...)) - - require.FailNowf(e.t, reason, format, args...) -} type PTY struct { - outExpecter + expecter.Expecter pty.PTY + t *testing.T closeOnce sync.Once closeErr error } @@ -391,11 +70,11 @@ func (p *PTY) Close() error { p.closeOnce.Do(func() { pErr := p.PTY.Close() if pErr != nil { - p.logf("PTY: Close failed: %v", pErr) + p.Logf("PTY: Close failed: %v", pErr) } - eErr := p.outExpecter.close("PTY close") + eErr := p.Expecter.Close("PTY close") if eErr != nil { - p.logf("PTY: close expecter failed: %v", eErr) + p.Logf("PTY: close expecter failed: %v", eErr) } if pErr != nil { p.closeErr = pErr @@ -418,7 +97,7 @@ func (p *PTY) Attach(inv *serpent.Invocation) *PTY { func (p *PTY) Write(r rune) { p.t.Helper() - p.logf("stdin: %q", r) + p.Logf("stdin: %q", r) _, err := p.Input().Write([]byte{byte(r)}) require.NoError(p.t, err, "write failed") } @@ -430,7 +109,7 @@ func (p *PTY) WriteLine(str string) { if runtime.GOOS == "windows" { newline = append(newline, '\n') } - p.logf("stdin: %q", str+string(newline)) + p.Logf("stdin: %q", str+string(newline)) _, err := p.Input().Write(append([]byte(str), newline...)) require.NoError(p.t, err, "write line failed") } @@ -440,137 +119,28 @@ func (p *PTY) WriteLine(str string) { // // p := New(t).Named("myCmd") func (p *PTY) Named(name string) *PTY { - p.name.Store(name) + p.Rename(name) return p } type PTYCmd struct { - outExpecter + expecter.Expecter pty.PTYCmd + t *testing.T } func (p *PTYCmd) Close() error { p.t.Helper() pErr := p.PTYCmd.Close() if pErr != nil { - p.logf("PTYCmd: Close failed: %v", pErr) + p.Logf("PTYCmd: Close failed: %v", pErr) } - eErr := p.outExpecter.close("PTYCmd close") + eErr := p.Expecter.Close("PTYCmd close") if eErr != nil { - p.logf("PTYCmd: close expecter failed: %v", eErr) + p.Logf("PTYCmd: close expecter failed: %v", eErr) } if pErr != nil { return pErr } return eErr } - -// stdbuf is like a buffered stdout, it buffers writes until read. -type stdbuf struct { - r io.Reader - - mu sync.Mutex // Protects following. - b []byte - more chan struct{} - err error -} - -func newStdbuf() *stdbuf { - return &stdbuf{more: make(chan struct{}, 1)} -} - -func (b *stdbuf) ReadAll() []byte { - b.mu.Lock() - defer b.mu.Unlock() - - if b.err != nil { - return nil - } - p := append([]byte(nil), b.b...) - b.b = b.b[len(b.b):] - return p -} - -func (b *stdbuf) Read(p []byte) (int, error) { - if b.r == nil { - return b.readOrWaitForMore(p) - } - - n, err := b.r.Read(p) - if xerrors.Is(err, io.EOF) { - b.r = nil - err = nil - if n == 0 { - return b.readOrWaitForMore(p) - } - } - return n, err -} - -func (b *stdbuf) readOrWaitForMore(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - - // Deplete channel so that more check - // is for future input into buffer. - select { - case <-b.more: - default: - } - - if len(b.b) == 0 { - if b.err != nil { - return 0, b.err - } - - b.mu.Unlock() - <-b.more - b.mu.Lock() - } - - b.r = bytes.NewReader(b.b) - b.b = b.b[len(b.b):] - - return b.r.Read(p) -} - -func (b *stdbuf) Write(p []byte) (int, error) { - if len(p) == 0 { - return 0, nil - } - - b.mu.Lock() - defer b.mu.Unlock() - - if b.err != nil { - return 0, b.err - } - - b.b = append(b.b, p...) - - select { - case b.more <- struct{}{}: - default: - } - - return len(p), nil -} - -func (b *stdbuf) Close() error { - return b.closeErr(nil) -} - -func (b *stdbuf) closeErr(err error) error { - b.mu.Lock() - defer b.mu.Unlock() - if b.err != nil { - return err - } - if err == nil { - b.err = io.EOF - } else { - b.err = err - } - close(b.more) - return err -} diff --git a/testutil/expecter/expecter.go b/testutil/expecter/expecter.go new file mode 100644 index 0000000000..5a370a9e64 --- /dev/null +++ b/testutil/expecter/expecter.go @@ -0,0 +1,346 @@ +package expecter + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "regexp" + "slices" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/acarl005/stripansi" + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/testutil" +) + +func New(t *testing.T, r io.Reader, name string) *Expecter { + // Use pipe for logging. + logDone := make(chan struct{}) + logr, logw := io.Pipe() + + // Write to log and output buffer. + copyDone := make(chan struct{}) + out := newStdbuf() + w := io.MultiWriter(logw, out) + + ex := &Expecter{ + t: t, + out: out, + name: atomic.NewString(name), + + runeReader: bufio.NewReaderSize(out, utf8.UTFMax), + logDone: logDone, + copyDone: copyDone, + logr: logr, + logw: logw, + } + + go func() { + defer close(copyDone) + _, err := io.Copy(w, r) + ex.Logf("copy done: %v", err) + ex.Logf("closing out") + err = out.closeErr(err) + ex.Logf("closed out: %v", err) + }() + + // Log all output as part of test for easier debugging on errors. + go func() { + defer close(logDone) + s := bufio.NewScanner(logr) + for s.Scan() { + ex.Logf("%q", stripansi.Strip(s.Text())) + } + // Surface non-EOF scanner errors; otherwise they're invisible. + if err := s.Err(); err != nil { + ex.Logf("log scanner stopped: %v", err) + } + }() + + return ex +} + +type Expecter struct { + t *testing.T + out *stdbuf + name *atomic.String + + runeReader *bufio.Reader + copyDone, logDone chan struct{} + logr, logw io.Closer +} + +// Rename the expecter. Make sure you set this before anything starts writing to the +// stream, or it may not be named consistently. +func (e *Expecter) Rename(name string) { + e.name.Store(name) +} + +func (e *Expecter) Close(reason string) error { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + e.Logf("closing expecter: %s", reason) + + // Caller needs to have closed the stream so that copying can complete + select { + case <-ctx.Done(): + e.fatalf("close", "copy did not close in time") + case <-e.copyDone: + } + + e.logClose("logw", e.logw) + e.logClose("logr", e.logr) + select { + case <-ctx.Done(): + e.fatalf("close", "log pipe did not close in time") + case <-e.logDone: + } + + e.Logf("closed expecter") + + return nil +} + +func (e *Expecter) logClose(name string, c io.Closer) { + e.Logf("closing %s", name) + err := c.Close() + e.Logf("closed %s: %v", name, err) +} + +// Deprecated: use ExpectMatchContext instead. +// This uses a background context, so will not respect the test's context. +func (e *Expecter) ExpectMatch(str string) string { + return e.expectMatchContextFunc(str, e.ExpectMatchContext) +} + +func (e *Expecter) ExpectRegexMatch(str string) string { + return e.expectMatchContextFunc(str, e.ExpectRegexMatchContext) +} + +func (e *Expecter) expectMatchContextFunc(str string, fn func(ctx context.Context, str string) string) string { + e.t.Helper() + + timeout, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + return fn(timeout, str) +} + +// TODO(mafredri): Rename this to ExpectMatch when refactoring. +func (e *Expecter) ExpectMatchContext(ctx context.Context, str string) string { + return e.expectMatcherFunc(ctx, str, strings.Contains) +} + +func (e *Expecter) ExpectRegexMatchContext(ctx context.Context, str string) string { + return e.expectMatcherFunc(ctx, str, func(src, pattern string) bool { + return regexp.MustCompile(pattern).MatchString(src) + }) +} + +func (e *Expecter) expectMatcherFunc(ctx context.Context, str string, fn func(src, pattern string) bool) string { + e.t.Helper() + + var buffer bytes.Buffer + err := e.doMatchWithDeadline(ctx, "ExpectMatchContext", func(rd *bufio.Reader) error { + for { + r, _, err := rd.ReadRune() + if err != nil { + return err + } + _, err = buffer.WriteRune(r) + if err != nil { + return err + } + if fn(buffer.String(), str) { + return nil + } + } + }) + if err != nil { + e.fatalf("read error", "%v (wanted %q; got %q)", err, str, buffer.String()) + return "" + } + e.Logf("matched %q = %q", str, buffer.String()) + return buffer.String() +} + +// ExpectNoMatchBefore validates that `match` does not occur before `before`. +func (e *Expecter) ExpectNoMatchBefore(ctx context.Context, match, before string) string { + e.t.Helper() + + var buffer bytes.Buffer + err := e.doMatchWithDeadline(ctx, "ExpectNoMatchBefore", func(rd *bufio.Reader) error { + for { + r, _, err := rd.ReadRune() + if err != nil { + return err + } + _, err = buffer.WriteRune(r) + if err != nil { + return err + } + + if strings.Contains(buffer.String(), match) { + return xerrors.Errorf("found %q before %q", match, before) + } + + if strings.Contains(buffer.String(), before) { + return nil + } + } + }) + if err != nil { + e.fatalf("read error", "%v (wanted no %q before %q; got %q)", err, match, before, buffer.String()) + return "" + } + e.Logf("matched %q = %q", before, stripansi.Strip(buffer.String())) + return buffer.String() +} + +func (e *Expecter) Peek(ctx context.Context, n int) []byte { + e.t.Helper() + + var out []byte + err := e.doMatchWithDeadline(ctx, "Peek", func(rd *bufio.Reader) error { + var err error + out, err = rd.Peek(n) + return err + }) + if err != nil { + e.fatalf("read error", "%v (wanted %d bytes; got %d: %q)", err, n, len(out), out) + return nil + } + e.Logf("peeked %d/%d bytes = %q", len(out), n, out) + return slices.Clone(out) +} + +//nolint:govet // We don't care about conforming to ReadRune() (rune, int, error). +func (e *Expecter) ReadRune(ctx context.Context) rune { + e.t.Helper() + + var r rune + err := e.doMatchWithDeadline(ctx, "ReadRune", func(rd *bufio.Reader) error { + var err error + r, _, err = rd.ReadRune() + return err + }) + if err != nil { + e.fatalf("read error", "%v (wanted rune; got %q)", err, r) + return 0 + } + e.Logf("matched rune = %q", r) + return r +} + +func (e *Expecter) ReadLine(ctx context.Context) string { + e.t.Helper() + + var buffer bytes.Buffer + err := e.doMatchWithDeadline(ctx, "ReadLine", func(rd *bufio.Reader) error { + for { + r, _, err := rd.ReadRune() + if err != nil { + return err + } + if r == '\n' { + return nil + } + if r == '\r' { + // Peek the next rune to see if it's an LF and then consume + // it. + + // Unicode code points can be up to 4 bytes, but the + // ones we're looking for are only 1 byte. + b, _ := rd.Peek(1) + if len(b) == 0 { + return nil + } + + r, _ = utf8.DecodeRune(b) + if r == '\n' { + _, _, err = rd.ReadRune() + if err != nil { + return err + } + } + + return nil + } + + _, err = buffer.WriteRune(r) + if err != nil { + return err + } + } + }) + if err != nil { + e.fatalf("read error", "%v (wanted newline; got %q)", err, buffer.String()) + return "" + } + e.Logf("matched newline = %q", buffer.String()) + return buffer.String() +} + +func (e *Expecter) ReadAll() []byte { + e.t.Helper() + return e.out.ReadAll() +} + +func (e *Expecter) doMatchWithDeadline(ctx context.Context, name string, fn func(*bufio.Reader) error) error { + e.t.Helper() + + // A timeout is mandatory, caller can decide by passing a context + // that times out. + if _, ok := ctx.Deadline(); !ok { + timeout := testutil.WaitMedium + e.Logf("%s ctx has no deadline, using %s", name, timeout) + var cancel context.CancelFunc + //nolint:gocritic // Rule guard doesn't detect that we're using testutil.Wait*. + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + match := make(chan error, 1) + go func() { + defer close(match) + match <- fn(e.runeReader) + }() + select { + case err := <-match: + return err + case <-ctx.Done(): + // Ensure goroutine is cleaned up before test exit, do not call + // (*outExpecter).close here to let the caller decide. + _ = e.out.Close() + <-match + + return xerrors.Errorf("match deadline exceeded: %w", ctx.Err()) + } +} + +func (e *Expecter) Logf(format string, args ...interface{}) { + e.t.Helper() + + // Match regular logger timestamp format, we seem to be logging in + // UTC in other places as well, so match here. + e.t.Logf("%s: %s: %s", time.Now().UTC().Format("2006-01-02 15:04:05.000"), e.name.Load(), fmt.Sprintf(format, args...)) +} + +func (e *Expecter) fatalf(reason string, format string, args ...interface{}) { + e.t.Helper() + + // Ensure the message is part of the normal log stream before + // failing the test. + e.Logf("%s: %s", reason, fmt.Sprintf(format, args...)) + + require.FailNowf(e.t, reason, format, args...) +} diff --git a/testutil/expecter/stdbuf.go b/testutil/expecter/stdbuf.go new file mode 100644 index 0000000000..092f401d1e --- /dev/null +++ b/testutil/expecter/stdbuf.go @@ -0,0 +1,119 @@ +package expecter + +import ( + "bytes" + "io" + "sync" + + "golang.org/x/xerrors" +) + +// stdbuf is like a buffered stdout, it buffers writes until read. +type stdbuf struct { + r io.Reader + + mu sync.Mutex // Protects following. + b []byte + more chan struct{} + err error +} + +func newStdbuf() *stdbuf { + return &stdbuf{more: make(chan struct{}, 1)} +} + +func (b *stdbuf) ReadAll() []byte { + b.mu.Lock() + defer b.mu.Unlock() + + if b.err != nil { + return nil + } + p := append([]byte(nil), b.b...) + b.b = b.b[len(b.b):] + return p +} + +func (b *stdbuf) Read(p []byte) (int, error) { + if b.r == nil { + return b.readOrWaitForMore(p) + } + + n, err := b.r.Read(p) + if xerrors.Is(err, io.EOF) { + b.r = nil + err = nil + if n == 0 { + return b.readOrWaitForMore(p) + } + } + return n, err +} + +func (b *stdbuf) readOrWaitForMore(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + // Deplete channel so that more check + // is for future input into buffer. + select { + case <-b.more: + default: + } + + if len(b.b) == 0 { + if b.err != nil { + return 0, b.err + } + + b.mu.Unlock() + <-b.more + b.mu.Lock() + } + + b.r = bytes.NewReader(b.b) + b.b = b.b[len(b.b):] + + return b.r.Read(p) +} + +func (b *stdbuf) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + b.mu.Lock() + defer b.mu.Unlock() + + if b.err != nil { + return 0, b.err + } + + b.b = append(b.b, p...) + + select { + case b.more <- struct{}{}: + default: + } + + return len(p), nil +} + +func (b *stdbuf) Close() error { + return b.closeErr(nil) +} + +func (b *stdbuf) closeErr(err error) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.err != nil { + return err + } + if err == nil { + b.err = io.EOF + } else { + b.err = err + } + close(b.more) + return err +} diff --git a/pty/ptytest/ptytest_internal_test.go b/testutil/expecter/stdbuf_internal_test.go similarity index 97% rename from pty/ptytest/ptytest_internal_test.go rename to testutil/expecter/stdbuf_internal_test.go index 2915417863..02365a8ff6 100644 --- a/pty/ptytest/ptytest_internal_test.go +++ b/testutil/expecter/stdbuf_internal_test.go @@ -1,4 +1,4 @@ -package ptytest +package expecter import ( "bytes" From ee4126e913402ca1d450d82f1f35396b83691a2e Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Thu, 28 May 2026 17:50:37 -0400 Subject: [PATCH 05/63] test: refactor CLI create tests not to use PTY (#25807) Part of https://github.com/coder/internal/issues/1400 Refactors CLI tests of the `create` command as the first batch of tests refactored to take a PTY out of the loop. One interesting difference I noticed between PTY and a direct pipe to standard in is that on the PTY we write `\r` to enter some input, but the kernel actually sends `\n` (or maybe `\r\n`) to the process, at least on Unix. (On windows we sent `\r\n` into the PTY). This is reflected in the implementation of the `Writer` , otherwise mostly inspired by the PTYTest equivalents. --- cli/create_test.go | 359 +++++++++++++++++----------------- pty/ptytest/ptytest.go | 17 +- testutil/expecter/expecter.go | 23 ++- testutil/writer.go | 55 ++++++ 4 files changed, 262 insertions(+), 192 deletions(-) create mode 100644 testutil/writer.go diff --git a/cli/create_test.go b/cli/create_test.go index 670f785791..043148d178 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -20,8 +20,8 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" - "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" + "github.com/coder/coder/v2/testutil/expecter" ) func TestCreateDynamic(t *testing.T) { @@ -74,14 +74,14 @@ func TestCreateDynamic(t *testing.T) { } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) doneChan := make(chan error) go func() { doneChan <- inv.Run() }() - pty.ExpectMatchContext(ctx, "has been created") + stdout.ExpectMatchContext(ctx, "has been created") err := testutil.RequireReceive(ctx, t, doneChan) require.NoError(t, err) @@ -103,14 +103,14 @@ func TestCreateDynamic(t *testing.T) { } inv, root = clitest.New(t, args...) clitest.SetupConfig(t, member, root) - pty = ptytest.New(t).Attach(inv) + stdout = expecter.NewAttachedToInvocation(t, inv) doneChan = make(chan error) go func() { doneChan <- inv.Run() }() - pty.ExpectMatchContext(ctx, "has been created") + stdout.ExpectMatchContext(ctx, "has been created") err = testutil.RequireReceive(ctx, t, doneChan) require.NoError(t, err) @@ -129,7 +129,8 @@ func TestCreateDynamic(t *testing.T) { // When enable_region=true, the region parameter becomes required and CLI should prompt. t.Run("PromptForConditionalParam", func(t *testing.T) { t.Parallel() - ctx := testutil.Context(t, testutil.WaitLong) + ctx := testutil.Context(t, time.Hour) + logger := testutil.Logger(t) template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{ MainTF: conditionalParamTF, @@ -143,7 +144,8 @@ func TestCreateDynamic(t *testing.T) { } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) doneChan := make(chan error) go func() { @@ -151,14 +153,14 @@ func TestCreateDynamic(t *testing.T) { }() // CLI should prompt for the region parameter since enable_region=true - pty.ExpectMatchContext(ctx, "region") - pty.WriteLine("eu-west") + stdout.ExpectMatchContext(ctx, "region") + stdin.WriteLine("eu-west") // Confirm creation - pty.ExpectMatchContext(ctx, "Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") - pty.ExpectMatchContext(ctx, "has been created") + stdout.ExpectMatchContext(ctx, "has been created") err := <-doneChan require.NoError(t, err) @@ -305,14 +307,14 @@ func TestCreateDynamic(t *testing.T) { "-y", ) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) doneChan := make(chan error) go func() { doneChan <- inv.Run() }() - pty.ExpectMatchContext(ctx, "has been created") + stdout.ExpectMatchContext(ctx, "has been created") err = <-doneChan require.NoError(t, err, "slider=8 should succeed when max_slider=10") @@ -331,6 +333,8 @@ func TestCreate(t *testing.T) { t.Parallel() t.Run("Create", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -348,7 +352,8 @@ func TestCreate(t *testing.T) { inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -363,9 +368,9 @@ func TestCreate(t *testing.T) { {match: "Confirm create", write: "yes"}, } for _, m := range matches { - pty.ExpectMatch(m.match) + stdout.ExpectMatchContext(ctx, m.match) if len(m.write) > 0 { - pty.WriteLine(m.write) + stdin.WriteLine(m.write) } } <-doneChan @@ -385,6 +390,8 @@ func TestCreate(t *testing.T) { t.Run("CreateForOtherUser", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithAgent()) @@ -403,7 +410,8 @@ func TestCreate(t *testing.T) { //nolint:gocritic // Creating a workspace for another user requires owner permissions. clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -418,9 +426,9 @@ func TestCreate(t *testing.T) { {match: "Confirm create", write: "yes"}, } for _, m := range matches { - pty.ExpectMatch(m.match) + stdout.ExpectMatchContext(ctx, m.match) if len(m.write) > 0 { - pty.WriteLine(m.write) + stdin.WriteLine(m.write) } } <-doneChan @@ -439,6 +447,8 @@ func TestCreate(t *testing.T) { t.Run("CreateWithSpecificTemplateVersion", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -467,7 +477,8 @@ func TestCreate(t *testing.T) { inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -482,9 +493,9 @@ func TestCreate(t *testing.T) { {match: "Confirm create", write: "yes"}, } for _, m := range matches { - pty.ExpectMatch(m.match) + stdout.ExpectMatchContext(ctx, m.match) if len(m.write) > 0 { - pty.WriteLine(m.write) + stdin.WriteLine(m.write) } } <-doneChan @@ -506,6 +517,8 @@ func TestCreate(t *testing.T) { t.Run("InheritStopAfterFromTemplate", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -522,7 +535,8 @@ func TestCreate(t *testing.T) { } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) waiter := clitest.StartWithWaiter(t, inv) matches := []struct { match string @@ -533,9 +547,9 @@ func TestCreate(t *testing.T) { {match: "Confirm create", write: "yes"}, } for _, m := range matches { - pty.ExpectMatch(m.match) + stdout.ExpectMatchContext(ctx, m.match) if len(m.write) > 0 { - pty.WriteLine(m.write) + stdin.WriteLine(m.write) } } waiter.RequireSuccess() @@ -570,6 +584,8 @@ func TestCreate(t *testing.T) { t.Run("FromNothing", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -579,7 +595,8 @@ func TestCreate(t *testing.T) { inv, root := clitest.New(t, "create", "") clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -592,8 +609,8 @@ func TestCreate(t *testing.T) { for i := 0; i < len(matches); i += 2 { match := matches[i] value := matches[i+1] - pty.ExpectMatch(match) - pty.WriteLine(value) + stdout.ExpectMatchContext(ctx, match) + stdin.WriteLine(value) } <-doneChan @@ -621,14 +638,14 @@ func TestCreate(t *testing.T) { ) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) go func() { defer close(doneChan) err := inv.Run() assert.NoError(t, err) }() - pty.ExpectMatchContext(ctx, "building in the background") + stdout.ExpectMatchContext(ctx, "building in the background") _ = testutil.TryReceive(ctx, t, doneChan) // Verify workspace was actually created. @@ -658,14 +675,14 @@ func TestCreate(t *testing.T) { ) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) go func() { defer close(doneChan) err := inv.Run() assert.NoError(t, err) }() - pty.ExpectMatchContext(ctx, "building in the background") + stdout.ExpectMatchContext(ctx, "building in the background") _ = testutil.TryReceive(ctx, t, doneChan) // Verify workspace was created and parameters were applied. @@ -706,14 +723,14 @@ func TestCreate(t *testing.T) { ) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) go func() { defer close(doneChan) err := inv.Run() assert.NoError(t, err) }() - pty.ExpectMatchContext(ctx, "building in the background") + stdout.ExpectMatchContext(ctx, "building in the background") _ = testutil.TryReceive(ctx, t, doneChan) ws, err := member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) @@ -801,7 +818,7 @@ func TestCreateWithRichParameters(t *testing.T) { setup func() []string // handlePty optionally runs after the command is started. It should handle // all expected prompts from the pty. - handlePty func(pty *ptytest.PTY) + handlePty func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) // postRun runs after the command has finished but before the workspace is // verified. It must return the workspace name to check (used for the copy // workspace tests). @@ -818,15 +835,15 @@ func TestCreateWithRichParameters(t *testing.T) { }{ { name: "ValuesFromPrompt", - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // Enter the value for each parameter as prompted. for _, param := range params { - pty.ExpectMatch(param.name) - pty.WriteLine(param.value) + stdout.ExpectMatchContext(ctx, param.name) + stdin.WriteLine(param.value) } // Confirm the creation. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, }, { @@ -839,16 +856,16 @@ func TestCreateWithRichParameters(t *testing.T) { } return args }, - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // Simply accept the defaults. for _, param := range params { - pty.ExpectMatch(param.name) - pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`) - pty.WriteLine("") + stdout.ExpectMatchContext(ctx, param.name) + stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`) + stdin.WriteLine("") } // Confirm the creation. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, }, { @@ -865,10 +882,10 @@ func TestCreateWithRichParameters(t *testing.T) { return []string{"--rich-parameter-file", parameterFile.Name()} }, - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // No prompts, we only need to confirm. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, }, { @@ -881,10 +898,10 @@ func TestCreateWithRichParameters(t *testing.T) { } return args }, - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // No prompts, we only need to confirm. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, }, { @@ -920,9 +937,6 @@ func TestCreateWithRichParameters(t *testing.T) { postRun: func(t *testing.T, tctx testContext) string { inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y") clitest.SetupConfig(t, tctx.member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() err := inv.Run() require.NoError(t, err, "failed to create a workspace based on the source workspace") return "other-workspace" @@ -952,9 +966,6 @@ func TestCreateWithRichParameters(t *testing.T) { // Then create the copy. It should use the old template version. inv, root := clitest.New(t, "create", "--copy-parameters-from", tctx.workspaceName, "other-workspace", "-y") clitest.SetupConfig(t, tctx.member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() err := inv.Run() require.NoError(t, err, "failed to create a workspace based on the source workspace") return "other-workspace" @@ -962,16 +973,16 @@ func TestCreateWithRichParameters(t *testing.T) { }, { name: "ValuesFromTemplateDefaults", - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // Simply accept the defaults. for _, param := range params { - pty.ExpectMatch(param.name) - pty.ExpectMatch(`Enter a value (default: "` + param.value + `")`) - pty.WriteLine("") + stdout.ExpectMatchContext(ctx, param.name) + stdout.ExpectMatchContext(ctx, `Enter a value (default: "`+param.value+`")`) + stdin.WriteLine("") } // Confirm the creation. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, withDefaults: true, }, @@ -980,14 +991,14 @@ func TestCreateWithRichParameters(t *testing.T) { setup: func() []string { return []string{"--use-parameter-defaults"} }, - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // Default values should get printed. for _, param := range params { - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value)) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value)) } // No prompts, we only need to confirm. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, withDefaults: true, }, @@ -1001,14 +1012,14 @@ func TestCreateWithRichParameters(t *testing.T) { } return args }, - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // Default values should get printed. for _, param := range params { - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", param.name, param.value)) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", param.name, param.value)) } // No prompts, we only need to confirm. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, }, { @@ -1031,14 +1042,14 @@ cli_param: from file`) "--parameter", "cli_param=from cli", } }, - handlePty: func(pty *ptytest.PTY) { + handlePty: func(ctx context.Context, stdout *expecter.Expecter, stdin *testutil.Writer) { // Should get prompted for the input param since it has no default. - pty.ExpectMatch("input_param") - pty.WriteLine("from input") + stdout.ExpectMatchContext(ctx, "input_param") + stdin.WriteLine("from input") // Confirm the creation. - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }, withDefaults: true, inputParameters: []param{ @@ -1082,6 +1093,8 @@ cli_param: from file`) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) parameters := params if len(tt.inputParameters) > 0 { @@ -1122,14 +1135,15 @@ cli_param: from file`) inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) doneChan := make(chan error) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { doneChan <- inv.Run() }() // The test may do something with the pty. if tt.handlePty != nil { - tt.handlePty(pty) + tt.handlePty(ctx, stdout, stdin) } // Wait for the command to exit. @@ -1235,6 +1249,7 @@ func TestCreateWithPreset(t *testing.T) { // the CLI uses the specified preset instead of the default t.Run("PresetFlag", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1263,17 +1278,15 @@ func TestCreateWithPreset(t *testing.T) { workspaceName := "my-workspace" inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", preset.Name) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() + stdout := expecter.NewAttachedToInvocation(t, inv) err := inv.Run() require.NoError(t, err) // Should: display the selected preset as well as its parameters presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name) - pty.ExpectMatch(presetName) - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue)) + stdout.ExpectMatchContext(ctx, presetName) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue)) // Verify if the new workspace uses expected parameters. ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) @@ -1312,6 +1325,7 @@ func TestCreateWithPreset(t *testing.T) { // the CLI automatically uses the default preset to create the workspace t.Run("DefaultPreset", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1340,22 +1354,17 @@ func TestCreateWithPreset(t *testing.T) { workspaceName := "my-workspace" inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y") clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() + stdout := expecter.NewAttachedToInvocation(t, inv) err := inv.Run() require.NoError(t, err) // Should: display the default preset as well as its parameters presetName := fmt.Sprintf("Preset '%s' (default) applied:", defaultPreset.Name) - pty.ExpectMatch(presetName) - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue)) + stdout.ExpectMatchContext(ctx, presetName) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", thirdParameterName, thirdParameterValue)) // Verify if the new workspace uses expected parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - tvPresets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) require.Len(t, tvPresets, 2) @@ -1389,12 +1398,14 @@ func TestCreateWithPreset(t *testing.T) { // the CLI prompts the user to select a preset. t.Run("NoDefaultPresetPromptUser", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - // Given: a template and a template version with two presets + // Given: a template and a template version with a single, non-default preset. preset := proto.Preset{ Name: "preset-test", Description: "Preset Test.", @@ -1414,7 +1425,8 @@ func TestCreateWithPreset(t *testing.T) { "--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue)) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -1422,18 +1434,16 @@ func TestCreateWithPreset(t *testing.T) { }() // Should: prompt the user for the preset - pty.ExpectMatch("Select a preset below:") - pty.WriteLine("\n") - pty.ExpectMatch("Preset 'preset-test' applied") - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Select a preset below:") + // We don't actually have to respond to the selector, since we hardcode the cliui.Select to return the + // first option in test scenarios (c.f. cliui/select.go) + stdout.ExpectMatchContext(ctx, "Preset 'preset-test' applied") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") <-doneChan // Verify if the new workspace uses expected parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - tvPresets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) require.Len(t, tvPresets, 1) @@ -1460,6 +1470,7 @@ func TestCreateWithPreset(t *testing.T) { // with workspace creation without applying any preset. t.Run("TemplateVersionWithoutPresets", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1476,17 +1487,12 @@ func TestCreateWithPreset(t *testing.T) { "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue), "--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue)) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() + stdout := expecter.NewAttachedToInvocation(t, inv) err := inv.Run() require.NoError(t, err) - pty.ExpectMatch("No preset applied.") + stdout.ExpectMatchContext(ctx, "No preset applied.") // Verify if the new workspace uses expected parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ Name: workspaceName, }) @@ -1509,6 +1515,7 @@ func TestCreateWithPreset(t *testing.T) { // The workspace should be created without using any preset-defined parameters. t.Run("PresetFlagNone", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1533,17 +1540,12 @@ func TestCreateWithPreset(t *testing.T) { "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue), "--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue)) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() + stdout := expecter.NewAttachedToInvocation(t, inv) err := inv.Run() require.NoError(t, err) - pty.ExpectMatch("No preset applied.") + stdout.ExpectMatchContext(ctx, "No preset applied.") // Verify that the new workspace doesn't use the preset parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - tvPresets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) require.Len(t, tvPresets, 1) @@ -1591,9 +1593,6 @@ func TestCreateWithPreset(t *testing.T) { workspaceName := "my-workspace" inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "-y", "--preset", "invalid-preset") clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() err := inv.Run() // Should: fail with an error indicating the preset was not found @@ -1610,6 +1609,7 @@ func TestCreateWithPreset(t *testing.T) { // - and the value of parameter B from the parameter flag. t.Run("PresetOverridesParameterFlagValues", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1633,21 +1633,16 @@ func TestCreateWithPreset(t *testing.T) { "--parameter", fmt.Sprintf("%s=%s", firstParameterName, firstOptionalParameterValue), "--parameter", fmt.Sprintf("%s=%s", thirdParameterName, thirdParameterValue)) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() + stdout := expecter.NewAttachedToInvocation(t, inv) err := inv.Run() require.NoError(t, err) // Should: display the selected preset as well as its parameter presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name) - pty.ExpectMatch(presetName) - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) + stdout.ExpectMatchContext(ctx, presetName) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) // Verify if the new workspace uses expected parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - tvPresets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) require.Len(t, tvPresets, 1) @@ -1679,6 +1674,7 @@ func TestCreateWithPreset(t *testing.T) { // - and the value of parameter B from the file. t.Run("PresetOverridesParameterFileValues", func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1707,21 +1703,16 @@ func TestCreateWithPreset(t *testing.T) { "--preset", preset.Name, "--rich-parameter-file", parameterFile.Name()) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - inv.Stdout = pty.Output() - inv.Stderr = pty.Output() + stdout := expecter.NewAttachedToInvocation(t, inv) err := inv.Run() require.NoError(t, err) // Should: display the selected preset as well as its parameter presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name) - pty.ExpectMatch(presetName) - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) + stdout.ExpectMatchContext(ctx, presetName) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) // Verify if the new workspace uses expected parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) - defer cancel() - tvPresets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) require.Len(t, tvPresets, 1) @@ -1748,7 +1739,8 @@ func TestCreateWithPreset(t *testing.T) { // the CLI prompts the user for input to fill in the missing parameters. t.Run("PromptsForMissingParametersWhenPresetIsIncomplete", func(t *testing.T) { t.Parallel() - + ctx := testutil.Context(t, testutil.WaitMedium) + logger := testutil.Logger(t) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -1769,7 +1761,8 @@ func TestCreateWithPreset(t *testing.T) { inv, root := clitest.New(t, "create", workspaceName, "--template", template.Name, "--preset", preset.Name) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -1778,21 +1771,18 @@ func TestCreateWithPreset(t *testing.T) { // Should: display the selected preset as well as its parameters presetName := fmt.Sprintf("Preset '%s' applied:", preset.Name) - pty.ExpectMatch(presetName) - pty.ExpectMatch(fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) + stdout.ExpectMatchContext(ctx, presetName) + stdout.ExpectMatchContext(ctx, fmt.Sprintf("%s: '%s'", firstParameterName, secondOptionalParameterValue)) // Should: prompt for the missing parameter - pty.ExpectMatch(thirdParameterDescription) - pty.WriteLine(thirdParameterValue) - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, thirdParameterDescription) + stdin.WriteLine(thirdParameterValue) + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") <-doneChan // Verify if the new workspace uses expected parameters. - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - tvPresets, err := client.TemplateVersionPresets(ctx, version.ID) require.NoError(t, err) require.Len(t, tvPresets, 1) @@ -1857,7 +1847,8 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Run("ValidateString", func(t *testing.T) { t.Parallel() - + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -1869,7 +1860,8 @@ func TestCreateValidateRichParameters(t *testing.T) { inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -1885,9 +1877,9 @@ func TestCreateValidateRichParameters(t *testing.T) { for i := 0; i < len(matches); i += 2 { match := matches[i] value := matches[i+1] - pty.ExpectMatch(match) + stdout.ExpectMatchContext(ctx, match) if value != "" { - pty.WriteLine(value) + stdin.WriteLine(value) } } <-doneChan @@ -1895,6 +1887,8 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Run("ValidateNumber", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1907,7 +1901,8 @@ func TestCreateValidateRichParameters(t *testing.T) { inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -1923,9 +1918,9 @@ func TestCreateValidateRichParameters(t *testing.T) { for i := 0; i < len(matches); i += 2 { match := matches[i] value := matches[i+1] - pty.ExpectMatch(match) + stdout.ExpectMatchContext(ctx, match) if value != "" { - pty.WriteLine(value) + stdin.WriteLine(value) } } <-doneChan @@ -1933,6 +1928,8 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Run("ValidateNumber_CustomError", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1945,7 +1942,8 @@ func TestCreateValidateRichParameters(t *testing.T) { inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -1961,9 +1959,9 @@ func TestCreateValidateRichParameters(t *testing.T) { for i := 0; i < len(matches); i += 2 { match := matches[i] value := matches[i+1] - pty.ExpectMatch(match) + stdout.ExpectMatchContext(ctx, match) if value != "" { - pty.WriteLine(value) + stdin.WriteLine(value) } } <-doneChan @@ -1971,6 +1969,8 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Run("ValidateBool", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -1983,7 +1983,8 @@ func TestCreateValidateRichParameters(t *testing.T) { inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) go func() { defer close(doneChan) err := inv.Run() @@ -1999,9 +2000,9 @@ func TestCreateValidateRichParameters(t *testing.T) { for i := 0; i < len(matches); i += 2 { match := matches[i] value := matches[i+1] - pty.ExpectMatch(match) + stdout.ExpectMatchContext(ctx, match) if value != "" { - pty.WriteLine(value) + stdin.WriteLine(value) } } <-doneChan @@ -2018,15 +2019,18 @@ func TestCreateValidateRichParameters(t *testing.T) { template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) t.Run("Prompt", func(t *testing.T) { + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) inv, root := clitest.New(t, "create", "my-workspace-1", "--template", template.Name) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) clitest.Start(t, inv) - pty.ExpectMatch(listOfStringsParameterName) - pty.ExpectMatch("aaa, bbb, ccc") - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, listOfStringsParameterName) + stdout.ExpectMatchContext(ctx, "aaa, bbb, ccc") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") }) t.Run("Default", func(t *testing.T) { @@ -2049,6 +2053,8 @@ func TestCreateValidateRichParameters(t *testing.T) { t.Run("ValidateListOfStrings_YAMLFile", func(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) @@ -2066,8 +2072,8 @@ func TestCreateValidateRichParameters(t *testing.T) { - fff`) inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name, "--rich-parameter-file", parameterFile.Name()) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) - + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) clitest.Start(t, inv) matches := []string{ @@ -2076,9 +2082,9 @@ func TestCreateValidateRichParameters(t *testing.T) { for i := 0; i < len(matches); i += 2 { match := matches[i] value := matches[i+1] - pty.ExpectMatch(match) + stdout.ExpectMatchContext(ctx, match) if value != "" { - pty.WriteLine(value) + stdin.WriteLine(value) } } }) @@ -2086,6 +2092,8 @@ func TestCreateValidateRichParameters(t *testing.T) { func TestCreateWithGitAuth(t *testing.T) { t.Parallel() + logger := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitMedium) echoResponses := &echo.Responses{ Parse: echo.ParseComplete, ProvisionInit: echo.InitComplete, @@ -2120,13 +2128,14 @@ func TestCreateWithGitAuth(t *testing.T) { inv, root := clitest.New(t, "create", "my-workspace", "--template", template.Name) clitest.SetupConfig(t, member, root) - pty := ptytest.New(t).Attach(inv) + stdout := expecter.NewAttachedToInvocation(t, inv) + stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv) clitest.Start(t, inv) - pty.ExpectMatch("You must authenticate with GitHub to create a workspace") + stdout.ExpectMatchContext(ctx, "You must authenticate with GitHub to create a workspace") resp := coderdtest.RequestExternalAuthCallback(t, "github", member) _ = resp.Body.Close() require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) - pty.ExpectMatch("Confirm create?") - pty.WriteLine("yes") + stdout.ExpectMatchContext(ctx, "Confirm create?") + stdin.WriteLine("yes") } diff --git a/pty/ptytest/ptytest.go b/pty/ptytest/ptytest.go index 43fff7c5d7..191f4cf622 100644 --- a/pty/ptytest/ptytest.go +++ b/pty/ptytest/ptytest.go @@ -72,15 +72,10 @@ func (p *PTY) Close() error { if pErr != nil { p.Logf("PTY: Close failed: %v", pErr) } - eErr := p.Expecter.Close("PTY close") - if eErr != nil { - p.Logf("PTY: close expecter failed: %v", eErr) - } + p.Expecter.Close("PTY close") if pErr != nil { p.closeErr = pErr - return } - p.closeErr = eErr }) return p.closeErr } @@ -135,12 +130,6 @@ func (p *PTYCmd) Close() error { if pErr != nil { p.Logf("PTYCmd: Close failed: %v", pErr) } - eErr := p.Expecter.Close("PTYCmd close") - if eErr != nil { - p.Logf("PTYCmd: close expecter failed: %v", eErr) - } - if pErr != nil { - return pErr - } - return eErr + p.Expecter.Close("PTYCmd close") + return pErr } diff --git a/testutil/expecter/expecter.go b/testutil/expecter/expecter.go index 5a370a9e64..333e9a18ab 100644 --- a/testutil/expecter/expecter.go +++ b/testutil/expecter/expecter.go @@ -19,6 +19,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" ) func New(t *testing.T, r io.Reader, name string) *Expecter { @@ -68,6 +69,22 @@ func New(t *testing.T, r io.Reader, name string) *Expecter { return ex } +func NewAttachedToInvocation(t *testing.T, invocation *serpent.Invocation) *Expecter { + r, w := io.Pipe() + invocation.Stdout = w + invocation.Stderr = w + e := New(t, r, "cmd") + + t.Cleanup(func() { + // Serpent doesn't handle closing stdout after running the Invocation; normally the OS does that automatically when + // the process exits. Close it here at the end of the test to ensure we don't leak goroutines reading from the + // stdout/stderr. + _ = w.Close() + e.Close("test end") + }) + return e +} + type Expecter struct { t *testing.T out *stdbuf @@ -84,7 +101,7 @@ func (e *Expecter) Rename(name string) { e.name.Store(name) } -func (e *Expecter) Close(reason string) error { +func (e *Expecter) Close(reason string) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -94,6 +111,7 @@ func (e *Expecter) Close(reason string) error { select { case <-ctx.Done(): e.fatalf("close", "copy did not close in time") + return case <-e.copyDone: } @@ -102,12 +120,11 @@ func (e *Expecter) Close(reason string) error { select { case <-ctx.Done(): e.fatalf("close", "log pipe did not close in time") + return case <-e.logDone: } e.Logf("closed expecter") - - return nil } func (e *Expecter) logClose(name string, c io.Closer) { diff --git a/testutil/writer.go b/testutil/writer.go new file mode 100644 index 0000000000..4def987e62 --- /dev/null +++ b/testutil/writer.go @@ -0,0 +1,55 @@ +package testutil + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "gvisor.dev/gvisor/pkg/context" + + "cdr.dev/slog/v3" + "github.com/coder/serpent" +) + +// Writer wraps an underlying io.Writer and provides friendlier methods to write to it, including logging. +type Writer struct { + t *testing.T + w io.Writer + l slog.Logger +} + +func NewWriterAttachedToInvocation(t *testing.T, logger slog.Logger, invocation *serpent.Invocation) *Writer { + r, w := io.Pipe() + invocation.Stdin = r + // Close the pipe at the end of the test to ensure any goroutine in the Invocation that reads from stdin won't leak. + t.Cleanup(func() { + _ = w.Close() + }) + return &Writer{ + t: t, + w: w, + l: logger, + } +} + +func (w *Writer) Write(r rune) { + w.t.Helper() + _, err := w.w.Write([]byte{byte(r)}) + if assert.NoError(w.t, err, "write failed") { + w.l.Debug(context.Background(), "wrote rune", slog.F("rune", r)) + } +} + +func (w *Writer) WriteLine(str string) { + w.t.Helper() + + // Always write Windows style endings since our CLI prompt readers trim both out. Note this is *different* than what + // PTY-based tests do. On Unix-like operating systems we write a single carriage-return (\r) to delimit a line + // and the PTY translates it to a line feed (\n) for the CLI command to read. Here there is no translation. + newline := []byte{'\r', '\n'} + + _, err := w.w.Write(append([]byte(str), newline...)) + if assert.NoError(w.t, err, "write line failed") { + w.l.Debug(context.Background(), "wrote line", slog.F("line", str+string(newline))) + } +} From d3bedb4a9399d99602851eb9c776fd14665999aa Mon Sep 17 00:00:00 2001 From: TJ Date: Thu, 28 May 2026 15:03:20 -0700 Subject: [PATCH 06/63] refactor(site): promote search to full-width sidebar nav item (#25595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the chat search button from a subtle icon next to the filter in the Chats header to a prominent full-width nav item below New Agent. The search bar shows a magnifying glass icon and "Search" label by default, with the keyboard shortcut badge (`⌘ K` / `Ctrl K`) and background appearing on hover/focus. Also pull the Chats header and filter row out of the scroll area so the scrollbar only covers the chat list content, and align the logo row with the nav item content inset. > 🤖 Generated by Coder Agents on behalf of @tracyjohnsonux --- .../ChatsSidebar/ChatsSidebar.stories.tsx | 199 ++++++++++++++++-- .../ChatsSidebar/chats/ChatsPanel.tsx | 93 ++++---- .../ChatsSidebar/filters/FilterPopover.tsx | 2 +- .../ChatsSidebar/settings/SettingsNavItem.tsx | 44 +++- 4 files changed, 263 insertions(+), 75 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx index 5294ba61c0..a15c7cee8c 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; import { useEffect, useState } from "react"; +import { useLocation } from "react-router"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { userChatProviderConfigsKey } from "#/api/queries/chats"; @@ -12,10 +13,25 @@ import { withDashboardProvider, } from "#/testHelpers/storybook"; import { useAgentsPageKeybindings } from "../../hooks/useAgentsPageKeybindings"; -import type { AgentSidebarFilters } from "../../utils/agentSidebarFilters"; +import { DEFAULT_AGENT_SIDEBAR_FILTERS as defaultSidebarFilters } from "../../utils/agentSidebarFilters"; import type { ModelSelectorOption } from "../ChatElements"; import { ChatsSidebar } from "./ChatsSidebar"; +// Probe element used by the archived-filter preservation story to surface the +// search string of whatever child route the sidebar's NavLink ends up at. +const ChildSearchProbe = () => { + const location = useLocation(); + return
{location.search}
; +}; + +// Probe element used by the settings-link preservation story to surface the +// state.from value passed when navigating to settings. +const SettingsStateProbe = () => { + const location = useLocation(); + const from = (location.state as { from?: string })?.from ?? ""; + return
{from}
; +}; + const defaultModelOptions: ModelSelectorOption[] = [ { id: "openai:gpt-4o", @@ -25,13 +41,6 @@ const defaultModelOptions: ModelSelectorOption[] = [ }, ]; -const defaultSidebarFilters: AgentSidebarFilters = { - archiveStatus: "active", - groupBy: "date", - prStatuses: [], - chatStatuses: ["unread", "read"], -}; - const defaultModelConfigs: TypesGen.ChatModelConfig[] = [ { id: "config-openai-gpt-4o", @@ -106,8 +115,8 @@ const meta: Meta = { isCreating: false, regeneratingTitleChatIds: [], sidebarFilters: defaultSidebarFilters, - onSidebarFiltersChange: fn(), isPersonalModelOverridesEnabled: true, + onSidebarFiltersChange: fn(), }, parameters: { layout: "fullscreen", @@ -724,6 +733,68 @@ export const SectionHeadersCollapse: Story = { }, }; +export const MobileHeaderActions: Story = { + render: ChatsSidebarWithKeybindings, + args: { + chats: sectionHeaderChats, + }, + parameters: { + viewport: { defaultViewport: "mobile1" }, + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const searchButton = canvas.getByRole("button", { name: "Search chats" }); + const filterButton = canvas.getByRole("button", { name: "Filter agents" }); + const searchRect = searchButton.getBoundingClientRect(); + const filterRect = filterButton.getBoundingClientRect(); + + await expect(searchButton).not.toHaveTextContent("Search"); + expect(Math.round(searchRect.width)).toBeGreaterThanOrEqual(28); + expect(Math.round(filterRect.width)).toBeGreaterThanOrEqual(28); + expect(searchRect.right).toBeLessThan(filterRect.left); + }, +}; + +export const SidebarFilterMenu: Story = { + args: { + chats: sectionHeaderChats, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click( + canvas.getByRole("button", { name: "Filter agents" }), + ); + await expect( + await body.findByRole("radio", { name: /Archived/i }), + ).toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect( + body.queryByRole("radio", { name: /Archived/i }), + ).not.toBeInTheDocument(); + }); + }, +}; + export const SearchDialogKeyboardShortcut: Story = { render: ChatsSidebarWithKeybindings, args: { @@ -740,11 +811,23 @@ export const SearchDialogKeyboardShortcut: Story = { const body = within(document.body); const searchButton = canvas.getByRole("button", { name: "Search chats" }); - await userEvent.hover(searchButton); - const tooltip = await body.findByRole("tooltip"); - await expect(tooltip).toHaveTextContent("Search chats"); - await expect(tooltip).toHaveTextContent("Ctrl"); - await expect(tooltip).toHaveTextContent("K"); + await expect(searchButton).toHaveTextContent("Search"); + await expect(searchButton).toHaveTextContent("Ctrl"); + await expect(searchButton).toHaveTextContent("K"); + + await userEvent.click(searchButton); + const clickedSearchInput = await body.findByRole("combobox", { + name: "Search chats", + }); + await waitFor(() => { + expect(clickedSearchInput).toHaveFocus(); + }); + await userEvent.keyboard("{Escape}"); + await waitFor(() => { + expect( + body.queryByRole("combobox", { name: "Search chats" }), + ).not.toBeInTheDocument(); + }); await userEvent.keyboard("{Control>}k{/Control}"); @@ -1346,10 +1429,7 @@ export const ArchivedFilterShowsArchivedAgents: Story = { updated_at: recentTimestamp, }), ], - sidebarFilters: { - ...defaultSidebarFilters, - archiveStatus: "archived", - }, + sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" }, }, parameters: { reactRouter: reactRouterParameters({ @@ -1367,6 +1447,44 @@ export const ArchivedFilterShowsArchivedAgents: Story = { }, }; +export const PreservesArchivedFilterOnChatNavigation: Story = { + args: { + chats: [ + buildChat({ + id: "archived-nav-1", + title: "Archived nav target", + archived: true, + updated_at: recentTimestamp, + }), + ], + sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" }, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents", + searchParams: { archived: "archived" }, + }, + routing: [ + { path: "/agents", useStoryElement: true }, + { path: "/agents/:agentId", element: }, + ], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const link = await canvas.findByRole("link", { + name: /Archived nav target/, + }); + await userEvent.click(link); + await waitFor(() => { + expect(canvas.getByTestId("child-search")).toHaveTextContent( + "archived=archived", + ); + }); + }, +}; + export const NoArchivedSection: Story = { args: { chats: [ @@ -1757,10 +1875,7 @@ export const ArchivedAgentUnarchiveOption: Story = { updated_at: recentTimestamp, }), ], - sidebarFilters: { - ...defaultSidebarFilters, - archiveStatus: "archived", - }, + sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" }, }, parameters: { reactRouter: reactRouterParameters({ @@ -2100,3 +2215,43 @@ export const SettingsAdminAgentsEntryPreserved: Story = { expect(canvas.getByText("Manage Agents")).toBeInTheDocument(); }, }; + +export const PreservesArchivedFilterOnSettingsNavigation: Story = { + args: { + chats: [ + buildChat({ + id: "archived-settings-1", + title: "Archived settings target", + archived: true, + updated_at: recentTimestamp, + }), + ], + sidebarFilters: { ...defaultSidebarFilters, archiveStatus: "archived" }, + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { + path: "/agents", + searchParams: { archived: "archived" }, + }, + routing: [ + { + path: "/agents/settings", + element: , + }, + ...agentsRouting, + ], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const settingsLink = await canvas.findByRole("link", { name: "Settings" }); + await userEvent.click(settingsLink); + await waitFor(() => { + const fromValue = + canvas.getByTestId("settings-state-from").textContent ?? ""; + expect(fromValue).toContain("/agents"); + expect(fromValue).toContain("archived=archived"); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx index d3190229ec..dca8dfb6d3 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx @@ -30,11 +30,6 @@ import { ProductLogo } from "#/components/Icons/ProductLogo"; import { Kbd, KbdGroup } from "#/components/Kbd/Kbd"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { Skeleton } from "#/components/Skeleton/Skeleton"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; import { getOSKey } from "#/utils/platform"; import { @@ -350,8 +345,11 @@ export const ChatsPanel: FC = ({ aria-hidden={isSettingsPanel} inert={isSettingsPanel ? true : undefined} > -
-
+ +
+
+
+

+ Chats +

+
+ {onOpenSearchDialog && ( + + )} + +
+
+
= ({ "sm:[mask-image:none] sm:[-webkit-mask-image:none]", )} > -
-
-

- Chats -

-
- - - - - - - - {getOSKey()} - K - - Search chats - - - - -
-
+
{loadError ? (
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx index 4241209507..9c017a0e74 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/filters/FilterPopover.tsx @@ -226,7 +226,7 @@ export const FilterPopover: FC = ({ size="icon" aria-label="Filter agents" className={cn( - "h-7 w-7 min-w-0 justify-end rounded-none px-0 text-content-secondary hover:text-content-primary", + "h-7 w-7 min-w-0 -mr-0.5 justify-end px-0 text-content-secondary hover:text-content-primary", hasActiveFilters(filters) && "text-content-primary", )} > diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx index 83566ff389..3282b6596f 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx @@ -1,6 +1,6 @@ import { ShieldIcon } from "lucide-react"; -import type { FC } from "react"; -import { Link, type To } from "react-router"; +import type { ComponentProps, FC, ReactNode } from "react"; +import { Link } from "react-router"; import { Tooltip, TooltipContent, @@ -13,32 +13,52 @@ type SettingsNavItemProps = { label: string; active: boolean; adminOnly?: boolean; + ariaLabel?: string; + className?: string; disabled?: boolean; + trailing?: ReactNode; trailingIcon?: FC<{ className?: string }>; } & ( - | { to: To; replace?: boolean; state?: unknown; onClick?: () => void } + | { + to: ComponentProps["to"]; + replace?: boolean; + state?: unknown; + onClick?: () => void; + } | { to?: never; replace?: never; state?: never; onClick: () => void } ); -const navItemClassName = (active: boolean, disabled: boolean | undefined) => +const navItemClassName = ( + active: boolean, + disabled: boolean | undefined, + className: string | undefined, +) => cn( "flex w-full items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-sm cursor-pointer transition-colors no-underline", active ? "bg-surface-quaternary/25 text-content-primary font-medium" : "bg-transparent text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary", disabled && "opacity-50 pointer-events-none", + className, ); const NavItemContent: FC<{ icon: FC<{ className?: string }>; label: string; adminOnly?: boolean; + trailing?: ReactNode; trailingIcon?: FC<{ className?: string }>; -}> = ({ icon: Icon, label, adminOnly, trailingIcon: TrailingIcon }) => ( +}> = ({ + icon: Icon, + label, + adminOnly, + trailing, + trailingIcon: TrailingIcon, +}) => ( <> {label} - {(adminOnly || TrailingIcon) && ( + {(adminOnly || trailing || TrailingIcon) && ( {adminOnly && ( @@ -51,6 +71,7 @@ const NavItemContent: FC<{ )} {TrailingIcon && } + {trailing} )} @@ -61,7 +82,10 @@ export const SettingsNavItem: FC = ({ label, active, adminOnly, + ariaLabel, + className, disabled, + trailing, trailingIcon, ...rest }) => { @@ -72,14 +96,16 @@ export const SettingsNavItem: FC = ({ replace={rest.replace} state={rest.state} onClick={rest.onClick} - className={navItemClassName(active, disabled)} + className={navItemClassName(active, disabled, className)} aria-current={active ? "page" : undefined} + aria-label={ariaLabel} tabIndex={disabled ? -1 : undefined} > @@ -91,13 +117,15 @@ export const SettingsNavItem: FC = ({ type="button" onClick={rest.onClick} disabled={disabled} - className={navItemClassName(active, disabled)} + className={navItemClassName(active, disabled, className)} aria-current={active ? "page" : undefined} + aria-label={ariaLabel} > From 650069f949f8f728c5ac4babc0b3440b8edb21ea Mon Sep 17 00:00:00 2001 From: TJ Date: Thu, 28 May 2026 21:08:36 -0700 Subject: [PATCH 07/63] feat: update search dialog with filter pills and default view (#25753) Update the ChatSearchDialog with four interaction states: 1. **Default (empty)**: quick actions (New chat, Settings, Personal Skills, View usage) + recent chats list 2. **Focused**: filter-by dropdown overlay with clickable chips (Unread, Archived, PR status, Diff URL) 3. **Active search**: filter pills and/or freeform text with debounced live results showing "N results" count 4. **Parameterized filter**: incomplete pill (dashed border) for filters needing a value (e.g. `pr_status:`); Space or Enter commits the value into a solid pill Filter pills and freeform text are combinable. Backspace on empty text removes the last pill. The filter dropdown overlays content below and dismisses on blur or Escape. Changes: - `ChatSearchDialog.tsx`: manages structured filter state with separate `incompleteFilterKey` tracking, renders filter dropdown, passes recent chats and quick action callbacks - `ChatSearchInput.tsx`: renders completed filter pills (solid border, x dismiss) and incomplete pills (dashed border) inline with the text input - `ChatSearchResults.tsx`: default view shows quick actions + recent chats instead of the old help text; "No matching chats" is centered in the modal - `ChatsSidebar.tsx`: passes `recentChats`, `onNewChat`, `onOpenSettings` to the dialog
Implementation notes - No backend changes; reuses existing `chatSearch()` API and `normalizeChatSearchInput()` normalizer - Filter state uses a clean model: `filters` array for committed filters + `incompleteFilterKey` string for the parameterized filter being typed; `freeText` serves dual purpose (filter value when incomplete key is set, otherwise freeform search) - The filter dropdown is absolutely positioned (`top-full`) below the search input, dismisses via container `onBlur` with `contains(relatedTarget)` check - Quick action links (Personal Skills, View usage) use `Link` components that close the dialog on click > Generated with [Coder Agents](https://coder.com/agents)
--- .../components/ChatsSidebar/ChatsSidebar.tsx | 1 + .../dialogs/ChatSearchDialog.stories.tsx | 204 ++++++++++ .../ChatsSidebar/dialogs/ChatSearchDialog.tsx | 353 ++++++++++++++++-- .../ChatsSidebar/dialogs/ChatSearchInput.tsx | 73 +++- .../dialogs/ChatSearchResults.tsx | 123 ++++-- 5 files changed, 695 insertions(+), 59 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx index 9bc3e69bdf..042c1417c0 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx @@ -151,6 +151,7 @@ export const ChatsSidebar: FC = (props) => { open={isSearchDialogOpen} onOpenChange={onSearchDialogOpenChange} location={location} + recentChats={chats} /> {onRenameTitle && ( = { args: { open: true, onOpenChange: fn(), + recentChats: mockChats, location: { pathname: "/agents", search: "", @@ -106,6 +107,8 @@ const meta: Meta = { { path: "/agents", useStoryElement: true }, { path: "/agents/:agentId", useStoryElement: true }, { path: "/agents/settings", useStoryElement: true }, + { path: "/agents/settings/personal-skills", useStoryElement: true }, + { path: "/agents/analytics", useStoryElement: true }, ], }), }, @@ -188,6 +191,13 @@ export const RefreshingResults: Story = { // spinner were always visible. expect(body.queryByLabelText("Searching chats")).not.toBeInTheDocument(); + // Ensure the first debounced API call has been registered before + // clearing, so the clear+retype cycle triggers a distinct second call + // rather than coalescing within a single debounce window. + await waitFor(() => { + expect(API.experimental.getChats).toHaveBeenCalledTimes(1); + }); + await userEvent.clear(searchInput); await userEvent.type(searchInput, "review"); @@ -338,3 +348,197 @@ export const ErrorState: Story = { await expect(await body.findByRole("alert")).toBeInTheDocument(); }, }; + +export const ErrorStateWithStackTrace: Story = { + beforeEach: () => { + const err = new Error( + "NetworkError: Failed to fetch chats from the server API endpoint /api/v2/chats", + ); + err.stack = [ + "Error: NetworkError: Failed to fetch chats from the server API endpoint /api/v2/chats", + " at fetchChats (http://localhost:6006/src/api/queries/chats.ts:42:11)", + " at async queryFn (http://localhost:6006/src/api/queries/chats.ts:58:14)", + " at async Object.fetchQuery (http://localhost:6006/node_modules/@tanstack/react-query/src/queryClient.ts:198:16)", + " at async ChatSearchDialogContent (http://localhost:6006/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx:180:20)", + " at async renderWithHooks (http://localhost:6006/node_modules/react-dom/cjs/react-dom.development.js:14985:18)", + " at async mountIndeterminateComponent (http://localhost:6006/node_modules/react-dom/cjs/react-dom.development.js:17811:13)", + " at async beginWork (http://localhost:6006/node_modules/react-dom/cjs/react-dom.development.js:19049:16)", + ].join("\n"); + spyOn(API.experimental, "getChats").mockRejectedValue(err); + }, + play: async () => { + const body = within(document.body); + await userEvent.type( + body.getByRole("combobox", { name: "Search chats" }), + "title:", + ); + const alert = await body.findByRole("alert"); + await expect(alert).toBeInTheDocument(); + + // Open the stack trace details and verify it stays contained. + const details = body.getByText("Stack Trace"); + await userEvent.click(details); + await expect(body.getByText(/fetchChats/)).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// Interaction states: default view, filter pills, dropdown. +// --------------------------------------------------------------------------- + +export const DefaultViewWithRecentChats: Story = { + play: async () => { + const body = within(document.body); + await expect(await body.findByText("Recent chats")).toBeInTheDocument(); + await expect( + body.getByText("Fix race condition in auth middleware"), + ).toBeInTheDocument(); + }, +}; + +export const FilterDropdownOnFocus: Story = { + play: async () => { + const body = within(document.body); + const toggleButton = body.getByRole("button", { name: "Toggle filters" }); + + await userEvent.click(toggleButton); + await expect(await body.findByText("Filter by")).toBeInTheDocument(); + await expect(body.getByText("Unread")).toBeInTheDocument(); + await expect(body.getByText("Archived")).toBeInTheDocument(); + await expect(body.getByText("PR status")).toBeInTheDocument(); + await expect(body.getByText("Diff URL")).toBeInTheDocument(); + }, +}; + +export const BooleanFilterPill: Story = { + play: async () => { + const body = within(document.body); + const toggleButton = body.getByRole("button", { name: "Toggle filters" }); + + await userEvent.click(toggleButton); + await userEvent.click(await body.findByText("Unread")); + + await expect(await body.findByText("has_unread:true")).toBeInTheDocument(); + await expect( + body.getByRole("button", { name: "Remove has_unread filter" }), + ).toBeInTheDocument(); + + await waitFor(() => { + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: CHAT_SEARCH_LIMIT, + q: "has_unread:true", + }); + }); + }, +}; + +export const ParameterizedFilterPill: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChats").mockResolvedValue(mockChats); + }, + play: async () => { + const body = within(document.body); + const searchInput = body.getByRole("combobox", { name: "Search chats" }); + const toggleButton = body.getByRole("button", { name: "Toggle filters" }); + + await userEvent.click(toggleButton); + await userEvent.click(await body.findByText("PR status")); + + await expect(await body.findByText("pr_status:")).toBeInTheDocument(); + + await userEvent.click(searchInput); + await userEvent.type(searchInput, "open "); + + await expect(await body.findByText("pr_status:open")).toBeInTheDocument(); + + await waitFor(() => { + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: CHAT_SEARCH_LIMIT, + q: "pr_status:open", + }); + }); + }, +}; + +export const ParameterizedFilterPillEnterCommit: Story = { + beforeEach: () => { + spyOn(API.experimental, "getChats").mockResolvedValue(mockChats); + }, + play: async () => { + const body = within(document.body); + const searchInput = body.getByRole("combobox", { name: "Search chats" }); + const toggleButton = body.getByRole("button", { name: "Toggle filters" }); + + await userEvent.click(toggleButton); + await userEvent.click(await body.findByText("PR status")); + + await expect(await body.findByText("pr_status:")).toBeInTheDocument(); + + await userEvent.click(searchInput); + await userEvent.type(searchInput, "closed"); + await userEvent.keyboard("{Enter}"); + + await expect(await body.findByText("pr_status:closed")).toBeInTheDocument(); + + await waitFor(() => { + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: CHAT_SEARCH_LIMIT, + q: "pr_status:closed", + }); + }); + }, +}; + +export const BackspaceRemovesFilter: Story = { + play: async () => { + const body = within(document.body); + const searchInput = body.getByRole("combobox", { name: "Search chats" }); + const toggleButton = body.getByRole("button", { name: "Toggle filters" }); + + await userEvent.click(toggleButton); + await userEvent.click(await body.findByText("Unread")); + await expect(await body.findByText("has_unread:true")).toBeInTheDocument(); + + await userEvent.click(searchInput); + await userEvent.keyboard("{Backspace}"); + await waitFor(() => { + expect(body.queryByText("has_unread:true")).not.toBeInTheDocument(); + }); + }, +}; + +export const TypedFilterAutoDetection: Story = { + play: async () => { + const body = within(document.body); + const searchInput = body.getByRole("combobox", { name: "Search chats" }); + + await userEvent.type(searchInput, "has_unread:true "); + + await expect(await body.findByText("has_unread:true")).toBeInTheDocument(); + await expect( + body.getByRole("button", { name: "Remove has_unread filter" }), + ).toBeInTheDocument(); + }, +}; + +export const CombinedFilterAndText: Story = { + play: async () => { + const body = within(document.body); + const searchInput = body.getByRole("combobox", { name: "Search chats" }); + const toggleButton = body.getByRole("button", { name: "Toggle filters" }); + + await userEvent.click(toggleButton); + await userEvent.click(await body.findByText("Unread")); + await expect(await body.findByText("has_unread:true")).toBeInTheDocument(); + + await userEvent.click(searchInput); + await userEvent.type(searchInput, "Fix"); + + await waitFor(() => { + expect(API.experimental.getChats).toHaveBeenCalledWith({ + limit: CHAT_SEARCH_LIMIT, + q: 'has_unread:true title:"Fix"', + }); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx index 912fe40271..25aa41dca7 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchDialog.tsx @@ -1,18 +1,72 @@ +import { + ArchiveIcon, + CircleDotIcon, + FileTextIcon, + LinkIcon, +} from "lucide-react"; import type { FC, RefObject } from "react"; -import { type KeyboardEventHandler, useId, useRef, useState } from "react"; +import { + type KeyboardEventHandler, + useId, + useMemo, + useRef, + useState, +} from "react"; import { keepPreviousData, useQuery } from "react-query"; import { type Location, useNavigate } from "react-router"; import { chatSearch } from "#/api/queries/chats"; +import type { Chat } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; import { Dialog, DialogContent, DialogTitle } from "#/components/Dialog/Dialog"; import { useDebouncedValue } from "#/hooks/debounce"; -import { ChatSearchInput } from "./ChatSearchInput"; +import { ChatSearchInput, type SearchFilter } from "./ChatSearchInput"; import { ChatSearchResults } from "./ChatSearchResults"; import { normalizeChatSearchInput } from "./searchQuery"; +// Filter definitions. Filters with a defaultValue are inserted as complete +// pills (e.g. has_unread:true). Filters without one are inserted as +// incomplete pills so the user can type the value. +type FilterDefinition = { + readonly key: string; + readonly label: string; + readonly icon: FC<{ className?: string }>; + readonly defaultValue: string | null; +}; + +const FILTER_DEFINITIONS: readonly FilterDefinition[] = [ + { + key: "has_unread", + label: "Unread", + icon: CircleDotIcon, + defaultValue: "true", + }, + { + key: "archived", + label: "Archived", + icon: ArchiveIcon, + defaultValue: "true", + }, + { + key: "pr_status", + label: "PR status", + icon: FileTextIcon, + defaultValue: null, + }, + { key: "diff_url", label: "Diff URL", icon: LinkIcon, defaultValue: null }, +]; + +// Set of recognized filter keys for detecting typed filter patterns +// (e.g. "has_unread:true" typed directly into the input). Derived from +// FILTER_DEFINITIONS; the backend equivalent lives in searchQuery.ts as +// passthroughChatSearchFilterKeys. +const KNOWN_FILTER_KEYS = new Set(FILTER_DEFINITIONS.map((def) => def.key)); + type ChatSearchDialogProps = { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; + readonly focusInputOnOpen?: boolean; readonly location: Location; + readonly recentChats?: readonly Chat[]; }; const SEARCH_DEBOUNCE_MS = 500; @@ -20,13 +74,17 @@ const SEARCH_DEBOUNCE_MS = 500; export const ChatSearchDialog: FC = ({ open, onOpenChange, + focusInputOnOpen = true, location, + recentChats = [], }) => { + const contentRef = useRef(null); const inputRef = useRef(null); return ( = ({ // dialog resizing visibly as results stream in. style={{ animation: "none", transition: "none" }} aria-describedby={undefined} + tabIndex={-1} + // When opened from the mobile sidebar button, skip autofocusing + // the input so the virtual keyboard doesn't push the dialog + // off-screen. Focus the dialog container instead to keep the + // element in the accessibility tree. onOpenAutoFocus={(event) => { + if (focusInputOnOpen) { + return; + } event.preventDefault(); - requestAnimationFrame(() => { - inputRef.current?.focus(); - }); + contentRef.current?.focus({ preventScroll: true }); }} > = ({ onOpenChange={onOpenChange} location={location} inputRef={inputRef} + recentChats={recentChats} /> ); }; -type ChatSearchDialogContentProps = ChatSearchDialogProps & { +type ChatSearchDialogContentProps = Omit< + ChatSearchDialogProps, + "focusInputOnOpen" +> & { readonly inputRef: RefObject; }; +// Build a raw query string from structured filters + freeform text, then +// normalize it through the existing parser that the backend expects. +const buildQuery = ( + filters: readonly SearchFilter[], + freeText: string, +): string | undefined => { + const parts: string[] = []; + for (const f of filters) { + if (f.value !== null && f.value !== "") { + // Strip internal quotes before wrapping so the resulting + // key:"value" token stays well-formed for the backend. + const stripped = f.value.replaceAll('"', ""); + const v = stripped.includes(" ") ? `"${stripped}"` : stripped; + parts.push(`${f.key}:${v}`); + } + } + if (freeText.trim()) { + parts.push(freeText.trim()); + } + const raw = parts.join(" "); + return normalizeChatSearchInput(raw); +}; + const ChatSearchDialogContent: FC = ({ open, onOpenChange, location, inputRef, + recentChats = [], }) => { const navigate = useNavigate(); - const [inputValue, setInputValue] = useState(""); + const [filters, setFilters] = useState([]); + const [freeText, setFreeText] = useState(""); + // Tracks the key of a parameterized filter being typed (e.g. "pr_status"). + // While set, freeText holds the in-progress value and the pill shows as + // incomplete (dashed border). Space or Enter commits the value. + const [incompleteFilterKey, setIncompleteFilterKey] = useState( + null, + ); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [selectedChatIndex, setSelectedChatIndex] = useState< number | undefined >(undefined); const listboxId = useId(); - const debouncedInput = useDebouncedValue(inputValue, SEARCH_DEBOUNCE_MS); - const normalizedQuery = normalizeChatSearchInput(debouncedInput); - const hasQuery = inputValue.trim() !== "" && normalizedQuery !== undefined; + + // Build the full filter list for query building. When an incomplete filter + // has text, include it so debounced search can run against partial values. + const effectiveFilters = useMemo( + () => + incompleteFilterKey && freeText.trim() + ? [...filters, { key: incompleteFilterKey, value: freeText.trim() }] + : filters, + [filters, incompleteFilterKey, freeText], + ); + const hasActiveSearch = effectiveFilters.length > 0 || freeText.trim() !== ""; + + const debouncedFreeText = useDebouncedValue(freeText, SEARCH_DEBOUNCE_MS); + const debouncedFilters = useDebouncedValue( + effectiveFilters, + SEARCH_DEBOUNCE_MS, + ); + // When typing into an incomplete filter, only send the filter (not + // freeText as bare title search). + // When freeText is cleared (e.g. after committing a filter), zero + // queryFreeText immediately instead of waiting for the debounce to + // flush. Otherwise the stale debouncedFreeText leaks into the query. + const queryFreeText = + incompleteFilterKey || !freeText.trim() ? "" : debouncedFreeText; + const normalizedQuery = buildQuery(debouncedFilters, queryFreeText); + const hasQuery = hasActiveSearch && normalizedQuery !== undefined; const searchQuery = useQuery({ ...chatSearch(normalizedQuery ?? ""), @@ -85,14 +208,21 @@ const ChatSearchDialogContent: FC = ({ placeholderData: keepPreviousData, }); - const resultCount = searchQuery.data?.length ?? 0; + // Use search results count when a query is active, otherwise count + // recent chats so keyboard navigation works in the default view too. + const recentChatsSlice = (recentChats ?? []).slice(0, 10); + const resultCount = hasQuery + ? (searchQuery.data?.length ?? 0) + : recentChatsSlice.length; const safeSelectedChatIndex = selectedChatIndex !== undefined && selectedChatIndex < resultCount ? selectedChatIndex : undefined; const selectedChat = safeSelectedChatIndex !== undefined - ? searchQuery.data?.[safeSelectedChatIndex] + ? hasQuery + ? searchQuery.data?.[safeSelectedChatIndex] + : recentChatsSlice[safeSelectedChatIndex] : undefined; const activeResultId = safeSelectedChatIndex !== undefined @@ -109,9 +239,130 @@ const ChatSearchDialogContent: FC = ({ searchQuery.isFetching && searchQuery.isPlaceholderData && !showResultsLoading; + + const commitIncompleteFilter = () => { + if (incompleteFilterKey && freeText.trim()) { + setFilters((prev) => [ + ...prev, + { key: incompleteFilterKey, value: freeText.trim() }, + ]); + setFreeText(""); + setIncompleteFilterKey(null); + } + }; + + const addFilter = (def: FilterDefinition) => { + if ( + filters.some((f) => f.key === def.key) || + incompleteFilterKey === def.key + ) { + return; + } + commitIncompleteFilter(); + + if (def.defaultValue !== null) { + setFilters((prev) => [ + ...prev, + { key: def.key, value: def.defaultValue }, + ]); + } else { + setIncompleteFilterKey(def.key); + setFreeText(""); + } + setIsDropdownOpen(false); + setSelectedChatIndex(undefined); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + + const removeFilter = (key: string) => { + if (incompleteFilterKey === key) { + setIncompleteFilterKey(null); + setFreeText(""); + } else { + setFilters((prev) => prev.filter((f) => f.key !== key)); + } + setSelectedChatIndex(undefined); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + + const handleInputChange = (value: string) => { + setFreeText(value); + setSelectedChatIndex(undefined); + }; + + // Build the display filters for ChatSearchInput: completed filters plus + // the incomplete one (shown with dashed border). + const displayFilters: SearchFilter[] = incompleteFilterKey + ? [...filters, { key: incompleteFilterKey, value: null }] + : filters; + const handleInputKeyDown: KeyboardEventHandler = ( event, ) => { + if ( + (event.key === " " || event.key === "Enter") && + incompleteFilterKey && + freeText.trim() + ) { + event.preventDefault(); + commitIncompleteFilter(); + return; + } + + if ( + (event.key === " " || event.key === "Enter") && + !incompleteFilterKey && + freeText.trim() + ) { + const activeKeys = new Set(filters.map((f) => f.key)); + const tokens = freeText.trim().split(/\s+/); + const newFilters: SearchFilter[] = []; + const remaining: string[] = []; + + for (const token of tokens) { + const colonIndex = token.indexOf(":"); + if (colonIndex > 0 && colonIndex < token.length - 1) { + const key = token.slice(0, colonIndex); + const val = token.slice(colonIndex + 1); + if (KNOWN_FILTER_KEYS.has(key)) { + // Drop duplicate filter keys silently instead of + // letting them fall through to freeform text. + if (!activeKeys.has(key)) { + newFilters.push({ key, value: val }); + activeKeys.add(key); + } + continue; + } + } + remaining.push(token); + } + + if (newFilters.length > 0) { + event.preventDefault(); + setFilters((prev) => [...prev, ...newFilters]); + setFreeText(remaining.join(" ")); + return; + } + } + + if (event.key === "Backspace" && freeText === "") { + if (incompleteFilterKey) { + setIncompleteFilterKey(null); + return; + } + if (filters.length > 0) { + const lastFilter = filters[filters.length - 1]; + removeFilter(lastFilter.key); + return; + } + } + + if (event.key === "Escape" && isDropdownOpen) { + setIsDropdownOpen(false); + event.stopPropagation(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { if (resultCount === 0) { return; @@ -145,21 +396,38 @@ const ChatSearchDialogContent: FC = ({ return ( <> Search chats - 0} - inputRef={inputRef} - listboxId={listboxId} - value={inputValue} - onChange={(event) => { - setInputValue(event.target.value); - setSelectedChatIndex(undefined); + {/* Wrap input + dropdown so onBlur on the container closes + the dropdown, but clicks within the dropdown (which is + inside the same container) don't trigger blur. */} +
{ + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsDropdownOpen(false); + } }} - onKeyDown={handleInputKeyDown} - /> + > + 0} + inputRef={inputRef} + listboxId={listboxId} + filters={displayFilters} + value={freeText} + onChange={(event) => handleInputChange(event.target.value)} + onKeyDown={handleInputKeyDown} + onRemoveFilter={removeFilter} + isDropdownOpen={isDropdownOpen} + onToggleDropdown={() => setIsDropdownOpen((prev) => !prev)} + /> + {isDropdownOpen && ( + + )} +
= ({ selectedChatIndex={safeSelectedChatIndex} showLoading={showResultsLoading} isRefreshing={isRefreshing} - onSelectChat={closeDialog} + onDismiss={closeDialog} /> ); }; + +// --------------------------------------------------------------------------- +// Filter dropdown: appears on focus, shows clickable filter chips. +// --------------------------------------------------------------------------- + +const FilterDropdown: FC<{ + readonly filters: readonly SearchFilter[]; + readonly onSelectFilter: (def: FilterDefinition) => void; +}> = ({ filters, onSelectFilter }) => { + const activeKeys = new Set(filters.map((f) => f.key)); + + return ( +
+

+ Filter by +

+
+ {FILTER_DEFINITIONS.map((def) => { + const Icon = def.icon; + const isActive = activeKeys.has(def.key); + return ( + + ); + })} +
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx index 1d5cc66dc0..b134f8854b 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchInput.tsx @@ -1,20 +1,29 @@ -import { SearchIcon } from "lucide-react"; +import { ListFilterIcon, SearchIcon, XIcon } from "lucide-react"; import type { ChangeEventHandler, FC, KeyboardEventHandler, RefObject, } from "react"; -import { Input } from "#/components/Input/Input"; +import { cn } from "#/utils/cn"; + +export type SearchFilter = { + readonly key: string; + readonly value: string | null; +}; type ChatSearchInputProps = { readonly activeResultId: string | undefined; readonly hasResults: boolean; readonly inputRef: RefObject; readonly listboxId: string; + readonly filters: readonly SearchFilter[]; readonly value: string; readonly onChange: ChangeEventHandler; readonly onKeyDown: KeyboardEventHandler; + readonly onRemoveFilter: (key: string) => void; + readonly isDropdownOpen: boolean; + readonly onToggleDropdown: () => void; }; export const ChatSearchInput: FC = ({ @@ -22,20 +31,58 @@ export const ChatSearchInput: FC = ({ hasResults, inputRef, listboxId, + filters, value, onChange, onKeyDown, + onRemoveFilter, + isDropdownOpen, + onToggleDropdown, }) => { + const completedFilters = filters.filter((f) => f.value !== null); + const incompleteFilter = filters.find((f) => f.value === null); + return ( -
- - + + {completedFilters.map((f) => ( + + + {f.key}:{f.value} + + + + ))} + {incompleteFilter && ( + + {incompleteFilter.key}: + + )} + 0 ? "" : "Search chats..."} + className="min-w-[60px] flex-1 border-none bg-transparent py-2 text-sm text-content-primary outline-none placeholder:text-content-disabled" aria-label="Search chats" role="combobox" aria-controls={hasResults ? listboxId : undefined} @@ -43,6 +90,18 @@ export const ChatSearchInput: FC = ({ aria-haspopup="listbox" aria-activedescendant={activeResultId} /> +
); }; diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx index e4a2cb170a..c49c900332 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/dialogs/ChatSearchResults.tsx @@ -12,6 +12,7 @@ import { getChatDisplayConfig } from "../tree/statusConfig"; type ChatSearchResultsProps = { readonly chats: readonly Chat[] | undefined; + readonly recentChats: readonly Chat[]; readonly error: unknown; readonly hasQuery: boolean; readonly location: Location; @@ -19,11 +20,23 @@ type ChatSearchResultsProps = { readonly selectedChatIndex: number | undefined; readonly showLoading: boolean; readonly isRefreshing: boolean; - readonly onSelectChat: () => void; + readonly onDismiss: () => void; +}; + +const RECENT_CHATS_COUNT = 10; + +// !block overrides Radix ScrollArea viewport's display:table so truncated text can shrink. +const SCROLL_AREA_PROPS = { + className: + "h-[300px] w-full [&_[data-radix-scroll-area-viewport]>div]:!block", + scrollBarClassName: "w-[0.375rem]", + viewportClassName: "pr-3", + viewportTabIndex: -1, }; export const ChatSearchResults: FC = ({ chats, + recentChats, error, hasQuery, location, @@ -31,25 +44,28 @@ export const ChatSearchResults: FC = ({ selectedChatIndex, showLoading, isRefreshing, - onSelectChat, + onDismiss, }) => { if (error) { return (
- +
); } if (!hasQuery) { return ( -
-
- Type to search by title, or use filters like{" "} - has_unread:true, archived:true,{" "} - pr_status:open, or diff_url:"...". -
-
+ ); } @@ -71,33 +87,25 @@ export const ChatSearchResults: FC = ({ return (
-

+

{resultSummary} {isRefreshing && ( )}

- +
@@ -105,13 +113,72 @@ export const ChatSearchResults: FC = ({ ); }; +// --------------------------------------------------------------------------- +// Default view: recent chats (shown when no query is active). +// --------------------------------------------------------------------------- + +type DefaultViewProps = { + readonly recentChats: readonly Chat[]; + readonly location: Location; + readonly listboxId: string; + readonly selectedChatIndex: number | undefined; + readonly onDismiss: () => void; +}; + +const DefaultView: FC = ({ + recentChats, + location, + listboxId, + selectedChatIndex, + onDismiss, +}) => { + const visibleRecentChats = recentChats.slice(0, RECENT_CHATS_COUNT); + + return ( +
+
+ {visibleRecentChats.length > 0 && ( +
+

+ Recent chats +

+ +
+ {visibleRecentChats.map((chat, index) => ( + + ))} +
+
+
+ )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Results list and row components. +// --------------------------------------------------------------------------- + type ChatSearchResultsListProps = { readonly chats: readonly Chat[] | undefined; readonly location: Location; readonly listboxId: string; readonly selectedChatIndex: number | undefined; readonly showLoading: boolean; - readonly onSelectChat: () => void; + readonly onDismiss: () => void; }; const ChatSearchResultsList: FC = ({ @@ -120,7 +187,7 @@ const ChatSearchResultsList: FC = ({ listboxId, selectedChatIndex, showLoading, - onSelectChat, + onDismiss, }) => { if (showLoading) { return ; @@ -128,9 +195,9 @@ const ChatSearchResultsList: FC = ({ if ((chats?.length ?? 0) === 0) { return ( -

- No matching chats -

+
+

No matching chats

+
); } @@ -148,7 +215,7 @@ const ChatSearchResultsList: FC = ({ id={`${listboxId}-option-${index}`} isSelected={selectedChatIndex === index} location={location} - onSelect={onSelectChat} + onSelect={onDismiss} /> ))}
From a801d996e7ee7a644eebf15d27265f4efa6fabc3 Mon Sep 17 00:00:00 2001 From: TJ Date: Thu, 28 May 2026 21:33:51 -0700 Subject: [PATCH 08/63] fix(site): update models settings page description text (#25830) Updates the description text on the Agent Settings > Models page (`/agents/settings/models`). **Before:** "Choose which models from your configured providers are available for users to select. You can set a default and adjust context limits." **After:** "Choose which models from your configured providers are available for Coder Agents. Set a default and adjust context limits." > Generated by Coder Agents on behalf of @tracyjohnsonux --- site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx index b7a8b36ba7..4b8c50fd96 100644 --- a/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsModelsPage.tsx @@ -47,7 +47,7 @@ const AgentSettingsModelsPage: FC = () => { Date: Fri, 29 May 2026 16:49:25 +1000 Subject: [PATCH 09/63] fix: strip deleted MCP IDs from chats on delete (#25763) Adds a database migration that reconciles existing stale chat MCP server IDs, then installs a `BEFORE DELETE` trigger on `mcp_server_configs` to remove the deleted ID from `chats.mcp_server_ids`. This keeps chat continuation from failing with `400 One or more MCP server IDs are invalid` after an MCP server config is deleted. This matches the existing repo precedent in `coderd/database/migrations/000241_delete_user_roles.up.sql`, where deleting a custom role cleans `organization_members.roles`, a similarly structured array of references that cannot be protected by a normal foreign key. Closes CODAGT-505 --- coderd/database/dump.sql | 15 +++++++ ...up_chats_mcp_server_ids_on_delete.down.sql | 2 + ...anup_chats_mcp_server_ids_on_delete.up.sql | 41 +++++++++++++++++++ coderd/mcp_test.go | 21 +++++++--- 4 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.down.sql create mode 100644 coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 9c2df36cab..ff743ba902 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1216,6 +1216,17 @@ BEGIN END; $$; +CREATE FUNCTION remove_mcp_server_config_id_from_chats() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + UPDATE chats + SET mcp_server_ids = array_remove(mcp_server_ids, OLD.id) + WHERE OLD.id = ANY(mcp_server_ids); + RETURN OLD; +END; +$$; + CREATE FUNCTION remove_organization_member_role() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -4435,6 +4446,10 @@ CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_message CREATE TRIGGER protect_deleting_organizations BEFORE UPDATE ON organizations FOR EACH ROW WHEN (((new.deleted = true) AND (old.deleted = false))) EXECUTE FUNCTION protect_deleting_organizations(); +CREATE TRIGGER remove_chat_mcp_server_config_id BEFORE DELETE ON mcp_server_configs FOR EACH ROW EXECUTE FUNCTION remove_mcp_server_config_id_from_chats(); + +COMMENT ON TRIGGER remove_chat_mcp_server_config_id ON mcp_server_configs IS 'When an MCP server config is deleted, this trigger removes its ID from all chats.'; + CREATE TRIGGER remove_organization_member_custom_role BEFORE DELETE ON custom_roles FOR EACH ROW EXECUTE FUNCTION remove_organization_member_role(); COMMENT ON TRIGGER remove_organization_member_custom_role ON custom_roles IS 'When a custom_role is deleted, this trigger removes the role from all organization members.'; diff --git a/coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.down.sql b/coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.down.sql new file mode 100644 index 0000000000..15c10e19e6 --- /dev/null +++ b/coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS remove_chat_mcp_server_config_id ON mcp_server_configs; +DROP FUNCTION IF EXISTS remove_mcp_server_config_id_from_chats; diff --git a/coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.up.sql b/coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.up.sql new file mode 100644 index 0000000000..5366328b3c --- /dev/null +++ b/coderd/database/migrations/000510_cleanup_chats_mcp_server_ids_on_delete.up.sql @@ -0,0 +1,41 @@ +-- Remove already-stale MCP server references before future deletes are +-- handled by the trigger below. +UPDATE chats +SET mcp_server_ids = ( + SELECT COALESCE(array_agg(ids.mcp_server_id ORDER BY ids.position), '{}'::uuid[]) + FROM unnest(chats.mcp_server_ids) WITH ORDINALITY AS ids(mcp_server_id, position) + WHERE EXISTS ( + SELECT 1 + FROM mcp_server_configs + WHERE mcp_server_configs.id = ids.mcp_server_id + ) +) +WHERE EXISTS ( + SELECT 1 + FROM unnest(chats.mcp_server_ids) AS ids(mcp_server_id) + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_server_configs + WHERE mcp_server_configs.id = ids.mcp_server_id + ) +); + +CREATE OR REPLACE FUNCTION remove_mcp_server_config_id_from_chats() + RETURNS TRIGGER AS +$$ +BEGIN + UPDATE chats + SET mcp_server_ids = array_remove(mcp_server_ids, OLD.id) + WHERE OLD.id = ANY(mcp_server_ids); + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER remove_chat_mcp_server_config_id + BEFORE DELETE ON mcp_server_configs FOR EACH ROW + EXECUTE PROCEDURE remove_mcp_server_config_id_from_chats(); + +COMMENT ON TRIGGER + remove_chat_mcp_server_config_id + ON mcp_server_configs IS + 'When an MCP server config is deleted, this trigger removes its ID from all chats.'; diff --git a/coderd/mcp_test.go b/coderd/mcp_test.go index add730960f..dde85f12e7 100644 --- a/coderd/mcp_test.go +++ b/coderd/mcp_test.go @@ -1396,10 +1396,11 @@ func TestChatWithMCPServerIDs(t *testing.T) { // Create the chat model config required for creating a chat. _ = createChatModelConfigForMCP(t, expClient) - // Create an enabled MCP server config. - mcpConfig := createMCPServerConfig(t, client, "chat-mcp-server", true) + // Create enabled MCP server configs. + mcpConfigA := createMCPServerConfig(t, client, "chat-mcp-server-a", true) + mcpConfigB := createMCPServerConfig(t, client, "chat-mcp-server-b", true) - // Create a chat referencing the MCP server. + // Create a chat referencing the MCP servers. chat, err := expClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, Content: []codersdk.ChatInputPart{ @@ -1408,16 +1409,24 @@ func TestChatWithMCPServerIDs(t *testing.T) { Text: "hello with mcp server", }, }, - MCPServerIDs: []uuid.UUID{mcpConfig.ID}, + MCPServerIDs: []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, }) require.NoError(t, err) require.NotEqual(t, uuid.Nil, chat.ID) - require.Contains(t, chat.MCPServerIDs, mcpConfig.ID) + require.ElementsMatch(t, []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, chat.MCPServerIDs) // Fetch the chat and verify the MCP server IDs persist. fetched, err := expClient.GetChat(ctx, chat.ID) require.NoError(t, err) - require.Contains(t, fetched.MCPServerIDs, mcpConfig.ID) + require.ElementsMatch(t, []uuid.UUID{mcpConfigA.ID, mcpConfigB.ID}, fetched.MCPServerIDs) + + err = client.DeleteMCPServerConfig(ctx, mcpConfigA.ID) + require.NoError(t, err) + + fetched, err = expClient.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.NotContains(t, fetched.MCPServerIDs, mcpConfigA.ID) + require.Contains(t, fetched.MCPServerIDs, mcpConfigB.ID) } func createChatModelConfigForMCP(t testing.TB, client *codersdk.ExperimentalClient) codersdk.ChatModelConfig { From 3fb4eefaf7ec945955303c5243eb8c7b83eb2fc6 Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Fri, 29 May 2026 10:23:00 +0200 Subject: [PATCH 10/63] docs(docs/admin/security): point security advisories to GitHub Security Advisories (#25813) Removes the inline security advisory table and the standalone advisory file (`0001_user_apikeys_invalidation.md`). The advisories section now directs readers to [GitHub Security Advisories](https://github.com/coder/coder/security/advisories). > Generated by Coder Agents on behalf of @jdomeracki-coder --- .../0001_user_apikeys_invalidation.md | 89 ------------------- docs/admin/security/index.md | 17 +--- 2 files changed, 3 insertions(+), 103 deletions(-) delete mode 100644 docs/admin/security/0001_user_apikeys_invalidation.md diff --git a/docs/admin/security/0001_user_apikeys_invalidation.md b/docs/admin/security/0001_user_apikeys_invalidation.md deleted file mode 100644 index 203a891766..0000000000 --- a/docs/admin/security/0001_user_apikeys_invalidation.md +++ /dev/null @@ -1,89 +0,0 @@ -# API Tokens of deleted users not invalidated - ---- - -## Summary - -Coder identified an issue in -[https://github.com/coder/coder](https://github.com/coder/coder) where API -tokens belonging to a deleted user were not invalidated. A deleted user in -possession of a valid and non-expired API token is still able to use the above -token with their full suite of capabilities. - -## Impact: HIGH - -If exploited, an attacker could perform any action that the deleted user was -authorized to perform. - -## Exploitability: HIGH - -The CLI writes the API key to `~/.coderv2/session` by default, so any deleted -user who previously logged in via the Coder CLI has the potential to exploit -this. Note that there is a time window for exploitation; API tokens have a -maximum lifetime after which they are no longer valid. - -The issue only affects users who were active (not suspended) at the time they -were deleted. Users who were first suspended and later deleted cannot exploit -this issue. - -## Affected Versions - -All versions of Coder between v0.8.15 and v0.22.2 (inclusive) are affected. - -All customers are advised to upgrade to -[v0.23.0](https://github.com/coder/coder/releases/tag/v0.23.0) as soon as -possible. - -## Details - -Coder incorrectly failed to invalidate API keys belonging to a user when they -were deleted. When authenticating a user via their API key, Coder incorrectly -failed to check whether the API key corresponds to a deleted user. - -## Indications of Compromise - -> [!TIP] -> Automated remediation steps in the upgrade purge all affected API keys. -> Either perform the following query before upgrade or run it on a backup of -> your database from before the upgrade. - -Execute the following SQL query: - -```sql -SELECT - users.email, - users.updated_at, - api_keys.id, - api_keys.last_used -FROM - users -LEFT JOIN - api_keys -ON - api_keys.user_id = users.id -WHERE - users.deleted -AND - api_keys.last_used > users.updated_at -; -``` - -If the output is similar to the below, then you are not affected: - -```sql ------ -(0 rows) -``` - -Otherwise, the following information will be reported: - -- User email -- Time the user was last modified (i.e. deleted) -- User API key ID -- Time the affected API key was last used - -> [!TIP] -> If your license includes the -> [Audit Logs](https://coder.com/docs/admin/audit-logs#filtering-logs) feature, -> you can then query all actions performed by the above users by using the -> filter `email:$USER_EMAIL`. diff --git a/docs/admin/security/index.md b/docs/admin/security/index.md index 37028093f8..f6684519e8 100644 --- a/docs/admin/security/index.md +++ b/docs/admin/security/index.md @@ -11,17 +11,6 @@ For other security tips, visit our guide to > If you discover a vulnerability in Coder, please do not hesitate to report it > to us by following the [security policy](https://github.com/coder/coder/blob/main/SECURITY.md). -From time to time, Coder employees or other community members may discover -vulnerabilities in the product. - -If a vulnerability requires an immediate upgrade to mitigate a potential -security risk, we will add it to the below table. - -Click on the description links to view more details about each specific -vulnerability. - ---- - -| Description | Severity | Fix | Vulnerable Versions | -|-----------------------------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------------------------|---------------------| -| [API tokens of deleted users not invalidated](https://github.com/coder/coder/blob/main/docs/admin/security/0001_user_apikeys_invalidation.md) | HIGH | [v0.23.0](https://github.com/coder/coder/releases/tag/v0.23.0) | v0.8.25 - v0.22.2 | +Security advisories are published on the +[GitHub Security Advisories](https://github.com/coder/coder/security/advisories) +page. From 5b10268827e8c3a8ac8e0ad563d042e66c7003c7 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 29 May 2026 10:24:16 +0200 Subject: [PATCH 11/63] feat: serve 503 sentinel for disabled providers (#25794) _Disclosure: created with Coder Agents._ When providers are disabled, we should serve a sentinel error so the requesting client (Claude Code, Coder Agents, etc) is informed. Coder Agents can also conditionalize its display to show a helpful error message. --------- Signed-off-by: Danny Kopping Co-authored-by: Claude Opus 4.7 (1M context) --- aibridge/api.go | 8 +++ aibridge/bridge.go | 27 +++++++++ aibridge/bridge_test.go | 55 +++++++++++++++++ aibridge/internal/testutil/mockprovider.go | 2 + aibridge/provider/anthropic.go | 2 + aibridge/provider/copilot.go | 2 + aibridge/provider/disabled.go | 47 +++++++++++++++ aibridge/provider/openai.go | 2 + aibridge/provider/provider.go | 2 + cli/aibridged.go | 49 +++++++++++---- cli/aibridged_internal_test.go | 69 ++++++++++++++++------ cli/server_aibridge_internal_test.go | 10 ++++ coderd/aibridged/pool.go | 7 ++- coderd/aibridged/provider.go | 6 +- enterprise/coderd/aibridge_reload_test.go | 18 +++++- 15 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 aibridge/provider/disabled.go diff --git a/aibridge/api.go b/aibridge/api.go index 809d452fe9..34dce84ef8 100644 --- a/aibridge/api.go +++ b/aibridge/api.go @@ -57,6 +57,14 @@ func NewCopilotProvider(cfg config.Copilot) provider.Provider { return provider.NewCopilot(cfg) } +// NewDisabledProviderStub returns a Provider that reports Enabled() == +// false and has no-op implementations for all other methods. Use this +// instead of constructing a concrete provider for disabled rows so that +// adding a new provider type does not require updating a switch here. +func NewDisabledProviderStub(name, providerType string) provider.Provider { + return provider.NewDisabledStub(name, providerType) +} + func NewMetrics(reg prometheus.Registerer) *metrics.Metrics { return metrics.NewMetrics(reg) } diff --git a/aibridge/bridge.go b/aibridge/bridge.go index f604d0a38a..79715af880 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -30,6 +30,11 @@ import ( const ( // The duration after which an async recording will be aborted. recordingTimeout = time.Second * 5 + + // ErrorCodeProviderDisabled is the code written in the response + // body when a request targets a configured-but-disabled provider. + // Paired with HTTP 503. + ErrorCodeProviderDisabled = "provider_disabled" ) // RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs; @@ -96,6 +101,14 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re mux := http.NewServeMux() for _, prov := range providers { + // Disabled providers serve a 503 sentinel on every path under + // "//". Bound to the bare name (not RoutePrefix) so paths + // outside the provider's normal "/v1" subtree are also caught. + if !prov.Enabled() { + prefix := fmt.Sprintf("/%s/", prov.Name()) + mux.HandleFunc(prefix, disabledProviderHandler(prov.Name(), logger)) + continue + } // Create per-provider circuit breaker if configured cfg := prov.CircuitBreakerConfig() providerName := prov.Name() @@ -170,6 +183,20 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re }, nil } +// disabledProviderHandler returns 503 with a body containing +// [ErrorCodeProviderDisabled] and the provider name for every request +// targeting name. +func disabledProviderHandler(name string, logger slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logger.Debug(r.Context(), "refusing request for disabled ai provider", + slog.F("provider", name), + slog.F("path", r.URL.Path), + slog.F("method", r.Method), + ) + http.Error(w, fmt.Sprintf("%s: AI provider %q is disabled", ErrorCodeProviderDisabled, name), http.StatusServiceUnavailable) + } +} + // newInterceptionProcessor returns an [http.HandlerFunc] which is capable of creating a new interceptor and processing a given request // using [Provider] p, recording all usage events using [Recorder] rec. // If cbs is non-nil, circuit breaker protection is applied per endpoint/model tuple. diff --git a/aibridge/bridge_test.go b/aibridge/bridge_test.go index f2657ab80f..93beb82de9 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -205,3 +205,58 @@ func TestPassthroughRoutesForProviders(t *testing.T) { }) } } + +// TestDisabledProviderHandler asserts that requests to a disabled +// provider return a 503 with an ErrorCodeProviderDisabled body and +// that a sibling enabled provider keeps routing normally. +func TestDisabledProviderHandler(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("upstream-reached")) + })) + t.Cleanup(upstream.Close) + + enabled := aibridge.NewOpenAIProvider(config.OpenAI{Name: "enabled-openai", BaseURL: upstream.URL}) + disabled := aibridge.NewDisabledProviderStub("disabled-openai", "openai") + bridge, err := aibridge.NewRequestBridge( + t.Context(), + []provider.Provider{enabled, disabled}, + nil, nil, logger, nil, bridgeTestTracer, + ) + require.NoError(t, err) + + for _, tc := range []struct { + name string + path string + }{ + {name: "Bridged", path: "/disabled-openai/v1/chat/completions"}, + {name: "Passthrough", path: "/disabled-openai/v1/models"}, + {name: "Unknown", path: "/disabled-openai/anything/else"}, + } { + t.Run("DisabledProviderReturnsSentinel/"+tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, tc.path, nil) + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusServiceUnavailable, resp.Code) + assert.Contains(t, resp.Body.String(), aibridge.ErrorCodeProviderDisabled) + assert.Contains(t, resp.Body.String(), "disabled-openai") + }) + } + + t.Run("EnabledProviderUnaffected", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/enabled-openai/v1/models", nil) + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "upstream-reached", resp.Body.String()) + }) +} diff --git a/aibridge/internal/testutil/mockprovider.go b/aibridge/internal/testutil/mockprovider.go index 0fd85d2863..e5015cd870 100644 --- a/aibridge/internal/testutil/mockprovider.go +++ b/aibridge/internal/testutil/mockprovider.go @@ -15,6 +15,7 @@ import ( type MockProvider struct { NameStr string URL string + Disabled bool Bridged []string Passthrough []string InterceptorFunc func(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (intercept.Interceptor, error) @@ -22,6 +23,7 @@ type MockProvider struct { func (m *MockProvider) Type() string { return m.NameStr } func (m *MockProvider) Name() string { return m.NameStr } +func (m *MockProvider) Enabled() bool { return !m.Disabled } func (m *MockProvider) BaseURL() string { return m.URL } func (m *MockProvider) RoutePrefix() string { return fmt.Sprintf("/%s", m.NameStr) } func (m *MockProvider) BridgedRoutes() []string { return m.Bridged } diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index eb50a3b296..664183f82f 100644 --- a/aibridge/provider/anthropic.go +++ b/aibridge/provider/anthropic.go @@ -95,6 +95,8 @@ func (p *Anthropic) Name() string { return p.cfg.Name } +func (*Anthropic) Enabled() bool { return true } + func (p *Anthropic) RoutePrefix() string { return fmt.Sprintf("/%s", p.Name()) } diff --git a/aibridge/provider/copilot.go b/aibridge/provider/copilot.go index 1186e8b253..fd317aadab 100644 --- a/aibridge/provider/copilot.go +++ b/aibridge/provider/copilot.go @@ -78,6 +78,8 @@ func (p *Copilot) Name() string { return p.cfg.Name } +func (*Copilot) Enabled() bool { return true } + func (p *Copilot) BaseURL() string { return p.cfg.BaseURL } diff --git a/aibridge/provider/disabled.go b/aibridge/provider/disabled.go new file mode 100644 index 0000000000..95384b4952 --- /dev/null +++ b/aibridge/provider/disabled.go @@ -0,0 +1,47 @@ +package provider + +import ( + "fmt" + "net/http" + + "go.opentelemetry.io/otel/trace" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/keypool" +) + +// DisabledStub is a Provider placeholder for a configured-but-disabled +// provider. Only Name and Enabled return meaningful values; all other +// methods return empty/nil so the stub never influences routing. +type DisabledStub struct { + name string + providerType string +} + +// NewDisabledStub returns a Provider stub that reports Enabled() == false. +// The type string is preserved so callers can distinguish provider families. +func NewDisabledStub(name, providerType string) *DisabledStub { + return &DisabledStub{name: name, providerType: providerType} +} + +func (d *DisabledStub) Type() string { return d.providerType } +func (d *DisabledStub) Name() string { return d.name } +func (*DisabledStub) Enabled() bool { return false } +func (*DisabledStub) BaseURL() string { return "" } +func (d *DisabledStub) RoutePrefix() string { + return fmt.Sprintf("/%s", d.name) +} +func (*DisabledStub) BridgedRoutes() []string { return nil } +func (*DisabledStub) PassthroughRoutes() []string { return nil } +func (*DisabledStub) AuthHeader() string { return "" } +func (*DisabledStub) KeyFailoverConfig(_ slog.Logger) keypool.KeyFailoverConfig { + return keypool.KeyFailoverConfig{} +} +func (*DisabledStub) CircuitBreakerConfig() *config.CircuitBreaker { return nil } +func (*DisabledStub) APIDumpDir() string { return "" } +func (*DisabledStub) CreateInterceptor(_ http.ResponseWriter, _ *http.Request, _ trace.Tracer) (intercept.Interceptor, error) { + //nolint:nilnil // disabled providers never reach the interceptor. + return nil, nil +} diff --git a/aibridge/provider/openai.go b/aibridge/provider/openai.go index 177ae03409..7a78069df6 100644 --- a/aibridge/provider/openai.go +++ b/aibridge/provider/openai.go @@ -84,6 +84,8 @@ func (p *OpenAI) Name() string { return p.cfg.Name } +func (*OpenAI) Enabled() bool { return true } + func (p *OpenAI) RoutePrefix() string { // Route prefix includes version to match default OpenAI base URL. // More detailed explanation: https://github.com/coder/aibridge/pull/174#discussion_r2782320152 diff --git a/aibridge/provider/provider.go b/aibridge/provider/provider.go index 7520333b53..6f21d7290d 100644 --- a/aibridge/provider/provider.go +++ b/aibridge/provider/provider.go @@ -53,6 +53,8 @@ type Provider interface { // Name returns the provider instance name. // Defaults to Type() when not explicitly configured. Name() string + // Enabled reports whether the provider should serve requests. + Enabled() bool // BaseURL defines the base URL endpoint for this provider's API. BaseURL() string diff --git a/cli/aibridged.go b/cli/aibridged.go index caf67082fc..a890488a10 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -4,6 +4,7 @@ package cli import ( "context" + "slices" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" @@ -101,10 +102,18 @@ func (r *poolDBReloader) Reload(ctx context.Context) error { return nil } -// BuildProviders loads every ai_providers row (including disabled) -// and returns the active provider list plus per-row outcomes. Per-row -// build errors are logged and excluded from providers but recorded in -// outcomes; only DB query failures propagate. +// BuildProviders loads all ai_providers rows (enabled and disabled), +// attaches keys to enabled rows, and constructs the equivalent +// [aibridge.Provider] instances. The database is the single source of +// truth for runtime provider configuration. +// +// Disabled rows produce a Provider stub with Enabled() == false so the +// bridge can answer requests targeting them with a 503 sentinel. +// +// Per-provider construction errors are logged and the offending row is +// excluded from the returned snapshot; only a failure of the DB query +// itself is propagated. This keeps a single misconfigured row from +// taking the whole daemon down. func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) { //nolint:gocritic // AsAIBridged has a minimal permission set for this purpose. authCtx := dbauthz.AsAIBridged(ctx) @@ -160,12 +169,9 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg Name: row.Name, Type: string(row.Type), } - if !row.Enabled { - outcome.Status = aibridged.ProviderStatusDisabled - outcomes = append(outcomes, outcome) - continue + if row.Enabled { + enabledCount++ } - enabledCount++ prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg) if err != nil { outcome.Status = aibridged.ProviderStatusError @@ -179,13 +185,17 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg ) continue } - outcome.Status = aibridged.ProviderStatusEnabled + if row.Enabled { + outcome.Status = aibridged.ProviderStatusEnabled + } else { + outcome.Status = aibridged.ProviderStatusDisabled + } outcomes = append(outcomes, outcome) providers = append(providers, prov) } - if enabledCount > 0 && len(providers) == 0 { - logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers") + if enabledCount > 0 && !slices.ContainsFunc(providers, func(p aibridge.Provider) bool { return p.Enabled() }) { + logger.Warn(ctx, "all enabled ai providers failed to build; only disabled providers remain") } return providers, outcomes, nil @@ -193,11 +203,18 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg // buildAIProviderFromRow decodes the settings blob and constructs the // appropriate [aibridge.Provider] for a single ai_providers row. +// Disabled rows return a Provider stub carrying only Name and +// Disabled: true; settings decode, key loading, and credential checks +// are skipped because the provider will never call upstream. func buildAIProviderFromRow( row database.AIProvider, keys []database.AIProviderKey, cfg codersdk.AIBridgeConfig, ) (aibridge.Provider, error) { + if !row.Enabled { + return disabledProviderFromRow(row) + } + settings, err := db2sdk.AIProviderSettings(row.Settings) if err != nil { return nil, xerrors.Errorf("decode settings: %w", err) @@ -287,6 +304,14 @@ func buildAIProviderFromRow( } } +// disabledProviderFromRow builds a Provider stub for a disabled row. +// Using provider.DisabledStub rather than a concrete provider avoids +// duplicating the row.Type switch and ensures that a new AiProviderType +// value is automatically handled without requiring a matching case here. +func disabledProviderFromRow(row database.AIProvider) (aibridge.Provider, error) { + return aibridge.NewDisabledProviderStub(row.Name, string(row.Type)), nil +} + // buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check // len(keys) > 0 first; keypool.New rejects empty input. func buildAIProviderKeyPool(keys []database.AIProviderKey) (*keypool.Pool, error) { diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go index 0226974520..6b3e1eb7ac 100644 --- a/cli/aibridged_internal_test.go +++ b/cli/aibridged_internal_test.go @@ -393,25 +393,60 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitShort) - logger := slogtest.Make(t, nil) - dbgen.AIProvider(t, db, database.AIProvider{ - Type: database.AiProviderTypeOpenai, - Name: "openai-off", - BaseUrl: "https://api.openai.com/", - }, func(p *database.InsertAIProviderParams) { - p.Enabled = false - }) + for _, tc := range []struct { + name string + row database.AIProvider + }{ + { + name: "OpenAI", + row: database.AIProvider{ + Type: database.AiProviderTypeOpenai, + Name: "openai-off", + BaseUrl: "https://api.openai.com/", + }, + }, + { + // Anthropic and Bedrock have stricter credential checks + // than the OpenAI family; the disabled short-circuit + // must reach them too. No keys, no bedrock settings. + name: "Anthropic", + row: database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-off", + BaseUrl: "https://api.anthropic.com/", + }, + }, + { + name: "Bedrock", + row: database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Name: "bedrock-off", + BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, nil) - providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) - require.NoError(t, err) - assert.Empty(t, providers, "disabled providers must not be in the active snapshot") - require.Len(t, outcomes, 1) - assert.Equal(t, "openai-off", outcomes[0].Name) - assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status) - assert.NoError(t, outcomes[0].Err) + dbgen.AIProvider(t, db, tc.row, func(p *database.InsertAIProviderParams) { + p.Enabled = false + }) + + providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + require.NoError(t, err) + require.Len(t, providers, 1, "disabled providers stay in the snapshot so the bridge can serve a 503 sentinel") + assert.Equal(t, tc.row.Name, providers[0].Name()) + assert.False(t, providers[0].Enabled()) + require.Len(t, outcomes, 1) + assert.Equal(t, tc.row.Name, outcomes[0].Name) + assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status) + assert.NoError(t, outcomes[0].Err) + }) + } }) } diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index 6b712a6352..a91e5b51d2 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -588,6 +588,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenAI", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenai, Name: "openai", BaseUrl: "https://api.openai.com/", @@ -597,6 +598,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Anthropic", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeAnthropic, Name: "anthropic", BaseUrl: "https://api.anthropic.com/", @@ -606,6 +608,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Copilot", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeCopilot, Name: "copilot", BaseUrl: "https://api.githubcopilot.com/", @@ -615,6 +618,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Azure", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeAzure, Name: "azure", BaseUrl: "https://example.openai.azure.com/", @@ -624,6 +628,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Google", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeGoogle, Name: "google", BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", @@ -633,6 +638,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenAICompat", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenaiCompat, Name: "openai-compat", BaseUrl: "https://compat.example.com/v1/", @@ -642,6 +648,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenRouter", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenrouter, Name: "openrouter", BaseUrl: "https://openrouter.ai/api/v1/", @@ -651,6 +658,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Vercel", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeVercel, Name: "vercel", BaseUrl: "https://api.v0.dev/v1/", @@ -660,6 +668,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Bedrock", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeBedrock, Name: "bedrock", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", @@ -694,6 +703,7 @@ func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) { t.Parallel() _, err := buildAIProviderFromRow(database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeBedrock, Name: "bedrock-no-settings", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", diff --git a/coderd/aibridged/pool.go b/coderd/aibridged/pool.go index 3b7e60955c..b86cefe00a 100644 --- a/coderd/aibridged/pool.go +++ b/coderd/aibridged/pool.go @@ -30,7 +30,9 @@ const ( type Pooler interface { Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpBootstrapper MCPProxyBuilder) (http.Handler, error) // ReplaceProviders swaps the providers used to construct future - // RequestBridge instances and clears the cache. + // RequestBridge instances and clears the cache. Disabled providers + // must be included; the bridge serves a 503 sentinel on their + // routes. ReplaceProviders(providers []aibridge.Provider) Shutdown(ctx context.Context) error } @@ -53,7 +55,8 @@ var _ Pooler = &CachedBridgePool{} type CachedBridgePool struct { cache *ristretto.Cache[string, *aibridge.RequestBridge] - // providers is the live provider set used by new RequestBridge instances. + // providers is the live provider set used by new RequestBridge + // instances. Includes disabled providers. providers atomic.Pointer[[]aibridge.Provider] providerVersion atomic.Int64 logger slog.Logger diff --git a/coderd/aibridged/provider.go b/coderd/aibridged/provider.go index 6fb53e1a93..9d2faa030b 100644 --- a/coderd/aibridged/provider.go +++ b/coderd/aibridged/provider.go @@ -17,9 +17,9 @@ const ( ) // ProviderOutcome classifies one ai_providers row, including disabled -// and errored rows the pool excludes. Err is populated only when -// Status == ProviderStatusError; the build error is already logged at -// the call site. +// rows (which the pool keeps as 503 stubs) and errored rows (which the +// pool excludes). Err is populated only when Status == ProviderStatusError; +// the build error is already logged at the call site. type ProviderOutcome struct { Name string Type string diff --git a/enterprise/coderd/aibridge_reload_test.go b/enterprise/coderd/aibridge_reload_test.go index 65f678df6e..e3370c8f7d 100644 --- a/enterprise/coderd/aibridge_reload_test.go +++ b/enterprise/coderd/aibridge_reload_test.go @@ -211,6 +211,18 @@ func TestAIBridgeProviderHotReload(t *testing.T) { "expected provider %q to stop routing", providerName) } + // requireDisabledSentinel polls until the provider name yields a + // 503 with the provider_disabled body, indicating the disabled + // handler is wired up for the row. + requireDisabledSentinel := func(t *testing.T, providerName string) { + t.Helper() + require.Eventuallyf(t, func() bool { + status, _ := sendRequest(providerName) + return status == http.StatusServiceUnavailable + }, testutil.WaitShort, testutil.IntervalFast, + "expected provider %q to serve the disabled sentinel", providerName) + } + // 1. Create: provider points at upstream A. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -233,14 +245,14 @@ func TestAIBridgeProviderHotReload(t *testing.T) { requireRoutesTo(t, "primary", upstreamB) requireProviderStatus(t, "primary", "enabled") - // 3. Disable: the provider drops out of the snapshot, requests - // stop reaching any upstream. The metric flips to "disabled". + // 3. Disable: requests stop reaching upstream and the bridge + // answers with the 503 sentinel. The metric flips to "disabled". disabled := false _, err = client.UpdateAIProvider(ctx, "primary", codersdk.UpdateAIProviderRequest{ Enabled: &disabled, }) require.NoError(t, err) - requireRoutingGone(t, "primary") + requireDisabledSentinel(t, "primary") requireProviderStatus(t, "primary", "disabled") // 4. Re-enable: routing comes back at the most recent BaseURL. From 2af037ce02e620b798202e898476b8b318ac6709 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 29 May 2026 11:25:06 +0300 Subject: [PATCH 12/63] fix(cli): use quartz mock clock in PausedDuringWaitForReady test (#25811) PausedDuringWaitForReady used the real clock, so the 5s poll in waitForTaskIdle could race with an in-flight stop build. The SQL view (tasks_with_status) returns "unknown" for stop builds with job_status != "succeeded" because the build_status CASE has no branch for (stop, pending) or (stop, running). On macOS CI, where the provisioner is slower, the poll fires during this transient window and hits the TaskStatusUnknown case instead of TaskStatusPaused, failing with "task entered unknown state" rather than the expected "was paused". Convert to the same quartz mock clock pattern that PR #25648 applied to WaitsForWorkingAppState: inject a mock clock via NewWithClock, trap ticker creation and reset, then advance time deterministically so the poll fires after the stop build completes. Closes CODAGT-482 --- cli/task_send_test.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cli/task_send_test.go b/cli/task_send_test.go index e545da80d1..c90cb335cc 100644 --- a/cli/task_send_test.go +++ b/cli/task_send_test.go @@ -245,8 +245,15 @@ func Test_TaskSend(t *testing.T) { pauseTask(setupCtx, t, setup.userClient, setup.task) resumeTask(setupCtx, t, setup.userClient, setup.task) + // Set up mock clock and traps before starting the command. + // Without a mock clock the poll can race with the stop build + // and see a transient 'unknown' status instead of 'paused'. + mClock := quartz.NewMock(t) + tickTrap := mClock.Trap().NewTicker("task_send", "poll") + resetTrap := mClock.Trap().TickerReset("task_send", "poll") + // When: We attempt to send input to the initializing task. - inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input") + inv, root := clitest.NewWithClock(t, mClock, "task", "send", setup.task.Name, "some task input") clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) @@ -259,11 +266,29 @@ func Test_TaskSend(t *testing.T) { // of waitForTaskIdle. pty.ExpectMatchContext(ctx, "Waiting for task to become idle") + // Wait for ticker creation and release it. + tickCall := tickTrap.MustWait(ctx) + tickCall.MustRelease(ctx) + tickTrap.Close() + + // Fire the immediate first poll (time.Nanosecond initial interval). + // This poll sees 'initializing' because no agent is connected. + mClock.Advance(time.Nanosecond).MustWait(ctx) + + // Wait for Reset (confirms first poll completed). + resetCall := resetTrap.MustWait(ctx) + resetCall.MustRelease(ctx) + resetTrap.Close() + // Pause the task while waitForTaskIdle is polling. Since // no agent is connected, the task stays initializing until // we pause it, at which point the status becomes paused. pauseTask(ctx, t, setup.userClient, setup.task) + // Fire second poll at the regular 5s interval. The stop + // build has completed, so the poll sees 'paused'. + mClock.Advance(5 * time.Second).MustWait(ctx) + // Then: The command should fail because the task was paused. err := w.Wait() require.Error(t, err) From 78d556fffc85e9b929638496d1d794c92df74bfc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 11:34:54 +0100 Subject: [PATCH 13/63] feat(site/src/pages/AgentsPage): show error details for generic errors (#25803) Error messages in agent chat now expose the actual error detail instead of hiding it entirely. Also captures API response detail for generic errors that previously dropped it. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 4 +++- .../ChatConversation/ChatStatusCallout.tsx | 16 +++++++++++----- .../ChatConversation/LiveStreamTail.stories.tsx | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 8eca4e5f7d..a6d968d8af 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1163,9 +1163,11 @@ const AgentChatPage: FC = () => { store.setStreamError(reason); setChatErrorReason(agentId, reason); } else if (isApiError(error)) { + const detail = error.response?.data?.detail?.trim() || undefined; const reason: ChatDetailError = { kind: "generic", - message: error.message || "An unexpected error occurred.", + message: getErrorMessage(error, "An unexpected error occurred."), + ...(detail ? { detail } : {}), }; store.setStreamError(reason); setChatErrorReason(agentId, reason); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx index 9cf3d8bc5b..857d15a5eb 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx @@ -157,11 +157,17 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => { )} - {status.phase === "failed" && status.detail && ( - - {status.detail} - - )} + {status.phase === "failed" && + status.detail && + (status.kind === "generic" ? ( + + {status.detail} + + ) : ( + + {status.detail} + + ))} ); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index 08fd59d1bb..b58a6b8ebe 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -317,7 +317,7 @@ export const GenericErrorDoesNotShowUsageAction: Story = { }, }; -/** Provider detail renders as a muted secondary line under the main error. */ +/** Provider detail renders in a monospace block for generic errors. */ export const GenericErrorShowsProviderDetail: Story = { args: { ...defaultArgs, From 88060b846ec2267f911c015b8dba94595e3530d1 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Fri, 29 May 2026 11:35:05 +0100 Subject: [PATCH 14/63] style(site): use lightbulb icon for thinking (#25844) --- .../AgentsPage/components/ChatElements/tools/ToolIcon.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx index dd8cb1cd04..c2ff74debb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx @@ -4,12 +4,12 @@ import { CompassIcon, FilePenLineIcon, FileTextIcon, + LightbulbIcon, LogInIcon, MonitorIcon, PowerIcon, RouteIcon, ServerIcon, - SparklesIcon, TerminalIcon, WrenchIcon, } from "lucide-react"; @@ -118,7 +118,7 @@ export const ToolIcon: React.FC<{ case "chat_summarized": return ; case "thinking": - return ; + return ; case "propose_plan": return ; case "ask_user_question": From a586b7e5e0d93e7674bcb5bdfb5c482eac6feea0 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 29 May 2026 12:50:39 +0200 Subject: [PATCH 15/63] feat: add `boundary_log` rbac resource (#24810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC: [Bridge ↔ Boundaries Correlation RFC](https://www.notion.so/coderhq/Gateway-and-Firewall-Correlation-RFC-31ad579be592803aa8b3d48348ccdde9) Register a dedicated `boundary_log` RBAC resource type with `create`, `read`, and `delete` actions, replacing the placeholder `rbac.ResourceAuditLog` and `rbac.ResourceSystem` references previously used in the dbauthz layer. Create is granted at user-level so workspace agents can only write logs owned by their workspace owner, preventing cross-workspace log fabrication. Delete is restricted to `DBPurge` only; no human role (including owner) can delete boundary logs. | Subject | Create (own) | Create (other) | Read (all) | Delete | |---|---|---|---|---| | Workspace agent | yes | no | no | no | | Owner (site admin) | yes (via member) | no | yes | no | | Auditor | no | no | yes | no | | DBPurge | no | no | no | yes | ### Changes - **RBAC policy & resource definition**: add `boundary_log` to `policy.go` and generate `ResourceBoundaryLog` object, scope constants, and codersdk/TypeScript types. - **dbauthz authorization**: replace all `ResourceAuditLog`/`ResourceSystem` placeholders with `ResourceBoundaryLog`. `InsertBoundaryLog` and `InsertBoundarySession` derive the workspace owner from the agent and authorize with `.WithOwner()` for user-scoped create. - **Role assignments:** - **Owner (site):** read only. Excluded from `allPermsExcept` wildcard; create is inherited from member at user-level. - **Member (user-level):** create. User-scoped so agents can only write logs they own. - **Auditor (site):** read. - `boundary_log` is excluded from org-admin, org-member, and org-service-account `allPermsExcept` calls for consistency with `ResourceBoundaryUsage`. - **System subjects:** - **DB Purge** (`SubjectTypeDBPurge`): delete. The only subject that can remove boundary logs. - **Workspace agent scope**: `ResourceBoundaryLog` with wildcard ID in the agent scope allow-list (necessary for creation since no pre-existing ID exists). User-level role scoping prevents deployment-wide access. - **DB migration** (`000510_boundary_log_scopes`): add `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read` enum values to `api_key_scope`. - **Test coverage**: `BoundaryLogCreate` (user-scoped, only matching owner succeeds), `BoundaryLogDelete` (all human roles denied), `BoundaryLogRead` (owner + auditor). dbauthz mock tests set up workspace agent lookups for owner derivation. - **Generated docs**: update OpenAPI specs, API reference docs, and frontend type definitions. --------- Co-authored-by: Muhammad Danish Co-authored-by: Coder Agents --- coderd/apidoc/docs.go | 10 + coderd/apidoc/swagger.json | 10 + coderd/database/dbauthz/dbauthz.go | 39 ++-- coderd/database/dbauthz/dbauthz_test.go | 48 +++-- coderd/database/dbgen/dbgen.go | 59 ++++-- coderd/database/dbmetrics/querymetrics.go | 8 +- coderd/database/dbmock/dbmock.go | 14 +- coderd/database/dump.sql | 14 +- coderd/database/foreign_key_constraint.go | 1 + .../000511_boundary_log_scopes.down.sql | 1 + .../000511_boundary_log_scopes.up.sql | 5 + .../000512_boundary_session_owner.down.sql | 2 + .../000512_boundary_session_owner.up.sql | 28 +++ .../000512_boundary_session_owner.up.sql | 42 ++++ coderd/database/modelmethods.go | 7 + coderd/database/models.go | 16 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 129 +++++++----- coderd/database/queries/boundarylogs.sql | 27 +-- coderd/rbac/object_gen.go | 10 + coderd/rbac/policy/policy.go | 7 + coderd/rbac/roles.go | 18 +- coderd/rbac/roles_test.go | 187 ++++++++++++++++++ coderd/rbac/scopes.go | 5 + coderd/rbac/scopes_constants_gen.go | 9 + codersdk/apikey_scopes_gen.go | 4 + codersdk/rbacresources_gen.go | 2 + docs/reference/api/members.md | 40 ++-- docs/reference/api/schemas.md | 12 +- docs/reference/api/users.md | 10 +- site/src/api/rbacresourcesGenerated.ts | 5 + site/src/api/typesGenerated.ts | 10 + 32 files changed, 627 insertions(+), 154 deletions(-) create mode 100644 coderd/database/migrations/000511_boundary_log_scopes.down.sql create mode 100644 coderd/database/migrations/000511_boundary_log_scopes.up.sql create mode 100644 coderd/database/migrations/000512_boundary_session_owner.down.sql create mode 100644 coderd/database/migrations/000512_boundary_session_owner.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 22616628d9..835823ecf4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15199,6 +15199,10 @@ const docTemplate = `{ "audit_log:*", "audit_log:create", "audit_log:read", + "boundary_log:*", + "boundary_log:create", + "boundary_log:delete", + "boundary_log:read", "boundary_usage:*", "boundary_usage:delete", "boundary_usage:read", @@ -15425,6 +15429,10 @@ const docTemplate = `{ "APIKeyScopeAuditLogAll", "APIKeyScopeAuditLogCreate", "APIKeyScopeAuditLogRead", + "APIKeyScopeBoundaryLogAll", + "APIKeyScopeBoundaryLogCreate", + "APIKeyScopeBoundaryLogDelete", + "APIKeyScopeBoundaryLogRead", "APIKeyScopeBoundaryUsageAll", "APIKeyScopeBoundaryUsageDelete", "APIKeyScopeBoundaryUsageRead", @@ -22223,6 +22231,7 @@ const docTemplate = `{ "assign_org_role", "assign_role", "audit_log", + "boundary_log", "boundary_usage", "chat", "connection_log", @@ -22273,6 +22282,7 @@ const docTemplate = `{ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceBoundaryLog", "ResourceBoundaryUsage", "ResourceChat", "ResourceConnectionLog", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b32ea11968..db7cbf6809 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13595,6 +13595,10 @@ "audit_log:*", "audit_log:create", "audit_log:read", + "boundary_log:*", + "boundary_log:create", + "boundary_log:delete", + "boundary_log:read", "boundary_usage:*", "boundary_usage:delete", "boundary_usage:read", @@ -13821,6 +13825,10 @@ "APIKeyScopeAuditLogAll", "APIKeyScopeAuditLogCreate", "APIKeyScopeAuditLogRead", + "APIKeyScopeBoundaryLogAll", + "APIKeyScopeBoundaryLogCreate", + "APIKeyScopeBoundaryLogDelete", + "APIKeyScopeBoundaryLogRead", "APIKeyScopeBoundaryUsageAll", "APIKeyScopeBoundaryUsageDelete", "APIKeyScopeBoundaryUsageRead", @@ -20366,6 +20374,7 @@ "assign_org_role", "assign_role", "audit_log", + "boundary_log", "boundary_usage", "chat", "connection_log", @@ -20416,6 +20425,7 @@ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceBoundaryLog", "ResourceBoundaryUsage", "ResourceChat", "ResourceConnectionLog", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3e41260d82..5e98dfcf30 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -651,6 +651,8 @@ var ( rbac.ResourceAibridgeInterception.Type: {policy.ActionDelete}, // Chat auto-archive sets archived=true on inactive chats. rbac.ResourceChat.Type: {policy.ActionRead, policy.ActionUpdate}, + // Purge old boundary logs past the retention period. + rbac.ResourceBoundaryLog.Type: {policy.ActionDelete}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -2191,9 +2193,8 @@ func (q *querier) DeleteOldAuditLogs(ctx context.Context, arg database.DeleteOld return q.db.DeleteOldAuditLogs(ctx, arg) } -// TODO (PR #24810): Replace rbac.ResourceSystem with dedicated boundary_log resource type. func (q *querier) DeleteOldBoundaryLogs(ctx context.Context, arg database.DeleteOldBoundaryLogsParams) (int64, error) { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceBoundaryLog); err != nil { return 0, err } return q.db.DeleteOldBoundaryLogs(ctx, arg) @@ -2780,17 +2781,15 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (database.BoundaryLog, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil { return database.BoundaryLog{}, err } return q.db.GetBoundaryLogByID(ctx, id) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil { return database.BoundarySession{}, err } return q.db.GetBoundarySessionByID(ctx, id) @@ -5468,14 +5467,29 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. -func (q *querier) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { - return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundaryLog)(ctx, arg) +func (q *querier) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) { + session, err := q.db.GetBoundarySessionByID(ctx, arg.SessionID) + if err != nil { + return nil, xerrors.Errorf("get boundary session for owner: %w", err) + } + if err := q.authorizeContext(ctx, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(session.OwnerID.UUID.String())); err != nil { + return nil, err + } + return q.db.InsertBoundaryLogs(ctx, arg) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) InsertBoundarySession(ctx context.Context, arg database.InsertBoundarySessionParams) (database.BoundarySession, error) { - return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundarySession)(ctx, arg) + row, err := q.db.GetWorkspaceAgentAndWorkspaceByID(ctx, arg.WorkspaceAgentID) + if err != nil { + return database.BoundarySession{}, xerrors.Errorf("get workspace for boundary session owner: %w", err) + } + arg.OwnerID = uuid.NullUUID{UUID: row.WorkspaceTable.OwnerID, Valid: true} + if err := q.authorizeContext(ctx, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(arg.OwnerID.UUID.String())); err != nil { + return database.BoundarySession{}, err + } + return q.db.InsertBoundarySession(ctx, arg) } func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { @@ -6191,9 +6205,8 @@ func (q *querier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, return q.db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, interceptionIDs) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil { return nil, err } return q.db.ListBoundaryLogsBySessionID(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 3296d5cebe..cfc87d3ed4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -440,35 +440,55 @@ func (s *MethodTestSuite) TestAuditLogs() { })) } -// TODO (PR #24810): These RBAC assertions use placeholder resource types. -// They will be updated when the dedicated boundary_log resource type is added. func (s *MethodTestSuite) TestBoundaryLogs() { - s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.InsertBoundarySessionParams{} - dbm.EXPECT().InsertBoundarySession(gomock.Any(), arg).Return(database.BoundarySession{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate) + s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + aww := testutil.Fake(s.T(), faker, database.GetWorkspaceAgentAndWorkspaceByIDRow{}) + arg := database.InsertBoundarySessionParams{ + WorkspaceAgentID: aww.WorkspaceAgent.ID, + } + dbm.EXPECT().GetWorkspaceAgentAndWorkspaceByID(gomock.Any(), aww.WorkspaceAgent.ID).Return(aww, nil).AnyTimes() + expectedArg := database.InsertBoundarySessionParams{ + WorkspaceAgentID: aww.WorkspaceAgent.ID, + OwnerID: uuid.NullUUID{UUID: aww.WorkspaceTable.OwnerID, Valid: true}, + } + dbm.EXPECT().InsertBoundarySession(gomock.Any(), expectedArg).Return(database.BoundarySession{}, nil).AnyTimes() + check.Args(arg).Asserts( + rbac.ResourceBoundaryLog.WithOwner(aww.WorkspaceTable.OwnerID.String()), policy.ActionCreate, + ) })) s.Run("GetBoundarySessionByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), uuid.Nil).Return(database.BoundarySession{}, nil).AnyTimes() - check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) - s.Run("InsertBoundaryLog", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.InsertBoundaryLogParams{} - dbm.EXPECT().InsertBoundaryLog(gomock.Any(), arg).Return(database.BoundaryLog{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate) + s.Run("InsertBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ownerID := uuid.New() + sessionID := uuid.New() + session := database.BoundarySession{ + ID: sessionID, + OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true}, + } + arg := database.InsertBoundaryLogsParams{ + SessionID: sessionID, + ID: []uuid.UUID{uuid.New(), uuid.New()}, + } + dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), sessionID).Return(session, nil).AnyTimes() + dbm.EXPECT().InsertBoundaryLogs(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes() + check.Args(arg).Asserts( + rbac.ResourceBoundaryLog.WithOwner(ownerID.String()), policy.ActionCreate, + ) })) s.Run("GetBoundaryLogByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetBoundaryLogByID(gomock.Any(), uuid.Nil).Return(database.BoundaryLog{}, nil).AnyTimes() - check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) s.Run("ListBoundaryLogsBySessionID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.ListBoundaryLogsBySessionIDParams{} dbm.EXPECT().ListBoundaryLogsBySessionID(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + check.Args(arg).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) s.Run("DeleteOldBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().DeleteOldBoundaryLogs(gomock.Any(), database.DeleteOldBoundaryLogsParams{}).Return(int64(0), nil).AnyTimes() - check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete) + check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceBoundaryLog, policy.ActionDelete) })) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 834bad6274..edc1d710cb 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -458,6 +458,7 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess session, err := db.InsertBoundarySession(genCtx, database.InsertBoundarySessionParams{ ID: takeFirst(seed.ID, uuid.New()), WorkspaceAgentID: takeFirst(seed.WorkspaceAgentID, uuid.New()), + OwnerID: takeFirst(seed.OwnerID, uuid.NullUUID{UUID: uuid.New(), Valid: true}), ConfinedProcessName: takeFirst(seed.ConfinedProcessName, "claude-code"), StartedAt: takeFirst(seed.StartedAt, dbtime.Now()), UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), @@ -466,20 +467,52 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess return session } -func BoundaryLog(t testing.TB, db database.Store, seed database.BoundaryLog) database.BoundaryLog { - log, err := db.InsertBoundaryLog(genCtx, database.InsertBoundaryLogParams{ - ID: takeFirst(seed.ID, uuid.New()), - SessionID: seed.SessionID, - SequenceNumber: takeFirst(seed.SequenceNumber, 0), - CapturedAt: takeFirst(seed.CapturedAt, dbtime.Now()), - CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), - Proto: takeFirst(seed.Proto, "http"), - Method: takeFirst(seed.Method, "GET"), - Detail: takeFirst(seed.Detail, "https://example.com"), - MatchedRule: seed.MatchedRule, +func BoundaryLogs(t testing.TB, db database.Store, seed []database.BoundaryLog) []database.BoundaryLog { + ids := make([]uuid.UUID, 0, len(seed)) + sessionID := seed[0].SessionID + sequenceNumbers := make([]int32, 0, len(seed)) + capturedAt := make([]time.Time, 0, len(seed)) + createdAt := make([]time.Time, 0, len(seed)) + protos := make([]string, 0, len(seed)) + method := make([]string, 0, len(seed)) + detail := make([]string, 0, len(seed)) + matchedRule := make([]string, 0, len(seed)) + for _, log := range seed { + log = takeFirstBoundaryLog(log) + ids = append(ids, log.ID) + sequenceNumbers = append(sequenceNumbers, log.SequenceNumber) + capturedAt = append(capturedAt, log.CapturedAt) + createdAt = append(createdAt, log.CreatedAt) + protos = append(protos, log.Proto) + method = append(method, log.Method) + detail = append(detail, log.Detail) + matchedRule = append(matchedRule, log.MatchedRule.String) + } + logs, err := db.InsertBoundaryLogs(genCtx, database.InsertBoundaryLogsParams{ + ID: ids, + SessionID: sessionID, + SequenceNumber: sequenceNumbers, + CapturedAt: capturedAt, + CreatedAt: createdAt, + Proto: protos, + Method: method, + Detail: detail, + MatchedRule: matchedRule, }) - require.NoError(t, err, "insert boundary log") - return log + require.NoError(t, err, "insert boundary logs") + return logs +} + +func takeFirstBoundaryLog(seed database.BoundaryLog) database.BoundaryLog { + seed.ID = takeFirst(seed.ID, uuid.New()) + seed.SessionID = takeFirst(seed.SessionID, uuid.New()) + seed.SequenceNumber = takeFirst(seed.SequenceNumber, 0) + seed.CapturedAt = takeFirst(seed.CapturedAt, dbtime.Now()) + seed.CreatedAt = takeFirst(seed.CreatedAt, dbtime.Now()) + seed.Proto = takeFirst(seed.Proto, "http") + seed.Method = takeFirst(seed.Method, "GET") + seed.Detail = takeFirst(seed.Detail, "https://example.com") + return seed } func Template(t testing.TB, db database.Store, seed database.Template) database.Template { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index fd4537ccec..c963d41d3f 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3745,11 +3745,11 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse return r0, r1 } -func (m queryMetricsStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { +func (m queryMetricsStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) { start := time.Now() - r0, r1 := m.s.InsertBoundaryLog(ctx, arg) - m.queryLatencies.WithLabelValues("InsertBoundaryLog").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLog").Inc() + r0, r1 := m.s.InsertBoundaryLogs(ctx, arg) + m.queryLatencies.WithLabelValues("InsertBoundaryLogs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLogs").Inc() return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 36f8429e8f..75ac8f44ad 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7034,19 +7034,19 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg) } -// InsertBoundaryLog mocks base method. -func (m *MockStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { +// InsertBoundaryLogs mocks base method. +func (m *MockStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertBoundaryLog", ctx, arg) - ret0, _ := ret[0].(database.BoundaryLog) + ret := m.ctrl.Call(m, "InsertBoundaryLogs", ctx, arg) + ret0, _ := ret[0].([]database.BoundaryLog) ret1, _ := ret[1].(error) return ret0, ret1 } -// InsertBoundaryLog indicates an expected call of InsertBoundaryLog. -func (mr *MockStoreMockRecorder) InsertBoundaryLog(ctx, arg any) *gomock.Call { +// InsertBoundaryLogs indicates an expected call of InsertBoundaryLogs. +func (mr *MockStoreMockRecorder) InsertBoundaryLogs(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLog", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLog), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLogs", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLogs), ctx, arg) } // InsertBoundarySession mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ff743ba902..fad35755b9 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -249,7 +249,11 @@ CREATE TYPE api_key_scope AS ENUM ( 'user_skill:read', 'user_skill:update', 'user_skill:delete', - 'user_skill:*' + 'user_skill:*', + 'boundary_log:*', + 'boundary_log:create', + 'boundary_log:delete', + 'boundary_log:read' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -1485,7 +1489,8 @@ CREATE TABLE boundary_sessions ( workspace_agent_id uuid NOT NULL, confined_process_name text NOT NULL, started_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL + updated_at timestamp with time zone NOT NULL, + owner_id uuid ); COMMENT ON TABLE boundary_sessions IS 'Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent.'; @@ -1500,6 +1505,8 @@ COMMENT ON COLUMN boundary_sessions.started_at IS 'Time when the first log for t COMMENT ON COLUMN boundary_sessions.updated_at IS 'Time when the session was last updated.'; +COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.'; + CREATE TABLE boundary_usage_stats ( replica_id uuid NOT NULL, unique_workspaces_count bigint DEFAULT 0 NOT NULL, @@ -4509,6 +4516,9 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; +ALTER TABLE ONLY boundary_sessions + ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; + ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 624f3229b6..a31346b423 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -13,6 +13,7 @@ const ( ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyBoundaryLogsSessionID ForeignKeyConstraint = "boundary_logs_session_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; + ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000511_boundary_log_scopes.down.sql b/coderd/database/migrations/000511_boundary_log_scopes.down.sql new file mode 100644 index 0000000000..5a1baaa20c --- /dev/null +++ b/coderd/database/migrations/000511_boundary_log_scopes.down.sql @@ -0,0 +1 @@ +-- No-op for boundary_log scopes: keep enum values to avoid dependency churn. diff --git a/coderd/database/migrations/000511_boundary_log_scopes.up.sql b/coderd/database/migrations/000511_boundary_log_scopes.up.sql new file mode 100644 index 0000000000..12ec141591 --- /dev/null +++ b/coderd/database/migrations/000511_boundary_log_scopes.up.sql @@ -0,0 +1,5 @@ +-- Add boundary_log scopes for RBAC. +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:read'; diff --git a/coderd/database/migrations/000512_boundary_session_owner.down.sql b/coderd/database/migrations/000512_boundary_session_owner.down.sql new file mode 100644 index 0000000000..3429fee351 --- /dev/null +++ b/coderd/database/migrations/000512_boundary_session_owner.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE boundary_sessions DROP CONSTRAINT IF EXISTS boundary_sessions_owner_id_fkey; +ALTER TABLE boundary_sessions DROP COLUMN IF EXISTS owner_id; diff --git a/coderd/database/migrations/000512_boundary_session_owner.up.sql b/coderd/database/migrations/000512_boundary_session_owner.up.sql new file mode 100644 index 0000000000..d97140df57 --- /dev/null +++ b/coderd/database/migrations/000512_boundary_session_owner.up.sql @@ -0,0 +1,28 @@ +-- Add owner_id to boundary_sessions to avoid expensive JOINs when +-- deriving the workspace owner for RBAC checks during log insertion. +ALTER TABLE boundary_sessions ADD COLUMN owner_id uuid; + +COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.'; + +-- Backfill owner_id from the workspace agent -> workspace -> owner chain. +-- Soft-deleted agents and workspaces are included so that their audit +-- data is preserved. +UPDATE boundary_sessions bs +SET owner_id = w.owner_id +FROM workspace_agents wa +JOIN workspace_resources wr ON wa.resource_id = wr.id +JOIN provisioner_jobs pj ON wr.job_id = pj.id +JOIN workspace_builds wb ON pj.id = wb.job_id +JOIN workspaces w ON wb.workspace_id = w.id +WHERE wa.id = bs.workspace_agent_id + AND pj.type = 'workspace_build'; + +-- Delete any sessions that could not be backfilled (orphaned data +-- with no resolvable workspace agent or workspace build chain). +DELETE FROM boundary_sessions WHERE owner_id IS NULL; + +-- Add FK constraint. SET NULL preserves audit data when a user is +-- hard-deleted; the session and its logs survive with a NULL owner. +ALTER TABLE boundary_sessions + ADD CONSTRAINT boundary_sessions_owner_id_fkey + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql b/coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql new file mode 100644 index 0000000000..d1942bd5a5 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql @@ -0,0 +1,42 @@ +-- Re-insert boundary session and log fixture data after migration 000511 +-- deletes orphaned rows (the original fixture's workspace_agent links to a +-- template_version_import job, not a workspace_build, so the backfill +-- cannot resolve the owner). + +INSERT INTO boundary_sessions ( + id, + workspace_agent_id, + confined_process_name, + started_at, + updated_at, + owner_id +) VALUES ( + 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', + '45e89705-e09d-4850-bcec-f9a937f5d78d', + 'claude-code', + '2026-04-01 10:00:00+00', + '2026-04-01 10:00:00+00', + '30095c71-380b-457a-8995-97b8ee6e5307' +); + +INSERT INTO boundary_logs ( + id, + session_id, + sequence_number, + captured_at, + created_at, + proto, + method, + detail, + matched_rule +) VALUES ( + 'b2c3d4e5-f6a7-4901-bcde-f12345678901', + 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', + 0, + '2026-04-01 10:00:01+00', + '2026-04-01 10:00:00+00', + 'http', + 'GET', + 'https://api.anthropic.com/v1/messages', + 'domain=api.anthropic.com' +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index bab9759762..62eb12a1d2 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -1003,3 +1003,10 @@ type UpsertConnectionLogParams struct { func (r GetLatestWorkspaceBuildWithStatusByWorkspaceIDRow) RBACObject() rbac.Object { return r.WorkspaceTable.RBACObject() } + +func (s BoundarySession) RBACObject() rbac.Object { + if s.OwnerID.Valid { + return rbac.ResourceBoundaryLog.WithOwner(s.OwnerID.UUID.String()) + } + return rbac.ResourceBoundaryLog +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 940904385a..6149e12a10 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -320,6 +320,10 @@ const ( ApiKeyScopeUserSkillUpdate APIKeyScope = "user_skill:update" ApiKeyScopeUserSkillDelete APIKeyScope = "user_skill:delete" ApiKeyScopeUserSkill APIKeyScope = "user_skill:*" + ApiKeyScopeBoundaryLog APIKeyScope = "boundary_log:*" + ApiKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create" + ApiKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete" + ApiKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -580,7 +584,11 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeUserSkillRead, ApiKeyScopeUserSkillUpdate, ApiKeyScopeUserSkillDelete, - ApiKeyScopeUserSkill: + ApiKeyScopeUserSkill, + ApiKeyScopeBoundaryLog, + ApiKeyScopeBoundaryLogCreate, + ApiKeyScopeBoundaryLogDelete, + ApiKeyScopeBoundaryLogRead: return true } return false @@ -810,6 +818,10 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeUserSkillUpdate, ApiKeyScopeUserSkillDelete, ApiKeyScopeUserSkill, + ApiKeyScopeBoundaryLog, + ApiKeyScopeBoundaryLogCreate, + ApiKeyScopeBoundaryLogDelete, + ApiKeyScopeBoundaryLogRead, } } @@ -4543,6 +4555,8 @@ type BoundarySession struct { StartedAt time.Time `db:"started_at" json:"started_at"` // Time when the session was last updated. UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // The ID of the user who owns the workspace. NULL if the user has been deleted. + OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` } // Per-replica boundary usage statistics for telemetry aggregation. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6b16e0771a..0b3d565b69 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -920,7 +920,7 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) - InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error) + InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) // updated_at is the retention clock used by DeleteOldChatDebugRuns. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1c04d4906b..d4cf7468f4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3627,7 +3627,7 @@ func (q *sqlQuerier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (Boun } const getBoundarySessionByID = `-- name: GetBoundarySessionByID :one -SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at FROM boundary_sessions WHERE id = $1 +SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at, owner_id FROM boundary_sessions WHERE id = $1 ` func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (BoundarySession, error) { @@ -3639,11 +3639,12 @@ func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) ( &i.ConfinedProcessName, &i.StartedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } -const insertBoundaryLog = `-- name: InsertBoundaryLog :one +const insertBoundaryLogs = `-- name: InsertBoundaryLogs :many INSERT INTO boundary_logs ( id, session_id, @@ -3654,62 +3655,80 @@ INSERT INTO boundary_logs ( method, detail, matched_rule -) VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9 -) RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule +) +SELECT + unnest($1 :: uuid[]), + $2 :: uuid, + unnest($3 :: int[]), + unnest($4 :: timestamptz[]), + unnest($5 :: timestamptz[]), + unnest($6 :: text[]), + unnest($7 :: text[]), + unnest($8 :: text[]), + unnest($9 :: text[]) +RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule ` -type InsertBoundaryLogParams struct { - ID uuid.UUID `db:"id" json:"id"` - SessionID uuid.UUID `db:"session_id" json:"session_id"` - SequenceNumber int32 `db:"sequence_number" json:"sequence_number"` - CapturedAt time.Time `db:"captured_at" json:"captured_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - Proto string `db:"proto" json:"proto"` - Method string `db:"method" json:"method"` - Detail string `db:"detail" json:"detail"` - MatchedRule sql.NullString `db:"matched_rule" json:"matched_rule"` +type InsertBoundaryLogsParams struct { + ID []uuid.UUID `db:"id" json:"id"` + SessionID uuid.UUID `db:"session_id" json:"session_id"` + SequenceNumber []int32 `db:"sequence_number" json:"sequence_number"` + CapturedAt []time.Time `db:"captured_at" json:"captured_at"` + CreatedAt []time.Time `db:"created_at" json:"created_at"` + Proto []string `db:"proto" json:"proto"` + Method []string `db:"method" json:"method"` + Detail []string `db:"detail" json:"detail"` + MatchedRule []string `db:"matched_rule" json:"matched_rule"` } -func (q *sqlQuerier) InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error) { - row := q.db.QueryRowContext(ctx, insertBoundaryLog, - arg.ID, +func (q *sqlQuerier) InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error) { + rows, err := q.db.QueryContext(ctx, insertBoundaryLogs, + pq.Array(arg.ID), arg.SessionID, - arg.SequenceNumber, - arg.CapturedAt, - arg.CreatedAt, - arg.Proto, - arg.Method, - arg.Detail, - arg.MatchedRule, + pq.Array(arg.SequenceNumber), + pq.Array(arg.CapturedAt), + pq.Array(arg.CreatedAt), + pq.Array(arg.Proto), + pq.Array(arg.Method), + pq.Array(arg.Detail), + pq.Array(arg.MatchedRule), ) - var i BoundaryLog - err := row.Scan( - &i.ID, - &i.SessionID, - &i.SequenceNumber, - &i.CapturedAt, - &i.CreatedAt, - &i.Proto, - &i.Method, - &i.Detail, - &i.MatchedRule, - ) - return i, err + if err != nil { + return nil, err + } + defer rows.Close() + var items []BoundaryLog + for rows.Next() { + var i BoundaryLog + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const insertBoundarySession = `-- name: InsertBoundarySession :one INSERT INTO boundary_sessions ( id, workspace_agent_id, + owner_id, confined_process_name, started_at, updated_at @@ -3718,22 +3737,25 @@ INSERT INTO boundary_sessions ( $2, $3, $4, - $5 -) RETURNING id, workspace_agent_id, confined_process_name, started_at, updated_at + $5, + $6 +) RETURNING id, workspace_agent_id, confined_process_name, started_at, updated_at, owner_id ` type InsertBoundarySessionParams struct { - ID uuid.UUID `db:"id" json:"id"` - WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` - ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"` - StartedAt time.Time `db:"started_at" json:"started_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` + ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) { row := q.db.QueryRowContext(ctx, insertBoundarySession, arg.ID, arg.WorkspaceAgentID, + arg.OwnerID, arg.ConfinedProcessName, arg.StartedAt, arg.UpdatedAt, @@ -3745,6 +3767,7 @@ func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBounda &i.ConfinedProcessName, &i.StartedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } diff --git a/coderd/database/queries/boundarylogs.sql b/coderd/database/queries/boundarylogs.sql index d8c35fd7eb..3abeb618a5 100644 --- a/coderd/database/queries/boundarylogs.sql +++ b/coderd/database/queries/boundarylogs.sql @@ -2,12 +2,14 @@ INSERT INTO boundary_sessions ( id, workspace_agent_id, + owner_id, confined_process_name, started_at, updated_at ) VALUES ( @id, @workspace_agent_id, + @owner_id, @confined_process_name, @started_at, @updated_at @@ -16,7 +18,7 @@ INSERT INTO boundary_sessions ( -- name: GetBoundarySessionByID :one SELECT * FROM boundary_sessions WHERE id = @id; --- name: InsertBoundaryLog :one +-- name: InsertBoundaryLogs :many INSERT INTO boundary_logs ( id, session_id, @@ -27,17 +29,18 @@ INSERT INTO boundary_logs ( method, detail, matched_rule -) VALUES ( - @id, - @session_id, - @sequence_number, - @captured_at, - @created_at, - @proto, - @method, - @detail, - @matched_rule -) RETURNING *; +) +SELECT + unnest(@id :: uuid[]), + @session_id :: uuid, + unnest(@sequence_number :: int[]), + unnest(@captured_at :: timestamptz[]), + unnest(@created_at :: timestamptz[]), + unnest(@proto :: text[]), + unnest(@method :: text[]), + unnest(@detail :: text[]), + unnest(@matched_rule :: text[]) +RETURNING *; -- name: GetBoundaryLogByID :one SELECT * FROM boundary_logs WHERE id = @id; diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 340221f611..824cf92fdd 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -89,6 +89,15 @@ var ( Type: "audit_log", } + // ResourceBoundaryLog + // Valid Actions + // - "ActionCreate" :: create boundary log records + // - "ActionDelete" :: delete boundary logs + // - "ActionRead" :: read boundary logs and session metadata + ResourceBoundaryLog = Object{ + Type: "boundary_log", + } + // ResourceBoundaryUsage // Valid Actions // - "ActionDelete" :: delete boundary usage statistics @@ -478,6 +487,7 @@ func AllResources() []Objecter { ResourceAssignOrgRole, ResourceAssignRole, ResourceAuditLog, + ResourceBoundaryLog, ResourceBoundaryUsage, ResourceChat, ResourceConnectionLog, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 7d7a42110d..f2b17927bd 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -422,6 +422,13 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionRead: "read AI seat state", }, }, + "boundary_log": { + Actions: map[Action]ActionDefinition{ + ActionCreate: "create boundary log records", + ActionRead: "read boundary logs and session metadata", + ActionDelete: "delete boundary logs", + }, + }, "boundary_usage": { Actions: map[Action]ActionDefinition{ ActionRead: "read boundary usage statistics", diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index cbaf49f9c0..1b19947ea6 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -303,7 +303,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. // Owners can inspect and delete personal skills for operability and // abuse handling, but cannot create or edit user-authored instructions. - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat), // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, @@ -313,6 +313,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Explicitly setting PrebuiltWorkspace permissions for clarity. // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete}, + // Owners can read all boundary logs. Delete is reserved for + // DBPurge only. Create is user-scoped (inherited from member). + ResourceBoundaryLog.Type: {policy.ActionRead}, })..., ), User: []Permission{}, @@ -332,7 +335,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { denyPermissions..., ), User: append( - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat), Permissions(map[string][]policy.Action{ // Users cannot do create/update/delete on themselves, but they // can read their own details. @@ -342,6 +345,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Members can create and update AI Bridge interceptions but // cannot read them back. ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate}, + // Workspace agents create boundary logs under their owner's + // identity. Create is user-scoped so agents can only write + // logs owned by their workspace owner. + // Read: owners and auditors. Delete: DBPurge only. + ResourceBoundaryLog.Type: {policy.ActionCreate}, })..., ), ByOrgID: map[string]OrgPermissions{}, @@ -366,6 +374,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceDeploymentConfig.Type: {policy.ActionRead}, // Allow auditors to query AI Bridge interceptions. ResourceAibridgeInterception.Type: {policy.ActionRead}, + // Allow auditors to read boundary logs. + ResourceBoundaryLog.Type: {policy.ActionRead}, }), User: []Permission{}, ByOrgID: map[string]OrgPermissions{}, @@ -465,7 +475,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Org admins should not have workspace exec perms. organizationID.String(): { Org: append( - allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceAiSeat), + allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat), Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent}, @@ -1052,6 +1062,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember, + ResourceBoundaryLog, ResourceAibridgeInterception, // Chat access requires the agents-access role. ResourceChat, @@ -1137,6 +1148,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember, + ResourceBoundaryLog, ResourceAibridgeInterception, // Chat access requires the agents-access role. ResourceChat, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0170d308e0..0ac992fc86 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1229,6 +1229,75 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin}, }, }, + { + // Boundary logs: members can create logs they own (user-scoped). + // memberMe and agentsAccessUser have ID == currentUser, so they + // match the resource owner. Other subjects have different IDs. + Name: "BoundaryLogCreate", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceBoundaryLog.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {memberMe, agentsAccessUser}, + false: { + owner, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, auditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, + { + // Cross-user isolation: no subject can create boundary logs + // owned by a different user. The resource owner is a random + // UUID that does not match any test subject's ID. + Name: "BoundaryLogCreateOther", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceBoundaryLog.WithOwner(uuid.New().String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, auditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, + { + // Boundary logs: only DBPurge can delete. No human role + // has delete; DBPurge is a system subject outside this matrix. + Name: "BoundaryLogDelete", + Actions: []policy.Action{policy.ActionDelete}, + Resource: rbac.ResourceBoundaryLog, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, auditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, + { + // Boundary logs: owner and auditor get read. + Name: "BoundaryLogRead", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceBoundaryLog, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, auditor}, + false: { + memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, { Name: "ChatUsageCRU", Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, @@ -1471,3 +1540,121 @@ func TestChangeSet(t *testing.T) { }) } } + +// TestWorkspaceAgentScopeBoundaryLog verifies that a real workspace agent +// scope (not ScopeAll) can create boundary logs for its own owner but +// cannot create them for other users, and cannot read or delete them. +func TestWorkspaceAgentScopeBoundaryLog(t *testing.T) { + t.Parallel() + + auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) + + ownerID := uuid.New() + otherOwnerID := uuid.New() + workspaceID := uuid.New() + templateID := uuid.New() + versionID := uuid.New() + + agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{ + WorkspaceID: workspaceID, + OwnerID: ownerID, + TemplateID: templateID, + VersionID: versionID, + }) + + memberRole, err := rbac.RoleByName(rbac.RoleMember()) + require.NoError(t, err) + + agent := rbac.Subject{ + ID: ownerID.String(), + Roles: rbac.Roles{memberRole}, + Scope: agentScope, + }.WithCachedASTValue() + + // Agent can create boundary logs for its own owner. + err = auth.Authorize(context.Background(), agent, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(ownerID.String())) + require.NoError(t, err, "agent should create boundary logs for own owner") + + // Agent cannot create boundary logs for a different owner. + err = auth.Authorize(context.Background(), agent, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String())) + require.Error(t, err, "agent must not create boundary logs for other owner") + + // Agent cannot read boundary logs (even its own owner's). + err = auth.Authorize(context.Background(), agent, policy.ActionRead, + rbac.ResourceBoundaryLog.WithOwner(ownerID.String())) + require.Error(t, err, "agent must not read boundary logs") + + // Agent cannot delete boundary logs (even its own owner's). + err = auth.Authorize(context.Background(), agent, policy.ActionDelete, + rbac.ResourceBoundaryLog.WithOwner(ownerID.String())) + require.Error(t, err, "agent must not delete boundary logs") + + // When the workspace owner is a site admin, the agent scope + // wildcard for boundary_log combined with the owner role's site-level + // read grant means the agent CAN read all boundary logs. This is an + // accepted consequence of the wildcard scope needed for creation. + ownerRole, err := rbac.RoleByName(rbac.RoleOwner()) + require.NoError(t, err) + + adminAgent := rbac.Subject{ + ID: ownerID.String(), + Roles: rbac.Roles{memberRole, ownerRole}, + Scope: agentScope, + }.WithCachedASTValue() + + // Admin-owned agent CAN read boundary logs due to site-level owner + // role + wildcard scope. + err = auth.Authorize(context.Background(), adminAgent, policy.ActionRead, + rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String())) + require.NoError(t, err, "admin agent inherits site-level read via owner role") + + // Admin-owned agent still cannot create boundary logs for another owner + // because member-level create is user-scoped (subject.id must match owner). + err = auth.Authorize(context.Background(), adminAgent, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String())) + require.Error(t, err, "admin agent must not create boundary logs for other owner") +} + +// TestDBPurgeBoundaryLogDelete verifies that the DBPurge system subject +// can delete boundary logs but cannot create or read them. +func TestDBPurgeBoundaryLogDelete(t *testing.T) { + t.Parallel() + + auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) + + // Build the DBPurge subject the same way dbauthz does. + dbPurge := rbac.Subject{ + Type: rbac.SubjectTypeDBPurge, + FriendlyName: "DB Purge", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "dbpurge"}, + DisplayName: "DB Purge Daemon", + Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceBoundaryLog.Type: {policy.ActionDelete}, + }), + User: []rbac.Permission{}, + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + + // DBPurge can delete boundary logs. + err := auth.Authorize(context.Background(), dbPurge, policy.ActionDelete, + rbac.ResourceBoundaryLog) + require.NoError(t, err, "DBPurge should delete boundary logs") + + // DBPurge cannot create boundary logs. + err = auth.Authorize(context.Background(), dbPurge, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(uuid.New().String())) + require.Error(t, err, "DBPurge must not create boundary logs") + + // DBPurge cannot read boundary logs. + err = auth.Authorize(context.Background(), dbPurge, policy.ActionRead, + rbac.ResourceBoundaryLog) + require.Error(t, err, "DBPurge must not read boundary logs") +} diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 17e3990c31..7cbec46d74 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -65,6 +65,11 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope { {Type: ResourceTemplate.Type, ID: params.TemplateID.String()}, {Type: ResourceTemplate.Type, ID: params.VersionID.String()}, {Type: ResourceUser.Type, ID: params.OwnerID.String()}, + // No pre-existing ID for new records; wildcard is required. + // Owner-scoped create (user-level) limits agents to their own + // logs. Adding site-level actions to the member role would + // bypass this and grant deployment-wide access. + {Type: ResourceBoundaryLog.Type, ID: policy.WildcardSymbol}, }, extraAllowList...), } } diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index c12cba430a..b664a4371a 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -33,6 +33,9 @@ const ( ScopeAssignRoleUnassign ScopeName = "assign_role:unassign" ScopeAuditLogCreate ScopeName = "audit_log:create" ScopeAuditLogRead ScopeName = "audit_log:read" + ScopeBoundaryLogCreate ScopeName = "boundary_log:create" + ScopeBoundaryLogDelete ScopeName = "boundary_log:delete" + ScopeBoundaryLogRead ScopeName = "boundary_log:read" ScopeBoundaryUsageDelete ScopeName = "boundary_usage:delete" ScopeBoundaryUsageRead ScopeName = "boundary_usage:read" ScopeBoundaryUsageUpdate ScopeName = "boundary_usage:update" @@ -210,6 +213,9 @@ func (e ScopeName) Valid() bool { ScopeAssignRoleUnassign, ScopeAuditLogCreate, ScopeAuditLogRead, + ScopeBoundaryLogCreate, + ScopeBoundaryLogDelete, + ScopeBoundaryLogRead, ScopeBoundaryUsageDelete, ScopeBoundaryUsageRead, ScopeBoundaryUsageUpdate, @@ -388,6 +394,9 @@ func AllScopeNameValues() []ScopeName { ScopeAssignRoleUnassign, ScopeAuditLogCreate, ScopeAuditLogRead, + ScopeBoundaryLogCreate, + ScopeBoundaryLogDelete, + ScopeBoundaryLogRead, ScopeBoundaryUsageDelete, ScopeBoundaryUsageRead, ScopeBoundaryUsageUpdate, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 7bad39ccc2..4e4fb8d803 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -40,6 +40,10 @@ const ( APIKeyScopeAuditLogAll APIKeyScope = "audit_log:*" APIKeyScopeAuditLogCreate APIKeyScope = "audit_log:create" APIKeyScopeAuditLogRead APIKeyScope = "audit_log:read" + APIKeyScopeBoundaryLogAll APIKeyScope = "boundary_log:*" + APIKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create" + APIKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete" + APIKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read" APIKeyScopeBoundaryUsageAll APIKeyScope = "boundary_usage:*" APIKeyScopeBoundaryUsageDelete APIKeyScope = "boundary_usage:delete" APIKeyScopeBoundaryUsageRead APIKeyScope = "boundary_usage:read" diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 11b6488182..75b1e82421 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -13,6 +13,7 @@ const ( ResourceAssignOrgRole RBACResource = "assign_org_role" ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" + ResourceBoundaryLog RBACResource = "boundary_log" ResourceBoundaryUsage RBACResource = "boundary_usage" ResourceChat RBACResource = "chat" ResourceConnectionLog RBACResource = "connection_log" @@ -89,6 +90,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate}, ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, + ResourceBoundaryLog: {ActionCreate, ActionDelete, ActionRead}, ResourceBoundaryUsage: {ActionDelete, ActionRead, ActionUpdate}, ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionShare, ActionUpdate}, ResourceConnectionLog: {ActionRead, ActionUpdate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 1556ced557..fae805d3a7 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -193,10 +193,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -326,10 +326,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -459,10 +459,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -554,10 +554,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -960,9 +960,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8ab5372f65..cd5f603559 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1444,9 +1444,9 @@ None #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -10818,9 +10818,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 376a415031..0bedde7b0c 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -865,11 +865,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 23bd95350c..2ac260c98a 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -50,6 +50,11 @@ export const RBACResourceActions: Partial< create: "create new audit log entries", read: "read audit logs", }, + boundary_log: { + create: "create boundary log records", + delete: "delete boundary logs", + read: "read boundary logs and session metadata", + }, boundary_usage: { delete: "delete boundary usage statistics", read: "read boundary usage statistics", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 11d33b28a8..74b6041f3b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -554,6 +554,10 @@ export type APIKeyScope = | "audit_log:*" | "audit_log:create" | "audit_log:read" + | "boundary_log:*" + | "boundary_log:create" + | "boundary_log:delete" + | "boundary_log:read" | "boundary_usage:*" | "boundary_usage:delete" | "boundary_usage:read" @@ -780,6 +784,10 @@ export const APIKeyScopes: APIKeyScope[] = [ "audit_log:*", "audit_log:create", "audit_log:read", + "boundary_log:*", + "boundary_log:create", + "boundary_log:delete", + "boundary_log:read", "boundary_usage:*", "boundary_usage:delete", "boundary_usage:read", @@ -6870,6 +6878,7 @@ export type RBACResource = | "assign_org_role" | "assign_role" | "audit_log" + | "boundary_log" | "boundary_usage" | "chat" | "connection_log" @@ -6920,6 +6929,7 @@ export const RBACResources: RBACResource[] = [ "assign_org_role", "assign_role", "audit_log", + "boundary_log", "boundary_usage", "chat", "connection_log", From 7b903cad739f434cb240752972b4b5b2a4b1a9aa Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 29 May 2026 12:01:37 +0100 Subject: [PATCH 16/63] fix: track credential hint across key failover attempts in aibridge (#25735) ## Problem Centralized requests recorded *the first available key from the pool at `CreateInterceptor` time* as `credential_hint`, so the interception could be persisted in the database with a hint that didn't match the key that actually served the request. The fix consists in storing, at end-of-interception, the hint of the key that succeeded, or the last attempted key if all keys are unavailable. ## Changes - Add `Key.Hint()` and update `credential_hint` on every failover attempt so it reflects the actually-used key. - Stop pre-populating `credential_hint` at `CreateInterceptor`. Centralized starts empty and is updated by the key failover loop. - Persist the final hint via `RecordInterceptionEnded`; SQL updates `credential_hint` only when `credential_kind = 'centralized'` so BYOK keeps its start-time value. - Log the actually-used hint on interception end/failure; start log uses a `` placeholder for centralized. > [!NOTE] > Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira --- aibridge/bridge.go | 34 +- .../intercept/chatcompletions/blocking.go | 7 +- .../chatcompletions/blocking_internal_test.go | 83 ++-- .../intercept/chatcompletions/streaming.go | 5 + .../streaming_internal_test.go | 83 ++-- aibridge/intercept/messages/blocking.go | 7 +- .../messages/blocking_internal_test.go | 84 ++-- aibridge/intercept/messages/streaming.go | 5 + .../messages/streaming_internal_test.go | 84 ++-- aibridge/intercept/responses/blocking.go | 7 +- .../responses/blocking_internal_test.go | 81 +-- aibridge/intercept/responses/streaming.go | 5 + .../responses/streaming_internal_test.go | 81 +-- aibridge/keypool/keymark.go | 5 +- aibridge/keypool/keypool.go | 7 + aibridge/provider/anthropic.go | 11 +- aibridge/provider/anthropic_internal_test.go | 4 +- aibridge/provider/openai.go | 10 +- aibridge/provider/openai_internal_test.go | 8 +- aibridge/recorder/types.go | 11 +- coderd/aibridged/proto/aibridged.pb.go | 467 +++++++++--------- coderd/aibridged/proto/aibridged.proto | 1 + coderd/aibridged/translator.go | 5 +- coderd/aibridgedserver/aibridgedserver.go | 5 +- .../aibridgedserver/aibridgedserver_test.go | 23 +- coderd/database/dbgen/dbgen.go | 5 +- coderd/database/querier_test.go | 62 ++- coderd/database/queries.sql.go | 18 +- coderd/database/queries/aibridge.sql | 9 +- 29 files changed, 736 insertions(+), 481 deletions(-) diff --git a/aibridge/bridge.go b/aibridge/bridge.go index 79715af880..daf103fb10 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -20,6 +20,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/aibridge/circuitbreaker" aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/aibridge/metrics" "github.com/coder/coder/v2/aibridge/provider" @@ -275,11 +276,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC slog.F("user_agent", r.UserAgent()), slog.F("streaming", interceptor.Streaming()), slog.F("credential_kind", string(cred.Kind)), - slog.F("credential_hint", cred.Hint), - slog.F("credential_length", cred.Length), ) - log.Debug(ctx, "interception started") + // Log BYOK credentials. Centralized credentials are set by + // the key failover loop. + credLogFields := []slog.Field{} + if cred.Kind == intercept.CredentialKindBYOK { + credLogFields = append(credLogFields, + slog.F("credential_hint", cred.Hint), + slog.F("credential_length", cred.Length), + ) + } + log.Debug(ctx, "interception started", credLogFields...) if m != nil { m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Add(1) defer func() { @@ -288,22 +296,30 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC } // Process request with circuit breaker protection if configured - if err := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error { + execErr := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error { return interceptor.ProcessRequest(rw, r) - }); err != nil { + }) + // For centralized, the hint now reflects the last attempted + // key from the failover loop. + credHint := interceptor.Credential().Hint + credLen := interceptor.Credential().Length + if execErr != nil { if m != nil { m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusFailed, route, r.Method, actor.ID, string(client)).Add(1) } - span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", err)) - log.Warn(ctx, "interception failed", slog.Error(err)) + span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", execErr)) + log.Warn(ctx, "interception failed", slog.Error(execErr), slog.F("credential_hint", credHint), slog.F("credential_length", credLen)) } else { if m != nil { m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusCompleted, route, r.Method, actor.ID, string(client)).Add(1) } - log.Debug(ctx, "interception ended") + log.Debug(ctx, "interception ended", slog.F("credential_hint", credHint), slog.F("credential_length", credLen)) } - _ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ID: interceptor.ID().String()}) + _ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ + ID: interceptor.ID().String(), + CredentialHint: credHint, + }) // Ensure all recording have completed before completing request. asyncRecorder.Wait() diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go index 95d065ce5b..fa1511f660 100644 --- a/aibridge/intercept/chatcompletions/blocking.go +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -291,15 +291,16 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc // 401/403. Errors that aren't key-specific don't trigger // failover and are returned to the caller. func (i *BlockingInterception) newChatCompletionWithKeyFailover(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) (*openai.ChatCompletion, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) requestOpts := append([]option.RequestOption{}, opts...) requestOpts = append(requestOpts, diff --git a/aibridge/intercept/chatcompletions/blocking_internal_test.go b/aibridge/intercept/chatcompletions/blocking_internal_test.go index 3b3a917a54..2b9afaadea 100644 --- a/aibridge/intercept/chatcompletions/blocking_internal_test.go +++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go @@ -72,31 +72,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -104,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -120,15 +125,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -136,25 +142,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -168,15 +175,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -184,14 +192,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -199,6 +208,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -219,9 +229,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -252,6 +263,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg := config.OpenAI{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -259,6 +271,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } interceptor := NewBlockingInterceptor( @@ -269,7 +282,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -288,6 +301,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -309,6 +323,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { expectedSeenKeys []string expectedStatusCode int expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -319,12 +336,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -342,12 +360,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -369,12 +388,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -409,7 +429,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -459,6 +479,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/chatcompletions/streaming.go b/aibridge/intercept/chatcompletions/streaming.go index 581ab49d03..e20a2a801d 100644 --- a/aibridge/intercept/chatcompletions/streaming.go +++ b/aibridge/intercept/chatcompletions/streaming.go @@ -164,6 +164,11 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re break } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + opts = append(opts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/chatcompletions/streaming_internal_test.go b/aibridge/intercept/chatcompletions/streaming_internal_test.go index 82c58f9bc1..9561c0948a 100644 --- a/aibridge/intercept/chatcompletions/streaming_internal_test.go +++ b/aibridge/intercept/chatcompletions/streaming_internal_test.go @@ -144,36 +144,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -185,16 +189,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -206,15 +211,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -226,6 +232,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -233,19 +240,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -259,15 +266,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -275,14 +283,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -290,6 +299,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -310,9 +320,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -342,6 +353,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg := config.OpenAI{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -349,6 +361,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } interceptor := NewStreamingInterceptor( @@ -359,7 +372,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -378,6 +391,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -435,6 +449,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -445,13 +462,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -469,13 +487,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -497,7 +516,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectErrorAsSSEEvent: true, expectedErr: true, @@ -505,6 +524,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -538,7 +558,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -596,6 +616,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/messages/blocking.go b/aibridge/intercept/messages/blocking.go index e91f80feb9..bf74885b2b 100644 --- a/aibridge/intercept/messages/blocking.go +++ b/aibridge/intercept/messages/blocking.go @@ -367,15 +367,16 @@ func (i *BlockingInterception) newMessageWithKey(ctx context.Context, svc anthro // Errors that aren't key-specific don't trigger failover and // are returned to the caller. func (i *BlockingInterception) newMessageWithKeyFailover(ctx context.Context, svc anthropic.MessageService) (*anthropic.Message, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) msg, err := i.newMessageWithKey(ctx, svc, option.WithAPIKey(key.Value()), diff --git a/aibridge/intercept/messages/blocking_internal_test.go b/aibridge/intercept/messages/blocking_internal_test.go index 857d425fe3..9b3f0d447b 100644 --- a/aibridge/intercept/messages/blocking_internal_test.go +++ b/aibridge/intercept/messages/blocking_internal_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -54,31 +55,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -86,15 +91,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -102,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -118,25 +125,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -150,15 +158,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -166,14 +175,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -181,6 +191,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -201,9 +212,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -234,6 +246,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg := config.Anthropic{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -241,6 +254,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -255,7 +269,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { http.Header{}, "X-Api-Key", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -271,6 +285,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -296,6 +311,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { expectedStatusCode int expectedRetryAfter string expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -306,12 +324,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -329,12 +348,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -356,13 +376,14 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedRetryAfter: "3", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -397,7 +418,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.Anthropic{ @@ -447,6 +468,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/intercept/messages/streaming.go b/aibridge/intercept/messages/streaming.go index 475f32c99c..47c49528a9 100644 --- a/aibridge/intercept/messages/streaming.go +++ b/aibridge/intercept/messages/streaming.go @@ -195,6 +195,11 @@ newStream: break } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + streamOpts = append(streamOpts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/messages/streaming_internal_test.go b/aibridge/intercept/messages/streaming_internal_test.go index 97f48d4cc3..5fc7da00df 100644 --- a/aibridge/intercept/messages/streaming_internal_test.go +++ b/aibridge/intercept/messages/streaming_internal_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -60,36 +61,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -101,16 +106,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -122,15 +128,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -142,6 +149,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -149,19 +157,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -175,15 +183,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -191,14 +200,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -206,6 +216,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -226,9 +237,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -258,6 +270,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg := config.Anthropic{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -265,6 +278,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -279,7 +293,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { http.Header{}, "X-Api-Key", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -301,6 +315,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -387,6 +402,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -397,13 +415,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -421,13 +440,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -453,7 +473,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectErrorAsSSEEvent: true, expectedErr: true, @@ -461,6 +481,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -494,7 +515,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.Anthropic{ @@ -553,6 +574,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/responses/blocking.go b/aibridge/intercept/responses/blocking.go index 9726b6f750..892dc1e71d 100644 --- a/aibridge/intercept/responses/blocking.go +++ b/aibridge/intercept/responses/blocking.go @@ -171,15 +171,16 @@ func (i *BlockingResponsesInterceptor) newResponseWithKey(ctx context.Context, s // Errors that aren't key-specific don't trigger failover and // are returned to the caller. func (i *BlockingResponsesInterceptor) newResponseWithKeyFailover(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (*responses.Response, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) requestOpts := append([]option.RequestOption{}, opts...) requestOpts = append(requestOpts, diff --git a/aibridge/intercept/responses/blocking_internal_test.go b/aibridge/intercept/responses/blocking_internal_test.go index 678c2ce0f3..94acf0deef 100644 --- a/aibridge/intercept/responses/blocking_internal_test.go +++ b/aibridge/intercept/responses/blocking_internal_test.go @@ -58,31 +58,35 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -90,15 +94,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -106,15 +111,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -122,25 +128,26 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -154,15 +161,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -170,14 +178,15 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -185,6 +194,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -204,8 +214,9 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -235,6 +246,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { t.Cleanup(upstream.Close) cfg := config.OpenAI{BaseURL: upstream.URL + "/"} + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") var pool *keypool.Pool if len(tc.keys) > 0 { var err error @@ -243,6 +255,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -256,7 +269,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -272,6 +285,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -296,6 +310,9 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { expectedSeenKeys []string expectedStatusCode int expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -306,12 +323,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -329,12 +347,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -356,12 +375,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -396,7 +416,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -444,6 +464,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/intercept/responses/streaming.go b/aibridge/intercept/responses/streaming.go index 2140c5e6c8..3b38b7a7e6 100644 --- a/aibridge/intercept/responses/streaming.go +++ b/aibridge/intercept/responses/streaming.go @@ -144,6 +144,11 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r return xerrors.Errorf("key pool exhausted: %w", keyPoolErr) } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + opts = append(opts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/responses/streaming_internal_test.go b/aibridge/intercept/responses/streaming_internal_test.go index 3226147cbd..4f20d76c17 100644 --- a/aibridge/intercept/responses/streaming_internal_test.go +++ b/aibridge/intercept/responses/streaming_internal_test.go @@ -51,36 +51,40 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -92,16 +96,17 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -113,15 +118,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -133,6 +139,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -140,19 +147,19 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -166,15 +173,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -182,14 +190,15 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -197,6 +206,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -216,8 +226,9 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -246,6 +257,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { t.Cleanup(upstream.Close) cfg := config.OpenAI{BaseURL: upstream.URL + "/"} + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") var pool *keypool.Pool if len(tc.keys) > 0 { var err error @@ -254,6 +266,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(streamingRequestBody)) @@ -267,7 +280,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -283,6 +296,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -339,6 +353,9 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -349,12 +366,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -372,12 +390,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -399,13 +418,14 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectedErr: true, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -439,7 +459,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -489,6 +509,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") body := w.Body.String() assert.Contains(t, body, tc.expectedBodyContains, "response body") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/keypool/keymark.go b/aibridge/keypool/keymark.go index 9b00bb400a..9dfedb3e44 100644 --- a/aibridge/keypool/keymark.go +++ b/aibridge/keypool/keymark.go @@ -5,7 +5,6 @@ import ( "net/http" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/aibridge/utils" ) // MarkKeyOnStatus marks key based on a key-specific HTTP @@ -32,7 +31,7 @@ func MarkKeyOnStatus( if key.MarkTemporary(cooldown) { logger.Info(ctx, "key marked temporary", slog.F("provider", providerName), - slog.F("api_key_hint", utils.MaskSecret(key.Value())), + slog.F("api_key_hint", key.Hint()), slog.F("status", statusCode), slog.F("cooldown", cooldown)) } @@ -41,7 +40,7 @@ func MarkKeyOnStatus( if key.MarkPermanent() { logger.Warn(ctx, "key marked permanent", slog.F("provider", providerName), - slog.F("api_key_hint", utils.MaskSecret(key.Value())), + slog.F("api_key_hint", key.Hint()), slog.F("status", statusCode)) } return true diff --git a/aibridge/keypool/keypool.go b/aibridge/keypool/keypool.go index 55d1712a93..e28ae78325 100644 --- a/aibridge/keypool/keypool.go +++ b/aibridge/keypool/keypool.go @@ -7,6 +7,7 @@ import ( "golang.org/x/xerrors" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -116,6 +117,12 @@ func (k *Key) Value() string { return k.value } +// Hint returns a masked, identifiable fragment of the key, suitable +// for logs and persisted records. +func (k *Key) Hint() string { + return utils.MaskSecret(k.value) +} + // State returns the current state of the key, derived from its // permanent flag and cooldown deadline. func (k *Key) State() KeyState { diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index 664183f82f..d053cce903 100644 --- a/aibridge/provider/anthropic.go +++ b/aibridge/provider/anthropic.go @@ -170,15 +170,10 @@ func (p *Anthropic) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tr authHeaderName = "Authorization" credKind = intercept.CredentialKindBYOK credSecret = token - } else if cfg.KeyPool != nil { - // Centralized: use the first key as a placeholder hint. - // TODO(ssncferreira): record the actually-used key in - // the interception record to reflect failover. - if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil { - credSecret = key.Value() - } } - + // Centralized leaves credSecret empty: the hint is set by the + // failover loop on each key attempt and persisted at + // end-of-interception. cred := intercept.NewCredentialInfo(credKind, credSecret) var interceptor intercept.Interceptor diff --git a/aibridge/provider/anthropic_internal_test.go b/aibridge/provider/anthropic_internal_test.go index b3d89556a8..815a83ba03 100644 --- a/aibridge/provider/anthropic_internal_test.go +++ b/aibridge/provider/anthropic_internal_test.go @@ -257,7 +257,9 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { setHeaders: map[string]string{}, wantXApiKey: "test-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "t...y", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, { name: "Messages_BYOK_BearerToken_And_APIKey", diff --git a/aibridge/provider/openai.go b/aibridge/provider/openai.go index 7a78069df6..88020b7eb2 100644 --- a/aibridge/provider/openai.go +++ b/aibridge/provider/openai.go @@ -143,14 +143,10 @@ func (p *OpenAI) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trace cfg.KeyPool = nil credKind = intercept.CredentialKindBYOK credSecret = token - } else if cfg.KeyPool != nil { - // Centralized: use the first key as a placeholder hint. - // TODO(ssncferreira): record the actually-used key in - // the interception record to reflect failover. - if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil { - credSecret = key.Value() - } } + // Centralized leaves credSecret empty: the hint is set by the + // failover loop on each key attempt and persisted at + // end-of-interception. cred := intercept.NewCredentialInfo(credKind, credSecret) path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) diff --git a/aibridge/provider/openai_internal_test.go b/aibridge/provider/openai_internal_test.go index e1afcc872c..1922d22c30 100644 --- a/aibridge/provider/openai_internal_test.go +++ b/aibridge/provider/openai_internal_test.go @@ -229,7 +229,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...ey", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, { name: "Responses_BYOK", @@ -249,7 +251,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...ey", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. diff --git a/aibridge/recorder/types.go b/aibridge/recorder/types.go index cd541eebd4..faa5713900 100644 --- a/aibridge/recorder/types.go +++ b/aibridge/recorder/types.go @@ -39,13 +39,20 @@ type InterceptionRecord struct { Client string UserAgent string CorrelatingToolCallID *string - CredentialKind string - CredentialHint string + // CredentialKind is always set: either BYOK or centralized. + CredentialKind string + // CredentialHint is only set for BYOK, where the key is known + // from the request. Centralized uses key failover, so the hint + // can only be determined at end-of-interception. + CredentialHint string } type InterceptionRecordEnded struct { ID string EndedAt time.Time + // CredentialHint is the hint observed at end-of-interception. + // Only applied to the DB row for centralized; ignored for BYOK. + CredentialHint string } type TokenUsageRecord struct { diff --git a/coderd/aibridged/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index c364aeda40..17fef851ea 100644 --- a/coderd/aibridged/proto/aibridged.pb.go +++ b/coderd/aibridged/proto/aibridged.pb.go @@ -216,8 +216,9 @@ type RecordInterceptionEndedRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID. - EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID. + EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + CredentialHint string `protobuf:"bytes,3,opt,name=credential_hint,json=credentialHint,proto3" json:"credential_hint,omitempty"` } func (x *RecordInterceptionEndedRequest) Reset() { @@ -266,6 +267,13 @@ func (x *RecordInterceptionEndedRequest) GetEndedAt() *timestamppb.Timestamp { return nil } +func (x *RecordInterceptionEndedRequest) GetCredentialHint() string { + if x != nil { + return x.CredentialHint + } + return "" +} + type RecordInterceptionEndedResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1295,249 +1303,252 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x67, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, - 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, - 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22, 0x21, 0x0a, - 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, - 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x90, 0x01, 0x0a, 0x1e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, + 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x12, 0x27, + 0x0a, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x6e, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x61, 0x6c, 0x48, 0x69, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x1f, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, + 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, - 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, - 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, - 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x52, 0x65, 0x61, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, - 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, - 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, - 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, - 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, + 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, + 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, + 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, + 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, + 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, + 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, - 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, - 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, - 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, - 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, - 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, - 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, - 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, - 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, - 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, - 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, - 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, - 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, - 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, - 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, + 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, + 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, + 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, + 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0x1b, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, + 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x8f, 0x04, + 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, + 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, + 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, + 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, + 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, + 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, + 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x19, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, - 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, - 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, - 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, - 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, - 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, - 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, - 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, - 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, - 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, - 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, - 0x63, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, - 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, - 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, - 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, - 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, - 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, - 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, - 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, - 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, - 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, - 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, - 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, - 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, - 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, - 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, - 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, + 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, + 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, + 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x35, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb2, 0x01, 0x0a, 0x1b, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, - 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, - 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, - 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, + 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, + 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, + 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, + 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, + 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, + 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x24, 0x47, 0x65, 0x74, 0x4d, 0x43, + 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, 0x63, 0x70, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, - 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3e, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, + 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, + 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, + 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, + 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, + 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, + 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, + 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, + 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, + 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, + 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, + 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, + 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, + 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, + 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, + 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( diff --git a/coderd/aibridged/proto/aibridged.proto b/coderd/aibridged/proto/aibridged.proto index cd61411516..08fceee676 100644 --- a/coderd/aibridged/proto/aibridged.proto +++ b/coderd/aibridged/proto/aibridged.proto @@ -58,6 +58,7 @@ message RecordInterceptionResponse {} message RecordInterceptionEndedRequest { string id = 1; // UUID. google.protobuf.Timestamp ended_at = 2; + string credential_hint = 3; } message RecordInterceptionEndedResponse {} diff --git a/coderd/aibridged/translator.go b/coderd/aibridged/translator.go index 2769ef0d89..6d251df0fe 100644 --- a/coderd/aibridged/translator.go +++ b/coderd/aibridged/translator.go @@ -45,8 +45,9 @@ func (t *recorderTranslation) RecordInterception(ctx context.Context, req *aibri func (t *recorderTranslation) RecordInterceptionEnded(ctx context.Context, req *aibridge.InterceptionRecordEnded) error { _, err := t.client.RecordInterceptionEnded(ctx, &proto.RecordInterceptionEndedRequest{ - Id: req.ID, - EndedAt: timestamppb.New(req.EndedAt), + Id: req.ID, + EndedAt: timestamppb.New(req.EndedAt), + CredentialHint: req.CredentialHint, }) return err } diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index c593b18f79..8dbaa10bfa 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -222,8 +222,9 @@ func (s *Server) RecordInterceptionEnded(ctx context.Context, in *proto.RecordIn } _, err = s.store.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intcID, - EndedAt: in.EndedAt.AsTime(), + ID: intcID, + EndedAt: in.EndedAt.AsTime(), + CredentialHint: in.CredentialHint, }) if err != nil { return nil, xerrors.Errorf("end interception: %w", err) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index eb2f413e1e..9aeb082069 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -944,23 +944,26 @@ func TestRecordInterceptionEnded(t *testing.T) { { name: "ok", request: &proto.RecordInterceptionEndedRequest{ - Id: uuid.UUID{1}.String(), - EndedAt: timestamppb.Now(), + Id: uuid.UUID{1}.String(), + EndedAt: timestamppb.Now(), + CredentialHint: "sk-a...efgh", }, setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) { interceptionID, err := uuid.Parse(req.GetId()) assert.NoError(t, err, "parse interception UUID") db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{ - ID: interceptionID, - EndedAt: req.EndedAt.AsTime(), + ID: interceptionID, + EndedAt: req.EndedAt.AsTime(), + CredentialHint: req.CredentialHint, }).Return(database.AIBridgeInterception{ - ID: interceptionID, - InitiatorID: uuid.UUID{2}, - Provider: "prov", - Model: "mod", - StartedAt: time.Now(), - EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true}, + ID: interceptionID, + InitiatorID: uuid.UUID{2}, + Provider: "prov", + Model: "mod", + StartedAt: time.Now(), + EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true}, + CredentialHint: req.CredentialHint, }, nil) }, }, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index edc1d710cb..416a2b7257 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -2002,8 +2002,9 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA }) if endedAt != nil { interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: interception.ID, - EndedAt: *endedAt, + ID: interception.ID, + EndedAt: *endedAt, + CredentialHint: takeFirst(seed.CredentialHint, ""), }) require.NoError(t, err, "insert aibridge interception") } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index f181e2e94b..cefe6a866e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -9921,8 +9921,9 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) got, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: uuid.New(), - EndedAt: time.Now(), + ID: uuid.New(), + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", }) require.ErrorContains(t, err, "no rows in result set") require.EqualValues(t, database.AIBridgeInterception{}, got) @@ -9957,18 +9958,21 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { endedAt := time.Now() // Mark first interception as done updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intc0.ID, - EndedAt: endedAt, + ID: intc0.ID, + EndedAt: endedAt, + CredentialHint: "sk-a...efgh", }) require.NoError(t, err) require.EqualValues(t, updated.ID, intc0.ID) require.True(t, updated.EndedAt.Valid) require.WithinDuration(t, endedAt, updated.EndedAt.Time, 5*time.Second) + require.Equal(t, "sk-a...efgh", updated.CredentialHint) // Updating first interception again should fail updated, err = db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intc0.ID, - EndedAt: endedAt.Add(time.Hour), + ID: intc0.ID, + EndedAt: endedAt.Add(time.Hour), + CredentialHint: "sk-a...efgh", }) require.ErrorIs(t, err, sql.ErrNoRows) @@ -9979,6 +9983,52 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { require.False(t, got.EndedAt.Valid) } }) + + t.Run("CentralizedHintUpdated", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{ + ID: uuid.New(), + InitiatorID: user.ID, + Metadata: json.RawMessage("{}"), + CredentialKind: database.CredentialKindCentralized, + CredentialHint: "", + }) + require.NoError(t, err) + + updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ + ID: intc.ID, + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", + }) + require.NoError(t, err) + require.Equal(t, "sk-a...efgh", updated.CredentialHint) + }) + + t.Run("BYOKHintPreserved", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{ + ID: uuid.New(), + InitiatorID: user.ID, + Metadata: json.RawMessage("{}"), + CredentialKind: database.CredentialKindByok, + CredentialHint: "sk-u...byok", + }) + require.NoError(t, err) + + updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ + ID: intc.ID, + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", + }) + require.NoError(t, err) + require.Equal(t, "sk-u...byok", updated.CredentialHint) + }) } func TestDeleteExpiredAPIKeys(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d4cf7468f4..b49e757549 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2389,20 +2389,28 @@ func (q *sqlQuerier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Contex const updateAIBridgeInterceptionEnded = `-- name: UpdateAIBridgeInterceptionEnded :one UPDATE aibridge_interceptions - SET ended_at = $1::timestamptz + SET ended_at = $1::timestamptz, + -- BYOK records its hint at the start of the interception. + -- Centralized uses key failover, so its hint is only known + -- at end-of-interception. + credential_hint = CASE + WHEN credential_kind = 'centralized' THEN $2::text + ELSE credential_hint + END WHERE - id = $2::uuid + id = $3::uuid AND ended_at IS NULL RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name, credential_kind, credential_hint ` type UpdateAIBridgeInterceptionEndedParams struct { - EndedAt time.Time `db:"ended_at" json:"ended_at"` - ID uuid.UUID `db:"id" json:"id"` + EndedAt time.Time `db:"ended_at" json:"ended_at"` + CredentialHint string `db:"credential_hint" json:"credential_hint"` + ID uuid.UUID `db:"id" json:"id"` } func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) { - row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.ID) + row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.CredentialHint, arg.ID) var i AIBridgeInterception err := row.Scan( &i.ID, diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 7756c7086b..a1b49d25cd 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -8,7 +8,14 @@ RETURNING *; -- name: UpdateAIBridgeInterceptionEnded :one UPDATE aibridge_interceptions - SET ended_at = @ended_at::timestamptz + SET ended_at = @ended_at::timestamptz, + -- BYOK records its hint at the start of the interception. + -- Centralized uses key failover, so its hint is only known + -- at end-of-interception. + credential_hint = CASE + WHEN credential_kind = 'centralized' THEN @credential_hint::text + ELSE credential_hint + END WHERE id = @id::uuid AND ended_at IS NULL From 60f0bfe94c6ffba5c0b27012d4d4039aba585d82 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 29 May 2026 14:15:21 +0300 Subject: [PATCH 17/63] fix: update tailscale fork to fix goroutine leak in TestRun (#25838) Update the coder/tailscale fork to include the fix for goroutine leaks in measureHTTPLatency. The function creates an http.Transport per DERP probe for a single HTTP request but did not disable keep-alives, causing readLoop/writeLoop goroutines to persist after the function returns. These were detected by goleak in support/TestRun. The fork fix sets DisableKeepAlives: true on the transport. Closes PLAT-289 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fc7e53722e..e13cebfcf3 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399 // This is replaced to include // 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25 diff --git a/go.sum b/go.sum index 3c08d31834..e639f168f3 100644 --- a/go.sum +++ b/go.sum @@ -350,8 +350,8 @@ github.com/coder/serpent v0.15.0 h1:jobR7DnPsxzEMD0cRiailwlY+4v6HAPS/8emIgBpaIU= github.com/coder/serpent v0.15.0/go.mod h1:7OIvFBYMd+OqarMy5einBl8AtRr8LliopVU7pyrwucY= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f h1:gYivllu5CHhvRr4SM93zSQDj9cG2V+Pc0URTFy3fF/Y= -github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f/go.mod h1:WTWP5ZNODDXHwWlQ1Jc2MFhqxu93pUs7lIy28Fd5a5E= +github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399 h1:4IhFSmu0DSfWrvmHCb8aXDjWqSEYoIDA1L7Ar82Dm84= +github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399/go.mod h1:IatCC3hlq/ncu6DjZ+GJ/hNjSf5TmO+Xtc6B20k0q/c= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= github.com/coder/terraform-provider-coder/v2 v2.18.0 h1:b60ixwf7pVPuiL0GkHZf+1mVj94/HZhCNpsfjAK34mI= From 4144eb3c4fb0b3d01e838675cf11c84788209c89 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Fri, 29 May 2026 12:55:16 +0100 Subject: [PATCH 18/63] fix(site/src/pages/AgentsPage): avoid stale live tail spacing (#25846) --- .../components/ChatConversation/LiveStreamTail.tsx | 8 +++++++- site/src/pages/AgentsPage/components/ChatPageContent.tsx | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index 93b2c5ce69..9d3d5da412 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -71,7 +71,13 @@ export const LiveStreamTailContent = ({ } return ( -
+
{shouldRenderEmptyState && (

Start a conversation with your agent.

diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 098d767070..68d2644603 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -106,7 +106,7 @@ export const ChatPageTimeline: FC = ({
From d0a51da0a93b642792b9db36be3653ddbaacac34 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 13:14:04 +0100 Subject: [PATCH 19/63] feat: classify provider_disabled 503 as non-retryable (#25800) Builds on top of https://github.com/coder/coder/pull/25794 Adds a new `provider_disabled` error classification in `chatd` with the corresponding plumbing to classify it as non-retryable. Also adds a story for how this particular error kind is displayed in the UI. --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/x/chatd/chaterror/classify.go | 8 ++ coderd/x/chatd/chaterror/classify_test.go | 81 +++++++++++++++++++ coderd/x/chatd/chaterror/message.go | 77 +++++++++--------- coderd/x/chatd/chaterror/signals.go | 3 + codersdk/chats.go | 20 ++--- docs/reference/api/chats.md | 14 ++-- docs/reference/api/schemas.md | 6 +- site/src/api/typesGenerated.ts | 2 + .../LiveStreamTail.stories.tsx | 34 ++++++++ .../ChatConversation/chatStatusHelpers.ts | 2 + .../AgentsPage/utils/usageLimitMessage.ts | 5 +- 13 files changed, 200 insertions(+), 64 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 835823ecf4..7bca7d7988 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16498,7 +16498,8 @@ const docTemplate = `{ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -16509,7 +16510,8 @@ const docTemplate = `{ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index db7cbf6809..d641744117 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14848,7 +14848,8 @@ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -14859,7 +14860,8 @@ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 73e50f083b..4bf28efd4f 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -195,6 +195,7 @@ func Classify(err error) ClassifiedError { } retryableHTTP2StreamReset, hasHTTP2StreamReset := classifyHTTP2StreamReset(err) + providerDisabledMatch := containsAny(lower, providerDisabledPatterns...) deadline := errors.Is(err, context.DeadlineExceeded) || strings.Contains(lower, "context deadline exceeded") overloadedMatch := statusCode == 529 || containsAny(lower, overloadedPatterns...) usageLimitMatch := containsAny(lower, usageLimitPatterns...) @@ -221,6 +222,8 @@ func Classify(err error) ClassifiedError { // over whatever HTTP status code the provider happened to use. // Strong auth still stays above config because bad credentials are // the root cause when both signals appear. + // Provider-disabled must precede timeout because disabled providers + // return 503, which matches the timeout rule. rules := []struct { match bool kind codersdk.ChatErrorKind @@ -251,6 +254,11 @@ func Classify(err error) ClassifiedError { kind: codersdk.ChatErrorKindRateLimit, retryable: true, }, + { + match: providerDisabledMatch, + kind: codersdk.ChatErrorKindProviderDisabled, + retryable: false, + }, { match: timeoutMatch && !configMatch, kind: codersdk.ChatErrorKindTimeout, diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 8e1a9783c3..0e2e008bb8 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -2,6 +2,7 @@ package chaterror_test import ( "context" + "fmt" "io" "net/http" "strings" @@ -218,6 +219,85 @@ func TestClassify(t *testing.T) { StatusCode: 0, }, }, + // The next cases model the error that fantasy produces + // when aibridge's disabledProviderHandler returns a 503 + // plain-text sentinel. Fantasy sets Title from the HTTP + // status text and Message from the response body (including + // the trailing newline written by http.Error). + { + name: "ProviderDisabled503ClassifiesAsProviderDisabled", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "openai"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The OpenAI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "openai"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "openai", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabled503UnknownProvider", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabledPlainErrorString", + err: xerrors.New(fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "anthropic")), + want: chaterror.ClassifiedError{ + Message: "The Anthropic provider has been disabled. Contact your Coder administrator.", + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "anthropic", + Retryable: false, + StatusCode: 0, + }, + }, + { + name: "ProviderDisabledBeatsTimeout503", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "google"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The Google provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "google"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "google", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "Generic503StillClassifiesAsTimeout", + err: &fantasy.ProviderError{ + Message: "service unavailable", + StatusCode: 503, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider is temporarily unavailable.", + Detail: "service unavailable", + Kind: codersdk.ChatErrorKindTimeout, + Provider: "", + Retryable: true, + StatusCode: 503, + }, + }, } for _, tt := range tests { @@ -363,6 +443,7 @@ func TestClassify_PatternCoverage(t *testing.T) { {name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false}, {name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, {name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true}, + {name: "ProviderDisabledLiteral", err: "provider_disabled", wantKind: codersdk.ChatErrorKindProviderDisabled, wantRetry: false}, } for _, tt := range tests { diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index 5257420061..fef3ba78fa 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + stringutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/codersdk" ) @@ -16,60 +17,58 @@ func terminalMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject)) case codersdk.ChatErrorKindTimeout: if !classified.Retryable && classified.StatusCode == 0 { return "The request timed out before it completed." } - return fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindUsageLimit: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "The usage quota for %s has been exceeded."+ " Check the billing and quota settings for the provider account.", - displayName, - ) + subject, + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( "Authentication with %s failed."+ " Check the API key and permissions.", - displayName, + subject, ) case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s rejected the model configuration."+ " Check the selected model and provider settings.", subject, - ) + )) case codersdk.ChatErrorKindMissingKey: return "This conversation was started with an API key that is no longer available." + " Send your message again to continue." - + case codersdk.ChatErrorKindProviderDisabled: + displayName := providerDisplayName(classified.Provider) + return fmt.Sprintf( + "The %s provider has been disabled."+ + " Contact your Coder administrator.", + displayName, + ) default: if !classified.Retryable && classified.StatusCode == 0 { return "The chat request failed unexpectedly." } - return fmt.Sprintf("%s returned an unexpected error.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s returned an unexpected error.", subject)) } } @@ -85,41 +84,43 @@ func retryMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject)) case codersdk.ChatErrorKindTimeout: - return fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( - "Authentication with %s failed.", displayName, + "Authentication with %s failed.", subject, ) case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s rejected the model configuration.", subject, - ) + )) case codersdk.ChatErrorKindMissingKey: return "The API key for this conversation is no longer available." - default: + case codersdk.ChatErrorKindProviderDisabled: + displayName := providerDisplayName(classified.Provider) return fmt.Sprintf( - "%s returned an unexpected error.", subject, + "The %s provider has been disabled by an administrator.", + displayName, ) + default: + return stringutil.Capitalize(fmt.Sprintf( + "%s returned an unexpected error.", subject, + )) } } func providerSubject(provider string) string { - if displayName := providerDisplayName(provider); displayName != "" { + if displayName := providerDisplayName(provider); displayName != "AI" && displayName != "" { return displayName } - return "The AI provider" + return "the AI provider" } func providerDisplayName(provider string) string { @@ -141,7 +142,7 @@ func providerDisplayName(provider string) string { case "vercel": return "Vercel AI Gateway" default: - return "" + return "AI" } } diff --git a/coderd/x/chatd/chaterror/signals.go b/coderd/x/chatd/chaterror/signals.go index ebe6ff939b..8dad919127 100644 --- a/coderd/x/chatd/chaterror/signals.go +++ b/coderd/x/chatd/chaterror/signals.go @@ -4,6 +4,8 @@ import ( "regexp" "strconv" "strings" + + "github.com/coder/coder/v2/aibridge" ) type providerHint struct { @@ -83,6 +85,7 @@ var ( } genericRetryablePatterns = []string{"server error", "internal server error"} interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"} + providerDisabledPatterns = []string{aibridge.ErrorCodeProviderDisabled} ) func extractStatusCode(lower string) int { diff --git a/codersdk/chats.go b/codersdk/chats.go index c6deeb35aa..bcf235f590 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1525,15 +1525,16 @@ type ChatStreamStatus struct { type ChatErrorKind string const ( - ChatErrorKindGeneric ChatErrorKind = "generic" - ChatErrorKindOverloaded ChatErrorKind = "overloaded" - ChatErrorKindRateLimit ChatErrorKind = "rate_limit" - ChatErrorKindTimeout ChatErrorKind = "timeout" - ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" - ChatErrorKindAuth ChatErrorKind = "auth" - ChatErrorKindConfig ChatErrorKind = "config" - ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" - ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindGeneric ChatErrorKind = "generic" + ChatErrorKindOverloaded ChatErrorKind = "overloaded" + ChatErrorKindRateLimit ChatErrorKind = "rate_limit" + ChatErrorKindTimeout ChatErrorKind = "timeout" + ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" + ChatErrorKindAuth ChatErrorKind = "auth" + ChatErrorKindConfig ChatErrorKind = "config" + ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" + ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled" ) // AllChatErrorKinds contains every ChatErrorKind value. @@ -1548,6 +1549,7 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindConfig, ChatErrorKindUsageLimit, ChatErrorKindMissingKey, + ChatErrorKindProviderDisabled, } // ChatError represents a terminal chat error in persisted chat state or the diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 691b2a2bc5..f475d8482d 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -292,13 +292,13 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|---------------|---------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | -| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | -| `plan_mode` | `plan` | -| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` | +| Property | Value(s) | +|---------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | +| `plan_mode` | `plan` | +| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cd5f603559..771657d3c0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2681,9 +2681,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------| -| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------| +| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 74b6041f3b..8a465a3073 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1969,6 +1969,7 @@ export type ChatErrorKind = | "generic" | "missing_key" | "overloaded" + | "provider_disabled" | "rate_limit" | "startup_timeout" | "timeout" @@ -1980,6 +1981,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "generic", "missing_key", "overloaded", + "provider_disabled", "rate_limit", "startup_timeout", "timeout", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index b58a6b8ebe..32484ed15c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -288,6 +288,40 @@ export const TerminalStartupTimeoutError: Story = { }, }; +/** Disabled provider errors render an admin-oriented message without retry. */ +export const TerminalProviderDisabledError: Story = { + args: { + ...defaultArgs, + liveStatus: buildLiveStatus({ + streamError: { + kind: "provider_disabled", + message: + "The OpenAI provider has been disabled. Contact your Coder administrator.", + provider: "openai", + retryable: false, + statusCode: 503, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /provider disabled/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /the openai provider has been disabled.*contact your coder administrator/i, + ), + ).toBeVisible(); + expect(canvas.getByText(/^HTTP 503$/)).toBeVisible(); + // No retry or status link for administrative disablement. + expect(canvas.queryByText(/retrying/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + }, +}; + /** Generic failures do not show usage or provider CTAs. */ export const GenericErrorDoesNotShowUsageAction: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 243296ea2c..d9ea6f6e59 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -44,6 +44,8 @@ export const getErrorTitle = ( return "Usage limit reached"; case "missing_key": return "Chat interrupted"; + case "provider_disabled": + return "Provider disabled"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 1986da0227..1c7f10f00f 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -11,9 +11,8 @@ type UsageLimitData = Partial< /** * Typed classification for errors surfaced in the agent detail view. * - "usage_limit": the user hit a spending cap (409 + valid usage data). - * - other kinds come from normalized stream/provider failures such as - * "generic", "overloaded", "rate_limit", "timeout", - * "startup_timeout", "auth", and "config". + * - other kinds come from normalized stream/provider failures. + * See ChatErrorKind for the full set. */ export type ChatDetailError = { message: string; From 110210d7c96dbd5c843bd7d817104d521ea38407 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 29 May 2026 15:14:55 +0200 Subject: [PATCH 20/63] fix(coderd): block ai provider env key drift (#25849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, `SeedAIProvidersFromEnv` only hashed provider-level fields, so env var key changes were silently ignored once a provider already existed in the database. Include bearer keys and Bedrock credentials in the canonical drift hash, and cover multi-key, multi-provider cases so restarts now fail loudly when the configured credentials no longer match what is stored. When changing a key, you'll now see this in the server startup logs: ``` 2026-05-29 12:29:02.674 [info] api: Encountered an error running "coder server", see "coder server --help" for more information 2026-05-29 12:29:02.674 [info] api: error: create coder API: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/cli.(*RootCmd).Server.func2 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/cli/server.go:1015 2026-05-29 12:29:02.674 [info] api: - seed ai providers from env: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/enterprise/cli.(*RootCmd).Server.func1 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/enterprise/cli/server.go:187 2026-05-29 12:29:02.674 [info] api: - execute transaction: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/coderd/database.(*sqlQuerier).runTx 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/coderd/database/db.go:212 ---> 2026-05-29 12:29:02.674 [info] api: - AI provider "vercel" already exists in the database and differs from the current environment configuration; update the provider through the API or remove the CODER_AIBRIDGE_* env vars to stop seeding it: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/coderd.SeedAIProvidersFromEnv.func1 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/coderd/ai_providers_migrate.go:139 2026-05-29 12:29:02.674 [info] api: slogjson: failed to write entry: io: read/write on closed pipe 2026-05-29 12:29:02.700 [info] dlv: Stop reason: exited 2026-05-29 12:29:02.825 [info] site:  ELIFECYCLE  Command failed. error: running command "develop": server did not become ready in 1m0s: main.waitForHealthy /home/coder/coder/scripts/develop/main.go:877 - context canceled ``` _This PR was generated with Coder Agents._ --- coderd/ai_providers_migrate.go | 50 +++++++++-- coderd/ai_providers_migrate_test.go | 134 +++++++++++++++++++++++----- 2 files changed, 157 insertions(+), 27 deletions(-) diff --git a/coderd/ai_providers_migrate.go b/coderd/ai_providers_migrate.go index 6bc99ad840..055877ecce 100644 --- a/coderd/ai_providers_migrate.go +++ b/coderd/ai_providers_migrate.go @@ -116,10 +116,21 @@ func SeedAIProvidersFromEnv( if err != nil { return xerrors.Errorf("decode existing settings for %q: %w", dp.Name, err) } + // Load existing bearer keys so the canonical hash + // includes credentials for comparison. + existingKeyRows, err := tx.GetAIProviderKeysByProviderID(sysCtx, existing.ID) + if err != nil { + return xerrors.Errorf("load existing keys for %q: %w", dp.Name, err) + } + existingKeys := make([]string, 0, len(existingKeyRows)) + for _, k := range existingKeyRows { + existingKeys = append(existingKeys, k.APIKey) + } existingDP := desiredAIProvider{ Type: existing.Type, BaseURL: existing.BaseUrl, Bedrock: existingSettings.Bedrock, + Keys: existingKeys, } existingHash := computeProviderHash(existingDP.canonical()) if existingHash == dp.Hash { @@ -196,18 +207,15 @@ func SeedAIProvidersFromEnv( // canonicalAIProvider is the shape we hash to detect drift between the // configured environment and the row stored in the database. The fields // we hash are exactly the operator-controllable inputs that affect -// runtime behavior. Credentials are intentionally NOT part of the hash -// so operators can rotate them via the API without forcing a server -// restart. This applies to both bearer API keys (stored in -// ai_provider_keys) and to Bedrock access key/secret pairs (stored in -// the settings blob because Bedrock authenticates via settings rather -// than a bearer token). +// runtime behavior, including credentials. +// // Model and SmallFastModel are excluded: they're tunables, and their // serpent defaults shift across releases. type canonicalAIProvider struct { Type string `json:"type"` BaseURL string `json:"base_url"` BedrockRegion string `json:"bedrock_region"` + KeysHash string `json:"keys_hash"` } // desiredAIProvider is a normalized provider description sourced from @@ -235,9 +243,39 @@ func (d desiredAIProvider) canonical() canonicalAIProvider { if d.Bedrock != nil { c.BedrockRegion = d.Bedrock.Region } + c.KeysHash = computeKeysHash(d.Keys, d.Bedrock) return c } +// computeKeysHash produces a deterministic hash over the bearer API +// keys and, for Bedrock providers, the access key and secret. +func computeKeysHash(bearerKeys []string, bedrock *codersdk.AIProviderBedrockSettings) string { + // Collect all credential material in a deterministic order. + // Bearer keys are sorted so reordering in env vars does not + // trigger a false-positive drift. + sorted := make([]string, len(bearerKeys)) + copy(sorted, bearerKeys) + slices.Sort(sorted) + + h := sha256.New() + for _, k := range sorted { + _, _ = h.Write([]byte(k)) + // Separator so "ab"+"c" != "a"+"bc". + _, _ = h.Write([]byte{0}) + } + if bedrock != nil { + if bedrock.AccessKey != nil { + _, _ = h.Write([]byte(*bedrock.AccessKey)) + } + _, _ = h.Write([]byte{0}) + if bedrock.AccessKeySecret != nil { + _, _ = h.Write([]byte(*bedrock.AccessKeySecret)) + } + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + func computeProviderHash(c canonicalAIProvider) string { // json.Marshal is deterministic for structs because field order is // fixed by the struct definition. diff --git a/coderd/ai_providers_migrate_test.go b/coderd/ai_providers_migrate_test.go index d4a07bfdc2..89165002b0 100644 --- a/coderd/ai_providers_migrate_test.go +++ b/coderd/ai_providers_migrate_test.go @@ -91,21 +91,23 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { } require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - // Changing the API key alone does NOT count as drift: keys - // live in a separate table and operators rotate them via the - // API. Only changes to non-credential provider-level fields - // (base_url, type, Bedrock region/model) trip the drift check. + // Changing the API key counts as drift: keys are included + // in the canonical hash so operators notice when env-var + // credential changes are ignored by an existing provider. cfg.LegacyOpenAI.Key = serpent.String("sk-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - - // Changing the base URL is real drift. - cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2") err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) require.Error(t, err) require.Contains(t, err.Error(), "differs from the current environment configuration") + + // Changing the base URL is also real drift. + cfg.LegacyOpenAI.Key = serpent.String("sk-original") + cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2") + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") }) - t.Run("BedrockCredentialRotationIsNotDrift", func(t *testing.T) { + t.Run("BedrockCredentialChangeIsDrift", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -120,17 +122,20 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { } require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - // Rotating the Bedrock access key and secret in env must NOT - // trip the drift check: they're credentials, equivalent to - // bearer API keys, and operators rotate them via the API. + // Rotating the Bedrock access key in env trips the drift + // check so operators know the change did not take effect. cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-rotated") cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") // Changing the Bedrock region (a non-credential field) is - // real drift. + // also real drift. + cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-original") + cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-original") cfg.LegacyBedrock.Region = serpent.String("us-west-2") - err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) require.Error(t, err) require.Contains(t, err.Error(), "differs from the current environment configuration") }) @@ -293,6 +298,57 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Equal(t, "sk-ant-1", anKeys[0].APIKey) }) + t.Run("IndexedProvidersKeyDriftWithMultipleKeysAndProviders", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "openai", + Name: "primary-openai", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-openai-1", "sk-openai-2"}, + }, + { + Type: "anthropic", + Name: "primary-anthropic", + BaseURL: "https://api.anthropic.com/", + Keys: []string{"sk-ant-1", "sk-ant-2"}, + }, + }, + } + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + // Reordering keys must not count as drift. The canonical hash + // sorts keys before hashing, so equivalent key sets remain + // stable across restarts. + cfg.Providers[0].Keys = []string{"sk-openai-2", "sk-openai-1"} + cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-1"} + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + // Changing one key on one provider must block startup even + // when multiple providers are configured. + cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-rotated"} + err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") + require.Contains(t, err.Error(), `"primary-anthropic"`) + + oa, err := db.GetAIProviderByName(ctx, "primary-openai") + require.NoError(t, err) + oaKeys, err := db.GetAIProviderKeysByProviderID(ctx, oa.ID) + require.NoError(t, err) + require.ElementsMatch(t, []string{"sk-openai-1", "sk-openai-2"}, []string{oaKeys[0].APIKey, oaKeys[1].APIKey}) + + an, err := db.GetAIProviderByName(ctx, "primary-anthropic") + require.NoError(t, err) + anKeys, err := db.GetAIProviderKeysByProviderID(ctx, an.ID) + require.NoError(t, err) + require.ElementsMatch(t, []string{"sk-ant-1", "sk-ant-2"}, []string{anKeys[0].APIKey, anKeys[1].APIKey}) + }) + t.Run("BedrockIndexedProviderHasNoKeys", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -424,7 +480,7 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Empty(t, all, "expected no active rows after soft-delete + re-seed") }) - t.Run("ExistingKeysArePreserved", func(t *testing.T) { + t.Run("ExistingKeysBlockOnDrift", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -440,15 +496,17 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { row, err := db.GetAIProviderByName(ctx, "openai") require.NoError(t, err) - // Operator rotates the env key. The seed must not duplicate - // keys on a row that already exists; the new key is only - // installed via the API/CRUD layer in this flow. + // Operator rotates the env key. The seed now blocks startup + // because the keys differ, alerting the operator. cfg.LegacyOpenAI.Key = serpent.String("sk-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") + // The original key is still in the database. keys, err := db.GetAIProviderKeysByProviderID(ctx, row.ID) require.NoError(t, err) - require.Len(t, keys, 1, "env reseed must not duplicate keys on existing rows") + require.Len(t, keys, 1) require.Equal(t, "sk-original", keys[0].APIKey) }) @@ -482,6 +540,40 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Len(t, all, 1, "duplicate indexed entries with matching hash must produce a single row") }) + t.Run("IndexedDuplicateNameMatchingHashDedupesReorderedKeys", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Key order should not affect the canonical hash. Reordered + // duplicates under the same name should still dedupe. + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "openai", + Name: "shared", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-1", "sk-2"}, + }, + { + Type: "openai", + Name: "shared", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-2", "sk-1"}, + }, + }, + } + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + all, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{}) + require.NoError(t, err) + require.Len(t, all, 1) + keys, err := db.GetAIProviderKeysByProviderID(ctx, all[0].ID) + require.NoError(t, err) + require.Len(t, keys, 2) + require.ElementsMatch(t, []string{"sk-1", "sk-2"}, []string{keys[0].APIKey, keys[1].APIKey}) + }) + t.Run("IndexedDuplicateNameMismatchingHashFails", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) From 9448624d2de386fd60c42e2a6677679877b6f997 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 15:27:24 +0200 Subject: [PATCH 21/63] feat(site): add Opus 4.8 known model (#25839) --- .../ChatModelAdminPanel.stories.tsx | 6 ++--- .../knownModels/anthropic.test.ts | 18 +++++++++++-- .../knownModels/anthropic.ts | 25 ++++++++++++++++--- .../applyKnownModelDefaults.test.ts | 4 +-- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index ec422bca5e..09a5386369 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -1374,9 +1374,9 @@ export const AnthropicKnownModelHappyPath: Story = { await openKnownModelPopover(body); const options = await body.findAllByRole("option"); - await userEvent.click(findOptionByText(options, "claude-opus-4-7")); + await userEvent.click(findOptionByText(options, "claude-opus-4-8")); - await expectModelIdentifierValue(body, "claude-opus-4-7"); + await expectModelIdentifierValue(body, "claude-opus-4-8"); await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1000000"); await expandSection(body, "Advanced"); @@ -1858,7 +1858,7 @@ export const KnownModelAutoHidePopoverWhenNoMatches: Story = { name: /Model Identifier/i, }); await userEvent.click(input); - await expect(await body.findByText("Claude Opus 4.7")).toBeInTheDocument(); + await expect(await body.findByText("Claude Opus 4.8")).toBeInTheDocument(); await userEvent.clear(input); await userEvent.type(input, "claude-opus-4-5"); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts index 0ad417439c..2e1762d758 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts @@ -22,6 +22,7 @@ describe("anthropicKnownModels", () => { (knownModel) => knownModel.modelIdentifier, ), ).toEqual([ + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", @@ -31,7 +32,11 @@ describe("anthropicKnownModels", () => { }); it("declares Anthropic reasoning defaults by API support", () => { - for (const modelIdentifier of ["claude-opus-4-7", "claude-opus-4-6"]) { + for (const modelIdentifier of [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + ]) { const knownModel = requireAnthropicKnownModel(modelIdentifier); expect(knownModel.reasoningEffort).toBe("high"); @@ -54,6 +59,7 @@ describe("anthropicKnownModels", () => { expect( anthropicKnownModels.map((knownModel) => knownModel.modelIdentifier), ).toEqual([ + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", @@ -64,8 +70,16 @@ describe("anthropicKnownModels", () => { for (const knownModel of anthropicKnownModels) { expect(knownModel.provider).toBe("anthropic"); expect(knownModel.sourceMetadata.sourceName).toBe("models.dev"); - expect(knownModel.sourceMetadata.sourceRetrievedAt).toBe("2026-04-30"); + expect(knownModel.sourceMetadata.sourceRetrievedAt).not.toBe(""); expect(knownModel.sourceMetadata.lastUpdated).not.toBe(""); } + + expect( + requireAnthropicKnownModel("claude-opus-4-8").sourceMetadata, + ).toEqual({ + sourceName: "models.dev", + sourceRetrievedAt: "2026-05-29", + lastUpdated: "2026-05-28", + }); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts index 297ddabe2c..985fb49f7e 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts @@ -10,14 +10,33 @@ import type { KnownModel } from "./types"; // catalog and should be reviewed when the catalog is refreshed. // // Reasoning configuration is split per model based on Anthropic API support: -// models that support adaptive thinking (Opus 4.7, Opus 4.6, Sonnet 4.6) -// carry `reasoningEffort`, which Coder maps to `thinking.type: "adaptive"` -// with the `effort` parameter. Models that do not (Haiku 4.5, Sonnet 4.5) +// models that support adaptive thinking (Opus 4.8, Opus 4.7, Opus 4.6, +// Sonnet 4.6) carry `reasoningEffort`, which Coder maps to +// `thinking.type: "adaptive"` with the `effort` parameter. Models that do not +// (Haiku 4.5, Sonnet 4.5) // carry `thinkingBudgetTokens` instead, which Coder maps to the legacy // `thinking.type: "enabled"` path with `budget_tokens`. Setting `effort` on // the legacy path produces an "adaptive thinking is not supported on this // model" HTTP 400 from Anthropic. export const anthropicKnownModels = [ + { + provider: "anthropic", + modelIdentifier: "claude-opus-4-8", + displayName: "Claude Opus 4.8", + aliases: [], + contextLimit: 1_000_000, + maxOutputTokens: 128_000, + reasoningEffort: "high", + inputCost: 5, + outputCost: 25, + cacheReadCost: 0.5, + cacheWriteCost: 6.25, + sourceMetadata: { + sourceName: "models.dev", + sourceRetrievedAt: "2026-05-29", + lastUpdated: "2026-05-28", + }, + }, { provider: "anthropic", modelIdentifier: "claude-opus-4-7", diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts index d325c53385..d537aa726c 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts @@ -294,7 +294,7 @@ describe("applyKnownModelDefaults", () => { values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), provider: "anthropic", - knownModel: requireKnownModel("anthropic", "claude-opus-4-7"), + knownModel: requireKnownModel("anthropic", "claude-opus-4-8"), }); expect(getPath(result.values, "config.anthropic.effort")).toBe("high"); @@ -327,7 +327,7 @@ describe("applyKnownModelDefaults", () => { values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), provider: "anthropic", - knownModel: requireKnownModel("anthropic", "claude-opus-4-7"), + knownModel: requireKnownModel("anthropic", "claude-opus-4-8"), }); expect(getPath(result.values, "config.anthropic.sendReasoning")).toBe(""); From 1a91d3179366055b6f74b5b5940fcae1d9729abd Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 29 May 2026 10:08:25 -0400 Subject: [PATCH 22/63] feat: add user AI budget override endpoints (#25439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements https://linear.app/codercom/issue/AIGOV-285 Follow the structure established in https://github.com/coder/coder/pull/25203 ## Summary Adds the `user_ai_budget_overrides` table and CRUD API at `/api/v2/users/{user}/ai/budget`. An override sets a custom per-user spend cap that supersedes group-budget resolution, attributing spend to a specific group. ## Schema ```sql CREATE TABLE user_ai_budget_overrides ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ## Membership lifecycle The membership invariant — a user must be a member of the attributed group, including when that group is "Everyone" — would naturally be expressed as a composite FK on `(user_id, group_id) → group_members_expanded(user_id, group_id)`. PostgreSQL doesn't allow foreign keys to reference views, so enforcement is split across two mechanisms: - **Write-time check.** A CHECK constraint on the table (`user_ai_budget_overrides_must_be_group_member`) calls a `STABLE` function `is_group_member(user_id, group_id)` that queries `group_members_expanded`. The view surfaces both regular group memberships and the implicit "Everyone" group memberships from `organization_members`. Any INSERT or UPDATE that violates the predicate is rejected with a Postgres `check_violation`, which the handler maps to a 400. `is_group_member` is defined as a general predicate, reusable by any future table that needs the same check. - **Cascade on removal.** Two `BEFORE DELETE` triggers handle membership loss: - `trigger_delete_user_ai_budget_overrides_on_group_member_delete` on `group_members` — covers regular group removals (admin action, OIDC sync). - `trigger_delete_user_ai_budget_overrides_on_org_member_delete` on `organization_members` — covers the "Everyone" group, whose membership lives in `organization_members`. The single-column FKs on `users(id)` and `groups(id)` remain to cascade on user or group deletion (those paths don't pass through `group_members`). ## Authorization The dbauthz layer gates each operation against the `User` and (for writes) `Group` resources: | Operation | User resource | Group resource | |-----------|----------------|----------------| | `GET` | `ActionRead` | — | | `PUT` | `ActionUpdate` | `ActionUpdate` | | `DELETE` | `ActionUpdate` | `ActionUpdate` | For `DELETE`, the dbauthz layer fetches the existing override first to learn the attributed `group_id`, then runs both checks. ### Role matrix | Role | GET | PUT | DELETE | |--------------|-----|-----|--------| | Owner | ✅ | ✅ | ✅ | | UserAdmin | ✅ | ✅ | ✅ | | OrgAdmin | ✅ | ❌ | ❌ | | OrgUserAdmin | ✅ | ❌ | ❌ | Internal discussion: https://codercom.slack.com/archives/C096PFVBZKN/p1779392747885359 ## Audit logs Audit logs will be addressed in a follow-up PR. --- coderd/apidoc/docs.go | 145 ++++++ coderd/apidoc/swagger.json | 131 ++++++ coderd/database/check_constraint.go | 1 + coderd/database/db2sdk/db2sdk.go | 10 + coderd/database/dbauthz/dbauthz.go | 53 +++ coderd/database/dbauthz/dbauthz_test.go | 30 ++ coderd/database/dbmetrics/querymetrics.go | 24 + coderd/database/dbmock/dbmock.go | 45 ++ coderd/database/dump.sql | 62 +++ coderd/database/foreign_key_constraint.go | 2 + .../000513_user_ai_budget_overrides.down.sql | 7 + .../000513_user_ai_budget_overrides.up.sql | 76 +++ .../000513_user_ai_budget_overrides.up.sql | 15 + coderd/database/models.go | 9 + coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 65 +++ coderd/database/queries/aicostcontrol.sql | 17 + coderd/database/unique_constraint.go | 1 + codersdk/aibridge.go | 68 +++ docs/reference/api/enterprise.md | 119 +++++ docs/reference/api/schemas.md | 38 ++ enterprise/coderd/aibridge.go | 118 +++++ enterprise/coderd/aibridge_test.go | 441 ++++++++++++++++++ enterprise/coderd/coderd.go | 11 + site/src/api/typesGenerated.ts | 19 + 25 files changed, 1510 insertions(+) create mode 100644 coderd/database/migrations/000513_user_ai_budget_overrides.down.sql create mode 100644 coderd/database/migrations/000513_user_ai_budget_overrides.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7bca7d7988..1bbb216b1a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9171,6 +9171,110 @@ const docTemplate = `{ ] } }, + "/api/v2/users/{user}/ai/budget": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get user AI budget override", + "operationId": "get-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Upsert user AI budget override", + "operationId": "upsert-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Upsert user AI budget override request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "delete": { + "tags": [ + "Enterprise" + ], + "summary": "Delete user AI budget override", + "operationId": "delete-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/appearance": { "get": { "produces": [ @@ -24663,6 +24767,23 @@ const docTemplate = `{ } } }, + "codersdk.UpsertUserAIBudgetOverrideRequest": { + "type": "object", + "required": [ + "group_id" + ], + "properties": { + "group_id": { + "description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.", + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer", + "minimum": 0 + } + } + }, "codersdk.UpsertWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -24817,6 +24938,30 @@ const docTemplate = `{ } } }, + "codersdk.UserAIBudgetOverride": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.UserActivity": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d641744117..6f7224e972 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8132,6 +8132,98 @@ ] } }, + "/api/v2/users/{user}/ai/budget": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get user AI budget override", + "operationId": "get-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "put": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Upsert user AI budget override", + "operationId": "upsert-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Upsert user AI budget override request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "delete": { + "tags": ["Enterprise"], + "summary": "Delete user AI budget override", + "operationId": "delete-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/appearance": { "get": { "produces": ["application/json"], @@ -22696,6 +22788,21 @@ } } }, + "codersdk.UpsertUserAIBudgetOverrideRequest": { + "type": "object", + "required": ["group_id"], + "properties": { + "group_id": { + "description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.", + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer", + "minimum": 0 + } + } + }, "codersdk.UpsertWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -22829,6 +22936,30 @@ } } }, + "codersdk.UserAIBudgetOverride": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.UserActivity": { "type": "object", "properties": { diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 5682341ef9..1c20622e58 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -44,6 +44,7 @@ const ( CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events + CheckUserAiBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides CheckUserAiProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 36b081b86b..bc93df7cd3 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1509,6 +1509,16 @@ func GroupAIBudget(b database.GroupAiBudget) codersdk.GroupAIBudget { } } +func UserAIBudgetOverride(o database.UserAiBudgetOverride) codersdk.UserAIBudgetOverride { + return codersdk.UserAIBudgetOverride{ + UserID: o.UserID, + GroupID: o.GroupID, + SpendLimitMicros: o.SpendLimitMicros, + CreatedAt: o.CreatedAt, + UpdatedAt: o.UpdatedAt, + } +} + func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset { var presets []codersdk.InvalidatedPreset for _, p := range invalidatedPresets { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5e98dfcf30..a1a7497153 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2323,6 +2323,32 @@ func (q *querier) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) return q.db.DeleteTask(ctx, arg) } +func (q *querier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + // Removing a user's AI budget override affects both the user (clearing + // their per-user spend cap) and the group it was attributed to. + u, err := q.db.GetUserByID(ctx, userID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil { + return database.UserAiBudgetOverride{}, err + } + // Fetch the existing override to learn which group it attributes spend to, + // so we can authorize the caller against that group as well. + userOverride, err := q.db.GetUserAIBudgetOverride(ctx, userID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + g, err := q.db.GetGroupByID(ctx, userOverride.GroupID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil { + return database.UserAiBudgetOverride{}, err + } + return q.db.DeleteUserAIBudgetOverride(ctx, userID) +} + func (q *querier) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { @@ -4536,6 +4562,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, return q.db.GetUnexpiredLicenses(ctx) } +func (q *querier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + if _, err := q.GetUserByID(ctx, userID); err != nil { // AuthZ check + return database.UserAiBudgetOverride{}, err + } + return q.db.GetUserAIBudgetOverride(ctx, userID) +} + func (q *querier) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { @@ -8342,6 +8375,26 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error { return q.db.UpsertTemplateUsageStats(ctx) } +func (q *querier) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) { + // Setting a user's AI budget override affects both the user (their + // per-user spend cap) and the group (spend attribution). + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil { + return database.UserAiBudgetOverride{}, err + } + g, err := q.db.GetGroupByID(ctx, arg.GroupID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil { + return database.UserAiBudgetOverride{}, err + } + return q.db.UpsertUserAIBudgetOverride(ctx, arg) +} + func (q *querier) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index cfc87d3ed4..f788fa71e2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6475,6 +6475,36 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(g.ID).Asserts(g, policy.ActionUpdate).Returns(b) })) + s.Run("GetUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID}) + dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes() + dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes() + check.Args(user.ID).Asserts(user, policy.ActionRead).Returns(override) + })) + + s.Run("UpsertUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + group := testutil.Fake(s.T(), faker, database.Group{}) + override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID}) + arg := database.UpsertUserAIBudgetOverrideParams{UserID: user.ID, GroupID: group.ID, SpendLimitMicros: override.SpendLimitMicros} + dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes() + dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes() + dbm.EXPECT().UpsertUserAIBudgetOverride(gomock.Any(), arg).Return(override, nil).AnyTimes() + check.Args(arg).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override) + })) + + s.Run("DeleteUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + group := testutil.Fake(s.T(), faker, database.Group{}) + override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID}) + dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes() + dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes() + dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes() + dbm.EXPECT().DeleteUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes() + check.Args(user.ID).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override) + })) + s.Run("GetAIProviderByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { provider := testutil.Fake(s.T(), faker, database.AIProvider{}) dbm.EXPECT().GetAIProviderByID(gomock.Any(), provider.ID).Return(provider, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c963d41d3f..e7120ec588 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -793,6 +793,14 @@ func (m queryMetricsStore) DeleteTask(ctx context.Context, arg database.DeleteTa return r0, r1 } +func (m queryMetricsStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + start := time.Now() + r0, r1 := m.s.DeleteUserAIBudgetOverride(ctx, userID) + m.queryLatencies.WithLabelValues("DeleteUserAIBudgetOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserAIBudgetOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error { start := time.Now() r0 := m.s.DeleteUserAIProviderKey(ctx, arg) @@ -2905,6 +2913,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database return r0, r1 } +func (m queryMetricsStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + start := time.Now() + r0, r1 := m.s.GetUserAIBudgetOverride(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserAIBudgetOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAIBudgetOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) { start := time.Now() r0, r1 := m.s.GetUserAIProviderKeyByProviderID(ctx, arg) @@ -6049,6 +6065,14 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error { return r0 } +func (m queryMetricsStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) { + start := time.Now() + r0, r1 := m.s.UpsertUserAIBudgetOverride(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertUserAIBudgetOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserAIBudgetOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) { start := time.Now() r0, r1 := m.s.UpsertUserAIProviderKey(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 75ac8f44ad..0f6799e638 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1349,6 +1349,21 @@ func (mr *MockStoreMockRecorder) DeleteTask(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockStore)(nil).DeleteTask), ctx, arg) } +// DeleteUserAIBudgetOverride mocks base method. +func (m *MockStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserAIBudgetOverride", ctx, userID) + ret0, _ := ret[0].(database.UserAiBudgetOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteUserAIBudgetOverride indicates an expected call of DeleteUserAIBudgetOverride. +func (mr *MockStoreMockRecorder) DeleteUserAIBudgetOverride(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).DeleteUserAIBudgetOverride), ctx, userID) +} + // DeleteUserAIProviderKey mocks base method. func (m *MockStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error { m.ctrl.T.Helper() @@ -5445,6 +5460,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx) } +// GetUserAIBudgetOverride mocks base method. +func (m *MockStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserAIBudgetOverride", ctx, userID) + ret0, _ := ret[0].(database.UserAiBudgetOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserAIBudgetOverride indicates an expected call of GetUserAIBudgetOverride. +func (mr *MockStoreMockRecorder) GetUserAIBudgetOverride(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).GetUserAIBudgetOverride), ctx, userID) +} + // GetUserAIProviderKeyByProviderID mocks base method. func (m *MockStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) { m.ctrl.T.Helper() @@ -11344,6 +11374,21 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx) } +// UpsertUserAIBudgetOverride mocks base method. +func (m *MockStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertUserAIBudgetOverride", ctx, arg) + ret0, _ := ret[0].(database.UserAiBudgetOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertUserAIBudgetOverride indicates an expected call of UpsertUserAIBudgetOverride. +func (mr *MockStoreMockRecorder) UpsertUserAIBudgetOverride(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).UpsertUserAIBudgetOverride), ctx, arg) +} + // UpsertUserAIProviderKey mocks base method. func (m *MockStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index fad35755b9..82aa376d34 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -841,6 +841,42 @@ BEGIN END; $$; +CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.group_id; + RETURN OLD; +END; +$$; + +CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.organization_id; + RETURN OLD; +END; +$$; + +CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM group_members_expanded + WHERE user_id = NEW.user_id AND group_id = NEW.group_id + ) THEN + RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member'; + END IF; + RETURN NEW; +END; +$$; + CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -3137,6 +3173,17 @@ COMMENT ON TABLE usage_events_daily IS 'usage_events_daily is a daily rollup of COMMENT ON COLUMN usage_events_daily.day IS 'The date of the summed usage events, always in UTC.'; +CREATE TABLE user_ai_budget_overrides ( + user_id uuid NOT NULL, + group_id uuid NOT NULL, + spend_limit_micros bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_ai_budget_overrides_spend_limit_micros_check CHECK ((spend_limit_micros >= 0)) +); + +COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.'; + CREATE TABLE user_ai_provider_keys ( id uuid DEFAULT gen_random_uuid() NOT NULL, user_id uuid NOT NULL, @@ -3986,6 +4033,9 @@ ALTER TABLE ONLY usage_events_daily ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); +ALTER TABLE ONLY user_ai_budget_overrides + ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id); + ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id); @@ -4467,6 +4517,12 @@ CREATE TRIGGER trigger_delete_group_members_on_org_member_delete BEFORE DELETE O CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_provider_app_tokens FOR EACH ROW EXECUTE FUNCTION delete_deleted_oauth2_provider_app_token_api_key(); +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete BEFORE DELETE ON group_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete(); + +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete BEFORE DELETE ON organization_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete(); + +CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership BEFORE INSERT OR UPDATE ON user_ai_budget_overrides FOR EACH ROW EXECUTE FUNCTION enforce_user_ai_budget_override_membership(); + CREATE TRIGGER trigger_insert_apikeys BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION insert_apikey_fail_if_user_deleted(); CREATE TRIGGER trigger_insert_organization_system_roles AFTER INSERT ON organizations FOR EACH ROW EXECUTE FUNCTION insert_organization_system_roles(); @@ -4792,6 +4848,12 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_ai_budget_overrides + ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + +ALTER TABLE ONLY user_ai_budget_overrides + ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index a31346b423..5eeb24587a 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -105,6 +105,8 @@ const ( ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE; ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyUserAiBudgetOverridesGroupID ForeignKeyConstraint = "user_ai_budget_overrides_group_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + ForeignKeyUserAiBudgetOverridesUserID ForeignKeyConstraint = "user_ai_budget_overrides_user_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserAiProviderKeysAiProviderID ForeignKeyConstraint = "user_ai_provider_keys_ai_provider_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; ForeignKeyUserAiProviderKeysAPIKeyKeyID ForeignKeyConstraint = "user_ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserAiProviderKeysUserID ForeignKeyConstraint = "user_ai_provider_keys_user_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000513_user_ai_budget_overrides.down.sql b/coderd/database/migrations/000513_user_ai_budget_overrides.down.sql new file mode 100644 index 0000000000..1a1a8e2160 --- /dev/null +++ b/coderd/database/migrations/000513_user_ai_budget_overrides.down.sql @@ -0,0 +1,7 @@ +DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_org_member_delete ON organization_members; +DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_org_member_delete; +DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_group_member_delete ON group_members; +DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_group_member_delete; +DROP TRIGGER IF EXISTS trigger_enforce_user_ai_budget_override_membership ON user_ai_budget_overrides; +DROP FUNCTION IF EXISTS enforce_user_ai_budget_override_membership; +DROP TABLE IF EXISTS user_ai_budget_overrides CASCADE; diff --git a/coderd/database/migrations/000513_user_ai_budget_overrides.up.sql b/coderd/database/migrations/000513_user_ai_budget_overrides.up.sql new file mode 100644 index 0000000000..b1ab1cd9d2 --- /dev/null +++ b/coderd/database/migrations/000513_user_ai_budget_overrides.up.sql @@ -0,0 +1,76 @@ +CREATE TABLE user_ai_budget_overrides ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + -- Spend limit applied to the user, in micro-units (1 unit = 1,000,000). + spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- The membership invariant (user must be a member of the attributed + -- group, including when that group is "Everyone") would naturally be + -- a composite FK to group_members_expanded, but PostgreSQL does not + -- allow FKs to views. It's enforced instead by a write-time trigger + -- on this table and removal-time triggers on the underlying + -- membership tables. +); + +COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.'; + +-- Write-time membership check. Reads from group_members_expanded so +-- the "Everyone" group (whose membership lives in organization_members) +-- is correctly handled. Raises check_violation with a constraint name +-- so callers can match it via database.IsCheckViolation in Go. +CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM group_members_expanded + WHERE user_id = NEW.user_id AND group_id = NEW.group_id + ) THEN + RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership + BEFORE INSERT OR UPDATE ON user_ai_budget_overrides + FOR EACH ROW +EXECUTE PROCEDURE enforce_user_ai_budget_override_membership(); + +-- When a user is removed from a regular group (any group except +-- "Everyone"), delete any override attributed to that group. +CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.group_id; + RETURN OLD; +END; +$$; + +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete + BEFORE DELETE ON group_members + FOR EACH ROW +EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_group_member_delete(); + +-- When a user is removed from an organization, delete any override +-- attributed to that organization's "Everyone" group (which has +-- id == organization_id). +CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.organization_id; + RETURN OLD; +END; +$$; + +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete + BEFORE DELETE ON organization_members + FOR EACH ROW +EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_org_member_delete(); diff --git a/coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql b/coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql new file mode 100644 index 0000000000..787b808b7d --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql @@ -0,0 +1,15 @@ +-- Seed a group_members row so the override below references a real +-- membership. +INSERT INTO group_members ( + user_id, + group_id +) VALUES + ('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1') +ON CONFLICT DO NOTHING; + +INSERT INTO user_ai_budget_overrides ( + user_id, + group_id, + spend_limit_micros +) VALUES + ('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 500000000); diff --git a/coderd/database/models.go b/coderd/database/models.go index 6149e12a10..ebfaa7a051 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5730,6 +5730,15 @@ type User struct { ChatSpendLimitMicros sql.NullInt64 `db:"chat_spend_limit_micros" json:"chat_spend_limit_micros"` } +// Per-user AI spend override that supersedes group budget resolution. +type UserAiBudgetOverride struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` + SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // User-owned API keys associated with AI providers. These keys are used only when BYOK is enabled. type UserAiProviderKey struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 0b3d565b69..a6c8f3e7db 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -198,6 +198,7 @@ type sqlcQuerier interface { DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) DeleteTask(ctx context.Context, arg DeleteTaskParams) (uuid.UUID, error) + DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) DeleteUserAIProviderKey(ctx context.Context, arg DeleteUserAIProviderKeyParams) error DeleteUserAIProviderKeysByProviderID(ctx context.Context, aiProviderID uuid.UUID) error DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error @@ -738,6 +739,7 @@ type sqlcQuerier interface { // inclusive. GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) + GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) GetUserAIProviderKeyByProviderID(ctx context.Context, arg GetUserAIProviderKeyByProviderIDParams) (UserAiProviderKey, error) // GetUserAIProviderKeys is used by dbcrypt key rotation. Request paths should use // user-scoped lookups instead of this bulk accessor. @@ -1407,6 +1409,7 @@ type sqlcQuerier interface { // used to store the data, and the minutes are summed for each user and template // combination. The result is stored in the template_usage_stats table. UpsertTemplateUsageStats(ctx context.Context) error + UpsertUserAIBudgetOverride(ctx context.Context, arg UpsertUserAIBudgetOverrideParams) (UserAiBudgetOverride, error) // UpsertUserAIProviderKey preserves the original id and created_at when the // user/provider pair already exists. On conflict, callers provide id and // created_at for the insert path only. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b49e757549..dc646121dc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2449,6 +2449,23 @@ func (q *sqlQuerier) DeleteGroupAIBudget(ctx context.Context, groupID uuid.UUID) return i, err } +const deleteUserAIBudgetOverride = `-- name: DeleteUserAIBudgetOverride :one +DELETE FROM user_ai_budget_overrides WHERE user_id = $1 RETURNING user_id, group_id, spend_limit_micros, created_at, updated_at +` + +func (q *sqlQuerier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) { + row := q.db.QueryRowContext(ctx, deleteUserAIBudgetOverride, userID) + var i UserAiBudgetOverride + err := row.Scan( + &i.UserID, + &i.GroupID, + &i.SpendLimitMicros, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getAIModelPriceByProviderModel = `-- name: GetAIModelPriceByProviderModel :one SELECT provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at FROM ai_model_prices @@ -2494,6 +2511,25 @@ func (q *sqlQuerier) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (G return i, err } +const getUserAIBudgetOverride = `-- name: GetUserAIBudgetOverride :one +SELECT user_id, group_id, spend_limit_micros, created_at, updated_at +FROM user_ai_budget_overrides +WHERE user_id = $1 +` + +func (q *sqlQuerier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) { + row := q.db.QueryRowContext(ctx, getUserAIBudgetOverride, userID) + var i UserAiBudgetOverride + err := row.Scan( + &i.UserID, + &i.GroupID, + &i.SpendLimitMicros, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const upsertAIModelPrices = `-- name: UpsertAIModelPrices :exec INSERT INTO ai_model_prices ( provider, model, input_price, output_price, cache_read_price, cache_write_price @@ -2548,6 +2584,35 @@ func (q *sqlQuerier) UpsertGroupAIBudget(ctx context.Context, arg UpsertGroupAIB return i, err } +const upsertUserAIBudgetOverride = `-- name: UpsertUserAIBudgetOverride :one +INSERT INTO user_ai_budget_overrides (user_id, group_id, spend_limit_micros) +VALUES ($1, $2, $3) +ON CONFLICT (user_id) DO UPDATE SET + group_id = EXCLUDED.group_id, + spend_limit_micros = EXCLUDED.spend_limit_micros, + updated_at = NOW() +RETURNING user_id, group_id, spend_limit_micros, created_at, updated_at +` + +type UpsertUserAIBudgetOverrideParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` + SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"` +} + +func (q *sqlQuerier) UpsertUserAIBudgetOverride(ctx context.Context, arg UpsertUserAIBudgetOverrideParams) (UserAiBudgetOverride, error) { + row := q.db.QueryRowContext(ctx, upsertUserAIBudgetOverride, arg.UserID, arg.GroupID, arg.SpendLimitMicros) + var i UserAiBudgetOverride + err := row.Scan( + &i.UserID, + &i.GroupID, + &i.SpendLimitMicros, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one SELECT COUNT(*) diff --git a/coderd/database/queries/aicostcontrol.sql b/coderd/database/queries/aicostcontrol.sql index 6740b2568c..188ec7357e 100644 --- a/coderd/database/queries/aicostcontrol.sql +++ b/coderd/database/queries/aicostcontrol.sql @@ -40,3 +40,20 @@ RETURNING *; -- name: DeleteGroupAIBudget :one DELETE FROM group_ai_budgets WHERE group_id = @group_id RETURNING *; + +-- name: GetUserAIBudgetOverride :one +SELECT * +FROM user_ai_budget_overrides +WHERE user_id = @user_id; + +-- name: UpsertUserAIBudgetOverride :one +INSERT INTO user_ai_budget_overrides (user_id, group_id, spend_limit_micros) +VALUES (@user_id, @group_id, @spend_limit_micros) +ON CONFLICT (user_id) DO UPDATE SET + group_id = EXCLUDED.group_id, + spend_limit_micros = EXCLUDED.spend_limit_micros, + updated_at = NOW() +RETURNING *; + +-- name: DeleteUserAIBudgetOverride :one +DELETE FROM user_ai_budget_overrides WHERE user_id = @user_id RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 8ef517a9cb..3d5e5dabcf 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -97,6 +97,7 @@ const ( UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); UniqueUsageEventsDailyPkey UniqueConstraint = "usage_events_daily_pkey" // ALTER TABLE ONLY usage_events_daily ADD CONSTRAINT usage_events_daily_pkey PRIMARY KEY (day, event_type); UniqueUsageEventsPkey UniqueConstraint = "usage_events_pkey" // ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); + UniqueUserAiBudgetOverridesPkey UniqueConstraint = "user_ai_budget_overrides_pkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id); UniqueUserAiProviderKeysPkey UniqueConstraint = "user_ai_provider_keys_pkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id); UniqueUserAiProviderKeysUserIDAiProviderIDKey UniqueConstraint = "user_ai_provider_keys_user_id_ai_provider_id_key" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_ai_provider_id_key UNIQUE (user_id, ai_provider_id); UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 9bb7df0aac..d04359acb3 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -435,3 +435,71 @@ func (c *Client) DeleteGroupAIBudget(ctx context.Context, group uuid.UUID) error } return nil } + +type UserAIBudgetOverride struct { + UserID uuid.UUID `json:"user_id" format:"uuid"` + GroupID uuid.UUID `json:"group_id" format:"uuid"` + SpendLimitMicros int64 `json:"spend_limit_micros"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +type UpsertUserAIBudgetOverrideRequest struct { + // GroupID is the group the user's spend is attributed to. The user must + // be a member of this group. + GroupID uuid.UUID `json:"group_id" format:"uuid" validate:"required"` + SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"` +} + +// UserAIBudgetOverride returns the AI spend budget override configured for the given user. +func (c *Client) UserAIBudgetOverride(ctx context.Context, user uuid.UUID) (UserAIBudgetOverride, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + nil, + ) + if err != nil { + return UserAIBudgetOverride{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return UserAIBudgetOverride{}, ReadBodyAsError(res) + } + var resp UserAIBudgetOverride + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpsertUserAIBudgetOverride creates or updates the AI spend budget override for the given user. +func (c *Client) UpsertUserAIBudgetOverride(ctx context.Context, user uuid.UUID, req UpsertUserAIBudgetOverrideRequest) (UserAIBudgetOverride, error) { + res, err := c.Request(ctx, http.MethodPut, + fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + req, + ) + if err != nil { + return UserAIBudgetOverride{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return UserAIBudgetOverride{}, ReadBodyAsError(res) + } + var resp UserAIBudgetOverride + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// DeleteUserAIBudgetOverride removes the AI spend budget override for the given user. +func (c *Client) DeleteUserAIBudgetOverride(ctx context.Context, user uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, + fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + nil, + ) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index eca72025c0..ed1ce268e7 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -3418,6 +3418,125 @@ curl -X POST http://coder-server:8080/api/v2/templates/{template}/prebuilds/inva To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get user AI budget override + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/ai/budget \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/users/{user}/ai/budget` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|--------------------------| +| `user` | path | string | true | User ID, username, or me | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0, + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAIBudgetOverride](schemas.md#codersdkuseraibudgetoverride) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Upsert user AI budget override + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/ai/budget \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /api/v2/users/{user}/ai/budget` + +> Body parameter + +```json +{ + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0 +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------------------------------------|----------|----------------------------------------| +| `user` | path | string | true | User ID, username, or me | +| `body` | body | [codersdk.UpsertUserAIBudgetOverrideRequest](schemas.md#codersdkupsertuseraibudgetoverriderequest) | true | Upsert user AI budget override request | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0, + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAIBudgetOverride](schemas.md#codersdkuseraibudgetoverride) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete user AI budget override + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/users/{user}/ai/budget \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /api/v2/users/{user}/ai/budget` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|--------------------------| +| `user` | path | string | true | User ID, username, or me | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get user quiet hours schedule ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 771657d3c0..ea8f19c4bf 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -13601,6 +13601,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| |----------------------|---------|----------|--------------|-------------| | `spend_limit_micros` | integer | false | | | +## codersdk.UpsertUserAIBudgetOverrideRequest + +```json +{ + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------| +| `group_id` | string | true | | Group ID is the group the user's spend is attributed to. The user must be a member of this group. | +| `spend_limit_micros` | integer | false | | | + ## codersdk.UpsertWorkspaceAgentPortShareRequest ```json @@ -13730,6 +13746,28 @@ If the schedule is empty, the user will be updated to use the default schedule.| |----------|-----------------------| | `status` | `active`, `suspended` | +## codersdk.UserAIBudgetOverride + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0, + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|---------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `group_id` | string | false | | | +| `spend_limit_micros` | integer | false | | | +| `updated_at` | string | false | | | +| `user_id` | string | false | | | + ## codersdk.UserActivity ```json diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 9773dba352..8a220760de 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -43,6 +43,11 @@ const ( // reference a valid resource in the expected scope. var errInvalidCursor = xerrors.New("invalid pagination cursor") +// This name is raised by a trigger function with USING CONSTRAINT. +// It is not a table CHECK constraint, so dbgen does not emit it in +// check_constraint.go. +const userAIBudgetOverridesMustBeGroupMemberConstraint database.CheckConstraint = "user_ai_budget_overrides_must_be_group_member" + // aibridgeHandler handles all aibridged-related endpoints. func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { // Build the overload protection middleware chain for the aibridged handler. @@ -821,3 +826,116 @@ func (api *API) deleteGroupAIBudget(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } + +// @Summary Get user AI budget override +// @ID get-user-ai-budget-override +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID, username, or me" +// @Success 200 {object} codersdk.UserAIBudgetOverride +// @Router /api/v2/users/{user}/ai/budget [get] +func (api *API) userAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + override, err := api.Database.GetUserAIBudgetOverride(ctx, user.ID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + api.Logger.Error(ctx, "get user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override)) +} + +// @Summary Upsert user AI budget override +// @ID upsert-user-ai-budget-override +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID, username, or me" +// @Param request body codersdk.UpsertUserAIBudgetOverrideRequest true "Upsert user AI budget override request" +// @Success 200 {object} codersdk.UserAIBudgetOverride +// @Router /api/v2/users/{user}/ai/budget [put] +func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + var req codersdk.UpsertUserAIBudgetOverrideRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Look up the group first so a missing or forbidden group_id returns + // 404, distinct from the 400 "not a member" case handled below. + if _, err := api.Database.GetGroupByID(ctx, req.GroupID); err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + api.Logger.Error(ctx, "get group for user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + override, err := api.Database.UpsertUserAIBudgetOverride(ctx, database.UpsertUserAIBudgetOverrideParams{ + UserID: user.ID, + GroupID: req.GroupID, + SpendLimitMicros: req.SpendLimitMicros, + }) + // A trigger enforces that the user must be a member of the attributed + // group; it raises check_violation with this constraint name. Map + // the violation to a structured 400. + if database.IsCheckViolation(err, userAIBudgetOverridesMustBeGroupMemberConstraint) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "User is not a member of the referenced group.", + Validations: []codersdk.ValidationError{{ + Field: "group_id", + Detail: "user must be a member of this group", + }}, + }) + return + } + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + api.Logger.Error(ctx, "upsert user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override)) +} + +// @Summary Delete user AI budget override +// @ID delete-user-ai-budget-override +// @Security CoderSessionToken +// @Tags Enterprise +// @Param user path string true "User ID, username, or me" +// @Success 204 +// @Router /api/v2/users/{user}/ai/budget [delete] +func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + _, err := api.Database.DeleteUserAIBudgetOverride(ctx, user.ID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + api.Logger.Error(ctx, "delete user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 158f682842..1faadd1f53 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -2871,6 +2871,447 @@ func TestGroupAIBudget(t *testing.T) { }) } +func TestUserAIBudgetOverride(t *testing.T) { + t.Parallel() + + t.Run("Upsert/CreatesAndUpdates", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // First upsert creates the override. + newOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + require.Equal(t, targetUser.ID, newOverride.UserID) + require.Equal(t, group.ID, newOverride.GroupID) + require.EqualValues(t, 500_000_000, newOverride.SpendLimitMicros) + + // Second upsert updates the existing override. + updatedOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 1_000_000_000, + }) + require.NoError(t, err) + require.EqualValues(t, 1_000_000_000, updatedOverride.SpendLimitMicros) + + // GET returns the latest value. + currentOverride, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err) + require.EqualValues(t, 1_000_000_000, currentOverride.SpendLimitMicros) + }) + + t.Run("Upsert/ReassignsGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, groupA := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // First upsert: attribute spend to groupA. + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: groupA.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + + // Create groupB in the same org and add the target user. + groupB, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: "reassign-test-group-b", + }) + require.NoError(t, err) + _, err = adminClient.PatchGroup(ctx, groupB.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + // Reassign the override's attribution to groupB. + updated, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: groupB.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + require.Equal(t, groupB.ID, updated.GroupID, "upsert should change attributed group") + + // GET reflects the new group. + got, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err) + require.Equal(t, groupB.ID, got.GroupID, "GET should reflect new group") + }) + + t.Run("Upsert/EveryoneGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // The Everyone group has id == organization_id, and the target user + // is implicitly a member via organization_members rather than + // group_members. The membership trigger queries + // group_members_expanded (a UNION of both tables), so this case + // exercises the organization_members branch. + everyoneGroupID := targetUser.OrganizationIDs[0] + + override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: everyoneGroupID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err, "should be able to attribute override to Everyone group") + require.Equal(t, targetUser.ID, override.UserID) + require.Equal(t, everyoneGroupID, override.GroupID) + require.EqualValues(t, 500_000_000, override.SpendLimitMicros) + }) + + t.Run("Upsert/AcceptsZeroSpendLimit", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // 0 is a valid value: it blocks all spend for the user. + override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 0, + }) + require.NoError(t, err) + require.EqualValues(t, 0, override.SpendLimitMicros) + }) + + t.Run("Upsert/RejectsNegativeSpend", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: -1, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("Upsert/RejectsUnknownGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // A group_id that doesn't exist (or that the caller can't see) + // is rejected by the visibility check before the membership check. + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: uuid.New(), + SpendLimitMicros: 500_000_000, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Upsert/RejectsNonMemberGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a second group the target is NOT a member of. + outsiderGroup, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: "outsider-group", + }) + require.NoError(t, err) + + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: outsiderGroup.ID, + SpendLimitMicros: 500_000_000, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("Get/AbsentReturns404", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Get/UnknownUserReturns404", func(t *testing.T) { + t.Parallel() + + adminClient, _, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UserAIBudgetOverride(ctx, uuid.New()) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Delete/RoundTrip", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + + require.NoError(t, adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID)) + + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Delete/AbsentReturns404", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + err := adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} + +// TestUserAIBudgetOverrideRoleAccess verifies the authz matrix for the roles +// expected to interact with user budget overrides: +// +// - Owner / UserAdmin: full CRUD. +// - OrgAdmin / OrgUserAdmin: read-only. Writes require ActionUpdate on the +// User resource (site-scoped), which neither role has. +// +//nolint:tparallel // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race. +func TestUserAIBudgetOverrideRoleAccess(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + }, + }, + }) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + orgUserAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgUserAdmin(owner.OrganizationID)) + + setupCtx := testutil.Context(t, testutil.WaitLong) + group, err := userAdminClient.CreateGroup(setupCtx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "role-access-group", + }) + require.NoError(t, err) + + cases := []struct { + Name string + Client *codersdk.Client + CanWrite bool + }{ + {Name: "Owner", Client: ownerClient, CanWrite: true}, + {Name: "UserAdmin", Client: userAdminClient, CanWrite: true}, + {Name: "OrgAdmin", Client: orgAdminClient, CanWrite: false}, + {Name: "OrgUserAdmin", Client: orgUserAdminClient, CanWrite: false}, + } + + //nolint:paralleltest // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race. + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Each case gets a fresh target user. + _, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + _, err := userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + upsertReq := codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + } + + if tc.CanWrite { + // Full CRUD lifecycle. + override, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq) + require.NoError(t, err, "PUT") + require.Equal(t, group.ID, override.GroupID) + + got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "GET") + require.EqualValues(t, 500_000_000, got.SpendLimitMicros) + + err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "DELETE") + } else { + // PUT rejected. + _, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "PUT") + + // Seed a row via UserAdmin so we can verify read access still works. + _, err = userAdminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq) + require.NoError(t, err) + + // GET still works (all roles have ActionRead on User). + got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "GET") + require.EqualValues(t, 500_000_000, got.SpendLimitMicros) + + // DELETE rejected. + err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "DELETE") + } + }) + } +} + +// TestUserAIBudgetOverrideDeletedOnMembershipRemoval verifies that a per-user +// override is deleted automatically when the user loses membership in the +// attributed group. Two paths are exercised: +// +// - RegularGroup: membership stored in group_members; removed via +// PatchGroup with RemoveUsers. +// - EveryoneGroup: membership stored in organization_members; removed +// via DeleteOrganizationMember. +func TestUserAIBudgetOverrideDeletedOnMembershipRemoval(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + }, + }, + }) + adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + + // "Regular group" means any group except "Everyone". + t.Run("RegularGroup", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + _, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "cascade-regular-group", + }) + require.NoError(t, err) + + _, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err, "set override") + + // Sanity-check the override exists. + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "override should exist before removal") + + _, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + RemoveUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err, "remove user from group") + + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), + "override should be deleted after user is removed from the attributed group") + }) + + t.Run("EveryoneGroup", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + _, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // The Everyone group has id == organization_id. + everyoneGroupID := owner.OrganizationID + + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: everyoneGroupID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err, "set override") + + // Sanity-check the override exists. + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "override should exist before removal") + + err = adminClient.DeleteOrganizationMember(ctx, owner.OrganizationID, targetUser.ID.String()) + require.NoError(t, err, "remove user from organization") + + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), + "override should be deleted after user is removed from the organization") + }) +} + +// setupUserAIBudgetOverrideTest returns an Admin client, a target user, and a +// group the target user is a member of. +func setupUserAIBudgetOverrideTest(t *testing.T) (adminClient *codersdk.Client, targetUser codersdk.User, group codersdk.Group) { + t.Helper() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + }, + }, + }) + adminClient, _ = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + _, targetUser = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + g, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "override-test-group", + }) + require.NoError(t, err) + g, err = adminClient.PatchGroup(ctx, g.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + return adminClient, targetUser, g +} + // setupGroupAIBudgetTest returns an Admin client along with a newly created group inside it. func setupGroupAIBudgetTest(t *testing.T) (adminClient *codersdk.Client, group codersdk.Group) { t.Helper() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 2a55c6218f..33732eea3d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -596,6 +596,17 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.userQuietHoursSchedule) r.Put("/", api.putUserQuietHoursSchedule) }) + r.Route("/users/{user}/ai/budget", func(r chi.Router) { + // AI cost controls are a paid feature (AI Governance add-on). + r.Use( + api.RequireFeatureMW(codersdk.FeatureAIBridge), + apiKeyMiddleware, + httpmw.ExtractUserParam(options.Database), + ) + r.Get("/", api.userAIBudgetOverride) + r.Put("/", api.upsertUserAIBudgetOverride) + r.Delete("/", api.deleteUserAIBudgetOverride) + }) r.Route("/prebuilds", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8a465a3073..c4fae7a3d0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -9160,6 +9160,16 @@ export interface UpsertGroupAIBudgetRequest { readonly spend_limit_micros: number; } +// From codersdk/aibridge.go +export interface UpsertUserAIBudgetOverrideRequest { + /** + * GroupID is the group the user's spend is attributed to. The user must + * be a member of this group. + */ + readonly group_id: string; + readonly spend_limit_micros: number; +} + // From codersdk/workspaceagentportshare.go export interface UpsertWorkspaceAgentPortShareRequest { readonly agent_name: string; @@ -9204,6 +9214,15 @@ export interface User extends ReducedUser { readonly has_ai_seat: boolean; } +// From codersdk/aibridge.go +export interface UserAIBudgetOverride { + readonly user_id: string; + readonly group_id: string; + readonly spend_limit_micros: number; + readonly created_at: string; + readonly updated_at: string; +} + // From codersdk/chats.go /** * UserAIProviderKeyConfig is a provider summary from the current user's From 98d5e7948d88e5a4f0b906efe09e630c54b6d0e4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 29 May 2026 17:12:31 +0300 Subject: [PATCH 23/63] fix(coderd/autobuild): handle concurrent build number race in lifecycle executor (#25824) The lifecycle executor did not handle unique-violation errors from InsertWorkspaceBuild. When a concurrent actor (API handler, another lifecycle executor, or prebuilds reconciler) inserts a workspace build with the same build number, PostgreSQL returns a unique constraint violation on workspace_builds_workspace_id_build_number_key. The lifecycle executor treated this as a hard error, logging it and storing it in stats.Errors. The per-workspace advisory lock (pg_try_advisory_xact_lock) prevents two lifecycle executors from racing, but does not protect against races with the CreateWorkspaceBuild API handler or the prebuilds reconciler, which use different (or no) locking. Catch the specific unique-violation error after InTx returns (where the transaction is already rolled back) and clear it. The concurrent actor's build takes effect; the lifecycle executor treats the workspace as a no-op for this tick. Closes coder/internal#455 Closes PLAT-290 --- coderd/autobuild/lifecycle_executor.go | 17 ++++ coderd/autobuild/lifecycle_executor_test.go | 88 +++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 84fff375e0..5a141ce8cf 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -422,6 +422,23 @@ func (e *Executor) runOnce(t time.Time) Stats { Isolation: sql.LevelRepeatableRead, TxIdentifier: "lifecycle", }) + // A concurrent build (e.g. from the API or another lifecycle + // executor) may have already inserted a build with the same + // number. This is a benign race; the other actor's build + // will take effect. Clear the error so downstream checks + // (audit, notification, stats) treat this as a no-op. + if database.IsUniqueViolation(err, database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey) { + log.Info(e.ctx, "skipping workspace: concurrent build already inserted", slog.Error(err)) + err = nil + // Reset notification flags set before builder.Build. + // The build was rolled back, so this executor did not + // perform the transition. The concurrent actor handles + // both the build and any notifications. Without these + // resets, downstream code would send duplicate or + // incorrect notifications. + didAutoUpdate = false + shouldNotifyTaskPause = false + } if auditLog != nil { // If the transition didn't succeed then updating the workspace // to indicate dormant didn't either. diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 345647977d..89805429b9 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -4,10 +4,12 @@ import ( "context" "database/sql" "errors" + "sync/atomic" "testing" "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -160,6 +162,92 @@ func TestMultipleLifecycleExecutors(t *testing.T) { assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID]) } +// uniqueViolationStore wraps a database.Store and injects a unique violation +// error from InsertWorkspaceBuild after a configurable number of successful +// calls. This simulates a concurrent build race (e.g. an API-driven start +// racing with the lifecycle executor autostart). +type uniqueViolationStore struct { + database.Store + insertCount *atomic.Int32 // pointer: shared across InTx copies + failAfterN int32 +} + +func newUniqueViolationStore(db database.Store, failAfterN int32) *uniqueViolationStore { + return &uniqueViolationStore{ + Store: db, + insertCount: &atomic.Int32{}, + failAfterN: failAfterN, + } +} + +func (s *uniqueViolationStore) InTx(fn func(database.Store) error, opts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return fn(&uniqueViolationStore{ + Store: tx, + insertCount: s.insertCount, // shared pointer + failAfterN: s.failAfterN, + }) + }, opts) +} + +func (s *uniqueViolationStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { + n := s.insertCount.Add(1) + if n > s.failAfterN { + return &pq.Error{ + Code: pq.ErrorCode("23505"), + Constraint: string(database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey), + Message: `duplicate key value violates unique constraint "workspace_builds_workspace_id_build_number_key"`, + } + } + return s.Store.InsertWorkspaceBuild(ctx, arg) +} + +func TestExecutorBuildNumberRaceIsHandled(t *testing.T) { + t.Parallel() + + // The lifecycle executor must handle a unique-violation from + // InsertWorkspaceBuild gracefully. This error occurs when a concurrent + // actor (API handler, another executor, prebuilds reconciler) inserts a + // build with the same number before the executor's INSERT lands. + // + // We inject the error via a store wrapper. The first two + // InsertWorkspaceBuild calls succeed (setup builds), then the third + // (the lifecycle executor's autostart build) gets a unique violation. + + realDB, ps := dbtestutil.NewDB(t) + wrappedDB := newUniqueViolationStore(realDB, 2) // Allow builds 1 (start) and 2 (stop); fail build 3 (autostart) + + var ( + sched, _ = cron.Weekly("CRON_TZ=UTC 0 * * * *") + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AutobuildTicker: tickCh, + AutobuildStats: statsCh, + Database: wrappedDB, + Pubsub: ps, + }) + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + ) + + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + + p, err := coderdtest.GetProvisionerForTags(realDB, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + next := sched.Next(workspace.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, realDB, p.ID, next) + + tickCh <- next + stats := <-statsCh + + // The lifecycle executor should treat the unique violation as a benign + // race, not as a hard error. + assert.Empty(t, stats.Errors, "lifecycle executor should not report unique-violation as error") +} + func TestExecutorAutostartTemplateUpdated(t *testing.T) { t.Parallel() From 011914bb142a4d67fa7d7618c145ffd3ffceb701 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 16:07:32 +0000 Subject: [PATCH 24/63] chore: bump axios from 1.15.2 to 1.16.0 in /site (#25861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
Release notes

Sourced from axios's releases.

v1.16.0 — May 2, 2026

This release adds support for the QUERY HTTP method and a new ECONNREFUSED error constant, lands a substantial wave of HTTP, fetch, and XHR adapter bug fixes around redirects, aborts, headers, and timeouts, and welcomes 23 new contributors.

⚠️ Notable Changes

A handful of fixes in this release are either security-adjacent or change observable behaviour. Please review before upgrading:

  • Fetch adapter now enforces maxBodyLength and maxContentLength. These limits were silently ignored on the fetch adapter prior to 1.16.0 — anyone relying on them as a safety net (DoS protection, accidental large uploads) had no protection. (#10795)
  • Proxy requests now preserve user-supplied Host headers. Previously, the proxy path could overwrite a custom Host. Virtual-host-style routing through a proxy will now behave correctly. (#10822)
  • Basic auth credentials embedded in URLs are now URL-decoded. If you have percent-encoded credentials in a URL (e.g. https://user:p%40ss@host), the decoded value is what now goes on the wire. (#10825)
  • parseProtocol now strictly requires a colon in the protocol separator. Strings that loosely parsed as protocols before may no longer match. (#10729)
  • Deprecated unescape() replaced with modern UTF-8 encoding. Non-ASCII URL handling is now spec-correct; consumers depending on legacy unescape() quirks may see different output bytes. (#7378)
  • transformRequest input typing change was reverted. The typing change introduced in #10745 was reverted in #10810 after follow-up review — net behavior is unchanged from 1.15.2. (#10745, #10810)

🚀 New Features

  • QUERY HTTP Method: Added support for the QUERY HTTP method across adapters and type definitions. (#10802)
  • ECONNREFUSED Error Constant: Exposed ECONNREFUSED as a constant on AxiosError so callers can match connection-refused failures without comparing string literals (closes #6485). (#10680)
  • Encode Helper Export: Exported the internal encode helper from buildURL so userland param serializers can reuse the same encoding logic that axios uses internally. (#6897)

🐛 Bug Fixes

  • HTTP Adapter — Redirects & Headers: Cleared stale headers when a redirect targets a no-proxy host, fixed the redirect listener chain so listeners no longer stack across hops, restored the missing requestDetails argument on beforeRedirect, preserved user-supplied Host headers when forwarding through a proxy, and properly URL-decoded basic auth credentials. (#10794, #10800, #6241, #10822, #10825)
  • HTTP Adapter — Streams & Timeouts: Preserved the partial response object on AxiosError when a stream is aborted after headers arrive, honoured the timeout option during the connect phase when redirects are disabled, and resolved an unsettled-promise hang when an aborted request was combined with compression and maxRedirects: 0. (#10708, #10819, #7149)
  • Fetch Adapter: Enforced maxBodyLength / maxContentLength in the fetch adapter, set the User-Agent header to match the HTTP adapter, preserved the original abort reason instead of replacing it with a generic error, and deferred global access so importing the module no longer throws a TypeError in restricted environments. (#10795, #10772, #10806, #7260)
  • XHR Adapter: Unsubscribed the cancelToken and AbortSignal listeners on the error, timeout, and abort code paths to prevent leaked subscriptions. (#10787)
  • Error Handling: Attached the parsed response to AxiosError when JSON.parse fails inside dispatchRequest, prevented settle from emitting undefined error codes, and tightened the parseProtocol regex to require a colon in the protocol separator. (#10724, #7276, #10729)
  • Types & Exports: Aligned the CommonJS CancelToken typings with the ESM build, fixed a compiler error caused by RawAxiosHeaders, and re-exported create from the package index. (#7414, #6389, #6460)
  • UTF-8 Encoding: Replaced the deprecated unescape() call with a modern UTF-8 encoding implementation. (#7378)
  • Misc Cleanup: Resolved a batch of small inconsistencies and gadget-level issues across the codebase. (#10833)

🔧 Maintenance & Chores

  • Refactor — ES6 Modernisation: Modernised the utils module and XHR adapter to use ES6 features, and tidied the multipart boundary error message. (#10588, #7419)
  • Tests: Hardened the HTTP test server lifecycle to fix flaky FormData EPIPE failures, fixed Win32 platform support for the pipe tests, and corrected an incorrect test assumption. (#10820, #10791, #10796)
  • Docs: Documented paramsSerializer.encode for strict RFC 3986 query encoding, updated the parseReviver TypeScript definitions and configuration docs for ES2023, added timeout guidance to the README's first async example, and expanded notes around the recent type changes. (#10821, #10782, #10759, #10804)
  • Reverted: Reverted the transformRequest input typing change from #10745 after follow-up review. (#10745, #10810)
  • Dependencies: Bumped actions/setup-node, the github-actions group, and postcss (in /docs) to their latest versions. (#10785, #10813, #10814)
  • Release: Updated changelog and packages, and prepared the 1.16.0 release. (#10790, #10834)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve axios:

... (truncated)

Changelog

Sourced from axios's changelog.

v1.16.0 — May 2, 2026

This release adds support for the QUERY HTTP method and a new ECONNREFUSED error constant, lands a substantial wave of HTTP, fetch, and XHR adapter bug fixes around redirects, aborts, headers, and timeouts, and welcomes 23 new contributors.

⚠️ Notable Changes

A handful of fixes in this release are either security-adjacent or change observable behaviour. Please review before upgrading:

  • Fetch adapter now enforces maxBodyLength and maxContentLength. These limits were silently ignored on the fetch adapter prior to 1.16.0 — anyone relying on them as a safety net (DoS protection, accidental large uploads) had no protection. (#10795)
  • Proxy requests now preserve user-supplied Host headers. Previously, the proxy path could overwrite a custom Host. Virtual-host-style routing through a proxy will now behave correctly. (#10822)
  • Basic auth credentials embedded in URLs are now URL-decoded. If you have percent-encoded credentials in a URL (e.g. https://user:p%40ss@host), the decoded value is what now goes on the wire. (#10825)
  • parseProtocol now strictly requires a colon in the protocol separator. Strings that loosely parsed as protocols before may no longer match. (#10729)
  • Deprecated unescape() replaced with modern UTF-8 encoding. Non-ASCII URL handling is now spec-correct; consumers depending on legacy unescape() quirks may see different output bytes. (#7378)
  • transformRequest input typing change was reverted. The typing change introduced in #10745 was reverted in #10810 after follow-up review — net behavior is unchanged from 1.15.2. (#10745, #10810)

🚀 New Features

  • QUERY HTTP Method: Added support for the QUERY HTTP method across adapters and type definitions. (#10802)
  • ECONNREFUSED Error Constant: Exposed ECONNREFUSED as a constant on AxiosError so callers can match connection-refused failures without comparing string literals (closes #6485). (#10680)
  • Encode Helper Export: Exported the internal encode helper from buildURL so userland param serializers can reuse the same encoding logic that axios uses internally. (#6897)

🐛 Bug Fixes

  • HTTP Adapter — Redirects & Headers: Cleared stale headers when a redirect targets a no-proxy host, fixed the redirect listener chain so listeners no longer stack across hops, restored the missing requestDetails argument on beforeRedirect, preserved user-supplied Host headers when forwarding through a proxy, and properly URL-decoded basic auth credentials. (#10794, #10800, #6241, #10822, #10825)
  • HTTP Adapter — Streams & Timeouts: Preserved the partial response object on AxiosError when a stream is aborted after headers arrive, honoured the timeout option during the connect phase when redirects are disabled, and resolved an unsettled-promise hang when an aborted request was combined with compression and maxRedirects: 0. (#10708, #10819, #7149)
  • Fetch Adapter: Enforced maxBodyLength / maxContentLength in the fetch adapter, set the User-Agent header to match the HTTP adapter, preserved the original abort reason instead of replacing it with a generic error, and deferred global access so importing the module no longer throws a TypeError in restricted environments. (#10795, #10772, #10806, #7260)
  • XHR Adapter: Unsubscribed the cancelToken and AbortSignal listeners on the error, timeout, and abort code paths to prevent leaked subscriptions. (#10787)
  • Error Handling: Attached the parsed response to AxiosError when JSON.parse fails inside dispatchRequest, prevented settle from emitting undefined error codes, and tightened the parseProtocol regex to require a colon in the protocol separator. (#10724, #7276, #10729)
  • Types & Exports: Aligned the CommonJS CancelToken typings with the ESM build, fixed a compiler error caused by RawAxiosHeaders, and re-exported create from the package index. (#7414, #6389, #6460)
  • UTF-8 Encoding: Replaced the deprecated unescape() call with a modern UTF-8 encoding implementation. (#7378)
  • Misc Cleanup: Resolved a batch of small inconsistencies and gadget-level issues across the codebase. (#10833)

🔧 Maintenance & Chores

  • Refactor — ES6 Modernisation: Modernised the utils module and XHR adapter to use ES6 features, and tidied the multipart boundary error message. (#10588, #7419)
  • Tests: Hardened the HTTP test server lifecycle to fix flaky FormData EPIPE failures, fixed Win32 platform support for the pipe tests, and corrected an incorrect test assumption. (#10820, #10791, #10796)
  • Docs: Documented paramsSerializer.encode for strict RFC 3986 query encoding, updated the parseReviver TypeScript definitions and configuration docs for ES2023, added timeout guidance to the README's first async example, and expanded notes around the recent type changes. (#10821, #10782, #10759, #10804)
  • Reverted: Reverted the transformRequest input typing change from #10745 after follow-up review. (#10745, #10810)
  • Dependencies: Bumped actions/setup-node, the github-actions group, and postcss (in /docs) to their latest versions. (#10785, #10813, #10814)
  • Release: Updated changelog and packages, and prepared the 1.16.0 release. (#10790, #10834)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve axios:

... (truncated)

Commits
  • df53d7d chore(release): prepare release 1.16.0 (#10834)
  • 9d92bcd fix: gadgets and smaller issues (#10833)
  • 5107ee6 fix: prevent undefined error codes in settle (#7276)
  • e573499 fix(fetch): defer global access in fetch adapter (#7260)
  • ad68e1a fix(http): honor timeout during connect without redirects (#10819)
  • 2a51828 fix(http): decode URL basic auth credentials (#10825)
  • 0e8b6bb fix(http): preserve user-supplied Host header when forwarding through a proxy...
  • 79f39e1 docs: document paramsSerializer.encode for strict RFC 3986 query encoding (#1...
  • 0fe3a5f [Docs/Types] Update parseReviver TypeScript definitions for ES2023 and add ...
  • cd6737f chore: matches the sibling responseStream.on(aborted) handler and added tests...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=axios&package-manager=npm_and_yarn&previous-version=1.15.2&new-version=1.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/coder/coder/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package.json | 2 +- site/pnpm-lock.yaml | 55 +++++++++++++++++++++++++++++---------------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/site/package.json b/site/package.json index d2cf5e5512..0df6778347 100644 --- a/site/package.json +++ b/site/package.json @@ -69,7 +69,7 @@ "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "5.5.0", "ansi-to-html": "0.7.2", - "axios": "1.15.2", + "axios": "1.16.0", "chroma-js": "2.6.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index be78e8c75d..f99dc40d62 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -123,8 +123,8 @@ importers: specifier: 0.7.2 version: 0.7.2 axios: - specifier: 1.15.2 - version: 1.15.2 + specifier: 1.16.0 + version: 1.16.0 chroma-js: specifier: 2.6.0 version: 2.6.0 @@ -495,6 +495,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==, tarball: https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz} engines: {node: '>=6.9.0'} @@ -545,6 +549,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==, tarball: https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz} engines: {node: '>=6.9.0'} @@ -3030,8 +3038,8 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==, tarball: https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz} engines: {node: '>=4'} - axios@1.15.2: - resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==, tarball: https://registry.npmjs.org/axios/-/axios-1.15.2.tgz} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==, tarball: https://registry.npmjs.org/axios/-/axios-1.16.0.tgz} babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==, tarball: https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz} @@ -3746,8 +3754,8 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==, tarball: https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==, tarball: https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==, tarball: https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -4015,8 +4023,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, tarball: https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz} engines: {node: '>= 0.4'} - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==, tarball: https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz} engines: {node: '>= 0.4'} hast-util-from-parse5@8.0.3: @@ -5343,6 +5351,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==, tarball: https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -6401,6 +6410,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} '@babel/core@7.29.0': @@ -6478,6 +6493,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.26.10': @@ -8442,7 +8459,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.26.10 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -9079,7 +9096,7 @@ snapshots: axe-core@4.11.1: {} - axios@1.15.2: + axios@1.16.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.4 @@ -9799,7 +9816,7 @@ snapshots: es-module-lexer@2.1.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -9808,7 +9825,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 esbuild@0.25.12: optionalDependencies: @@ -9972,7 +9989,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 format@0.2.2: {} @@ -10042,12 +10059,12 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -10055,7 +10072,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 glob-parent@5.1.2: dependencies: @@ -10100,7 +10117,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.3: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -10275,7 +10292,7 @@ snapshots: internal-slot@1.0.6: dependencies: get-intrinsic: 1.3.0 - hasown: 2.0.3 + hasown: 2.0.4 side-channel: 1.1.0 internmap@1.0.1: {} @@ -10328,7 +10345,7 @@ snapshots: is-core-module@2.16.1: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 is-date-object@1.0.5: dependencies: From 22cbf85e964a7bb614203f3f14aa3998d0628e1c Mon Sep 17 00:00:00 2001 From: TJ Date: Fri, 29 May 2026 09:24:53 -0700 Subject: [PATCH 25/63] fix(site): adjust agents sidebar spacing (#25857) Fixes minor spacing issues in the agents sidebar. ## Changes - Reduce gap between New Agent / Search nav items from `gap-1` (0.25rem) to `gap-[0.15rem]` - Change Chats header top spacing from `mt-4` (margin-top 1rem) to `pt-6` (padding-top 1.5rem) - Remove `pt-5` padding-top from the scroll content groups container (Today, Yesterday, etc.) - Add `pr-1` (0.25rem padding-right) to the unread indicator circle > Generated by Coder Agents on behalf of @tracyjohnsonux --- .../components/ChatsSidebar/chats/ChatsPanel.tsx | 8 ++++---- .../components/ChatsSidebar/settings/SettingsNavItem.tsx | 2 +- .../components/ChatsSidebar/tree/ChatTreeNode.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx index dca8dfb6d3..bd7482767d 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/chats/ChatsPanel.tsx @@ -347,7 +347,7 @@ export const ChatsPanel: FC = ({ >
-
+
-

+

Chats

@@ -446,7 +446,7 @@ export const ChatsPanel: FC = ({ "sm:[mask-image:none] sm:[-webkit-mask-image:none]", )} > -
+
{loadError ? (
diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx index 3282b6596f..97a6fbc856 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/settings/SettingsNavItem.tsx @@ -34,7 +34,7 @@ const navItemClassName = ( className: string | undefined, ) => cn( - "flex w-full items-center gap-2.5 rounded-md border-0 px-2.5 py-2 text-left text-sm cursor-pointer transition-colors no-underline", + "flex w-full items-center gap-2.5 rounded-md border-0 px-2.5 py-1.5 text-left text-sm cursor-pointer transition-colors no-underline", active ? "bg-surface-quaternary/25 text-content-primary font-medium" : "bg-transparent text-content-secondary hover:bg-surface-tertiary/50 hover:text-content-primary", diff --git a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx index 155058f4c0..aa0a693b40 100644 --- a/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx +++ b/site/src/pages/AgentsPage/components/ChatsSidebar/tree/ChatTreeNode.tsx @@ -208,7 +208,7 @@ export const ChatTreeNode: FC = ({ chat, isChildNode }) => { ); return ( -
+
= ({ chat, isChildNode }) => { {chat.has_unread && !isActiveChat ? (
diff --git a/flake.nix b/flake.nix index e47b078777..5b92eb07ce 100644 --- a/flake.nix +++ b/flake.nix @@ -61,6 +61,30 @@ inherit nodejs; # Ensure it points to the above nodejs version }; + mise = pkgs.stdenvNoCC.mkDerivation rec { + pname = "mise"; + version = "2026.5.12"; + target = { + x86_64-linux = "linux-x64"; + aarch64-linux = "linux-arm64"; + x86_64-darwin = "macos-x64"; + aarch64-darwin = "macos-arm64"; + }.${system}; + src = pkgs.fetchurl { + url = "https://github.com/jdx/mise/releases/download/v${version}/mise-v${version}-${target}"; + hash = { + x86_64-linux = "sha256-ojiXKjFi1xC4WyjDJDculspOS0hsgf54aVAA2fvHfEg="; + aarch64-linux = "sha256-/S1SJ6itCx41nHBSeoNFqa2nIHf43LtVk3FlPD2VRk8="; + x86_64-darwin = "sha256-3lfo3IK72ICmnJvIruBrncxXgYSz5c+G/O+AY11qkLQ="; + aarch64-darwin = "sha256-53cHBUD/4iz4srn4iu2ItGHQiH2UDE8cGpc1lGPN5uE="; + }.${system}; + }; + dontUnpack = true; + installPhase = '' + install -Dm755 "$src" "$out/bin/mise" + ''; + }; + # Check in https://search.nixos.org/packages to find new packages. # Use `nix --extra-experimental-features nix-command --extra-experimental-features flakes flake update` # to update the lock file if packages are out-of-date. @@ -109,6 +133,21 @@ vendorHash = "sha256-4Cb15MhKyhRvYVKfMqBwuC3WBBIJE6AinJt02+TSMVY="; }; + paralleltestctx = unstablePkgs.buildGo126Module { + pname = "paralleltestctx"; + version = "0.0.2"; + + src = pkgs.fetchFromGitHub { + owner = "coder"; + repo = "paralleltestctx"; + rev = "v0.0.2"; + sha256 = "sha256-qFQ4LZR2IwqscypD0URSZKXTlhUcz/axDb8NTH5CxLw="; + }; + + subPackages = [ "cmd/paralleltestctx" ]; + vendorHash = "sha256-OuQWmZmofdJKq1hvk43RPkILQwAuFzqhmB22Xf6Z3lA="; + }; + # Keep Terraform aligned with provisioner/terraform/testdata/version.txt # so `make gen` remains deterministic in Nix shells. terraform_1_15_5 = @@ -188,6 +227,7 @@ lazydocker lazygit less + mise unstablePkgs.mockgen moreutils nfpm @@ -195,6 +235,7 @@ nodejs openssh openssl + paralleltestctx pango pixman pkg-config diff --git a/go.mod b/go.mod index e13cebfcf3..d4ea36cb27 100644 --- a/go.mod +++ b/go.mod @@ -571,7 +571,6 @@ require ( github.com/clipperhouse/displaywidth v0.10.0 // indirect github.com/clipperhouse/uax29/v2 v2.6.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect - github.com/coder/paralleltestctx v0.0.2 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect @@ -618,7 +617,6 @@ require ( github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect - github.com/mattn/go-shellwords v1.0.12 // indirect github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect github.com/moby/moby/api v1.54.0 // indirect github.com/moby/moby/client v0.3.0 // indirect @@ -632,7 +630,6 @@ require ( github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect - github.com/rhysd/actionlint v1.7.10 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/samber/lo v1.52.0 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -664,9 +661,7 @@ require ( ) tool ( - github.com/coder/paralleltestctx/cmd/paralleltestctx github.com/daixiang0/gci - github.com/rhysd/actionlint/cmd/actionlint github.com/swaggo/swag/cmd/swag go.uber.org/mock/mockgen golang.org/x/tools/cmd/goimports diff --git a/go.sum b/go.sum index e639f168f3..5840cb7bf5 100644 --- a/go.sum +++ b/go.sum @@ -334,8 +334,6 @@ github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136 h1:0RgB61LcNs github.com/coder/go-scim/pkg/v2 v2.0.0-20230221055123-1d63c1222136/go.mod h1:VkD1P761nykiq75dz+4iFqIQIZka189tx1BQLOp0Skc= github.com/coder/guts v1.7.0 h1:TaZ/PR9wgN8dlbcckaWV1MxkkuEFZRwSRwBBEm8dYXs= github.com/coder/guts v1.7.0/go.mod h1:30SShdvpmsauNlsNjECRB5AppScjYk08rf2ZVpH3MFg= -github.com/coder/paralleltestctx v0.0.2 h1:0akzA1oSV0LOl7loR8Mmoq/mu7qGDaFV8DpojotmXiE= -github.com/coder/paralleltestctx v0.0.2/go.mod h1:q/wi6cmlBOhrJKjUtouTn4J9xZlRhK0MbgHvJNdGW3w= github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7R7n+wsCqNve7Brdvj0F1rDnU= github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= @@ -881,8 +879,6 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= -github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= @@ -1063,8 +1059,6 @@ github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuM github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/rhysd/actionlint v1.7.10 h1:FL3XIEs72G4/++168vlv5FKOWMSWvWIQw1kBCadyOcM= -github.com/rhysd/actionlint v1.7.10/go.mod h1:ZHX/hrmknlsJN73InPTKsKdXpAv9wVdrJy8h8HAwFHg= github.com/riandyrn/otelchi v0.5.1 h1:0/45omeqpP7f/cvdL16GddQBfAEmZvUyl2QzLSE6uYo= github.com/riandyrn/otelchi v0.5.1/go.mod h1:ZxVxNEl+jQ9uHseRYIxKWRb3OY8YXFEu+EkNiiSNUEA= github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 h1:4+LEVOB87y175cLJC/mbsgKmoDOjrBldtXvioEy96WY= diff --git a/mise.lock b/mise.lock index babc55e498..9acd58a7c9 100644 --- a/mise.lock +++ b/mise.lock @@ -1,5 +1,53 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html +[[tools.actionlint]] +version = "1.7.10" +backend = "aqua:rhysd/actionlint" + +[tools.actionlint."platforms.linux-arm64"] +checksum = "sha256:cd3dfe5f66887ec6b987752d8d9614e59fd22f39415c5ad9f28374623f41773a" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_arm64.tar.gz" + +[tools.actionlint."platforms.linux-arm64-musl"] +checksum = "sha256:cd3dfe5f66887ec6b987752d8d9614e59fd22f39415c5ad9f28374623f41773a" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_arm64.tar.gz" + +[tools.actionlint."platforms.linux-x64"] +checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz" + +[tools.actionlint."platforms.linux-x64-baseline"] +checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz" + +[tools.actionlint."platforms.linux-x64-musl"] +checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz" + +[tools.actionlint."platforms.linux-x64-musl-baseline"] +checksum = "sha256:f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_linux_amd64.tar.gz" + +[tools.actionlint."platforms.macos-arm64"] +checksum = "sha256:004ca87b367b37f4d75c55ab6cf80f9b8c043adbfbd440f31c604d417939c442" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_darwin_arm64.tar.gz" + +[tools.actionlint."platforms.macos-x64"] +checksum = "sha256:16782c41f2af264db80f855ee5d09164ca98fc78edf3bcd0f46eecff279682ba" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_darwin_amd64.tar.gz" + +[tools.actionlint."platforms.macos-x64-baseline"] +checksum = "sha256:16782c41f2af264db80f855ee5d09164ca98fc78edf3bcd0f46eecff279682ba" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_darwin_amd64.tar.gz" + +[tools.actionlint."platforms.windows-x64"] +checksum = "sha256:283467f9d6202a8cb8c00ad8dd0ee4e685b71fb86a6a56c68fcbb9ae8ed91237" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_windows_amd64.zip" + +[tools.actionlint."platforms.windows-x64-baseline"] +checksum = "sha256:283467f9d6202a8cb8c00ad8dd0ee4e685b71fb86a6a56c68fcbb9ae8ed91237" +url = "https://github.com/rhysd/actionlint/releases/download/v1.7.10/actionlint_1.7.10_windows_amd64.zip" + [[tools."aqua:ahmetb/kubectx/kubens"]] version = "0.9.4" backend = "aqua:ahmetb/kubectx/kubens" @@ -432,14 +480,26 @@ url = "https://dl.google.com/go/go1.26.2.windows-amd64.zip" checksum = "sha256:98eb3570bade15cb826b0909338df6cc6d2cf590bc39c471142002db3832b708" url = "https://dl.google.com/go/go1.26.2.windows-amd64.zip" +[[tools."go:github.com/coder/paralleltestctx/cmd/paralleltestctx"]] +version = "0.0.2" +backend = "go:github.com/coder/paralleltestctx/cmd/paralleltestctx" + [[tools."go:github.com/coder/sqlc/cmd/sqlc"]] version = "337309bfb9524f38466a5090e310040fc7af0203" backend = "go:github.com/coder/sqlc/cmd/sqlc" +[[tools."go:github.com/coder/whichtests"]] +version = "ec33bab1ec04cd86beb7a61a069db4463dba63f5" +backend = "go:github.com/coder/whichtests" + [[tools."go:github.com/golang-migrate/migrate/v4/cmd/migrate"]] version = "v4.19.0" backend = "go:github.com/golang-migrate/migrate/v4/cmd/migrate" +[[tools."go:github.com/golangci/golangci-lint/cmd/golangci-lint"]] +version = "1.64.8" +backend = "go:github.com/golangci/golangci-lint/cmd/golangci-lint" + [[tools."go:github.com/goreleaser/nfpm/v2/cmd/nfpm"]] version = "v2.35.1" backend = "go:github.com/goreleaser/nfpm/v2/cmd/nfpm" @@ -452,10 +512,18 @@ backend = "go:github.com/mikefarah/yq/v4" version = "v0.3.13" backend = "go:github.com/quasilyte/go-ruleguard/cmd/ruleguard" +[[tools."go:github.com/slsyy/mtimehash/cmd/mtimehash"]] +version = "1.0.0" +backend = "go:github.com/slsyy/mtimehash/cmd/mtimehash" + [[tools."go:github.com/swaggo/swag/cmd/swag"]] version = "v1.16.2" backend = "go:github.com/swaggo/swag/cmd/swag" +[[tools."go:github.com/tc-hib/go-winres"]] +version = "0.3.3" +backend = "go:github.com/tc-hib/go-winres" + [[tools."go:go.uber.org/mock/mockgen"]] version = "v0.6.0" backend = "go:go.uber.org/mock/mockgen" @@ -480,54 +548,6 @@ backend = "go:mvdan.cc/sh/v3/cmd/shfmt" version = "v0.0.34" backend = "go:storj.io/drpc/cmd/protoc-gen-go-drpc" -[[tools.golangci-lint]] -version = "1.64.8" -backend = "aqua:golangci/golangci-lint" - -[tools.golangci-lint."platforms.linux-arm64"] -checksum = "sha256:a6ab58ebcb1c48572622146cdaec2956f56871038a54ed1149f1386e287789a5" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-arm64.tar.gz" - -[tools.golangci-lint."platforms.linux-arm64-musl"] -checksum = "sha256:a6ab58ebcb1c48572622146cdaec2956f56871038a54ed1149f1386e287789a5" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-arm64.tar.gz" - -[tools.golangci-lint."platforms.linux-x64"] -checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz" - -[tools.golangci-lint."platforms.linux-x64-baseline"] -checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz" - -[tools.golangci-lint."platforms.linux-x64-musl"] -checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz" - -[tools.golangci-lint."platforms.linux-x64-musl-baseline"] -checksum = "sha256:b6270687afb143d019f387c791cd2a6f1cb383be9b3124d241ca11bd3ce2e54e" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-linux-amd64.tar.gz" - -[tools.golangci-lint."platforms.macos-arm64"] -checksum = "sha256:70543d21e5b02a94079be8aa11267a5b060865583e337fe768d39b5d3e2faf1f" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-darwin-arm64.tar.gz" - -[tools.golangci-lint."platforms.macos-x64"] -checksum = "sha256:b52aebb8cb51e00bfd5976099083fbe2c43ef556cef9c87e58a8ae656e740444" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-darwin-amd64.tar.gz" - -[tools.golangci-lint."platforms.macos-x64-baseline"] -checksum = "sha256:b52aebb8cb51e00bfd5976099083fbe2c43ef556cef9c87e58a8ae656e740444" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-darwin-amd64.tar.gz" - -[tools.golangci-lint."platforms.windows-x64"] -checksum = "sha256:54c2ed3a6b4f2f5da1056fb6e83d6b73b592e06684b65a5999174fabbb251a8f" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-windows-amd64.zip" - -[tools.golangci-lint."platforms.windows-x64-baseline"] -checksum = "sha256:54c2ed3a6b4f2f5da1056fb6e83d6b73b592e06684b65a5999174fabbb251a8f" -url = "https://github.com/golangci/golangci-lint/releases/download/v1.64.8/golangci-lint-1.64.8-windows-amd64.zip" - [[tools.helm]] version = "3.21.0" backend = "aqua:helm/helm" @@ -723,6 +743,10 @@ url = "https://nodejs.org/dist/v22.19.0/node-v22.19.0-win-x64.zip" version = "0.87.0" backend = "npm:@devcontainers/cli" +[[tools."npm:@puppeteer/browsers"]] +version = "2.13.0" +backend = "npm:@puppeteer/browsers" + [[tools.pnpm]] version = "10.33.2" backend = "aqua:pnpm/pnpm" @@ -848,52 +872,52 @@ url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/ url = "https://github.com/protocolbuffers/protobuf-go/releases/download/v1.30.0/protoc-gen-go.v1.30.0.windows.amd64.zip" [[tools.syft]] -version = "1.20.0" +version = "1.26.1" backend = "aqua:anchore/syft" [tools.syft."platforms.linux-arm64"] -checksum = "sha256:53f76737ddbf425c89240d5b0be0990b1a71e66890b44f19743221b17e6ee635" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_arm64.tar.gz" +checksum = "sha256:ed3915cbc9c039f0501cb49d4485125befbd729acc263e767f70a18de3fec10d" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_arm64.tar.gz" [tools.syft."platforms.linux-arm64-musl"] -checksum = "sha256:53f76737ddbf425c89240d5b0be0990b1a71e66890b44f19743221b17e6ee635" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_arm64.tar.gz" +checksum = "sha256:ed3915cbc9c039f0501cb49d4485125befbd729acc263e767f70a18de3fec10d" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_arm64.tar.gz" [tools.syft."platforms.linux-x64"] -checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz" +checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz" [tools.syft."platforms.linux-x64-baseline"] -checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz" +checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz" [tools.syft."platforms.linux-x64-musl"] -checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz" +checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz" [tools.syft."platforms.linux-x64-musl-baseline"] -checksum = "sha256:689e12c5cbf67521ce61b9c126068f9eaabe1223e77971b2fede50033ff6b5cc" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_linux_amd64.tar.gz" +checksum = "sha256:4f3e84f9467080c876deb0fa968da54309c6d21fb8c00fd3a4e547eb9f006835" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_linux_amd64.tar.gz" [tools.syft."platforms.macos-arm64"] -checksum = "sha256:91365712a06af0c0dcd06f5e87fc8791c4332831b3dd6f5474acaaf803d71d82" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_darwin_arm64.tar.gz" +checksum = "sha256:00435a3fe2ae940203708ee2eae9976d1719982c628d30b2b78aacd36133ec6b" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_darwin_arm64.tar.gz" [tools.syft."platforms.macos-x64"] -checksum = "sha256:5fdf7afd0f1bfdbb2a1a575eacef8e10edfcb4783631baaa7572a9f4a4d86441" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_darwin_amd64.tar.gz" +checksum = "sha256:2eae0b76a208c5916cf02847b94e861024c7a5a6c1e2e606f5436f97747b1f76" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_darwin_amd64.tar.gz" [tools.syft."platforms.macos-x64-baseline"] -checksum = "sha256:5fdf7afd0f1bfdbb2a1a575eacef8e10edfcb4783631baaa7572a9f4a4d86441" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_darwin_amd64.tar.gz" +checksum = "sha256:2eae0b76a208c5916cf02847b94e861024c7a5a6c1e2e606f5436f97747b1f76" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_darwin_amd64.tar.gz" [tools.syft."platforms.windows-x64"] -checksum = "sha256:b8bfdedb261de2a69768097422a73bc72273ee92136ff676a20c3161e658881f" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_windows_amd64.zip" +checksum = "sha256:7af7acb9f81bdddbc343855cb3a42e1d38ae9a1b044bfcd9b975a118d107849e" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_windows_amd64.zip" [tools.syft."platforms.windows-x64-baseline"] -checksum = "sha256:b8bfdedb261de2a69768097422a73bc72273ee92136ff676a20c3161e658881f" -url = "https://github.com/anchore/syft/releases/download/v1.20.0/syft_1.20.0_windows_amd64.zip" +checksum = "sha256:7af7acb9f81bdddbc343855cb3a42e1d38ae9a1b044bfcd9b975a118d107849e" +url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_windows_amd64.zip" [[tools.terraform]] version = "1.15.5" @@ -942,3 +966,56 @@ url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_ [tools.terraform."platforms.windows-x64-baseline"] checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38" url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip" + +[[tools.zizmor]] +version = "1.11.0" +backend = "aqua:zizmorcore/zizmor" + +[tools.zizmor."platforms.linux-arm64"] +checksum = "sha256:ce6d71e796b7d3663449151b08cee7c659f89bf36095c432e25169c857f479f0" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-aarch64-unknown-linux-gnu.tar.gz" +provenance = "github-attestations" + +[tools.zizmor."platforms.linux-arm64-musl"] +provenance = "github-attestations" + +[tools.zizmor."platforms.linux-x64"] +checksum = "sha256:da35e666827cbb1e6ca98b18b7969657b9f186467bfebfa25e730aac527c36f8" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-unknown-linux-gnu.tar.gz" +provenance = "github-attestations" + +[tools.zizmor."platforms.linux-x64-baseline"] +checksum = "sha256:da35e666827cbb1e6ca98b18b7969657b9f186467bfebfa25e730aac527c36f8" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-unknown-linux-gnu.tar.gz" +provenance = "github-attestations" + +[tools.zizmor."platforms.linux-x64-musl"] +provenance = "github-attestations" + +[tools.zizmor."platforms.linux-x64-musl-baseline"] +provenance = "github-attestations" + +[tools.zizmor."platforms.macos-arm64"] +checksum = "sha256:7cf59f08cb50f539ab9ddc6be1d463c81e31f5b189d148fc6f786adf9fc42a5f" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-aarch64-apple-darwin.tar.gz" +provenance = "github-attestations" + +[tools.zizmor."platforms.macos-x64"] +checksum = "sha256:a1f60dd09527ce546ff86e49ebfa1ab4a6c5d16365662e6932f8d0f46fbb18b2" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-apple-darwin.tar.gz" +provenance = "github-attestations" + +[tools.zizmor."platforms.macos-x64-baseline"] +checksum = "sha256:a1f60dd09527ce546ff86e49ebfa1ab4a6c5d16365662e6932f8d0f46fbb18b2" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-apple-darwin.tar.gz" +provenance = "github-attestations" + +[tools.zizmor."platforms.windows-x64"] +checksum = "sha256:35e038bdbde6fcfdf947c947c7c3fc83c5043e0ded0e5b0d59c30c8eda97fd3a" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-pc-windows-msvc.zip" +provenance = "github-attestations" + +[tools.zizmor."platforms.windows-x64-baseline"] +checksum = "sha256:35e038bdbde6fcfdf947c947c7c3fc83c5043e0ded0e5b0d59c30c8eda97fd3a" +url = "https://github.com/zizmorcore/zizmor/releases/download/v1.11.0/zizmor-x86_64-pc-windows-msvc.zip" +provenance = "github-attestations" diff --git a/mise.toml b/mise.toml index b148fe41c6..4ce58d3c86 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,6 @@ -# Keep in lockstep with MISE_VERSION in dogfood/coder/ubuntu-*/Dockerfile.base, -# .github/workflows/dogfood.yaml, and scripts/dogfood/mise-oci-wrapper.sh. +# Keep in lockstep with .github/actions/setup-mise/action.yml, +# .github/actions/setup-mise/checksums.toml, flake.nix, +# dogfood/coder/ubuntu-*/Dockerfile.base, and scripts/dogfood/mise-oci-wrapper.sh. min_version = "2026.5.12" [settings] @@ -19,8 +20,17 @@ protoc = "23.4" protoc-gen-go = "1.30.0" # Go development tools. +"go:github.com/coder/paralleltestctx/cmd/paralleltestctx" = "v0.0.2" +"go:github.com/coder/whichtests" = "ec33bab1ec04cd86beb7a61a069db4463dba63f5" +# Keep golangci-lint on the Go backend while pinned to v1. The upstream +# precompiled v1 binary is built with an older Go toolchain and cannot lint +# this module's Go version. Upgrading to v2 should let us use the native +# golangci-lint mise/aqua backend and GitHub release binaries. +"go:github.com/golangci/golangci-lint/cmd/golangci-lint" = "v1.64.8" "go:github.com/golang-migrate/migrate/v4/cmd/migrate" = "v4.19.0" "go:github.com/goreleaser/nfpm/v2/cmd/nfpm" = "v2.35.1" +"go:github.com/slsyy/mtimehash/cmd/mtimehash" = "v1.0.0" +"go:github.com/tc-hib/go-winres" = "v0.3.3" "go:github.com/mikefarah/yq/v4" = "v4.44.3" "go:github.com/quasilyte/go-ruleguard/cmd/ruleguard" = "v0.3.13" "go:github.com/swaggo/swag/cmd/swag" = "v1.16.2" @@ -30,17 +40,18 @@ protoc-gen-go = "1.30.0" "go:mvdan.cc/sh/v3/cmd/shfmt" = "v3.12.0" # Infrastructure, release, and lint CLIs. +actionlint = "1.7.10" "aqua:ahmetb/kubectx/kubens" = "0.9.4" cosign = "2.4.3" # crane is the registry client `mise oci push` shells out to. Sourced # here so it travels with the rest of the mise toolset (one source of # truth, deterministic version, no apt drift across CI / wrapper). crane = "0.21.6" -golangci-lint = "1.64.8" helm = "3.21.0" kubectx = "0.9.4" -syft = "1.20.0" +syft = "1.26.1" terraform = "1.15.5" +zizmor = "1.11.0" # Developer-environment niceties for the dogfood image. Non-dogfood # users who run `mise install` here will pull these too; they are @@ -60,6 +71,9 @@ lazygit = "0.61.1" # Pre-installs the binary so the upstream devcontainers-cli coder # module's `command -v devcontainer` short-circuit fires "npm:@devcontainers/cli" = "0.87.0" +# weekly-docs uses this pinned Puppeteer browser installer to install Chrome for +# action-linkspector without resolving mutable npm metadata at runtime. +"npm:@puppeteer/browsers" = "2.13.0" # sqlc (coder fork) bundles sqlite via cgo, so the `go install` build # needs CGO_ENABLED=1. Scope it with `install_env` so it only applies diff --git a/scripts/check_go_versions.sh b/scripts/check_go_versions.sh index fb811838a6..5cbd9c5fb9 100755 --- a/scripts/check_go_versions.sh +++ b/scripts/check_go_versions.sh @@ -5,7 +5,6 @@ # - go.mod # - mise.toml (the dogfood image installs from this manifest) # - flake.nix -# - .github/actions/setup-go/action.yml # The version of Go in go.mod is considered the source of truth. set -euo pipefail @@ -19,23 +18,17 @@ IGNORE_NIX=${IGNORE_NIX:-false} GO_VERSION_GO_MOD=$(grep -Eo 'go [0-9]+\.[0-9]+\.[0-9]+' ./go.mod | cut -d' ' -f2) GO_VERSION_MISE_TOML=$(grep -Eo '^go = "[0-9]+\.[0-9]+\.[0-9]+"' ./mise.toml | sed -E 's/.*"([^"]+)"/\1/') -GO_VERSION_SETUP_GO=$(yq '.inputs.version.default' .github/actions/setup-go/action.yaml) GO_VERSION_FLAKE_NIX=$(grep -Eo '\bgo_[0-9]+_[0-9]+\b' ./flake.nix) # Convert to major.minor format. GO_VERSION_FLAKE_NIX_MAJOR_MINOR=$(echo "$GO_VERSION_FLAKE_NIX" | cut -d '_' -f 2-3 | tr '_' '.') log "INFO : go.mod : $GO_VERSION_GO_MOD" log "INFO : mise.toml : $GO_VERSION_MISE_TOML" -log "INFO : setup-go/action.yaml : $GO_VERSION_SETUP_GO" log "INFO : flake.nix : $GO_VERSION_FLAKE_NIX_MAJOR_MINOR" if [ "$GO_VERSION_GO_MOD" != "$GO_VERSION_MISE_TOML" ]; then error "Go version mismatch between go.mod and mise.toml" fi -if [ "$GO_VERSION_GO_MOD" != "$GO_VERSION_SETUP_GO" ]; then - error "Go version mismatch between go.mod and .github/actions/setup-go/action.yaml" -fi - # At the time of writing, Nix only constrains the major.minor version. # We need to check that specifically. if [ "$IGNORE_NIX" = "false" ]; then diff --git a/scripts/check_mise_versions.sh b/scripts/check_mise_versions.sh new file mode 100755 index 0000000000..20ad1bc929 --- /dev/null +++ b/scripts/check_mise_versions.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash + +# This script checks the mise values used by CI and dogfood images: +# - mise.toml min_version is the source of truth for the mise version. +# - .github/actions/setup-mise/checksums.toml stores pinned binary checksums. +# - .github/actions/setup-mise/action.yml +# - flake.nix +# - scripts/dogfood/mise-oci-wrapper.sh +# - dogfood/coder/ubuntu-*/Dockerfile.base + +set -euo pipefail +# shellcheck source=scripts/lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +cdroot + +check_not_empty() { + local label="$1" + local value="$2" + + log "INFO : ${label}: ${value}" + if [[ -z "${value}" ]]; then + error "Missing mise value for ${label}" + fi +} + +check_equal() { + local label="$1" + local actual="$2" + local expected="$3" + + check_not_empty "${label}" "${actual}" + if [[ "${actual}" != "${expected}" ]]; then + error "Mise mismatch for ${label}: expected ${expected}, got ${actual}" + fi +} + +check_sha256_format() { + local label="$1" + local value="$2" + + if [[ -z "${value}" ]]; then + error "Missing mise value for ${label}" + fi + if [[ ! "${value}" =~ ^[a-f0-9]{64}$ ]]; then + error "Expected 64-character lowercase SHA256 for ${label}: ${value}" + fi +} + +mise_version="$(sed -n 's/^min_version = "\([^"]*\)"/\1/p' mise.toml)" +check_not_empty "mise.toml min_version" "${mise_version}" + +action_version="$( + awk ' + $1 == "mise-version:" { in_input = 1; next } + in_input && /^ [A-Za-z0-9_-]+:/ { exit } + in_input && $1 == "default:" { + gsub(/"/, "", $2) + print $2 + exit + } + ' .github/actions/setup-mise/action.yml +)" +check_equal ".github/actions/setup-mise/action.yml" "${action_version}" "${mise_version}" + +checksum_version="$( + awk -v version="${mise_version}" ' + $0 == "[\"" version "\"]" { + print version + exit + } + ' .github/actions/setup-mise/checksums.toml +)" +check_equal ".github/actions/setup-mise/checksums.toml" "${checksum_version}" "${mise_version}" + +declare -A setup_mise_checksums=() +for target in linux-x64 linux-arm64 macos-x64 macos-arm64 windows-x64; do + checksum="$(./scripts/mise_checksum.sh .github/actions/setup-mise/checksums.toml "${mise_version}" "${target}")" + check_not_empty ".github/actions/setup-mise/checksums.toml ${target}" "${checksum}" + check_sha256_format ".github/actions/setup-mise/checksums.toml ${target}" "${checksum}" + setup_mise_checksums["${target}"]="${checksum}" +done +linux_x64_checksum="${setup_mise_checksums["linux-x64"]}" + +sri_sha256_to_hex() { + local label="$1" + local sri="$2" + + if [[ "${sri}" != sha256-* ]]; then + error "Expected SRI SHA256 hash for ${label}: ${sri}" + fi + + printf '%s' "${sri#sha256-}" | openssl base64 -A -d | od -An -tx1 -v | tr -d ' \n' +} + +flake_version="$( + awk ' + /^[[:space:]]*mise = / { in_mise = 1; next } + in_mise && /^[[:space:]]*version = / { + gsub(/[";]/, "", $3) + print $3 + exit + } + in_mise && /^[[:space:]]*};/ { exit } + ' flake.nix +)" +check_equal "flake.nix" "${flake_version}" "${mise_version}" + +declare -A flake_targets=( + ["x86_64-linux"]="linux-x64" + ["aarch64-linux"]="linux-arm64" + ["x86_64-darwin"]="macos-x64" + ["aarch64-darwin"]="macos-arm64" +) +for system in "${!flake_targets[@]}"; do + target="${flake_targets[${system}]}" + expected_checksum="${setup_mise_checksums[${target}]}" + + flake_hash="$( + awk -v nix_system="${system}" ' + /^[[:space:]]*hash = \{/ { in_hash = 1; next } + in_hash && $1 == nix_system { + gsub(/[";]/, "", $3) + print $3 + exit + } + in_hash && /^[[:space:]]*};/ { exit } + ' flake.nix + )" + check_not_empty "flake.nix ${system} hash" "${flake_hash}" + + actual_checksum="$(sri_sha256_to_hex "flake.nix ${system}" "${flake_hash}")" + check_equal "flake.nix ${system} sha256" "${actual_checksum}" "${expected_checksum}" +done + +wrapper_version="$(sed -n 's/^MISE_VERSION="v\([^"]*\)"/\1/p' scripts/dogfood/mise-oci-wrapper.sh)" +check_equal "scripts/dogfood/mise-oci-wrapper.sh" "${wrapper_version}" "${mise_version}" +wrapper_checksum="$(sed -n 's/^MISE_SHA256="\([a-f0-9]*\)"/\1/p' scripts/dogfood/mise-oci-wrapper.sh)" +check_equal "scripts/dogfood/mise-oci-wrapper.sh sha256" "${wrapper_checksum}" "${linux_x64_checksum}" +check_sha256_format "scripts/dogfood/mise-oci-wrapper.sh sha256" "${wrapper_checksum}" + +for dockerfile in dogfood/coder/ubuntu-*/Dockerfile.base; do + dockerfile_version="$(sed -n 's/.*MISE_VERSION=v\([0-9.]*\).*/\1/p' "${dockerfile}" | head -n 1)" + check_equal "${dockerfile}" "${dockerfile_version}" "${mise_version}" + + dockerfile_checksum="$(sed -n 's/.*MISE_SHA256=\([a-f0-9]*\).*/\1/p' "${dockerfile}" | head -n 1)" + check_equal "${dockerfile} sha256" "${dockerfile_checksum}" "${linux_x64_checksum}" + check_sha256_format "${dockerfile} sha256" "${dockerfile_checksum}" +done + +log "Mise version check passed, all versions are ${mise_version}" diff --git a/scripts/mise_checksum.sh b/scripts/mise_checksum.sh new file mode 100755 index 0000000000..52fcc73aa1 --- /dev/null +++ b/scripts/mise_checksum.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# Print the pinned mise SHA256 checksum for a version and release target. + +set -euo pipefail + +if [[ "$#" -ne 3 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +checksums_file="$1" +mise_version="$2" +target="$3" + +awk -F= -v version="${mise_version}" -v target="${target}" ' + $0 == "[\"" version "\"]" { in_table = 1; next } + /^\[/ { in_table = 0 } + in_table { + key = $1 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", key) + if (key == target) { + value = $2 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + gsub(/^"|"$/, "", value) + print value + exit + } + } +' "${checksums_file}" diff --git a/scripts/should_deploy.sh b/scripts/should_deploy.sh index 003828b411..a23d3293d6 100755 --- a/scripts/should_deploy.sh +++ b/scripts/should_deploy.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# This script determines if a commit in either the main branch or a -# `release/x.y` branch should be deployed to dogfood. +# This script determines if the current branch should be deployed to dogfood. # # To avoid masking unrelated failures, this script will return 0 in either case, # and will print `DEPLOY` or `NOOP` to stdout. @@ -11,73 +10,16 @@ set -euo pipefail source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" cdroot -deploy_branch=main - -# Determine the current branch name and check that it is one of the supported -# branch names. branch_name=$(git branch --show-current) -# Short circuit: we no longer deploy release branches to dogfood, and instead -# test them on the stable deployment. +# We no longer deploy release branches to dogfood, and instead test them on the +# stable deployment. # TODO: once we're happy with the new deployment process, we can remove this -# script and the related github workflow stuff. +# script and the related GitHub workflow. if [[ "$branch_name" == "main" ]]; then log "VERDICT: DEPLOY" echo "DEPLOY" # stdout - exit 0 else log "VERDICT: NOOP" echo "NOOP" # stdout - exit 0 -fi - -if [[ "$branch_name" != "main" && ! "$branch_name" =~ ^release/[0-9]+\.[0-9]+$ ]]; then - error "Current branch '$branch_name' is not a supported branch name for dogfood, must be 'main' or 'release/x.y'" -fi -log "Current branch '$branch_name'" - -# Determine the remote name -remote=$(git remote -v | grep coder/coder | awk '{print $1}' | head -n1) -if [[ -z "${remote}" ]]; then - error "Could not find remote for coder/coder" -fi -log "Using remote '$remote'" - -# Step 1: List all release branches and sort them by major/minor so we can find -# the latest release branch. -release_branches=$( - git branch -r --format='%(refname:short)' | - grep -E "${remote}/release/[0-9]+\.[0-9]+$" | - sed "s|${remote}/||" | - sort -V -) - -# As a sanity check, release/2.26 should exist. -if ! echo "$release_branches" | grep "release/2.26" >/dev/null; then - error "Could not find existing release branches. Did you run 'git fetch -ap ${remote}'?" -fi - -latest_release_branch=$(echo "$release_branches" | tail -n 1) -latest_release_branch_version=${latest_release_branch#release/} -log "Latest release branch: $latest_release_branch" -log "Latest release branch version: $latest_release_branch_version" - -# Step 2: check if a matching tag `v.0` exists. If it does not, we will -# use the release branch as the deploy branch. -if ! git rev-parse "refs/tags/v${latest_release_branch_version}.0" >/dev/null 2>&1; then - log "Tag 'v${latest_release_branch_version}.0' does not exist, using release branch as deploy branch" - deploy_branch=$latest_release_branch -else - log "Matching tag 'v${latest_release_branch_version}.0' exists, using main as deploy branch" -fi -log "Deploy branch: $deploy_branch" - -# Finally, check if the current branch is the deploy branch. -log -if [[ "$branch_name" != "$deploy_branch" ]]; then - log "VERDICT: DO NOT DEPLOY" - echo "NOOP" # stdout -else - log "VERDICT: DEPLOY" - echo "DEPLOY" # stdout fi diff --git a/scripts/zizmor.sh b/scripts/zizmor.sh deleted file mode 100755 index a9326e2ee0..0000000000 --- a/scripts/zizmor.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash - -# Usage: ./zizmor.sh [args...] -# -# This script is a wrapper around the zizmor Docker image. Zizmor lints GitHub -# actions workflows. -# -# We use Docker to run zizmor since it's written in Rust and is difficult to -# install on Ubuntu runners without building it with a Rust toolchain, which -# takes a long time. -# -# The repo is mounted at /repo and the working directory is set to /repo. - -set -euo pipefail -# shellcheck source=scripts/lib.sh -source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -cdroot - -image_tag="ghcr.io/zizmorcore/zizmor:1.11.0" -docker_args=( - "--rm" - "--volume" "$(pwd):/repo" - "--workdir" "/repo" - "--network" "host" -) - -if [[ -t 0 ]]; then - docker_args+=("-it") -fi - -# If no GH_TOKEN is set, try to get one from `gh auth token`. -if [[ "${GH_TOKEN:-}" == "" ]] && command -v gh &>/dev/null; then - set +e - GH_TOKEN="$(gh auth token)" - export GH_TOKEN - set -e -fi - -# Pass through the GitHub token if it's set, which allows zizmor to scan -# imported workflows too. -if [[ "${GH_TOKEN:-}" != "" ]]; then - docker_args+=("--env" "GH_TOKEN") -fi - -logrun exec docker run "${docker_args[@]}" "$image_tag" "$@" From aa9ef66d8124df73a440f00b592ef586f8db2702 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:16:17 +1000 Subject: [PATCH 48/63] fix(site/src/pages/AgentsPage): drop misleading response-startup warning (#25905) The agents UI showed "Response startup is taking longer than expected" after a 15s grace period while waiting on the LLM provider. The message implied a problem was about to occur, but it does not actually lead to a timeout. The typical underlying cause is provider slowness rather than a client-side issue, so the warning is alarmist and unhelpful. Drop the delayed message and its timer entirely. The `starting` phase now keeps showing the shimmering "Thinking..." indicator until the first stream chunk arrives. Also remove the now-dead `startingResetKey` / `chatID` plumbing that only existed to remount the placeholder and reset the delayed-message timer when switching chats. Closes CODAGT-536 --- .../pages/AgentsPage/AgentChatPageView.tsx | 1 - .../ChatConversation/ChatStatusCallout.tsx | 28 +++------------- .../ChatConversation/LiveStreamTail.tsx | 6 ---- .../ChatConversation/StreamingOutput.tsx | 7 +--- .../components/ChatPageContent.stories.tsx | 32 +++---------------- .../AgentsPage/components/ChatPageContent.tsx | 3 -- 6 files changed, 9 insertions(+), 68 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index b71f4bf0c4..596c499162 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -531,7 +531,6 @@ export const AgentChatPageView: FC = ({ >
{ - const [isDelayed, setIsDelayed] = useState(false); - - useEffect(() => { - const timeout = window.setTimeout(() => { - setIsDelayed(true); - }, RESPONSE_STARTUP_GRACE_MS); - return () => window.clearTimeout(timeout); - }, []); - - return ( - - ); -}; - /** * Syncs with the system clock to produce a live countdown from an * ISO-8601 deadline. Polls at 100ms so the displayed second flips @@ -195,14 +174,15 @@ const ReconnectingAlert: FC<{ status: ReconnectingStatus }> = ({ status }) => { export const ChatStatusCallout: FC<{ status: LiveStatusModel; - startingResetKey?: string; -}> = ({ status, startingResetKey }) => { +}> = ({ status }) => { switch (status.phase) { case "idle": case "streaming": return null; case "starting": - return ; + return ( + + ); case "retrying": return ( <> diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index 9d3d5da412..e5eb558774 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -35,7 +35,6 @@ interface LiveStreamTailContentProps { streamState: StreamState | null; streamTools: readonly MergedTool[]; liveStatus: LiveStatusModel; - startingResetKey?: string; subagentTitles: Map; subagentVariants?: Map; subagentStatusOverrides: Map; @@ -48,7 +47,6 @@ export const LiveStreamTailContent = ({ streamState, streamTools, liveStatus, - startingResetKey, subagentTitles, subagentVariants, subagentStatusOverrides, @@ -88,7 +86,6 @@ export const LiveStreamTailContent = ({ streamState={streamState} streamTools={streamTools} liveStatus={liveStatus} - startingResetKey={startingResetKey} subagentTitles={subagentTitles} subagentVariants={subagentVariants} subagentStatusOverrides={subagentStatusOverrides} @@ -118,7 +115,6 @@ interface LiveStreamTailProps { store: ChatStoreHandle; persistedError: ChatDetailError | undefined; isTranscriptEmpty: boolean; - startingResetKey?: string; subagentTitles: Map; subagentVariants?: Map; urlTransform?: UrlTransform; @@ -129,7 +125,6 @@ export const LiveStreamTail = ({ store, persistedError, isTranscriptEmpty, - startingResetKey, subagentTitles, subagentVariants, urlTransform, @@ -166,7 +161,6 @@ export const LiveStreamTail = ({ streamState={streamState} streamTools={streamTools} liveStatus={liveStatus} - startingResetKey={startingResetKey} subagentTitles={subagentTitles} subagentVariants={subagentVariants} subagentStatusOverrides={subagentStatusOverrides} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx index af8534d960..4284394f6d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx @@ -52,7 +52,6 @@ export const StreamingOutput: FC<{ subagentVariants?: Map; subagentStatusOverrides?: Map; liveStatus: LiveStatusModel; - startingResetKey?: string; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; }> = ({ @@ -62,7 +61,6 @@ export const StreamingOutput: FC<{ subagentVariants, subagentStatusOverrides, liveStatus, - startingResetKey, urlTransform, mcpServers, }) => { @@ -113,10 +111,7 @@ export const StreamingOutput: FC<{ )} {needsStreamingThinking && } {!needsStreamingThinking && hasTransientLiveStatus(liveStatus) && ( - + )}
diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index ab327d1c83..43d8182066 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -101,13 +101,7 @@ export const StreamingToolCallGapRegression: Story = { store.setStreamState(streamState); store.setChatStatus("pending"); - return ( - - ); + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -120,13 +114,7 @@ export const StartingPhaseToolCallGapRegression: Story = { const store = buildRegressionStore(); store.setChatStatus("running"); - return ( - - ); + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -139,13 +127,7 @@ export const SpacerVisibleWhenNotStreaming: Story = { render: () => { const store = buildThinkingSpacerStore(); - return ( - - ); + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -165,13 +147,7 @@ export const HiddenAssistantPlaceholderDoesNotRender: Story = { buildMessage(4, "user", [{ type: "text", text: "Thanks!" }]), ]); - return ( - - ); + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 68d2644603..88e958b725 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -46,7 +46,6 @@ const isChatMessage = ( ): message is TypesGen.ChatMessage => Boolean(message); interface ChatPageTimelineProps { - chatID?: string; store: ChatStoreHandle; persistedError: ChatDetailError | undefined; onEditUserMessage?: ( @@ -62,7 +61,6 @@ interface ChatPageTimelineProps { } export const ChatPageTimeline: FC = ({ - chatID, store, persistedError, onEditUserMessage, @@ -133,7 +131,6 @@ export const ChatPageTimeline: FC = ({ Date: Mon, 1 Jun 2026 18:01:19 +0300 Subject: [PATCH 49/63] test(codersdk/toolsdk): use portable echo in WorkspaceSSHExec test (#25840) PowerShell's echo aliases to Write-Output, which rejects -e as an ambiguous parameter and exits 1. Use plain echo with spaces instead. Remove the Windows t.Skip and TestMain exception. TestMain untested-tools check now only fails on full-suite runs. Filtered runs (e.g. -run TestTools) warn instead. Closes CODAGT-518 --- codersdk/toolsdk/toolsdk_test.go | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/codersdk/toolsdk/toolsdk_test.go b/codersdk/toolsdk/toolsdk_test.go index 41bb6fe28a..bd4949baaa 100644 --- a/codersdk/toolsdk/toolsdk_test.go +++ b/codersdk/toolsdk/toolsdk_test.go @@ -4,12 +4,12 @@ import ( "context" "database/sql" "encoding/json" + "flag" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" - "runtime" "sort" "sync" "testing" @@ -1048,9 +1048,6 @@ func TestTools(t *testing.T) { }) t.Run("WorkspaceSSHExec", func(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("WorkspaceSSHExec is not supported on Windows") - } // Setup workspace exactly like main SSH tests client, workspace, agentToken := setupWorkspaceForAgent(t, nil) @@ -1076,7 +1073,7 @@ func TestTools(t *testing.T) { // Test output trimming result, err = testTool(t, toolsdk.WorkspaceBash, tb, toolsdk.WorkspaceBashArgs{ Workspace: workspace.Name, - Command: "echo -e '\\n test with whitespace \\n'", + Command: "echo ' test with whitespace '", }) require.NoError(t, err) require.Equal(t, 0, result.ExitCode) @@ -2576,29 +2573,31 @@ func TestMain(m *testing.M) { var untested []string for _, tool := range toolsdk.All { if tested, ok := testedTools.Load(tool.Name); !ok || !tested.(bool) { - // Test is skipped on Windows - if runtime.GOOS == "windows" && tool.Name == "coder_workspace_bash" { - continue - } untested = append(untested, tool.Name) } } if len(untested) > 0 && code == 0 { - code = 1 - println("The following tools were not tested:") + _, _ = fmt.Fprintln(os.Stderr, "The following tools were not tested:") for _, tool := range untested { - println(" - " + tool) + _, _ = fmt.Fprintf(os.Stderr, " - %s\n", tool) + } + _, _ = fmt.Fprintln(os.Stderr, "Please ensure that all tools are tested using testTool().") + _, _ = fmt.Fprintln(os.Stderr, "If you just added a new tool, please add a test for it.") + // Only fail when the full suite ran. When -run filters to a + // subset (e.g. CI flake checks use -run ^TestTools), tools + // covered by other top-level functions appear untested. + if f := flag.Lookup("test.run"); f == nil || f.Value.String() == "" { + code = 1 + } else { + _, _ = fmt.Fprintln(os.Stderr, "NOTE: if you just ran an individual test, this is expected.") } - println("Please ensure that all tools are tested using testTool().") - println("If you just added a new tool, please add a test for it.") - println("NOTE: if you just ran an individual test, this is expected.") } // Check for goroutine leaks. Below is adapted from goleak.VerifyTestMain: if code == 0 { if err := goleak.Find(testutil.GoleakOptions...); err != nil { - println("goleak: Errors on successful test run: ", err.Error()) + _, _ = fmt.Fprintln(os.Stderr, "goleak: Errors on successful test run:", err.Error()) code = 1 } } From 98c2b60820324afceb4180f10d274aece1362a66 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 1 Jun 2026 16:04:55 +0100 Subject: [PATCH 50/63] docs(docs/ai-coder/ai-gateway): document key failover for AI Gateway (#25893) Document the automatic key failover feature for AI Gateway, which allows configuring multiple centralized API keys per provider instance (OpenAI and Anthropic only). ## Changes - **`docs/ai-coder/ai-gateway/providers.md`**: Add "Key failover" section covering supported providers, configuration via the API (max 5 keys), and failover behavior (auth errors permanently disable a key until restart/reload, exhausted pool returns `429` or `502`). - **`docs/ai-coder/ai-gateway/auth.md`**: Add note in BYOK section clarifying that key failover is skipped when a user-supplied credential is present. > [!NOTE] > Generated by Coder Agents (by @ssncferreira) --- docs/ai-coder/ai-gateway/auth.md | 4 ++++ docs/ai-coder/ai-gateway/providers.md | 30 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/ai-coder/ai-gateway/auth.md b/docs/ai-coder/ai-gateway/auth.md index d7bb48aa85..e35e4f3d6b 100644 --- a/docs/ai-coder/ai-gateway/auth.md +++ b/docs/ai-coder/ai-gateway/auth.md @@ -92,6 +92,10 @@ When no user credential is present, AI Gateway falls back to the admin-configure This approach offers centralized keys as a default, while allowing individual users to bring their own key. +> [!NOTE] +> When a BYOK credential is present, [key failover](./providers.md#key-failover) +> is skipped. + Visit individual [client pages](./clients/index.md) for configuration details. ### Enable or disable BYOK diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md index fae50e282f..084a3227db 100644 --- a/docs/ai-coder/ai-gateway/providers.md +++ b/docs/ai-coder/ai-gateway/providers.md @@ -166,6 +166,36 @@ single failure, which may resolve on the next change. See [Monitoring](./monitoring.md#provider-metrics) for the full metric list and sample alert queries. +## Key failover + +You can configure multiple centralized API keys for a single provider instance +so that AI Gateway automatically retries with the next key when one fails. This +is transparent to end users, and clients see no difference in behavior or need +any configuration changes. + +Key failover is supported for **OpenAI** and **Anthropic** providers. Amazon +Bedrock and GitHub Copilot do not support key failover. + +Multiple keys can be added per provider through the +[AI Providers API](../../reference/api/aiproviders.md). Each provider supports +a maximum of **5 keys**. + +### Failover behavior + +Every request starts with the first key in the list. If a key is rate-limited +or returns an authentication error, AI Gateway automatically retries the request +with the next available key. + +> [!WARNING] +> A key that fails with an authentication error (`401 Unauthorized` or +> `403 Forbidden`) is permanently disabled and will not be used again until the +> server is restarted or the provider configuration is reloaded. + +If all keys in the pool are exhausted, AI Gateway returns: + +- `429 Too Many Requests` when at least one key is rate-limited, with a `Retry-After` header set to the shortest cooldown across all keys. +- `502 Bad Gateway` when every key has failed permanently. + ## Bring Your Own Key A provider's configured credentials are the centralized default. When From 372265a0b5f8e2a6d3fe9d81abded31cc01cceb0 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 1 Jun 2026 18:29:25 +0100 Subject: [PATCH 51/63] docs: document chat sharing (#25592) --- docs/ai-coder/agents/chat-sharing.md | 24 ++++++++++++++++++++++++ docs/manifest.json | 6 ++++++ 2 files changed, 30 insertions(+) create mode 100644 docs/ai-coder/agents/chat-sharing.md diff --git a/docs/ai-coder/agents/chat-sharing.md b/docs/ai-coder/agents/chat-sharing.md new file mode 100644 index 0000000000..89a9391d0e --- /dev/null +++ b/docs/ai-coder/agents/chat-sharing.md @@ -0,0 +1,24 @@ +# Chat Sharing + +Chat sharing lets you give other users or groups read-only access to a Coder Agents conversation. + +## Share a chat + +1. Open the chat you want to share on the **Agents** page. Only top-level chats can be shared; sub-agent chats inherit sharing from their parent. +1. Click the share icon in the chat top bar. +1. Click the **Search for user or group** field. +1. Search for and select a user or group. +1. Click **Add member** to grant **Read** access. +1. Copy the chat URL from your browser and send it to the recipients. + +Coder does not create a separate share link or notify recipients. They must open the chat from the URL you send them. + +## Shared chat access + +Viewers can open the chat from a direct link, view messages, stream live updates, and download chat attachments. They reach sub-agent chats by following sub-agent links inside the parent chat or by opening a direct URL. + +Shared chats do not appear in the viewer's normal chat list. Viewers have read-only access: they cannot send or edit messages, regenerate the chat title, archive the chat, or change its sharing settings. + +## Disable chat sharing + +Administrators can disable chat sharing for a deployment with `--disable-chat-sharing`, `CODER_DISABLE_CHAT_SHARING`, or `disableChatSharing`. When disabled, only chat owners can access their chats. diff --git a/docs/manifest.json b/docs/manifest.json index fa1b7c0f38..d0485b6ca8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1007,6 +1007,12 @@ "path": "./ai-coder/agents/architecture.md", "state": ["beta"] }, + { + "title": "Chat Sharing", + "description": "Share Coder Agents conversations with users and groups", + "path": "./ai-coder/agents/chat-sharing.md", + "state": ["beta"] + }, { "title": "Models", "description": "Configure LLM providers and models for Coder Agents", From fc01aeeb0f76f5fb6852a544de6805f5a5f30e2b Mon Sep 17 00:00:00 2001 From: TJ Date: Mon, 1 Jun 2026 10:59:13 -0700 Subject: [PATCH 52/63] fix(site): show condensed count for multi-provider in sessions list (#25705) The Provider column in the AI sessions list now shows: - **Multiple providers**: condensed count badge (e.g. `2 providers`) - **Single provider**: icon + display name badge (e.g. `OpenAI`) (existing behavior) - **Empty**: nothing rendered ## Changes | File | Change | |------|--------| | `ListSessionsRow.tsx` | Conditional rendering for the provider cell based on `providers.length` | | `ListSessionsRow.stories.tsx` | Added stories: `SingleProvider`, `MultipleProviders`, `EmptyProviders` | | `ListSessionsPageView.stories.tsx` | `MultipleSessions` story alternates single/multi provider rows | > Generated by Coder Agents on behalf of @tracyjohnsonux --- .../ListSessionsPageView.stories.tsx | 1 + .../ListSessionsRow.stories.tsx | 27 ++++++++++++++++++ .../ListSessionsPage/ListSessionsRow.tsx | 28 +++++++++++-------- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx index 0c5f021969..ede9692b8e 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.stories.tsx @@ -92,6 +92,7 @@ export const MultipleSessions: Story = { ...MockSession, id: `session-${i}`, threads: i + 1, + providers: i % 2 === 0 ? ["anthropic", "openai"] : ["anthropic"], last_prompt: [ "But *can* I really fix it?", "Can you refactor the entire authentication module to use JWT tokens instead of session cookies?", diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx index 1407977398..18db56d938 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.stories.tsx @@ -30,6 +30,33 @@ export const Default: Story = { }, }; +export const SingleProvider: Story = { + args: { + session: { + ...MockSession, + providers: ["anthropic"], + }, + }, +}; + +export const MultipleProviders: Story = { + args: { + session: { + ...MockSession, + providers: ["anthropic", "openai", "copilot"], + }, + }, +}; + +export const EmptyProviders: Story = { + args: { + session: { + ...MockSession, + providers: [], + }, + }, +}; + export const NullClient: Story = { args: { session: { ...MockSession, client: null }, diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx index a630512b49..92dd035178 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx @@ -63,17 +63,23 @@ export const ListSessionsRow: FC = ({
- -
- -
- - {getProviderDisplayName(session.providers[0])} - -
+ {session.providers.length > 1 ? ( + + {session.providers.length} providers + + ) : session.providers.length === 1 ? ( + +
+ +
+ + {getProviderDisplayName(session.providers[0])} + +
+ ) : null}
From ed4311b2cbca6ad5f9a8a3132724a9aab4f6aa96 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 2 Jun 2026 04:51:16 +0300 Subject: [PATCH 53/63] ci: add Git usr/bin to PATH on Windows (#25939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes all 9 Windows CI test failures caused by the mise CI refactor (`fe257666d7`, PR #25727). ### Root cause `jdx/mise-action` exports `Path` (Windows convention) via `GITHUB_ENV`. Bash on Windows maintains its own `PATH`. When Go's `os.Environ()` returns both, `cmd.exe` subprocesses non-deterministically pick the MSYS-translated `PATH` (forward slashes), causing Windows executables (`printf`, `powershell.exe`, `cmd.exe`) to be unresolvable. These failures only appeared on `main` (where `-count=1` forces real test execution) and were masked on PRs by Go test cache. ### Fixes applied **CI (`setup-mise` action)**: - Write both `Path` and `PATH` to `GITHUB_ENV` with Git usr/bin prepended **Code (`cli/root.go`)**: - Add `appendAndDedupEnv` helper that deduplicates case-insensitive env vars on Windows, preferring native Windows paths (backslashes) over MSYS paths **Code (`cli/configssh_windows.go`)**: - Use absolute paths for `powershell.exe` and `cmd.exe` in the SSH config `Match exec` escape function, avoiding PATH resolution entirely **Tests**: - Switch `--header-command` tests from `printf` to `echo` (cmd.exe builtin) for reliable cross-platform execution - Add env dedup in `Test_sshConfigMatchExecEscape` for subprocess PATH consistency Fixes coder/internal#1556, coder/internal#1558, coder/internal#1559 > 🤖 Generated by Coder agent, will be reviewed by @mafredri. 🏂🏻 --- .github/actions/setup-mise/action.yml | 20 +++++++++++++ cli/agent_test.go | 4 ++- cli/configssh_internal_test.go | 11 +++++-- cli/configssh_windows.go | 10 ++++++- cli/root.go | 41 +++++++++++++++++++++++++-- cli/root_test.go | 6 ++-- enterprise/cli/proxyserver_test.go | 2 +- 7 files changed, 85 insertions(+), 9 deletions(-) diff --git a/.github/actions/setup-mise/action.yml b/.github/actions/setup-mise/action.yml index 847eb6ef51..39aa9ab27d 100644 --- a/.github/actions/setup-mise/action.yml +++ b/.github/actions/setup-mise/action.yml @@ -166,3 +166,23 @@ runs: mise_dir: ${{ steps.mise-data-dir.outputs.path }} install_args: ${{ steps.cache-key.outputs.install-args }} cache: "false" + + - name: Ensure Git usr/bin is in PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + # jdx/mise-action exports "Path" via GITHUB_ENV which may + # collide with bash's "PATH". Ensure Git usr/bin is present + # and remove any duplicate Path/PATH entries from GITHUB_ENV + # by writing both forms. + run: | # zizmor: ignore[github-env] + $gitdir = "C:\Program Files\Git\usr\bin" + $current = $env:Path + if ($current -notlike "*$gitdir*") { + $current = "$gitdir;$current" + } + # Write both Path and PATH to GITHUB_ENV so that both + # cmd.exe (uses Path) and bash/Go (uses PATH) see the + # same value including Git usr/bin. + "Path=$current" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + "PATH=$current" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + diff --git a/cli/agent_test.go b/cli/agent_test.go index 60e8f68642..9ea7afdcb1 100644 --- a/cli/agent_test.go +++ b/cli/agent_test.go @@ -146,8 +146,10 @@ func TestWorkspaceAgent(t *testing.T) { }).WithAgent().Do() coderURLEnv := "$CODER_URL" + headerCmd := "printf X-Process-Testing=very-wow-" + coderURLEnv + "'\\r\\n'X-Process-Testing2=more-wow" if runtime.GOOS == "windows" { coderURLEnv = "%CODER_URL%" + headerCmd = "echo X-Process-Testing=very-wow-" + coderURLEnv + "& echo X-Process-Testing2=more-wow" } logDir := t.TempDir() @@ -159,7 +161,7 @@ func TestWorkspaceAgent(t *testing.T) { "--log-dir", logDir, "--agent-header", "X-Testing=agent", "--agent-header", "Cool-Header=Ethan was Here!", - "--agent-header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow", + "--agent-header-command", headerCmd, "--socket-path", testutil.AgentSocketPath(t), ) clitest.Start(t, agentInv) diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index 0ea2ae6ea5..59b57439af 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -229,8 +229,15 @@ func Test_sshConfigMatchExecEscape(t *testing.T) { // OpenSSH processes %% escape sequences into % escaped = strings.ReplaceAll(escaped, "%%", "%") - b, err := exec.Command(cmd, arg, escaped).CombinedOutput() //nolint:gosec - require.NoError(t, err) + c := exec.Command(cmd, arg, escaped) //nolint:gosec + if runtime.GOOS == "windows" { + // Deduplicate Path/PATH env vars so cmd.exe + // subprocesses (like powershell.exe used for + // paths with spaces) resolve correctly. + c.Env = appendAndDedupEnv(os.Environ()) + } + b, err := c.CombinedOutput() + require.NoError(t, err, "command output: %s", string(b)) got := strings.TrimSpace(string(b)) require.Equal(t, "yay", got) }) diff --git a/cli/configssh_windows.go b/cli/configssh_windows.go index db81bce1ff..53473c7aa4 100644 --- a/cli/configssh_windows.go +++ b/cli/configssh_windows.go @@ -4,6 +4,8 @@ package cli import ( "fmt" + "os" + "path/filepath" "strings" "golang.org/x/xerrors" @@ -50,7 +52,13 @@ func sshConfigMatchExecEscape(path string) (string, error) { if strings.ContainsAny(path, " ") { // c.f. function comment for how this works. - path = fmt.Sprintf("for /f %%%%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%%%a%s%%%%a", path) //nolint:gocritic // We don't want %q here. + // Use absolute paths for powershell.exe and cmd.exe + // to avoid PATH resolution issues when both Path and + // PATH (MSYS-translated) exist in the environment. + sysRoot := os.Getenv("SYSTEMROOT") + pwsh := filepath.Join(sysRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe") + cmd := filepath.Join(sysRoot, "System32", "cmd.exe") + path = fmt.Sprintf("for /f %%%%a in ('%s -Command [char]34') do @%s /c %%%%a%s%%%%a", pwsh, cmd, path) //nolint:gocritic // We don't want %q here. } return path, nil } diff --git a/cli/root.go b/cli/root.go index a40ac7c3c2..2e4aa7dd17 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1701,7 +1701,44 @@ func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return r(req) } -// HeaderTransport creates a new transport that executes `--header-command` +// appendAndDedupEnv appends extra environment variables and +// deduplicates entries with the same key (case-insensitive on +// Windows). For the PATH variable specifically, it prefers the +// value that contains native Windows paths (with backslashes) +// over MSYS-translated paths (with forward slashes). For all +// other variables, the last value wins. +func appendAndDedupEnv(env []string, extra ...string) []string { + env = append(env, extra...) + if runtime.GOOS != "windows" { + return env + } + seen := make(map[string]int, len(env)) + result := make([]string, 0, len(env)) + for _, e := range env { + key, val, ok := strings.Cut(e, "=") + if !ok { + result = append(result, e) + continue + } + upper := strings.ToUpper(key) + if idx, exists := seen[upper]; exists { + if upper == "PATH" { + // Prefer the value with native Windows paths. + existingVal := result[idx][len(key)+1:] + if strings.Contains(existingVal, "\\") && !strings.Contains(val, "\\") { + continue + } + } + result[idx] = e + continue + } + seen[upper] = len(result) + result = append(result, e) + } + return result +} + +// headerTransport creates a new transport that executes `--header-command` // if it is set to add headers for all outbound requests. func headerTransport(ctx context.Context, serverURL *url.URL, header []string, headerCommand string) (*codersdk.HeaderTransport, error) { transport := &codersdk.HeaderTransport{ @@ -1719,7 +1756,7 @@ func headerTransport(ctx context.Context, serverURL *url.URL, header []string, h var outBuf bytes.Buffer // #nosec cmd := exec.CommandContext(ctx, shell, caller, headerCommand) - cmd.Env = append(os.Environ(), "CODER_URL="+serverURL.String()) + cmd.Env = appendAndDedupEnv(os.Environ(), "CODER_URL="+serverURL.String()) cmd.Stdout = &outBuf cmd.Stderr = io.Discard err := cmd.Run() diff --git a/cli/root_test.go b/cli/root_test.go index fefb87382c..aaf81f574e 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -177,15 +177,17 @@ func TestRoot(t *testing.T) { url = srv.URL buf := new(bytes.Buffer) coderURLEnv := "$CODER_URL" + headerCmd := "printf X-Process-Testing=very-wow-" + coderURLEnv + "'\\r\\n'X-Process-Testing2=more-wow" if runtime.GOOS == "windows" { coderURLEnv = "%CODER_URL%" + headerCmd = "echo X-Process-Testing=very-wow-" + coderURLEnv + "& echo X-Process-Testing2=more-wow" } inv, _ := clitest.New(t, "--no-feature-warning", "--no-version-warning", "--header", "X-Testing=wow", "--header", "Cool-Header=Dean was Here!", - "--header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow", + "--header-command", headerCmd, "login", srv.URL, ) inv.Stdout = buf @@ -266,7 +268,7 @@ func TestDERPHeaders(t *testing.T) { "--no-version-warning", "ping", workspace.Name, "-n", "1", - "--header-command", "printf X-Process-Testing=very-wow", + "--header-command", "echo X-Process-Testing=very-wow", } for k, v := range expectedHeaders { if k != "X-Process-Testing" { diff --git a/enterprise/cli/proxyserver_test.go b/enterprise/cli/proxyserver_test.go index 556597ab76..15f0003099 100644 --- a/enterprise/cli/proxyserver_test.go +++ b/enterprise/cli/proxyserver_test.go @@ -48,7 +48,7 @@ func Test_ProxyServer_Headers(t *testing.T) { "--access-url", "http://localhost:8080", "--http-address", ":0", "--header", fmt.Sprintf("%s=%s", headerName1, headerVal1), - "--header-command", fmt.Sprintf("printf %s=%s", headerName2, headerVal2), + "--header-command", fmt.Sprintf("echo %s=%s", headerName2, headerVal2), ) pty := ptytest.New(t) inv.Stdout = pty.Output() From 550aa6d6a22b5f9522595fc315d2f02b6c318cc1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 2 Jun 2026 04:11:00 +0200 Subject: [PATCH 54/63] ci: install gotestsum in flake check workflow (#25934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flake Check workflow runs `make test` through the `test-go-pg` action, which invokes `gotestsum`, but the workflow never installs it. The mise refactor (#25727) deleted the `setup-go` action that previously installed `gotestsum` implicitly, and added explicit `mise install ... go:gotest.tools/gotestsum` steps to every other Go test job. The flake check's `Install Go mise tools` step only listed `whichtests`, so the check fails with `gotestsum: command not found` whenever it selects changed tests to run. Add `go:gotest.tools/gotestsum` to the flake check's install step, matching the other `test-go-pg` jobs in `ci.yaml` and `nightly-gauntlet.yaml`. Refs #25727 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/flake-go.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flake-go.yaml b/.github/workflows/flake-go.yaml index e416519216..b3226462d2 100644 --- a/.github/workflows/flake-go.yaml +++ b/.github/workflows/flake-go.yaml @@ -48,7 +48,7 @@ jobs: uses: ./.github/actions/go-cache - name: Install Go mise tools - run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/whichtests + run: ./.github/scripts/retry.sh -- mise install --locked go:github.com/coder/whichtests go:gotest.tools/gotestsum - name: Select changed tests id: selector From 97dde1f8246348c4b32172801f623268885dff33 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:37:12 +1000 Subject: [PATCH 55/63] fix: refresh attach workspace picker dynamically (#25834) After the chat agent creates a workspace via the `create_workspace` tool, opening the composer `+` menu and clicking "Attach workspace" could show "No workspaces found" until a full page refresh, even though the workspace pill already rendered the linked workspace correctly. The picker was sourced only from the `owner:me` workspace list query, whose cache could be stale right after `create_workspace` completed. The fix derives the picker options at render time from both the owner workspace list and the linked workspace already fetched by ID for the pill, prepending or replacing the linked workspace only when the current user owns it. This keeps the picker consistent with the pill without broadening visibility beyond `owner:me` or invalidating workspace lists on chat link updates. Relates to CODAGT-510 --- .../pages/AgentsPage/AgentChatPage.test.ts | 38 +++++++++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 34 ++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 21b7853879..9a20437dfe 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -2,9 +2,11 @@ import { act, renderHook } from "@testing-library/react"; import { createRef } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatQueuedMessage } from "#/api/typesGenerated"; +import { MockUserOwner, MockWorkspace } from "#/testHelpers/entities"; import { draftInputStorageKeyPrefix, getPersistedDraftInputValue, + getWorkspaceOptionsWithLinkedWorkspace, restoreOptimisticRequestSnapshot, runPromoteQueuedMessage, submitEditAndScroll, @@ -93,6 +95,42 @@ const createDeferred = (): Deferred => { return { promise, resolve, reject }; }; +describe("getWorkspaceOptionsWithLinkedWorkspace", () => { + it("includes a missing linked workspace only when the current user owns it", () => { + const existingWorkspace = { + ...MockWorkspace, + id: "existing-workspace", + }; + const ownerWorkspaceOptions = [existingWorkspace]; + const linkedWorkspace = { + ...MockWorkspace, + id: "linked-workspace", + owner_id: MockUserOwner.id, + }; + + expect( + getWorkspaceOptionsWithLinkedWorkspace( + ownerWorkspaceOptions, + linkedWorkspace, + MockUserOwner.id, + ), + ).toEqual([linkedWorkspace, existingWorkspace]); + + const sharedWorkspace = { + ...linkedWorkspace, + owner_id: "another-user", + }; + + expect( + getWorkspaceOptionsWithLinkedWorkspace( + ownerWorkspaceOptions, + sharedWorkspace, + MockUserOwner.id, + ), + ).toBe(ownerWorkspaceOptions); + }); +}); + describe("waitForPendingChatSettingsSyncs", () => { it("waits for plan-mode and workspace updates before resolving", async () => { const planModeUpdate = createDeferred(); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index a6d968d8af..b4196b7280 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -275,6 +275,32 @@ export const filterWorkspaceOptionsByOrganization = ( ); }; +/** @internal Exported for testing. */ +export const getWorkspaceOptionsWithLinkedWorkspace = ( + workspaceOptions: readonly TypesGen.Workspace[], + workspace: TypesGen.Workspace | undefined, + ownerID: string, +): readonly TypesGen.Workspace[] => { + if (!workspace || workspace.owner_id !== ownerID) { + return workspaceOptions; + } + + const existingIndex = workspaceOptions.findIndex( + (candidate) => candidate.id === workspace.id, + ); + if (existingIndex === -1) { + return [workspace, ...workspaceOptions]; + } + + if (workspaceOptions[existingIndex] === workspace) { + return workspaceOptions; + } + + const nextWorkspaceOptions = [...workspaceOptions]; + nextWorkspaceOptions[existingIndex] = workspace; + return nextWorkspaceOptions; +}; + const buildAttachmentMediaTypes = ( attachments?: readonly PendingAttachment[], ): ReadonlyMap | undefined => { @@ -723,6 +749,7 @@ const AgentChatPage: FC = () => { ...workspaceById(workspaceId ?? ""), enabled: Boolean(workspaceId), }); + const workspace = workspaceQuery.data; const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); @@ -736,7 +763,11 @@ const AgentChatPage: FC = () => { const userDebugLoggingQuery = useQuery(userChatDebugLogging()); const mcpServersQuery = useQuery(mcpServerConfigs()); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); - const workspaceOptions = workspacesQuery.data?.workspaces ?? []; + const workspaceOptions = getWorkspaceOptionsWithLinkedWorkspace( + workspacesQuery.data?.workspaces ?? [], + workspace, + currentUser.id, + ); const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false; const debugLoggingEnabled = userDebugLoggingQuery.data?.debug_logging_enabled ?? false; @@ -835,7 +866,6 @@ const AgentChatPage: FC = () => { }); }, [workspaceId, queryClient]); const sshConfigQuery = useQuery(deploymentSSHConfig()); - const workspace = workspaceQuery.data; const workspaceAgent = getWorkspaceAgent(workspace, undefined); const { proxy } = useProxy(); From 49c2142d2dc7f2a6194f2bed52101fb514f9ac12 Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:40:07 +1000 Subject: [PATCH 56/63] fix: allow unlinking chat workspaces (#25833) This allows a Coder Agents chat to detach from its linked workspace without deleting or changing the workspace, so a different workspace can be linked later. It adds detach controls wherever the linked workspace appears, including the workspace pill menu, fallback workspace badges, and the workspace picker. The workspace selection state now updates consistently across desktop and mobile. Running workspace: image Stopped workspace: image Closes CODAGT-510 --- site/src/modules/apps/apps.test.ts | 6 +- site/src/pages/AgentsPage/AgentChatPage.tsx | 5 +- .../AgentsPage/AgentChatPageView.stories.tsx | 17 ++- .../pages/AgentsPage/AgentChatPageView.tsx | 2 +- .../components/AgentChatInput.stories.tsx | 113 +++++++++++++++++- .../AgentsPage/components/AgentChatInput.tsx | 73 +++++++---- .../AgentsPage/components/ChatPageContent.tsx | 2 +- .../components/WorkspacePill.stories.tsx | 63 ++++++++-- .../AgentsPage/components/WorkspacePill.tsx | 82 ++++++++----- .../WorkspaceScheduleControls.test.tsx | 8 +- .../WorkspaceSchedulePage.test.tsx | 6 +- site/src/testHelpers/entities.ts | 2 +- 12 files changed, 291 insertions(+), 88 deletions(-) diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index 146964af78..6f8bc73f67 100644 --- a/site/src/modules/apps/apps.test.ts +++ b/site/src/modules/apps/apps.test.ts @@ -129,7 +129,7 @@ describe("getAppHref", () => { path: "/path-base", }); expect(href).toBe( - `/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, + `/path-base/@${MockWorkspace.owner_name}/test-workspace.a-workspace-agent/apps/${app.slug}/`, ); }); @@ -145,7 +145,7 @@ describe("getAppHref", () => { path: "", }); expect(href).toBe( - `/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?app=${app.slug}`, + `/@${MockWorkspace.owner_name}/test-workspace.a-workspace-agent/terminal?app=${app.slug}`, ); }); @@ -177,7 +177,7 @@ describe("getAppHref", () => { path: "/path-base", }); expect(href).toBe( - `/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, + `/path-base/@${MockWorkspace.owner_name}/test-workspace.a-workspace-agent/apps/${app.slug}/`, ); }); }); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index b4196b7280..358355adbe 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1160,6 +1160,7 @@ const AgentChatPage: FC = () => { isUpdateChatPlanModePending || isUpdateChatWorkspacePending; const isInputDisabled = !hasModelOptions || isArchived || isChatSettingsPending || isViewerNotOwner; + const canUpdateChatWorkspace = !isArchived && !isViewerNotOwner; const selectedWorkspaceId = chatQuery.data?.workspace_id ?? null; const isWorkspaceLoading = @@ -1633,7 +1634,9 @@ const AgentChatPage: FC = () => { isInterruptPending={isInterruptPending} workspaceOptions={workspaceOptions} selectedWorkspaceId={selectedWorkspaceId} - onWorkspaceChange={handleWorkspaceChange} + onWorkspaceChange={ + canUpdateChatWorkspace ? handleWorkspaceChange : undefined + } isWorkspaceLoading={isWorkspaceLoading} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index baed2e4d8e..1d4a94ed9d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -673,7 +673,22 @@ export const WorkspaceAgentStartTimeout: Story = { }; export const WorkspaceNoAgent: Story = { - render: () => , + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { + name: `Remove workspace ${MockWorkspace.name}`, + }), + ).toBeVisible(); + }, }; // --------------------------------------------------------------------------- diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 596c499162..8d448acc3d 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -226,7 +226,7 @@ export const AgentChatPageView: FC = ({ isInterruptPending, workspaceOptions = [], selectedWorkspaceId = null, - onWorkspaceChange = () => {}, + onWorkspaceChange, isWorkspaceLoading = false, isSidebarCollapsed, onToggleSidebarCollapsed, diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index 02d52ee3f3..375cf88430 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -910,15 +910,16 @@ export const DetailPageWorkspacePicker: Story = { statusLabel: "Workspace running", }, }, - play: async ({ canvasElement }) => { + play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getAllByText("agents-workspace")).toHaveLength(1); - expect( - canvas.queryByRole("button", { - name: "Remove workspace agents-workspace", - }), - ).not.toBeInTheDocument(); + const removeWorkspaceButton = canvas.getByRole("button", { + name: "Remove workspace agents-workspace", + }); + expect(removeWorkspaceButton).toBeVisible(); + await userEvent.click(removeWorkspaceButton); + expect(args.onWorkspaceChange).toHaveBeenCalledWith(null); const moreOptionsButton = canvas.getByRole("button", { name: "More options", @@ -940,6 +941,106 @@ export const DetailPageWorkspacePicker: Story = { }, }; +export const LinkedWorkspaceRemoveWhenInputDisabled: Story = { + args: { + isDisabled: true, + workspace: MockWorkspace, + workspaceAgent: MockWorkspaceAgent, + chatId: "chat-detail", + selectedWorkspaceId: MockWorkspace.id, + onWorkspaceChange: fn(), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const workspaceMenuButton = canvas.getByRole("button", { + name: `${MockWorkspace.name} workspace menu`, + }); + + expect( + canvas.queryByRole("button", { + name: `Remove workspace ${MockWorkspace.name}`, + }), + ).not.toBeInTheDocument(); + expect(workspaceMenuButton).toBeVisible(); + expect(workspaceMenuButton).toBeEnabled(); + await userEvent.click(workspaceMenuButton); + let detachWorkspaceItem: HTMLElement | null = null; + await waitFor(() => { + const menuId = workspaceMenuButton.getAttribute("aria-controls"); + if (!menuId) { + throw new Error("Expected workspace pill to control a menu."); + } + + const menu = canvasElement.ownerDocument.getElementById(menuId); + if (!(menu instanceof HTMLElement)) { + throw new Error("Expected workspace menu to render."); + } + + detachWorkspaceItem = within(menu).getByRole("menuitem", { + name: "Detach workspace", + }); + expect(detachWorkspaceItem).toBeVisible(); + }); + if (!detachWorkspaceItem) { + throw new Error("Expected detach workspace menu item to render."); + } + + await userEvent.click(detachWorkspaceItem); + expect(args.onWorkspaceChange).toHaveBeenCalledWith(null); + }, +}; + +export const UncheckSelectedWorkspaceFromPicker: Story = { + args: { + isDisabled: true, + workspace: MockWorkspace, + workspaceAgent: MockWorkspaceAgent, + chatId: "chat-detail", + workspaceOptions: [ + { + id: MockWorkspace.id, + name: MockWorkspace.name, + owner_name: MockWorkspace.owner_name, + organization_id: MockWorkspace.organization_id, + }, + ], + selectedWorkspaceId: MockWorkspace.id, + onWorkspaceChange: fn(), + }, + parameters: { + viewport: { defaultViewport: "mobile1" }, + chromatic: { viewports: [375] }, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + const moreOptionsButton = canvas.getByRole("button", { + name: "More options", + }); + expect(moreOptionsButton).toBeEnabled(); + await userEvent.click(moreOptionsButton); + + const attachWorkspaceButton = ( + await body.findByText("Attach workspace") + ).closest("button"); + if (!(attachWorkspaceButton instanceof HTMLButtonElement)) { + throw new Error("Expected Attach workspace to be a button."); + } + expect(attachWorkspaceButton).toBeEnabled(); + await userEvent.click(attachWorkspaceButton); + + const workspaceMatches = await body.findAllByText(MockWorkspace.name); + const selectedWorkspaceOption = workspaceMatches.at(-1); + if (!(selectedWorkspaceOption instanceof HTMLElement)) { + throw new Error("Expected workspace option to render."); + } + await userEvent.click(selectedWorkspaceOption); + + expect(args.onWorkspaceChange).toHaveBeenCalledWith(null); + }, +}; + const confluenceMCP = makeMCPServer({ id: "mcp-confluence", display_name: "Confluence Cloud", diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index ce03c71dd1..6ddafd1fa8 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -211,10 +211,12 @@ const BadgeDismissButton: FC<{ type="button" onClick={onClick} disabled={isDisabled} - className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0.5 text-content-secondary transition-colors hover:bg-surface-tertiary hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-content-secondary" + className="group -mx-1 -my-1 inline-flex size-5 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-0 text-content-secondary disabled:cursor-not-allowed disabled:opacity-50" aria-label={ariaLabel} > - + + + ); @@ -233,18 +235,28 @@ const ToolBadge: FC<{ return ( - - {badge.statusIcon} - {badge.name} - + + {badge.statusIcon} + {badge.name} + + {onRemoveWorkspace && ( + + )} + {badge.statusLabel} @@ -491,10 +503,12 @@ export const AgentChatInput: FC = ({ const selectedWorkspace = workspaceOptions?.find( (ws) => ws.id === selectedWorkspaceId, ); + const canUseWorkspacePicker = + Boolean(onWorkspaceChange) && !isWorkspaceLoading; + const linkedWorkspaceId = workspace?.id ?? attachedWorkspace?.id; const shouldShowSelectedWorkspaceBadge = selectedWorkspace - ? Boolean(onWorkspaceChange) && - selectedWorkspace.id !== attachedWorkspace?.id + ? selectedWorkspace.id !== linkedWorkspaceId : false; const enabledMcpServers = mcpServers?.filter((s) => s.enabled) ?? []; @@ -529,6 +543,9 @@ export const AgentChatInput: FC = ({ const overflowBadges = allBadges.slice(visibleCount); const handleRemoveWorkspace = () => onWorkspaceChange?.(null); + const removeWorkspaceHandler = onWorkspaceChange + ? handleRemoveWorkspace + : undefined; const handleRemoveMcp = (serverId: string) => handleMcpToggle(serverId, false); @@ -1128,7 +1145,9 @@ export const AgentChatInput: FC = ({ variant="subtle" size="icon" className="size-7 shrink-0 rounded-full [&>svg]:!size-icon-sm [&>svg]:p-0" - disabled={isDisabled && !agentSetupNotice} + disabled={ + isDisabled && !agentSetupNotice && !canUseWorkspacePicker + } aria-label="More options" > @@ -1197,7 +1216,7 @@ export const AgentChatInput: FC = ({ (isBelowMdViewport() ? ( - - - - {statusLabel} - - + > + + + {workspace.name} + + + + + + + {statusLabel} + + + = ({ View Workspace + {onRemoveWorkspace && ( + <> + + + + Detach workspace + + + )} ); diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 4793ee0172..3a7a35b352 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -63,7 +63,7 @@ test("add 3 hours to deadline", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - `Shutdown time for "Test-Workspace" updated successfully.`, + `Shutdown time for "test-workspace" updated successfully.`, ); expect(await screen.findByText("Stop in 6 hours")).toBeInTheDocument(); @@ -91,7 +91,7 @@ test("remove 2 hours to deadline", async () => { await user.click(subButton); await user.click(subButton); await screen.findByText( - `Shutdown time for "Test-Workspace" updated successfully.`, + `Shutdown time for "test-workspace" updated successfully.`, ); expect(await screen.findByText("Stop in an hour")).toBeInTheDocument(); @@ -119,7 +119,7 @@ test("rollback to previous deadline on error", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - `Failed to update shutdown time for "Test-Workspace". Please try again.`, + `Failed to update shutdown time for "test-workspace". Please try again.`, ); // In case of an error, the schedule message should remain unchanged expect(screen.getByText(initialScheduleMessage)).toBeInTheDocument(); @@ -140,7 +140,7 @@ test("request is only sent once when clicking multiple times", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - `Shutdown time for "Test-Workspace" updated successfully.`, + `Shutdown time for "test-workspace" updated successfully.`, ); expect(updateDeadlineSpy).toHaveBeenCalledTimes(1); }); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index 0abfbcc092..9b20d3e0ed 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -287,7 +287,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - `Schedule for workspace "Test-Workspace" updated successfully.`, + `Schedule for workspace "test-workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); @@ -320,7 +320,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - `Schedule for workspace "Test-Workspace" updated successfully.`, + `Schedule for workspace "test-workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); @@ -345,7 +345,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - `Schedule for workspace "Test-Workspace" updated successfully.`, + `Schedule for workspace "test-workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8682a63ed8..f85f3d4753 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1558,7 +1558,7 @@ export const MockBuilds = [ export const MockWorkspace: TypesGen.Workspace = { id: "test-workspace", - name: "Test-Workspace", + name: "test-workspace", created_at: "", updated_at: "", template_id: MockTemplate.id, From f22d4e2cbb064dfe3dae11b9097dca3f6065bda1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Tue, 2 Jun 2026 09:28:43 +0200 Subject: [PATCH 57/63] feat: add ai_gateway_keys table and related RBAC (#25563) Adds table to store keys that AI Gateway standalone replicas will use to authenticate into Coderd. Also adds RBAC and audit boilerplate. --- coderd/apidoc/docs.go | 12 ++++++ coderd/apidoc/swagger.json | 12 ++++++ coderd/audit/diff.go | 1 + coderd/audit/request.go | 9 +++++ coderd/database/check_constraint.go | 3 ++ coderd/database/dump.sql | 34 +++++++++++++++- .../000514_ai_gateway_keys.down.sql | 6 +++ .../migrations/000514_ai_gateway_keys.up.sql | 25 ++++++++++++ .../fixtures/000514_ai_gateway_keys.up.sql | 15 +++++++ coderd/database/models.go | 30 +++++++++++++- coderd/database/sqlc.yaml | 2 + coderd/database/unique_constraint.go | 4 ++ coderd/rbac/object_gen.go | 10 +++++ coderd/rbac/policy/policy.go | 8 ++++ coderd/rbac/roles_test.go | 18 +++++++++ coderd/rbac/scopes_constants_gen.go | 9 +++++ codersdk/apikey_scopes_gen.go | 4 ++ codersdk/audit.go | 3 ++ codersdk/rbacresources_gen.go | 2 + docs/admin/security/audit-logs.md | 1 + docs/reference/api/members.md | 40 +++++++++---------- docs/reference/api/schemas.md | 18 ++++----- docs/reference/api/users.md | 10 ++--- enterprise/audit/table.go | 9 +++++ site/src/api/rbacresourcesGenerated.ts | 5 +++ site/src/api/typesGenerated.ts | 12 ++++++ 26 files changed, 264 insertions(+), 38 deletions(-) create mode 100644 coderd/database/migrations/000514_ai_gateway_keys.down.sql create mode 100644 coderd/database/migrations/000514_ai_gateway_keys.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000514_ai_gateway_keys.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1bbb216b1a..7aca308f9d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15269,6 +15269,10 @@ const docTemplate = `{ "enum": [ "all", "application_connect", + "ai_gateway_key:*", + "ai_gateway_key:create", + "ai_gateway_key:delete", + "ai_gateway_key:read", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -15499,6 +15503,10 @@ const docTemplate = `{ "x-enum-varnames": [ "APIKeyScopeAll", "APIKeyScopeApplicationConnect", + "APIKeyScopeAiGatewayKeyAll", + "APIKeyScopeAiGatewayKeyCreate", + "APIKeyScopeAiGatewayKeyDelete", + "APIKeyScopeAiGatewayKeyRead", "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", @@ -22329,6 +22337,7 @@ const docTemplate = `{ "type": "string", "enum": [ "*", + "ai_gateway_key", "ai_model_price", "ai_provider", "ai_seat", @@ -22380,6 +22389,7 @@ const docTemplate = `{ ], "x-enum-varnames": [ "ResourceWildcard", + "ResourceAIGatewayKey", "ResourceAiModelPrice", "ResourceAIProvider", "ResourceAiSeat", @@ -22641,6 +22651,7 @@ const docTemplate = `{ "ai_seat", "ai_provider", "ai_provider_key", + "ai_gateway_key", "group_ai_budget", "chat", "user_secret", @@ -22676,6 +22687,7 @@ const docTemplate = `{ "ResourceTypeAISeat", "ResourceTypeAIProvider", "ResourceTypeAIProviderKey", + "ResourceTypeAIGatewayKey", "ResourceTypeGroupAIBudget", "ResourceTypeChat", "ResourceTypeUserSecret", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6f7224e972..842eac0c08 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13653,6 +13653,10 @@ "enum": [ "all", "application_connect", + "ai_gateway_key:*", + "ai_gateway_key:create", + "ai_gateway_key:delete", + "ai_gateway_key:read", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -13883,6 +13887,10 @@ "x-enum-varnames": [ "APIKeyScopeAll", "APIKeyScopeApplicationConnect", + "APIKeyScopeAiGatewayKeyAll", + "APIKeyScopeAiGatewayKeyCreate", + "APIKeyScopeAiGatewayKeyDelete", + "APIKeyScopeAiGatewayKeyRead", "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", @@ -20460,6 +20468,7 @@ "type": "string", "enum": [ "*", + "ai_gateway_key", "ai_model_price", "ai_provider", "ai_seat", @@ -20511,6 +20520,7 @@ ], "x-enum-varnames": [ "ResourceWildcard", + "ResourceAIGatewayKey", "ResourceAiModelPrice", "ResourceAIProvider", "ResourceAiSeat", @@ -20762,6 +20772,7 @@ "ai_seat", "ai_provider", "ai_provider_key", + "ai_gateway_key", "group_ai_budget", "chat", "user_secret", @@ -20797,6 +20808,7 @@ "ResourceTypeAISeat", "ResourceTypeAIProvider", "ResourceTypeAIProviderKey", + "ResourceTypeAIGatewayKey", "ResourceTypeGroupAIBudget", "ResourceTypeChat", "ResourceTypeUserSecret", diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index a26924552b..0beec46153 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -36,6 +36,7 @@ type Auditable interface { database.AiSeatState | database.AIProvider | database.AIProviderKey | + database.AIGatewayKey | database.Chat | database.AuditableGroupAiBudget | database.UserSecret | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index c690bd56f1..2304d37e82 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -138,6 +138,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.AIProviderKey: return typed.ID.String() + case database.AIGatewayKey: + return typed.Name case database.AuditableGroupAiBudget: return typed.GroupName case database.Chat: @@ -222,6 +224,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.AIProviderKey: return typed.ID + case database.AIGatewayKey: + return typed.ID case database.AuditableGroupAiBudget: return typed.GroupID case database.Chat: @@ -291,6 +295,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeAIProvider case database.AIProviderKey: return database.ResourceTypeAIProviderKey + case database.AIGatewayKey: + return database.ResourceTypeAIGatewayKey case database.AuditableGroupAiBudget: return database.ResourceTypeGroupAiBudget case database.Chat: @@ -366,6 +372,9 @@ func ResourceRequiresOrgID[T Auditable]() bool { // AI provider keys inherit the deployment scope of their parent // provider. return false + case database.AIGatewayKey: + // AI Gateway keys are deployment-scoped, not org-scoped. + return false case database.AuditableGroupAiBudget: // Group AI budgets are org-scoped through their parent group. return true diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 1c20622e58..c1fa991032 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -6,6 +6,9 @@ type CheckConstraint string // CheckConstraint enums. const ( + CheckAiGatewayKeysHashedSecretCheck CheckConstraint = "ai_gateway_keys_hashed_secret_check" // ai_gateway_keys + CheckAiGatewayKeysNameCheck CheckConstraint = "ai_gateway_keys_name_check" // ai_gateway_keys + CheckAiGatewayKeysSecretPrefixCheck CheckConstraint = "ai_gateway_keys_secret_prefix_check" // ai_gateway_keys CheckAiModelPricesCacheReadPriceCheck CheckConstraint = "ai_model_prices_cache_read_price_check" // ai_model_prices CheckAiModelPricesCacheWritePriceCheck CheckConstraint = "ai_model_prices_cache_write_price_check" // ai_model_prices CheckAiModelPricesInputPriceCheck CheckConstraint = "ai_model_prices_input_price_check" // ai_model_prices diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 82aa376d34..0bc0874e51 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -253,7 +253,11 @@ CREATE TYPE api_key_scope AS ENUM ( 'boundary_log:*', 'boundary_log:create', 'boundary_log:delete', - 'boundary_log:read' + 'boundary_log:read', + 'ai_gateway_key:*', + 'ai_gateway_key:create', + 'ai_gateway_key:delete', + 'ai_gateway_key:read' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -564,7 +568,8 @@ CREATE TYPE resource_type AS ENUM ( 'ai_provider', 'ai_provider_key', 'group_ai_budget', - 'user_skill' + 'user_skill', + 'ai_gateway_key' ); CREATE TYPE shareable_workspace_owners AS ENUM ( @@ -1287,6 +1292,22 @@ BEGIN END; $$; +CREATE TABLE ai_gateway_keys ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + name text NOT NULL, + secret_prefix character varying(11) NOT NULL, + hashed_secret bytea NOT NULL, + last_used_at timestamp with time zone, + CONSTRAINT ai_gateway_keys_hashed_secret_check CHECK ((length(hashed_secret) > 0)), + CONSTRAINT ai_gateway_keys_name_check CHECK (((length(name) <= 64) AND (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text))), + CONSTRAINT ai_gateway_keys_secret_prefix_check CHECK ((length((secret_prefix)::text) = 11)) +); + +COMMENT ON TABLE ai_gateway_keys IS 'Hashed bearer secrets used by AI Gateway standalone replicas to authenticate into coderd.'; + +COMMENT ON COLUMN ai_gateway_keys.secret_prefix IS 'Public token prefix for display and audit correlation. Auth uses hashed_secret.'; + CREATE TABLE ai_model_prices ( provider text NOT NULL, model text NOT NULL, @@ -3763,6 +3784,9 @@ ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); +ALTER TABLE ONLY ai_gateway_keys + ADD CONSTRAINT ai_gateway_keys_pkey PRIMARY KEY (id); + ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model); @@ -4147,6 +4171,12 @@ ALTER TABLE ONLY workspace_resources ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); +CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret); + +CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name)); + +CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix); + CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false); CREATE INDEX api_keys_last_used_idx ON api_keys USING btree (last_used DESC); diff --git a/coderd/database/migrations/000514_ai_gateway_keys.down.sql b/coderd/database/migrations/000514_ai_gateway_keys.down.sql new file mode 100644 index 0000000000..698983673f --- /dev/null +++ b/coderd/database/migrations/000514_ai_gateway_keys.down.sql @@ -0,0 +1,6 @@ +-- Enum additions to resource_type and api_key_scope are intentionally not +-- reverted because Postgres cannot drop enum values safely. +DROP INDEX IF EXISTS ai_gateway_keys_hashed_secret_idx; +DROP INDEX IF EXISTS ai_gateway_keys_secret_prefix_idx; +DROP INDEX IF EXISTS ai_gateway_keys_name_idx; +DROP TABLE IF EXISTS ai_gateway_keys; diff --git a/coderd/database/migrations/000514_ai_gateway_keys.up.sql b/coderd/database/migrations/000514_ai_gateway_keys.up.sql new file mode 100644 index 0000000000..537f437ce5 --- /dev/null +++ b/coderd/database/migrations/000514_ai_gateway_keys.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE ai_gateway_keys ( + id uuid PRIMARY KEY, + created_at timestamptz NOT NULL, + name text NOT NULL, + secret_prefix varchar(11) NOT NULL, + hashed_secret bytea NOT NULL, + last_used_at timestamptz NULL, + CONSTRAINT ai_gateway_keys_name_check CHECK (length(name) <= 64 AND name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT ai_gateway_keys_secret_prefix_check CHECK (length(secret_prefix) = 11), + CONSTRAINT ai_gateway_keys_hashed_secret_check CHECK (length(hashed_secret) > 0) +); + +COMMENT ON TABLE ai_gateway_keys IS 'Hashed bearer secrets used by AI Gateway standalone replicas to authenticate into coderd.'; +COMMENT ON COLUMN ai_gateway_keys.secret_prefix IS 'Public token prefix for display and audit correlation. Auth uses hashed_secret.'; + +CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name)); +CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix); +CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret); + +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'ai_gateway_key'; + +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_gateway_key:read'; diff --git a/coderd/database/migrations/testdata/fixtures/000514_ai_gateway_keys.up.sql b/coderd/database/migrations/testdata/fixtures/000514_ai_gateway_keys.up.sql new file mode 100644 index 0000000000..531946e06f --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000514_ai_gateway_keys.up.sql @@ -0,0 +1,15 @@ +INSERT INTO ai_gateway_keys ( + id, + created_at, + name, + secret_prefix, + hashed_secret, + last_used_at +) VALUES ( + '8b6f0a82-9a3a-4d2e-8c0c-2c9c9b9b1a01', + '2026-05-21 00:00:00+00', + 'example-key', + 'cdr_1234567', + '\x00'::bytea, + NULL +); diff --git a/coderd/database/models.go b/coderd/database/models.go index ebfaa7a051..b2d7fda98f 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -324,6 +324,10 @@ const ( ApiKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create" ApiKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete" ApiKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read" + ApiKeyScopeAiGatewayKey APIKeyScope = "ai_gateway_key:*" + ApiKeyScopeAiGatewayKeyCreate APIKeyScope = "ai_gateway_key:create" + ApiKeyScopeAiGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete" + ApiKeyScopeAiGatewayKeyRead APIKeyScope = "ai_gateway_key:read" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -588,7 +592,11 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeBoundaryLog, ApiKeyScopeBoundaryLogCreate, ApiKeyScopeBoundaryLogDelete, - ApiKeyScopeBoundaryLogRead: + ApiKeyScopeBoundaryLogRead, + ApiKeyScopeAiGatewayKey, + ApiKeyScopeAiGatewayKeyCreate, + ApiKeyScopeAiGatewayKeyDelete, + ApiKeyScopeAiGatewayKeyRead: return true } return false @@ -822,6 +830,10 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeBoundaryLogCreate, ApiKeyScopeBoundaryLogDelete, ApiKeyScopeBoundaryLogRead, + ApiKeyScopeAiGatewayKey, + ApiKeyScopeAiGatewayKeyCreate, + ApiKeyScopeAiGatewayKeyDelete, + ApiKeyScopeAiGatewayKeyRead, } } @@ -3353,6 +3365,7 @@ const ( ResourceTypeAIProviderKey ResourceType = "ai_provider_key" ResourceTypeGroupAiBudget ResourceType = "group_ai_budget" ResourceTypeUserSkill ResourceType = "user_skill" + ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3424,7 +3437,8 @@ func (e ResourceType) Valid() bool { ResourceTypeAIProvider, ResourceTypeAIProviderKey, ResourceTypeGroupAiBudget, - ResourceTypeUserSkill: + ResourceTypeUserSkill, + ResourceTypeAIGatewayKey: return true } return false @@ -3465,6 +3479,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeAIProviderKey, ResourceTypeGroupAiBudget, ResourceTypeUserSkill, + ResourceTypeAIGatewayKey, } } @@ -4435,6 +4450,17 @@ type AIBridgeUserPrompt struct { CreatedAt time.Time `db:"created_at" json:"created_at"` } +// Hashed bearer secrets used by AI Gateway standalone replicas to authenticate into coderd. +type AIGatewayKey struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Name string `db:"name" json:"name"` + // Public token prefix for display and audit correlation. Auth uses hashed_secret. + SecretPrefix string `db:"secret_prefix" json:"secret_prefix"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + LastUsedAt sql.NullTime `db:"last_used_at" json:"last_used_at"` +} + // Runtime configuration for AI providers. Authoritative source for the provider set served by aibridged. Replaces deployment-time CODER_AIBRIDGE_* environment variables. type AIProvider struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 18c738c992..78448df9de 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -261,8 +261,10 @@ sql: ai_provider: AIProvider ai_provider_key: AIProviderKey ai_provider_type: AIProviderType + ai_gateway_key: AIGatewayKey resource_type_ai_provider: ResourceTypeAIProvider resource_type_ai_provider_key: ResourceTypeAIProviderKey + resource_type_ai_gateway_key: ResourceTypeAIGatewayKey mcp_server_config: MCPServerConfig mcp_server_configs: MCPServerConfigs mcp_server_user_token: MCPServerUserToken diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 3d5e5dabcf..fd11ab2e06 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -7,6 +7,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); + UniqueAiGatewayKeysPkey UniqueConstraint = "ai_gateway_keys_pkey" // ALTER TABLE ONLY ai_gateway_keys ADD CONSTRAINT ai_gateway_keys_pkey PRIMARY KEY (id); UniqueAiModelPricesPkey UniqueConstraint = "ai_model_prices_pkey" // ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model); UniqueAiProviderKeysPkey UniqueConstraint = "ai_provider_keys_pkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_pkey PRIMARY KEY (id); UniqueAiProvidersPkey UniqueConstraint = "ai_providers_pkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_pkey PRIMARY KEY (id); @@ -135,6 +136,9 @@ const ( UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id); UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); + UniqueAiGatewayKeysHashedSecretIndex UniqueConstraint = "ai_gateway_keys_hashed_secret_idx" // CREATE UNIQUE INDEX ai_gateway_keys_hashed_secret_idx ON ai_gateway_keys USING btree (hashed_secret); + UniqueAiGatewayKeysNameIndex UniqueConstraint = "ai_gateway_keys_name_idx" // CREATE UNIQUE INDEX ai_gateway_keys_name_idx ON ai_gateway_keys USING btree (lower(name)); + UniqueAiGatewayKeysSecretPrefixIndex UniqueConstraint = "ai_gateway_keys_secret_prefix_idx" // CREATE UNIQUE INDEX ai_gateway_keys_secret_prefix_idx ON ai_gateway_keys USING btree (secret_prefix); UniqueAiProvidersNameUnique UniqueConstraint = "ai_providers_name_unique" // CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); UniqueIndexChatDebugRunsIDChat UniqueConstraint = "idx_chat_debug_runs_id_chat" // CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 824cf92fdd..5ff60562b1 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -15,6 +15,15 @@ var ( Type: "*", } + // ResourceAIGatewayKey + // Valid Actions + // - "ActionCreate" :: create an AI Gateway key + // - "ActionDelete" :: delete an AI Gateway key + // - "ActionRead" :: read AI Gateway keys + ResourceAIGatewayKey = Object{ + Type: "ai_gateway_key", + } + // ResourceAiModelPrice // Valid Actions // - "ActionRead" :: read AI model prices @@ -479,6 +488,7 @@ var ( func AllResources() []Objecter { return []Objecter{ ResourceWildcard, + ResourceAIGatewayKey, ResourceAiModelPrice, ResourceAIProvider, ResourceAiSeat, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index f2b17927bd..f97b2a78bc 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -429,6 +429,14 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionDelete: "delete boundary logs", }, }, + "ai_gateway_key": { + Name: "AIGatewayKey", + Actions: map[Action]ActionDefinition{ + ActionCreate: "create an AI Gateway key", + ActionRead: "read AI Gateway keys", + ActionDelete: "delete an AI Gateway key", + }, + }, "boundary_usage": { Actions: map[Action]ActionDefinition{ ActionRead: "read boundary usage statistics", diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0ac992fc86..7fee71935c 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1204,6 +1204,24 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + // Only owners can manage AI Gateway keys. They hold + // a hashed bearer secret used to authenticate Gateway + // replicas to coderd. Keys are deployment-wide. + Name: "AIGatewayKey", + Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, + Resource: rbac.ResourceAIGatewayKey, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: { + memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, { Name: "BoundaryUsage", Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index b664a4371a..3adad84a59 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -7,6 +7,9 @@ package rbac // declared in code, not here, to avoid duplication. const ( + ScopeAiGatewayKeyCreate ScopeName = "ai_gateway_key:create" + ScopeAiGatewayKeyDelete ScopeName = "ai_gateway_key:delete" + ScopeAiGatewayKeyRead ScopeName = "ai_gateway_key:read" ScopeAiModelPriceRead ScopeName = "ai_model_price:read" ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update" ScopeAiProviderCreate ScopeName = "ai_provider:create" @@ -187,6 +190,9 @@ func (e ScopeName) Valid() bool { case ScopeName("coder:all"), ScopeName("coder:application_connect"), ScopeName("no_user_data"), + ScopeAiGatewayKeyCreate, + ScopeAiGatewayKeyDelete, + ScopeAiGatewayKeyRead, ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, ScopeAiProviderCreate, @@ -368,6 +374,9 @@ func AllScopeNameValues() []ScopeName { ScopeName("coder:all"), ScopeName("coder:application_connect"), ScopeName("no_user_data"), + ScopeAiGatewayKeyCreate, + ScopeAiGatewayKeyDelete, + ScopeAiGatewayKeyRead, ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, ScopeAiProviderCreate, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 4e4fb8d803..450a5221f0 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -6,6 +6,10 @@ const ( APIKeyScopeAll APIKeyScope = "all" // Deprecated: use codersdk.APIKeyScopeCoderApplicationConnect instead. APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + APIKeyScopeAiGatewayKeyAll APIKeyScope = "ai_gateway_key:*" + APIKeyScopeAiGatewayKeyCreate APIKeyScope = "ai_gateway_key:create" + APIKeyScopeAiGatewayKeyDelete APIKeyScope = "ai_gateway_key:delete" + APIKeyScopeAiGatewayKeyRead APIKeyScope = "ai_gateway_key:read" APIKeyScopeAiModelPriceAll APIKeyScope = "ai_model_price:*" APIKeyScopeAiModelPriceRead APIKeyScope = "ai_model_price:read" APIKeyScopeAiModelPriceUpdate APIKeyScope = "ai_model_price:update" diff --git a/codersdk/audit.go b/codersdk/audit.go index eceae40649..e58bbb71f7 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -48,6 +48,7 @@ const ( ResourceTypeAISeat ResourceType = "ai_seat" ResourceTypeAIProvider ResourceType = "ai_provider" ResourceTypeAIProviderKey ResourceType = "ai_provider_key" + ResourceTypeAIGatewayKey ResourceType = "ai_gateway_key" ResourceTypeGroupAIBudget ResourceType = "group_ai_budget" ResourceTypeChat ResourceType = "chat" ResourceTypeUserSecret ResourceType = "user_secret" @@ -116,6 +117,8 @@ func (r ResourceType) FriendlyString() string { return "ai provider" case ResourceTypeAIProviderKey: return "ai provider key" + case ResourceTypeAIGatewayKey: + return "ai gateway key" case ResourceTypeGroupAIBudget: return "group ai budget" case ResourceTypeChat: diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 75b1e82421..622c59c54b 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -5,6 +5,7 @@ type RBACResource string const ( ResourceWildcard RBACResource = "*" + ResourceAIGatewayKey RBACResource = "ai_gateway_key" ResourceAiModelPrice RBACResource = "ai_model_price" ResourceAIProvider RBACResource = "ai_provider" ResourceAiSeat RBACResource = "ai_seat" @@ -82,6 +83,7 @@ const ( // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceWildcard: {}, + ResourceAIGatewayKey: {ActionCreate, ActionDelete, ActionRead}, ResourceAiModelPrice: {ActionRead, ActionUpdate}, ResourceAIProvider: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAiSeat: {ActionCreate, ActionRead}, diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 712724e064..6e65c5fec5 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -15,6 +15,7 @@ We track the following resources: | Resource | | | |-----------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| | AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| | AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| | APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index fae805d3a7..602577852e 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -193,10 +193,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -326,10 +326,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -459,10 +459,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -554,10 +554,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -960,9 +960,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ea8f19c4bf..47268ba974 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1444,9 +1444,9 @@ None #### Enumerated Values -| Value(s) | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key:*`, `ai_gateway_key:create`, `ai_gateway_key:delete`, `ai_gateway_key:read`, `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -10818,9 +10818,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig @@ -11036,9 +11036,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_gateway_key`, `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `group_ai_budget`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_secret`, `user_skill`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 0bedde7b0c..c82ca65701 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -865,11 +865,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `ai_gateway_key`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index e97a76daed..315f6af228 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -31,6 +31,7 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "AiSeatState": {codersdk.AuditActionCreate}, "AIProvider": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "AIProviderKey": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, + "AIGatewayKey": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, "AuditableGroupAiBudget": {codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, @@ -400,6 +401,14 @@ var auditableResourcesTypes = map[any]map[string]Action{ "created_at": ActionIgnore, // Implicit; not useful in a diff. "updated_at": ActionIgnore, // Changes; not useful in a diff. }, + &database.AIGatewayKey{}: { + "id": ActionTrack, + "name": ActionTrack, + "secret_prefix": ActionTrack, + "hashed_secret": ActionSecret, // Bearer token hash, never expose. + "created_at": ActionIgnore, // Implicit; not useful in a diff. + "last_used_at": ActionIgnore, // Bumped on every use. + }, &database.TaskTable{}: { "id": ActionTrack, "organization_id": ActionIgnore, // Never changes. diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 2ac260c98a..15fd4a0f43 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -8,6 +8,11 @@ import type { RBACAction, RBACResource } from "./typesGenerated"; export const RBACResourceActions: Partial< Record>> > = { + ai_gateway_key: { + create: "create an AI Gateway key", + delete: "delete an AI Gateway key", + read: "read AI Gateway keys", + }, ai_model_price: { read: "read AI model prices", update: "update AI model prices", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a42e1489ff..a0aad00206 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -518,6 +518,10 @@ export interface APIKey { // From codersdk/apikey.go export type APIKeyScope = + | "ai_gateway_key:*" + | "ai_gateway_key:create" + | "ai_gateway_key:delete" + | "ai_gateway_key:read" | "ai_model_price:*" | "ai_model_price:read" | "ai_model_price:update" @@ -748,6 +752,10 @@ export type APIKeyScope = | "workspace:update_agent"; export const APIKeyScopes: APIKeyScope[] = [ + "ai_gateway_key:*", + "ai_gateway_key:create", + "ai_gateway_key:delete", + "ai_gateway_key:read", "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", @@ -6873,6 +6881,7 @@ export const RBACActions: RBACAction[] = [ // From codersdk/rbacresources_gen.go export type RBACResource = + | "ai_gateway_key" | "ai_provider" | "ai_model_price" | "ai_seat" @@ -6924,6 +6933,7 @@ export type RBACResource = | "workspace_proxy"; export const RBACResources: RBACResource[] = [ + "ai_gateway_key", "ai_provider", "ai_model_price", "ai_seat", @@ -7080,6 +7090,7 @@ export interface ResolveAutostartResponse { // From codersdk/audit.go export type ResourceType = + | "ai_gateway_key" | "ai_provider" | "ai_provider_key" | "ai_seat" @@ -7115,6 +7126,7 @@ export type ResourceType = | "workspace_proxy"; export const ResourceTypes: ResourceType[] = [ + "ai_gateway_key", "ai_provider", "ai_provider_key", "ai_seat", From 8d93aea1b0954a3fc2c461226d36dea80b0f3a52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:15:48 +0000 Subject: [PATCH 58/63] chore: bump @types/node from 20.19.39 to 20.19.41 in /offlinedocs (#25952) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.39 to 20.19.41.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@types/node&package-manager=npm_and_yarn&previous-version=20.19.39&new-version=20.19.41)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- offlinedocs/package.json | 2 +- offlinedocs/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/offlinedocs/package.json b/offlinedocs/package.json index cdc35b1e50..94720c7a06 100644 --- a/offlinedocs/package.json +++ b/offlinedocs/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "@types/lodash": "4.17.24", - "@types/node": "20.19.39", + "@types/node": "20.19.41", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@types/sanitize-html": "2.16.1", diff --git a/offlinedocs/pnpm-lock.yaml b/offlinedocs/pnpm-lock.yaml index 60120df521..5d266d8204 100644 --- a/offlinedocs/pnpm-lock.yaml +++ b/offlinedocs/pnpm-lock.yaml @@ -69,8 +69,8 @@ importers: specifier: 4.17.24 version: 4.17.24 '@types/node': - specifier: 20.19.39 - version: 20.19.39 + specifier: 20.19.41 + version: 20.19.41 '@types/react': specifier: 18.3.12 version: 18.3.12 @@ -585,8 +585,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.39': - resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -3093,7 +3093,7 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@20.19.39': + '@types/node@20.19.41': dependencies: undici-types: 6.21.0 From 0182219011161495c31707f8b60ac9bd93a333e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:18:02 +0000 Subject: [PATCH 59/63] chore: bump the react group across 1 directory with 3 updates (#25950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps the react group with 3 updates in the /site directory: [react](https://github.com/facebook/react/tree/HEAD/packages/react), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom). Updates `react` from 19.2.5 to 19.2.6
Release notes

Sourced from react's releases.

19.2.6 (May 6th, 2026)

React Server Components

Commits

Updates `@types/react` from 19.2.14 to 19.2.15
Commits

Updates `react-dom` from 19.2.5 to 19.2.6
Release notes

Sourced from react-dom's releases.

19.2.6 (May 6th, 2026)

React Server Components

Commits

Updates `@types/react` from 19.2.14 to 19.2.15
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package.json | 6 +- site/pnpm-lock.yaml | 1856 +++++++++++++++++++++---------------------- 2 files changed, 931 insertions(+), 931 deletions(-) diff --git a/site/package.json b/site/package.json index 0df6778347..c14a76a6ab 100644 --- a/site/package.json +++ b/site/package.json @@ -92,11 +92,11 @@ "motion": "12.38.0", "pretty-bytes": "6.1.1", "radix-ui": "1.4.3", - "react": "19.2.5", + "react": "19.2.6", "react-color": "2.19.3", "react-confetti": "6.4.0", "react-day-picker": "9.14.0", - "react-dom": "19.2.5", + "react-dom": "19.2.6", "react-infinite-scroll-component": "7.1.0", "react-markdown": "9.1.0", "react-query": "npm:@tanstack/react-query@5.77.0", @@ -148,7 +148,7 @@ "@types/lodash": "4.17.21", "@types/node": "20.19.39", "@types/novnc__novnc": "1.5.0", - "@types/react": "19.2.14", + "@types/react": "19.2.15", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/react-syntax-highlighter": "15.5.13", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index f99dc40d62..fb5c914ab9 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -34,19 +34,19 @@ importers: dependencies: '@dnd-kit/core': specifier: 6.3.1 - version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/sortable': specifier: 10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) '@dnd-kit/utilities': specifier: 3.2.2 - version: 3.2.2(react@19.2.5) + version: 3.2.2(react@19.2.6) '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 '@emoji-mart/react': specifier: 1.1.1 - version: 1.1.1(emoji-mart@5.6.0)(react@19.2.5) + version: 1.1.1(emoji-mart@5.6.0)(react@19.2.6) '@emotion/cache': specifier: 11.14.0 version: 11.14.0 @@ -55,10 +55,10 @@ importers: version: 11.13.5 '@emotion/react': specifier: 11.14.0 - version: 11.14.0(@types/react@19.2.14)(react@19.2.5) + version: 11.14.0(@types/react@19.2.15)(react@19.2.6) '@emotion/styled': specifier: 11.14.1 - version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6) '@fontsource-variable/geist': specifier: 5.2.8 version: 5.2.8 @@ -79,28 +79,28 @@ importers: version: 5.2.7 '@lexical/react': specifier: 0.44.0 - version: 0.44.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yjs@13.6.29) + version: 0.44.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.29) '@lexical/utils': specifier: 0.44.0 version: 0.44.0 '@monaco-editor/react': specifier: 4.7.0 - version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mui/material': specifier: 5.18.0 - version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mui/system': specifier: 5.18.0 - version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6) '@novnc/novnc': specifier: ^1.5.0 version: 1.5.0 '@pierre/diffs': specifier: 1.1.19 - version: 1.1.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.1.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-query-devtools': specifier: 5.77.0 - version: 5.77.0(@tanstack/react-query@5.77.0(react@19.2.5))(react@19.2.5) + version: 5.77.0(@tanstack/react-query@5.77.0(react@19.2.6))(react@19.2.6) '@xterm/addon-canvas': specifier: 0.7.0 version: 0.7.0(@xterm/xterm@5.5.0) @@ -136,7 +136,7 @@ importers: version: 2.1.1 cmdk: specifier: 1.1.1 - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) color-convert: specifier: 2.0.1 version: 2.0.1 @@ -160,7 +160,7 @@ importers: version: 2.0.5 formik: specifier: 2.4.9 - version: 2.4.9(@types/react@19.2.14)(react@19.2.5) + version: 2.4.9(@types/react@19.2.15)(react@19.2.6) front-matter: specifier: 4.0.2 version: 4.0.2 @@ -178,64 +178,64 @@ importers: version: 4.18.1 lucide-react: specifier: 0.555.0 - version: 0.555.0(react@19.2.5) + version: 0.555.0(react@19.2.6) monaco-editor: specifier: 0.55.1 version: 0.55.1 motion: specifier: 12.38.0 - version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) pretty-bytes: specifier: 6.1.1 version: 6.1.1 radix-ui: specifier: 1.4.3 - version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: - specifier: 19.2.5 - version: 19.2.5 + specifier: 19.2.6 + version: 19.2.6 react-color: specifier: 2.19.3 - version: 2.19.3(react@19.2.5) + version: 2.19.3(react@19.2.6) react-confetti: specifier: 6.4.0 - version: 6.4.0(react@19.2.5) + version: 6.4.0(react@19.2.6) react-day-picker: specifier: 9.14.0 - version: 9.14.0(react@19.2.5) + version: 9.14.0(react@19.2.6) react-dom: - specifier: 19.2.5 - version: 19.2.5(react@19.2.5) + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) react-infinite-scroll-component: specifier: 7.1.0 - version: 7.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 7.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-markdown: specifier: 9.1.0 - version: 9.1.0(@types/react@19.2.14)(react@19.2.5) + version: 9.1.0(@types/react@19.2.15)(react@19.2.6) react-query: specifier: npm:@tanstack/react-query@5.77.0 - version: '@tanstack/react-query@5.77.0(react@19.2.5)' + version: '@tanstack/react-query@5.77.0(react@19.2.6)' react-resizable-panels: specifier: 3.0.6 - version: 3.0.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router: specifier: 7.12.0 - version: 7.12.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 7.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-syntax-highlighter: specifier: 15.6.6 - version: 15.6.6(react@19.2.5) + version: 15.6.6(react@19.2.6) react-textarea-autosize: specifier: 8.5.9 - version: 8.5.9(@types/react@19.2.14)(react@19.2.5) + version: 8.5.9(@types/react@19.2.15)(react@19.2.6) react-virtualized-auto-sizer: specifier: 1.0.26 - version: 1.0.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.0.26(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-window: specifier: 1.8.11 - version: 1.8.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.8.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6) recharts: specifier: 2.15.4 - version: 2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) remark-gfm: specifier: 4.0.1 version: 4.0.1 @@ -244,10 +244,10 @@ importers: version: 7.7.3 sonner: specifier: 2.0.7 - version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) streamdown: specifier: 2.5.0 - version: 2.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwind-merge: specifier: 2.6.0 version: 2.6.0 @@ -287,7 +287,7 @@ importers: version: 2.4.10 '@chromatic-com/storybook': specifier: 5.0.1 - version: 5.0.1(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 5.0.1(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@octokit/types': specifier: 12.6.0 version: 12.6.0 @@ -299,25 +299,25 @@ importers: version: 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) '@storybook/addon-a11y': specifier: 10.3.3 - version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@storybook/addon-docs': specifier: 10.3.3 - version: 10.3.3(@types/react@19.2.14)(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + version: 10.3.3(@types/react@19.2.15)(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) '@storybook/addon-links': specifier: 10.3.3 - version: 10.3.3(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 10.3.3(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@storybook/addon-mcp': specifier: ^0.6.0 - version: 0.6.0(@storybook/addon-vitest@10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5))(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + version: 0.6.0(@storybook/addon-vitest@10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vitest@4.1.5))(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.2) '@storybook/addon-themes': specifier: 10.3.3 - version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@storybook/addon-vitest': specifier: 10.3.3 - version: 10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5) + version: 10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vitest@4.1.5) '@storybook/react-vite': specifier: 10.3.3 - version: 10.3.3(esbuild@0.25.12)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + version: 10.3.3(esbuild@0.25.12)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.2)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19(tailwindcss@3.4.18(yaml@2.8.3)) @@ -326,7 +326,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: 14.3.1 - version: 14.3.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 14.3.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@testing-library/user-event': specifier: 14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) @@ -355,20 +355,20 @@ importers: specifier: 1.5.0 version: 1.5.0 '@types/react': - specifier: 19.2.14 - version: 19.2.14 + specifier: 19.2.15 + version: 19.2.15 '@types/react-color': specifier: 3.0.13 - version: 3.0.13(@types/react@19.2.14) + version: 3.0.13(@types/react@19.2.15) '@types/react-dom': specifier: 19.2.3 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.3(@types/react@19.2.15) '@types/react-syntax-highlighter': specifier: 15.5.13 version: 15.5.13 '@types/react-virtualized-auto-sizer': specifier: 1.0.8 - version: 1.0.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.0.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/react-window': specifier: 1.8.8 version: 1.8.8 @@ -440,10 +440,10 @@ importers: version: 1.17.0 storybook: specifier: 10.3.3 - version: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) storybook-addon-remix-react-router: specifier: 6.0.0 - version: 6.0.0(react-dom@19.2.5(react@19.2.5))(react-router@7.12.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + version: 6.0.0(react-dom@19.2.6(react@19.2.6))(react-router@7.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) tailwindcss: specifier: 3.4.18 version: 3.4.18(yaml@2.8.3) @@ -2770,8 +2770,8 @@ packages: '@types/react-window@1.8.8': resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==, tarball: https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz} - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==, tarball: https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz} + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==, tarball: https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz} '@types/reactcss@1.2.13': resolution: {integrity: sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==, tarball: https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz} @@ -5189,10 +5189,10 @@ packages: resolution: {integrity: sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==, tarball: https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.2.tgz} engines: {node: ^20.9.0 || >=22} - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz} peerDependencies: - react: ^19.2.5 + react: ^19.2.6 react-error-boundary@6.1.1: resolution: {integrity: sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==, tarball: https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz} @@ -5314,8 +5314,8 @@ packages: react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==, tarball: https://registry.npmjs.org/react/-/react-19.2.5.tgz} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==, tarball: https://registry.npmjs.org/react/-/react-19.2.6.tgz} engines: {node: '>=0.10.0'} reactcss@1.2.3: @@ -6634,13 +6634,13 @@ snapshots: '@chevrotain/utils@11.1.2': {} - '@chromatic-com/storybook@5.0.1(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@chromatic-com/storybook@5.0.1(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': dependencies: '@neoconfetti/react': 1.0.0 chromatic: 13.3.4 filesize: 10.1.6 jsonfile: 6.2.0 - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) strip-ansi: 7.1.2 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -6670,29 +6670,29 @@ snapshots: '@date-fns/tz@1.4.1': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.5)': + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.5) - '@dnd-kit/utilities': 3.2.2(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@dnd-kit/accessibility': 3.1.1(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@dnd-kit/utilities': 3.2.2(react@19.2.5) - react: 19.2.5 + '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.2.5)': + '@dnd-kit/utilities@3.2.2(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 '@emnapi/core@1.10.0': @@ -6713,10 +6713,10 @@ snapshots: '@emoji-mart/data@1.2.1': {} - '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.5)': + '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.6)': dependencies: emoji-mart: 5.6.0 - react: 19.2.5 + react: 19.2.6 '@emotion/babel-plugin@11.13.5': dependencies: @@ -6760,19 +6760,19 @@ snapshots: '@emotion/memoize@0.9.0': {} - '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5)': + '@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6) '@emotion/utils': 1.4.2 '@emotion/weak-memoize': 0.4.0 hoist-non-react-statics: 3.3.2 - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 transitivePeerDependencies: - supports-color @@ -6786,26 +6786,26 @@ snapshots: '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 '@emotion/babel-plugin': 11.13.5 '@emotion/is-prop-valid': 1.4.0 - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6) '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.5) + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6) '@emotion/utils': 1.4.2 - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 transitivePeerDependencies: - supports-color '@emotion/unitless@0.10.0': {} - '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.5)': + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 '@emotion/utils@1.4.2': {} @@ -6898,18 +6898,18 @@ snapshots: '@floating-ui/core': 1.7.4 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@floating-ui/react-dom@2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/dom': 1.7.5 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - '@floating-ui/react@0.27.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@floating-ui/react@0.27.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@floating-ui/utils': 0.2.10 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) tabbable: 6.4.0 '@floating-ui/utils@0.2.10': {} @@ -6934,9 +6934,9 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.2 - '@icons/material@0.2.4(react@19.2.5)': + '@icons/material@0.2.4(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 '@inquirer/confirm@3.2.0': dependencies: @@ -7025,7 +7025,7 @@ snapshots: '@lexical/extension': 0.44.0 lexical: 0.44.0 - '@lexical/devtools-core@0.44.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@lexical/devtools-core@0.44.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@lexical/html': 0.44.0 '@lexical/link': 0.44.0 @@ -7033,8 +7033,8 @@ snapshots: '@lexical/table': 0.44.0 '@lexical/utils': 0.44.0 lexical: 0.44.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) '@lexical/dragon@0.44.0': dependencies: @@ -7105,10 +7105,10 @@ snapshots: '@lexical/utils': 0.44.0 lexical: 0.44.0 - '@lexical/react@0.44.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(yjs@13.6.29)': + '@lexical/react@0.44.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.29)': dependencies: - '@floating-ui/react': 0.27.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@lexical/devtools-core': 0.44.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@floating-ui/react': 0.27.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@lexical/devtools-core': 0.44.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/dragon': 0.44.0 '@lexical/extension': 0.44.0 '@lexical/hashtag': 0.44.0 @@ -7125,9 +7125,9 @@ snapshots: '@lexical/utils': 0.44.0 '@lexical/yjs': 0.44.0(yjs@13.6.29) lexical: 0.44.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-error-boundary: 6.1.1(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-error-boundary: 6.1.1(react@19.2.6) optionalDependencies: yjs: 13.6.29 @@ -7165,11 +7165,11 @@ snapshots: lexical: 0.44.0 yjs: 13.6.29 - '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.5)': + '@mdx-js/react@3.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: '@types/mdx': 2.0.13 - '@types/react': 19.2.14 - react: 19.2.5 + '@types/react': 19.2.15 + react: 19.2.6 '@mermaid-js/parser@1.0.1': dependencies: @@ -7189,12 +7189,12 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@monaco-editor/loader': 1.5.0 monaco-editor: 0.55.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) '@mswjs/interceptors@0.35.9': dependencies: @@ -7207,79 +7207,79 @@ snapshots: '@mui/core-downloads-tracker@5.18.0': {} - '@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 '@mui/core-downloads-tracker': 5.18.0 - '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) - '@mui/types': 7.2.24(@types/react@19.2.14) - '@mui/utils': 5.17.1(@types/react@19.2.14)(react@19.2.5) + '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6) + '@mui/types': 7.2.24(@types/react@19.2.15) + '@mui/utils': 5.17.1(@types/react@19.2.15)(react@19.2.6) '@popperjs/core': 2.11.8 - '@types/react-transition-group': 4.4.12(@types/react@19.2.14) + '@types/react-transition-group': 4.4.12(@types/react@19.2.15) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) react-is: 19.1.1 - react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) - '@types/react': 19.2.14 + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6) + '@types/react': 19.2.15 - '@mui/private-theming@5.17.1(@types/react@19.2.14)(react@19.2.5)': + '@mui/private-theming@5.17.1(@types/react@19.2.15)(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 - '@mui/utils': 5.17.1(@types/react@19.2.14)(react@19.2.5) + '@mui/utils': 5.17.1(@types/react@19.2.15)(react@19.2.6) prop-types: 15.8.1 - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@mui/styled-engine@5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5)': + '@mui/styled-engine@5.18.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 csstype: 3.2.3 prop-types: 15.8.1 - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6) - '@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5)': + '@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 - '@mui/private-theming': 5.17.1(@types/react@19.2.14)(react@19.2.5) - '@mui/styled-engine': 5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5))(react@19.2.5) - '@mui/types': 7.2.24(@types/react@19.2.14) - '@mui/utils': 5.17.1(@types/react@19.2.14)(react@19.2.5) + '@mui/private-theming': 5.17.1(@types/react@19.2.15)(react@19.2.6) + '@mui/styled-engine': 5.18.0(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6))(react@19.2.6) + '@mui/types': 7.2.24(@types/react@19.2.15) + '@mui/utils': 5.17.1(@types/react@19.2.15)(react@19.2.6) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.5) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(@types/react@19.2.14)(react@19.2.5) - '@types/react': 19.2.14 + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6) + '@types/react': 19.2.15 - '@mui/types@7.2.24(@types/react@19.2.14)': + '@mui/types@7.2.24(@types/react@19.2.15)': optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@mui/utils@5.17.1(@types/react@19.2.14)(react@19.2.5)': + '@mui/utils@5.17.1(@types/react@19.2.15)(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 - '@mui/types': 7.2.24(@types/react@19.2.14) + '@mui/types': 7.2.24(@types/react@19.2.15) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 - react: 19.2.5 + react: 19.2.6 react-is: 19.1.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 '@napi-rs/wasm-runtime@1.0.7': dependencies: @@ -7387,15 +7387,15 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.14.0': optional: true - '@pierre/diffs@1.1.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@pierre/diffs@1.1.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 0.0.28 '@shikijs/transformers': 3.23.0 diff: 8.0.3 hast-util-to-html: 9.0.5 lru_map: 0.4.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) shiki: 3.23.0 '@pierre/theme@0.0.28': {} @@ -7440,746 +7440,746 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-context@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-direction@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-id@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) '@radix-ui/rect': 1.1.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-slot@1.2.3(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 - use-sync-external-store: 1.6.0(react@19.2.5) + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: '@radix-ui/rect': 1.1.1 - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.15)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) '@radix-ui/rect@1.1.1': {} @@ -8293,21 +8293,21 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-a11y@10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/addon-a11y@10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.11.1 - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@storybook/addon-docs@10.3.3(@types/react@19.2.14)(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': + '@storybook/addon-docs@10.3.3(@types/react@19.2.15)(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) - '@storybook/csf-plugin': 10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) - '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/react-dom-shim': 10.3.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mdx-js/react': 3.1.1(@types/react@19.2.15)(react@19.2.6) + '@storybook/csf-plugin': 10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + '@storybook/icons': 2.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@storybook/react-dom-shim': 10.3.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -8316,38 +8316,38 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.3.3(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/addon-links@10.3.3(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - react: 19.2.5 + react: 19.2.6 - '@storybook/addon-mcp@0.6.0(@storybook/addon-vitest@10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5))(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': + '@storybook/addon-mcp@0.6.0(@storybook/addon-vitest@10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vitest@4.1.5))(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.2)': dependencies: '@storybook/mcp': 0.7.0(typescript@6.0.2) '@tmcp/adapter-valibot': 0.1.5(tmcp@1.19.3(typescript@6.0.2))(valibot@1.2.0(typescript@6.0.2)) '@tmcp/transport-http': 0.8.5(tmcp@1.19.3(typescript@6.0.2)) picoquery: 2.5.0 - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tmcp: 1.19.3(typescript@6.0.2) valibot: 1.2.0(typescript@6.0.2) optionalDependencies: - '@storybook/addon-vitest': 10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5) + '@storybook/addon-vitest': 10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vitest@4.1.5) transitivePeerDependencies: - '@tmcp/auth' - typescript - '@storybook/addon-themes@10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/addon-themes@10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': dependencies: - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) ts-dedent: 2.2.0 - '@storybook/addon-vitest@10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5)': + '@storybook/addon-vitest@10.3.3(@vitest/browser-playwright@4.1.1)(@vitest/browser@4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5))(@vitest/runner@4.1.5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vitest@4.1.5)': dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/icons': 2.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: '@vitest/browser': 4.1.1(msw@2.4.8(typescript@6.0.2))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5) '@vitest/browser-playwright': 4.1.1(msw@2.4.8(typescript@6.0.2))(playwright@1.55.1)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5) @@ -8357,10 +8357,10 @@ snapshots: - react - react-dom - '@storybook/builder-vite@10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': + '@storybook/builder-vite@10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': dependencies: - '@storybook/csf-plugin': 10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/csf-plugin': 10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) ts-dedent: 2.2.0 vite: 8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3) transitivePeerDependencies: @@ -8368,9 +8368,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': + '@storybook/csf-plugin@10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': dependencies: - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) unplugin: 2.3.11 optionalDependencies: esbuild: 0.25.12 @@ -8378,10 +8378,10 @@ snapshots: '@storybook/global@5.0.0': {} - '@storybook/icons@2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@storybook/icons@2.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) '@storybook/mcp@0.7.0(typescript@6.0.2)': dependencies: @@ -8393,25 +8393,25 @@ snapshots: - '@tmcp/auth' - typescript - '@storybook/react-dom-shim@10.3.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/react-dom-shim@10.3.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@storybook/react-vite@10.3.3(esbuild@0.25.12)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': + '@storybook/react-vite@10.3.3(esbuild@0.25.12)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.2)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@6.0.2)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) '@rollup/pluginutils': 5.3.0 - '@storybook/builder-vite': 10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) - '@storybook/react': 10.3.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) + '@storybook/builder-vite': 10.3.3(esbuild@0.25.12)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + '@storybook/react': 10.3.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.2) empathic: 2.0.0 magic-string: 0.30.21 - react: 19.2.5 + react: 19.2.6 react-docgen: 8.0.2 - react-dom: 19.2.5(react@19.2.5) + react-dom: 19.2.6(react@19.2.6) resolve: 1.22.11 - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tsconfig-paths: 4.2.0 vite: 8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3) transitivePeerDependencies: @@ -8421,15 +8421,15 @@ snapshots: - typescript - webpack - '@storybook/react@10.3.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)': + '@storybook/react@10.3.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.2)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.3.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) - react: 19.2.5 + '@storybook/react-dom-shim': 10.3.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + react: 19.2.6 react-docgen: 8.0.2 react-docgen-typescript: 2.4.0(typescript@6.0.2) - react-dom: 19.2.5(react@19.2.5) - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-dom: 19.2.6(react@19.2.6) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: typescript: 6.0.2 transitivePeerDependencies: @@ -8446,16 +8446,16 @@ snapshots: '@tanstack/query-devtools@5.76.0': {} - '@tanstack/react-query-devtools@5.77.0(@tanstack/react-query@5.77.0(react@19.2.5))(react@19.2.5)': + '@tanstack/react-query-devtools@5.77.0(@tanstack/react-query@5.77.0(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-devtools': 5.76.0 - '@tanstack/react-query': 5.77.0(react@19.2.5) - react: 19.2.5 + '@tanstack/react-query': 5.77.0(react@19.2.6) + react: 19.2.6 - '@tanstack/react-query@5.77.0(react@19.2.5)': + '@tanstack/react-query@5.77.0(react@19.2.6)': dependencies: '@tanstack/query-core': 5.77.0 - react: 19.2.5 + react: 19.2.6 '@testing-library/dom@10.4.0': dependencies: @@ -8488,13 +8488,13 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@14.3.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@testing-library/react@14.3.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.26.10 '@testing-library/dom': 9.3.3 - '@types/react-dom': 18.3.7(@types/react@19.2.14) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@types/react-dom': 18.3.7(@types/react@19.2.15) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@types/react' @@ -8734,9 +8734,9 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)': + '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 hoist-non-react-statics: 3.3.2 '@types/http-errors@2.0.1': {} @@ -8783,45 +8783,45 @@ snapshots: '@types/range-parser@1.2.4': {} - '@types/react-color@3.0.13(@types/react@19.2.14)': + '@types/react-color@3.0.13(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 - '@types/reactcss': 1.2.13(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/reactcss': 1.2.13(@types/react@19.2.15) - '@types/react-dom@18.3.7(@types/react@19.2.14)': + '@types/react-dom@18.3.7(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 '@types/react-syntax-highlighter@15.5.13': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@types/react-transition-group@4.4.12(@types/react@19.2.14)': + '@types/react-transition-group@4.4.12(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@types/react-virtualized-auto-sizer@1.0.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@types/react-virtualized-auto-sizer@1.0.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - react-virtualized-auto-sizer: 1.0.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-virtualized-auto-sizer: 1.0.26(react-dom@19.2.6(react@19.2.6))(react@19.2.6) transitivePeerDependencies: - react - react-dom '@types/react-window@1.8.8': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - '@types/react@19.2.14': + '@types/react@19.2.15': dependencies: csstype: 3.2.3 - '@types/reactcss@1.2.13(@types/react@19.2.14)': + '@types/reactcss@1.2.13(@types/react@19.2.15)': dependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 '@types/resolve@1.20.6': {} @@ -9316,14 +9316,14 @@ snapshots: clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -9998,14 +9998,14 @@ snapshots: dependencies: fd-package-json: 2.0.0 - formik@2.4.9(@types/react@19.2.14)(react@19.2.5): + formik@2.4.9(@types/react@19.2.15)(react@19.2.6): dependencies: - '@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.14) + '@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.15) deepmerge: 2.2.1 hoist-non-react-statics: 3.3.2 lodash: 4.18.1 lodash-es: 4.18.1 - react: 19.2.5 + react: 19.2.6 react-fast-compare: 2.0.4 tiny-warning: 1.0.3 tslib: 2.8.1 @@ -10016,15 +10016,15 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: motion-dom: 12.38.0 motion-utils: 12.36.0 tslib: 2.8.1 optionalDependencies: '@emotion/is-prop-valid': 1.4.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) fresh@0.5.2: {} @@ -10663,9 +10663,9 @@ snapshots: lru_map@0.4.1: {} - lucide-react@0.555.0(react@19.2.5): + lucide-react@0.555.0(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 luxon@3.3.0: {} @@ -11116,14 +11116,14 @@ snapshots: motion-utils@12.36.0: {} - motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - framer-motion: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + framer-motion: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tslib: 2.8.1 optionalDependencies: '@emotion/is-prop-valid': 1.4.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) mrmime@2.0.1: {} @@ -11506,68 +11506,68 @@ snapshots: queue-microtask@1.2.3: {} - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) range-parser@1.2.1: {} @@ -11578,29 +11578,29 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-color@2.19.3(react@19.2.5): + react-color@2.19.3(react@19.2.6): dependencies: - '@icons/material': 0.2.4(react@19.2.5) + '@icons/material': 0.2.4(react@19.2.6) lodash: 4.18.1 lodash-es: 4.18.1 material-colors: 1.2.6 prop-types: 15.8.1 - react: 19.2.5 - reactcss: 1.2.3(react@19.2.5) + react: 19.2.6 + reactcss: 1.2.3(react@19.2.6) tinycolor2: 1.6.0 - react-confetti@6.4.0(react@19.2.5): + react-confetti@6.4.0(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 tween-functions: 1.2.0 - react-day-picker@9.14.0(react@19.2.5): + react-day-picker@9.14.0(react@19.2.6): dependencies: '@date-fns/tz': 1.4.1 '@tabby_ai/hijri-converter': 1.0.5 date-fns: 4.1.0 date-fns-jalali: 4.1.0-0 - react: 19.2.5 + react: 19.2.6 react-docgen-typescript@2.4.0(typescript@6.0.2): dependencies: @@ -11621,25 +11621,25 @@ snapshots: transitivePeerDependencies: - supports-color - react-dom@19.2.5(react@19.2.5): + react-dom@19.2.6(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 scheduler: 0.27.0 - react-error-boundary@6.1.1(react@19.2.5): + react-error-boundary@6.1.1(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 react-fast-compare@2.0.4: {} - react-infinite-scroll-component@7.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-infinite-scroll-component@7.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - react-inspector@6.0.2(react@19.2.5): + react-inspector@6.0.2(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 react-is@16.13.1: {} @@ -11649,16 +11649,16 @@ snapshots: react-is@19.1.1: {} - react-markdown@9.1.0(@types/react@19.2.14)(react@19.2.5): + react-markdown@9.1.0(@types/react@19.2.15)(react@19.2.6): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.14 + '@types/react': 19.2.15 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.5 + react: 19.2.6 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -11667,100 +11667,100 @@ snapshots: transitivePeerDependencies: - supports-color - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll-bar@2.3.8(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.2.5 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.6) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - react-remove-scroll@2.7.1(@types/react@19.2.14)(react@19.2.5): + react-remove-scroll@2.7.1(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.2.5 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.15)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.15)(react@19.2.6) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) + use-callback-ref: 1.3.3(@types/react@19.2.15)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - react-resizable-panels@3.0.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-resizable-panels@3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - react-router@7.12.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-router@7.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: cookie: 1.1.1 - react: 19.2.5 + react: 19.2.6 set-cookie-parser: 2.7.2 optionalDependencies: - react-dom: 19.2.5(react@19.2.5) + react-dom: 19.2.6(react@19.2.6) - react-smooth@4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-smooth@4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: fast-equals: 5.3.2 prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): + react-style-singleton@2.2.3(@types/react@19.2.15)(react@19.2.6): dependencies: get-nonce: 1.0.1 - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - react-syntax-highlighter@15.6.6(react@19.2.5): + react-syntax-highlighter@15.6.6(react@19.2.6): dependencies: '@babel/runtime': 7.26.10 highlight.js: 10.7.3 highlightjs-vue: 1.0.0 lowlight: 1.20.0 prismjs: 1.30.0 - react: 19.2.5 + react: 19.2.6 refractor: 3.6.0 - react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.5): + react-textarea-autosize@8.5.9(@types/react@19.2.15)(react@19.2.6): dependencies: '@babel/runtime': 7.26.10 - react: 19.2.5 - use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.5) - use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + use-composed-ref: 1.4.0(@types/react@19.2.15)(react@19.2.6) + use-latest: 1.3.0(@types/react@19.2.15)(react@19.2.6) transitivePeerDependencies: - '@types/react' - react-transition-group@4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-transition-group@4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/runtime': 7.26.10 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - react-virtualized-auto-sizer@1.0.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-virtualized-auto-sizer@1.0.26(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - react-window@1.8.11(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + react-window@1.8.11(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/runtime': 7.26.10 memoize-one: 5.2.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - react@19.2.5: {} + react@19.2.6: {} - reactcss@1.2.3(react@19.2.5): + reactcss@1.2.3(react@19.2.6): dependencies: lodash: 4.18.1 - react: 19.2.5 + react: 19.2.6 read-cache@1.0.0: dependencies: @@ -11800,15 +11800,15 @@ snapshots: dependencies: decimal.js-light: 2.5.1 - recharts@2.15.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + recharts@2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: clsx: 2.1.1 eventemitter3: 4.0.7 lodash: 4.18.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-smooth: 4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) recharts-scale: 0.4.5 tiny-invariant: 1.3.3 victory-vendor: 36.9.2 @@ -12096,10 +12096,10 @@ snapshots: smol-toml@1.5.2: {} - sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) source-map-js@1.2.1: {} @@ -12139,21 +12139,21 @@ snapshots: dependencies: internal-slot: 1.0.6 - storybook-addon-remix-react-router@6.0.0(react-dom@19.2.5(react@19.2.5))(react-router@7.12.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): + storybook-addon-remix-react-router@6.0.0(react-dom@19.2.6(react@19.2.6))(react-router@7.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)): dependencies: '@mjackson/form-data-parser': 0.4.0 compare-versions: 6.1.0 - react-inspector: 6.0.2(react@19.2.5) - react-router: 7.12.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-inspector: 6.0.2(react@19.2.6) + react-router: 7.12.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) - storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/icons': 2.0.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) '@vitest/expect': 3.2.4 @@ -12162,7 +12162,7 @@ snapshots: open: 10.2.0 recast: 0.23.11 semver: 7.7.3 - use-sync-external-store: 1.6.0(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.6) ws: 8.20.0 optionalDependencies: prettier: 3.4.1 @@ -12173,15 +12173,15 @@ snapshots: - react-dom - utf-8-validate - streamdown@2.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + streamdown@2.5.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: clsx: 2.1.1 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 marked: 17.0.5 mermaid: 11.13.0 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) rehype-harden: 1.1.8 rehype-raw: 7.0.0 rehype-sanitize: 6.0.0 @@ -12535,43 +12535,43 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): + use-callback-ref@1.3.3(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.5): + use-composed-ref@1.4.0(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.5): + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - use-latest@1.3.0(@types/react@19.2.14)(react@19.2.5): + use-latest@1.3.0(@types/react@19.2.15)(react@19.2.6): dependencies: - react: 19.2.5 - use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.15)(react@19.2.6) optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): + use-sidecar@1.1.3(@types/react@19.2.15)(react@19.2.6): dependencies: detect-node-es: 1.1.0 - react: 19.2.5 + react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.14 + '@types/react': 19.2.15 - use-sync-external-store@1.6.0(react@19.2.5): + use-sync-external-store@1.6.0(react@19.2.6): dependencies: - react: 19.2.5 + react: 19.2.6 util-deprecate@1.0.2: {} From 91aee5010de36884e76d989b40aa81a46dd08a0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:19:57 +0000 Subject: [PATCH 60/63] chore: bump @fontsource-variable/geist from 5.2.8 to 5.2.9 in /site (#25953) Bumps [@fontsource-variable/geist](https://github.com/fontsource/font-files/tree/HEAD/fonts/variable/geist) from 5.2.8 to 5.2.9.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@fontsource-variable/geist&package-manager=npm_and_yarn&previous-version=5.2.8&new-version=5.2.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package.json | 2 +- site/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/site/package.json b/site/package.json index c14a76a6ab..89143ea494 100644 --- a/site/package.json +++ b/site/package.json @@ -48,7 +48,7 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", - "@fontsource-variable/geist": "5.2.8", + "@fontsource-variable/geist": "5.2.9", "@fontsource-variable/geist-mono": "5.2.7", "@fontsource/fira-code": "5.2.7", "@fontsource/ibm-plex-mono": "5.2.7", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index fb5c914ab9..181f48ce04 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -60,8 +60,8 @@ importers: specifier: 11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6))(@types/react@19.2.15)(react@19.2.6) '@fontsource-variable/geist': - specifier: 5.2.8 - version: 5.2.8 + specifier: 5.2.9 + version: 5.2.9 '@fontsource-variable/geist-mono': specifier: 5.2.7 version: 5.2.7 @@ -1010,8 +1010,8 @@ packages: '@fontsource-variable/geist-mono@5.2.7': resolution: {integrity: sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA==, tarball: https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.2.7.tgz} - '@fontsource-variable/geist@5.2.8': - resolution: {integrity: sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw==, tarball: https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.8.tgz} + '@fontsource-variable/geist@5.2.9': + resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==, tarball: https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz} '@fontsource/fira-code@5.2.7': resolution: {integrity: sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==, tarball: https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz} @@ -6916,7 +6916,7 @@ snapshots: '@fontsource-variable/geist-mono@5.2.7': {} - '@fontsource-variable/geist@5.2.8': {} + '@fontsource-variable/geist@5.2.9': {} '@fontsource/fira-code@5.2.7': {} From 5320702a8a02fed59eccb9dbfe5c2c3199a588df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:20:33 +0000 Subject: [PATCH 61/63] chore: bump axios from 1.16.0 to 1.16.1 in /site (#25954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [axios](https://github.com/axios/axios) from 1.16.0 to 1.16.1.
Release notes

Sourced from axios's releases.

v1.16.1 — May 13, 2026

This release ships a defence-in-depth fix for prototype pollution in formDataToJSON, hardens proxy and CI workflows, restores Webpack 4 compatibility for the fetch adapter, and includes several small bug fixes and maintenance improvements.

🔒 Security Fixes

  • Prototype Pollution Defence-in-Depth: Hardened formDataToJSON against already-polluted Object.prototype by walking own properties only, so attacker-controlled keys inherited from a poisoned prototype cannot propagate through deserialization. (#7413)
  • Proxy Cleartext Leak: Fixed an issue where HTTPS request data could be transmitted in cleartext to an HTTP proxy under certain configurations. (#10858)
  • CI Cache Removal: Removed all GitHub Actions caches as a defence-in-depth measure against cache poisoning vectors in the build pipeline. (#10882)

🐛 Bug Fixes

  • Data URI Parsing: Updated the fromDataURI regex to match RFC 2397 more strictly, fixing edge cases in data: URL handling. (#10829)
  • Unicode Headers: Preserved Unicode header values when running through request interceptors, so non-ASCII header content is no longer corrupted before dispatch. (#10850)
  • XHR Upload Progress: Guarded against malformed ProgressEvent payloads emitted by some environments during XHR upload, preventing crashes when loaded / total are missing or invalid. (#10868)
  • Webpack 4 Fetch Adapter: Fixed an "unexpected token" error caused by syntax in the fetch adapter that Webpack 4 could not parse, restoring compatibility for legacy bundler users. (#10864)
  • Type Definitions: Made parseReviver context.source optional in the type definitions to align with the ES2023 specification. (#10837)
  • URL Object Support Reverted: Reverted the change that allowed passing a URL object as config.url (originally #10866) due to regressions; this support will be reintroduced in a later release once the underlying issues are addressed. (#10874)

🔧 Maintenance & Chores

  • Cycle Detection Refactor: Replaced the array-based cycle tracker in toJSONObject with a WeakSet, improving performance and memory behaviour on large nested structures. (#10832)
  • composeSignals Cleanup: Refactored composeSignals to use a clearer early-return structure, simplifying the cancellation/abort composition path. (#10844)
  • AI Readiness & Repo Docs: Added AGENTS.md and related contributor-guide updates for both human and AI agents, plus post-release documentation improvements. (#10835, #10841)
  • Docs Improvements: Clarified the GET request example, fixed the interceptor eject example to reference the correct instance, and corrected the Buzzoid sponsor description in the README. (#10836, #10853, #10856)
  • Sponsorship Tooling: Fixed empty sponsor arrays in the sponsor processing script, added the ability to inject additional sponsors, updated the sponsorship link, and added a Twicsy advertisement entry. (#10843, #10859, #10869)
  • Dependencies: Bumped @commitlint/cli from 20.5.0 to 20.5.2. (#10846)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve axios:

Full Changelog

Changelog

Sourced from axios's changelog.

v1.16.1 — May 13, 2026

This release ships a defence-in-depth fix for prototype pollution in formDataToJSON, hardens proxy and CI workflows, restores Webpack 4 compatibility for the fetch adapter, and includes several small bug fixes and maintenance improvements.

🔒 Security Fixes

  • Prototype Pollution Defence-in-Depth: Hardened formDataToJSON against already-polluted Object.prototype by walking own properties only, so attacker-controlled keys inherited from a poisoned prototype cannot propagate through deserialization. (#7413)
  • Proxy Cleartext Leak: Fixed an issue where HTTPS request data could be transmitted in cleartext to an HTTP proxy under certain configurations. (#10858)
  • CI Cache Removal: Removed all GitHub Actions caches as a defence-in-depth measure against cache poisoning vectors in the build pipeline. (#10882)

🐛 Bug Fixes

  • Data URI Parsing: Updated the fromDataURI regex to match RFC 2397 more strictly, fixing edge cases in data: URL handling. (#10829)
  • Unicode Headers: Preserved Unicode header values when running through request interceptors, so non-ASCII header content is no longer corrupted before dispatch. (#10850)
  • XHR Upload Progress: Guarded against malformed ProgressEvent payloads emitted by some environments during XHR upload, preventing crashes when loaded / total are missing or invalid. (#10868)
  • Webpack 4 Fetch Adapter: Fixed an "unexpected token" error caused by syntax in the fetch adapter that Webpack 4 could not parse, restoring compatibility for legacy bundler users. (#10864)
  • Type Definitions: Made parseReviver context.source optional in the type definitions to align with the ES2023 specification. (#10837)
  • URL Object Support Reverted: Reverted the change that allowed passing a URL object as config.url (originally #10866) due to regressions; this support will be reintroduced in a later release once the underlying issues are addressed. (#10874)

🔧 Maintenance & Chores

  • Cycle Detection Refactor: Replaced the array-based cycle tracker in toJSONObject with a WeakSet, improving performance and memory behaviour on large nested structures. (#10832)
  • composeSignals Cleanup: Refactored composeSignals to use a clearer early-return structure, simplifying the cancellation/abort composition path. (#10844)
  • AI Readiness & Repo Docs: Added AGENTS.md and related contributor-guide updates for both human and AI agents, plus post-release documentation improvements. (#10835, #10841)
  • Docs Improvements: Clarified the GET request example, fixed the interceptor eject example to reference the correct instance, and corrected the Buzzoid sponsor description in the README. (#10836, #10853, #10856)
  • Sponsorship Tooling: Fixed empty sponsor arrays in the sponsor processing script, added the ability to inject additional sponsors, updated the sponsorship link, and added a Twicsy advertisement entry. (#10843, #10859, #10869)
  • Dependencies: Bumped @commitlint/cli from 20.5.0 to 20.5.2. (#10846)

🌟 New Contributors

We are thrilled to welcome our new contributors. Thank you for helping improve axios:

Full Changelog

Commits
  • 1337d6b chore(release): prepare release 1.16.1 (#10877)
  • 858a790 fix: remove all caches (#10882)
  • 34adfd9 revert: "fix: support URL object as config.url input (#10866)" (#10874)
  • 847d89b fix: support URL object as config.url input (#10866)
  • 4094886 fix(progress): guard malformed XHR upload events (#10868)
  • 44f0c5b chore: change sponsorship link and add Twicsy advertisement (#10869)
  • 64e1095 chore: update PR and issue template to use h2 (#10865)
  • 3e6b4e1 fix: error unexpected token in fetch JS compatibility issue with Webpack 4 (#...
  • c4453ba fix: add the ability to add additional sponsors to the process sponsors scrip...
  • caa00a9 fix: https data in cleartext to proxy (#10858)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=axios&package-manager=npm_and_yarn&previous-version=1.16.0&new-version=1.16.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package.json | 2 +- site/pnpm-lock.yaml | 33 ++++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/site/package.json b/site/package.json index 89143ea494..d0f115c41c 100644 --- a/site/package.json +++ b/site/package.json @@ -69,7 +69,7 @@ "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "5.5.0", "ansi-to-html": "0.7.2", - "axios": "1.16.0", + "axios": "1.16.1", "chroma-js": "2.6.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 181f48ce04..40a9596d9e 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -123,8 +123,8 @@ importers: specifier: 0.7.2 version: 0.7.2 axios: - specifier: 1.16.0 - version: 1.16.0 + specifier: 1.16.1 + version: 1.16.1 chroma-js: specifier: 2.6.0 version: 2.6.0 @@ -2943,6 +2943,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==, tarball: https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz} engines: {node: '>= 14'} @@ -3038,8 +3042,8 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==, tarball: https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz} engines: {node: '>=4'} - axios@1.16.0: - resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==, tarball: https://registry.npmjs.org/axios/-/axios-1.16.0.tgz} + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==, tarball: https://registry.npmjs.org/axios/-/axios-1.16.1.tgz} babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==, tarball: https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz} @@ -4090,6 +4094,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==, tarball: https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==, tarball: https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, tarball: https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz} engines: {node: '>= 14'} @@ -9013,6 +9021,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ansi-escapes@4.3.2: @@ -9096,13 +9110,15 @@ snapshots: axe-core@4.11.1: {} - axios@1.16.0: + axios@1.16.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.4 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color babel-plugin-macros@3.1.0: dependencies: @@ -10257,6 +10273,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 From cc533846db0d14158468d05af27f6b83f817e71d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:20:55 +0000 Subject: [PATCH 62/63] chore: bump @babel/core from 7.29.0 to 7.29.7 in /site (#25956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.29.0 to 7.29.7.
Release notes

Sourced from @​babel/core's releases.

v7.29.7 (2026-05-25)

Re-release all packages with npm provenance attestations

v7.29.6 (2026-05-25)

:bug: Bug Fix

Committers: 3

v7.29.5 (2026-05-05)

:house: Internal

  • babel-preset-env
    • Update @babel/* dependencies

v7.29.4 (2026-05-05)

:bug: Bug Fix

  • babel-plugin-transform-modules-systemjs
    • #17974 [7.x backport]fix(systemjs): improve module string name support (@​JLHwung)

Committers: 1

v7.29.3 (2026-04-30)

:eyeglasses: Spec Compliance

:bug: Bug Fix

  • babel-helper-create-class-features-plugin, babel-plugin-proposal-decorators
    • #17931 fix(decorators): replace super within all removed static elements (@​JLHwung)
  • babel-register
  • babel-compat-data, babel-plugin-bugfix-safari-rest-destructuring-rhs-array, babel-preset-env

:nail_care: Polish

  • babel-parser

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@babel/core&package-manager=npm_and_yarn&previous-version=7.29.0&new-version=7.29.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package.json | 2 +- site/pnpm-lock.yaml | 245 +++++++++++++++++++------------------------- 2 files changed, 106 insertions(+), 141 deletions(-) diff --git a/site/package.json b/site/package.json index d0f115c41c..86bfbf708a 100644 --- a/site/package.json +++ b/site/package.json @@ -122,7 +122,7 @@ "yup": "1.7.1" }, "devDependencies": { - "@babel/core": "7.29.0", + "@babel/core": "7.29.7", "@babel/plugin-syntax-typescript": "7.28.6", "@biomejs/biome": "2.4.10", "@chromatic-com/storybook": "5.0.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 40a9596d9e..e6544806d7 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -277,11 +277,11 @@ importers: version: 1.7.1 devDependencies: '@babel/core': - specifier: 7.29.0 - version: 7.29.0 + specifier: 7.29.7 + version: 7.29.7 '@babel/plugin-syntax-typescript': specifier: 7.28.6 - version: 7.28.6(@babel/core@7.29.0) + version: 7.28.6(@babel/core@7.29.7) '@biomejs/biome': specifier: 2.4.10 version: 2.4.10 @@ -296,7 +296,7 @@ importers: version: 1.50.1 '@rolldown/plugin-babel': specifier: 0.2.3 - version: 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) '@storybook/addon-a11y': specifier: 10.3.3 version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.4.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) @@ -386,7 +386,7 @@ importers: version: 9.0.2 '@vitejs/plugin-react': specifier: 6.0.1 - version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) '@vitest/browser-playwright': specifier: 4.1.1 version: 4.1.1(msw@2.4.8(typescript@6.0.2))(playwright@1.55.1)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5) @@ -499,40 +499,36 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==, tarball: https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==, tarball: https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==, tarball: https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==, tarball: https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz} + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==, tarball: https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==, tarball: https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==, tarball: https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==, tarball: https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==, tarball: https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==, tarball: https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz} engines: {node: '>=6.9.0'} '@babel/helper-module-imports@7.27.1': resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==, tarball: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==, tarball: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==, tarball: https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==, tarball: https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==, tarball: https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -545,6 +541,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==, tarball: https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz} engines: {node: '>=6.9.0'} @@ -553,21 +553,16 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==, tarball: https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==, tarball: https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==, tarball: https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz} engines: {node: '>=6.9.0'} '@babel/helpers@7.26.10': resolution: {integrity: sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==, tarball: https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==, tarball: https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz} engines: {node: '>=6.0.0'} hasBin: true @@ -581,28 +576,20 @@ packages: resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==, tarball: https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==, tarball: https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==, tarball: https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz} engines: {node: '>=6.9.0'} '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==, tarball: https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz} engines: {node: '>=6.9.0'} '@biomejs/biome@2.4.10': @@ -4524,6 +4511,10 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz} engines: {node: 20 || >=22} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, tarball: https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz} @@ -6400,7 +6391,7 @@ snapshots: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - lru-cache: 11.2.4 + lru-cache: 11.5.1 '@asamuzakjp/dom-selector@6.7.5': dependencies: @@ -6424,19 +6415,19 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.26.10 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -6446,52 +6437,44 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.5': + '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@7.29.1': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 7.7.3 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} '@babel/helper-module-imports@7.27.1': dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -6499,66 +6482,46 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.26.10': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 - '@babel/parser@7.28.5': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 - '@babel/parser@7.29.2': + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.26.10': dependencies: regenerator-runtime: 0.14.1 - '@babel/template@7.27.2': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - '@babel/template@7.28.6': + '@babel/traverse@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6568,10 +6531,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 '@biomejs/biome@2.4.10': optionalDependencies: @@ -8240,9 +8203,9 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 picomatch: 4.0.4 rolldown: 1.0.0-rc.17 optionalDependencies: @@ -8478,7 +8441,7 @@ snapshots: '@testing-library/dom@9.3.3': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.26.10 '@types/aria-query': 5.0.3 aria-query: 5.1.3 @@ -8538,24 +8501,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/body-parser@1.19.2': dependencies: @@ -8877,12 +8840,12 @@ snapshots: dependencies: valibot: 1.2.0(typescript@6.0.2) - '@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.26.10)(rolldown@1.0.0-rc.17)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3)) babel-plugin-react-compiler: 1.0.0 '@vitest/browser-playwright@4.1.1(msw@2.4.8(typescript@6.0.2))(playwright@1.55.1)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.25.12)(jiti@1.21.7)(yaml@2.8.3))(vitest@4.1.5)': @@ -10680,6 +10643,8 @@ snapshots: lru-cache@11.2.4: {} + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -11331,7 +11296,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -11631,9 +11596,9 @@ snapshots: react-docgen@8.0.2: dependencies: - '@babel/core': 7.29.0 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/core': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 From da3ce16d0042dc3e501c07c98eee430eda0afc04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:22:09 +0000 Subject: [PATCH 63/63] chore: bump protobufjs from 7.5.6 to 7.6.1 in /site (#25958) Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 7.5.6 to 7.6.1.
Release notes

Sourced from protobufjs's releases.

protobufjs: v7.6.1

7.6.1 (2026-05-22)

Bug Fixes

protobufjs: v7.6.0

7.6.0 (2026-05-18)

Features

protobufjs: v7.5.9

7.5.9 (2026-05-17)

Bug Fixes

  • Backport bundler-safe optional module lookups (#2254) (0853a62)

protobufjs: v7.5.8

7.5.8 (2026-05-12)

Bug Fixes

protobufjs: v7.5.7

7.5.7 (2026-05-09)

Bug Fixes

Changelog

Sourced from protobufjs's changelog.

7.6.1 (2026-05-22)

Bug Fixes

7.6.0 (2026-05-18)

Features

7.5.9 (2026-05-17)

Bug Fixes

  • Backport bundler-safe optional module lookups (#2254) (0853a62)

7.5.8 (2026-05-12)

Bug Fixes

7.5.7 (2026-05-09)

Bug Fixes

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=protobufjs&package-manager=npm_and_yarn&previous-version=7.5.6&new-version=7.6.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package.json | 2 +- site/pnpm-lock.yaml | 39 +++++++++++++++++++-------------------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/site/package.json b/site/package.json index 86bfbf708a..ac5ada7669 100644 --- a/site/package.json +++ b/site/package.json @@ -171,7 +171,7 @@ "knip": "5.71.0", "msw": "2.4.8", "postcss": "8.5.10", - "protobufjs": "7.5.6", + "protobufjs": "7.6.1", "resize-observer-polyfill": "1.5.1", "rollup-plugin-visualizer": "7.0.1", "rxjs": "7.8.2", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index e6544806d7..23d9664bdc 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -424,8 +424,8 @@ importers: specifier: 8.5.10 version: 8.5.10 protobufjs: - specifier: 7.5.6 - version: 7.5.6 + specifier: 7.6.1 + version: 7.6.1 resize-observer-polyfill: specifier: 1.5.1 version: 1.5.1 @@ -1448,17 +1448,17 @@ packages: '@protobufjs/codegen@2.0.5': resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==, tarball: https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz} - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==, tarball: https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz} + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==, tarball: https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz} - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==, tarball: https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz} + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==, tarball: https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz} '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==, tarball: https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz} - '@protobufjs/inquire@1.1.1': - resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==, tarball: https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz} + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==, tarball: https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz} '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==, tarball: https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz} @@ -5112,8 +5112,8 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==, tarball: https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz} - protobufjs@7.5.6: - resolution: {integrity: sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==, tarball: https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz} + protobufjs@7.6.1: + resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==, tarball: https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -7390,16 +7390,15 @@ snapshots: '@protobufjs/codegen@2.0.5': {} - '@protobufjs/eventemitter@1.1.0': {} + '@protobufjs/eventemitter@1.1.1': {} - '@protobufjs/fetch@1.1.0': + '@protobufjs/fetch@1.1.1': dependencies: '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.1 '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.1': {} + '@protobufjs/inquire@1.1.2': {} '@protobufjs/path@1.1.2': {} @@ -11460,15 +11459,15 @@ snapshots: property-information@7.1.0: {} - protobufjs@7.5.6: + protobufjs@7.6.1: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.1 + '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -12394,12 +12393,12 @@ snapshots: ts-proto-descriptors@1.16.0: dependencies: long: 5.3.2 - protobufjs: 7.5.6 + protobufjs: 7.6.1 ts-proto@1.181.2: dependencies: case-anything: 2.1.13 - protobufjs: 7.5.6 + protobufjs: 7.6.1 ts-poet: 6.12.0 ts-proto-descriptors: 1.16.0