Compare commits

...

6 Commits

Author SHA1 Message Date
Atif Ali 5344570589 docs(ai-bridge): remove early access language from troubleshooting
Complete the transition to GA by removing remaining early access
reference in the troubleshooting section.
2025-11-26 14:44:06 +00:00
Atif Ali baa1029b90 docs: change AI Bridge state from 'early access' to 'beta' 2025-11-26 19:10:33 +05:00
Marcin Tojek 9c7135a61d chore: add license check for prebuilds (#20947)
Related: https://github.com/coder/coder/pull/20864
2025-11-26 15:00:07 +01:00
Mathias Fredriksson b7d8918d60 fix(site): only show active tasks in waiting for input tab (#20933)
This change filters out non-active tasks from the "Waiting for input"
tab filter for the tasks list.

---

🤖 This change was initially written by Claude Code using Coder Tasks, then reviewed and edited by a human 🏂
2025-11-26 13:13:39 +00:00
Danielle Maywood e7dbbcde87 fix: do not notify marked for deletion for deleted workspaces (#20937)
Closes https://github.com/coder/coder/issues/20913

I've ran the test without the fix, verified the test caught the issue,
then applied the fix, and confirmed the issue no longer happens.

---

🤖 PR was initially written by Claude Opus 4.5 Thinking using Claude Code
and then review by a human 👩
2025-11-26 09:23:16 +00:00
Zach bbf7b137da fix(cli): remove defaulting to keyring when --global-config set (#20943)
This fixes a regression that caused the VS code extension to be unable
to authenticate after making keyring usage on by default. This is
because the VS code extension assumes the CLI will always use the
session token stored on disk, specifically in the directory specified by
--global-config.

This fix makes keyring usage enabled when the --global-config directory
is not set. This is a bit wonky but necessary to allow the extension to
continue working without modification and without backwards compat
concerns. In the future we should modify these extensions to either
access the credential in the keyring (like Coder Desktop) or some other
approach that doesn't rely on the session token being stored on disk.

Tests:
`coder login dev.coder.com` -> token stored in keyring
`coder login --global-config=/tmp/ dev.coder.com` -> token stored in
`/tmp/session`
2025-11-26 10:17:31 +01:00
16 changed files with 310 additions and 27 deletions
+4 -4
View File
@@ -61,10 +61,10 @@ func NewWithCommand(
t testing.TB, cmd *serpent.Command, args ...string,
) (*serpent.Invocation, config.Root) {
configDir := config.Root(t.TempDir())
// Keyring usage is disabled here because many existing tests expect the session token
// to be stored on disk and is not properly instrumented for parallel testing against
// the actual operating system keyring.
invArgs := append([]string{"--global-config", string(configDir), "--use-keyring=false"}, args...)
// Keyring usage is disabled here when --global-config is set because many existing
// tests expect the session token to be stored on disk and is not properly instrumented
// for parallel testing against the actual operating system keyring.
invArgs := append([]string{"--global-config", string(configDir)}, args...)
return setupInvocation(t, cmd, invArgs...), configDir
}
+2
View File
@@ -54,6 +54,7 @@ func setupKeyringTestEnv(t *testing.T, clientURL string, args ...string) keyring
serviceName := keyringTestServiceName(t)
root.WithKeyringServiceName(serviceName)
root.UseKeyringWithGlobalConfig()
inv, cfg := clitest.NewWithDefaultKeyringCommand(t, cmd, args...)
@@ -169,6 +170,7 @@ func TestUseKeyring(t *testing.T) {
logoutCmd, err := logoutRoot.Command(logoutRoot.AGPL())
require.NoError(t, err)
logoutRoot.WithKeyringServiceName(env.serviceName)
logoutRoot.UseKeyringWithGlobalConfig()
logoutInv, _ := clitest.NewWithDefaultKeyringCommand(t, logoutCmd,
"logout",
+23 -9
View File
@@ -483,9 +483,9 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
Flag: varUseKeyring,
Env: envUseKeyring,
Description: "Store and retrieve session tokens using the operating system " +
"keyring. Enabled by default. If the keyring is not supported on the " +
"current platform, file-based storage is used automatically. Set to " +
"false to force file-based storage.",
"keyring. This flag is ignored and file-based storage is used when " +
"--global-config is set or keyring usage is not supported on the current " +
"platform. Set to false to force file-based storage on supported platforms.",
Default: "true",
Value: serpent.BoolOf(&r.useKeyring),
Group: globalGroup,
@@ -536,11 +536,12 @@ type RootCmd struct {
disableDirect bool
debugHTTP bool
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool
useKeyring bool
keyringServiceName string
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool
useKeyring bool
keyringServiceName string
useKeyringWithGlobalConfig bool
}
// InitClient creates and configures a new client with authentication, telemetry,
@@ -721,8 +722,14 @@ func (r *RootCmd) createUnauthenticatedClient(ctx context.Context, serverURL *ur
// flag.
func (r *RootCmd) ensureTokenBackend() sessionstore.Backend {
if r.tokenBackend == nil {
// Checking for the --global-config directory being set is a bit wonky but necessary
// to allow extensions that invoke the CLI with this flag (e.g. VS code) to continue
// working without modification. In the future we should modify these extensions to
// either access the credential in the keyring (like Coder Desktop) or some other
// approach that doesn't rely on the session token being stored on disk.
assumeExtensionInUse := r.globalConfig != config.DefaultDir() && !r.useKeyringWithGlobalConfig
keyringSupported := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
if r.useKeyring && keyringSupported {
if r.useKeyring && !assumeExtensionInUse && keyringSupported {
serviceName := sessionstore.DefaultServiceName
if r.keyringServiceName != "" {
serviceName = r.keyringServiceName
@@ -742,6 +749,13 @@ func (r *RootCmd) WithKeyringServiceName(serviceName string) {
r.keyringServiceName = serviceName
}
// UseKeyringWithGlobalConfig enables the use of the keyring storage backend
// when the --global-config directory is set. This is only intended as an override
// for tests, which require specifying the global config directory for test isolation.
func (r *RootCmd) UseKeyringWithGlobalConfig() {
r.useKeyringWithGlobalConfig = true
}
type AgentAuth struct {
// Agent Client config
agentToken string
+4 -3
View File
@@ -111,9 +111,10 @@ variables or flags.
--use-keyring bool, $CODER_USE_KEYRING (default: true)
Store and retrieve session tokens using the operating system keyring.
Enabled by default. If the keyring is not supported on the current
platform, file-based storage is used automatically. Set to false to
force file-based storage.
This flag is ignored and file-based storage is used when
--global-config is set or keyring usage is not supported on the
current platform. Set to false to force file-based storage on
supported platforms.
-v, --verbose bool, $CODER_VERBOSE
Enable verbose output.
+1
View File
@@ -23751,6 +23751,7 @@ SET
WHERE
template_id = $3
AND dormant_at IS NOT NULL
AND deleted = false
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
-- should not have their dormant or deleting at set, as these are handled by the
-- prebuilds reconciliation loop.
+1
View File
@@ -846,6 +846,7 @@ SET
WHERE
template_id = @template_id
AND dormant_at IS NOT NULL
AND deleted = false
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
-- should not have their dormant or deleting at set, as these are handled by the
-- prebuilds reconciliation loop.
+1 -1
View File
@@ -38,4 +38,4 @@ Where relevant, both streaming and non-streaming requests are supported.
## Troubleshooting
To report a bug, file a feature request, or view a list of known issues, please visit our [GitHub repository for AI Bridge](https://github.com/coder/aibridge). If you encounter issues with AI Bridge during early access, please reach out to us via [Discord](https://discord.gg/coder).
To report a bug, file a feature request, or view a list of known issues, please visit our [GitHub repository for AI Bridge](https://github.com/coder/aibridge). If you encounter issues with AI Bridge, please reach out to us via [Discord](https://discord.gg/coder).
+1 -1
View File
@@ -935,7 +935,7 @@
"description": "Centralized LLM and MCP proxy for platform teams",
"path": "./ai-coder/ai-bridge/index.md",
"icon_path": "./images/icons/api.svg",
"state": ["premium", "early access"],
"state": ["premium", "beta"],
"children": [
{
"title": "Setup",
+1 -1
View File
@@ -179,7 +179,7 @@ Disable network telemetry. Network telemetry is collected when connecting to wor
| Environment | <code>$CODER_USE_KEYRING</code> |
| Default | <code>true</code> |
Store and retrieve session tokens using the operating system keyring. Enabled by default. If the keyring is not supported on the current platform, file-based storage is used automatically. Set to false to force file-based storage.
Store and retrieve session tokens using the operating system keyring. This flag is ignored and file-based storage is used when --global-config is set or keyring usage is not supported on the current platform. Set to false to force file-based storage on supported platforms.
### --global-config
+4 -3
View File
@@ -70,9 +70,10 @@ variables or flags.
--use-keyring bool, $CODER_USE_KEYRING (default: true)
Store and retrieve session tokens using the operating system keyring.
Enabled by default. If the keyring is not supported on the current
platform, file-based storage is used automatically. Set to false to
force file-based storage.
This flag is ignored and file-based storage is used when
--global-config is set or keyring usage is not supported on the
current platform. Set to false to force file-based storage on
supported platforms.
-v, --verbose bool, $CODER_VERBOSE
Enable verbose output.
+1 -1
View File
@@ -460,7 +460,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
})
r.Route("/templates/{template}/prebuilds", func(r chi.Router) {
r.Use(
api.templateRBACEnabledMW,
api.RequireFeatureMW(codersdk.FeatureWorkspacePrebuilds),
apiKeyMiddleware,
httpmw.ExtractTemplateParam(api.Database),
)
@@ -737,6 +737,105 @@ func TestNotifications(t *testing.T) {
require.Contains(t, sent[i].Targets, dormantWs.OwnerID)
}
})
// Regression test for https://github.com/coder/coder/issues/20913
// Deleted workspaces should not receive dormancy notifications.
t.Run("DeletedWorkspacesNotNotified", func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
ctx = testutil.Context(t, testutil.WaitLong)
user = dbgen.User(t, db, database.User{})
file = dbgen.File(t, db, database.File{
CreatedBy: user.ID,
})
templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
FileID: file.ID,
InitiatorID: user.ID,
Tags: database.StringMap{
"foo": "bar",
},
})
timeTilDormant = time.Minute * 2
templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{
CreatedBy: user.ID,
JobID: templateJob.ID,
OrganizationID: templateJob.OrganizationID,
})
template = dbgen.Template(t, db, database.Template{
ActiveVersionID: templateVersion.ID,
CreatedBy: user.ID,
OrganizationID: templateJob.OrganizationID,
TimeTilDormant: int64(timeTilDormant),
TimeTilDormantAutoDelete: int64(timeTilDormant),
})
)
// Create a dormant workspace that is NOT deleted.
activeDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
OrganizationID: templateJob.OrganizationID,
LastUsedAt: time.Now().Add(-time.Hour),
})
_, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
ID: activeDormantWorkspace.ID,
DormantAt: sql.NullTime{
Time: activeDormantWorkspace.LastUsedAt.Add(timeTilDormant),
Valid: true,
},
})
require.NoError(t, err)
// Create a dormant workspace that IS deleted.
deletedDormantWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
TemplateID: template.ID,
OrganizationID: templateJob.OrganizationID,
LastUsedAt: time.Now().Add(-time.Hour),
Deleted: true, // Mark as deleted
})
_, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
ID: deletedDormantWorkspace.ID,
DormantAt: sql.NullTime{
Time: deletedDormantWorkspace.LastUsedAt.Add(timeTilDormant),
Valid: true,
},
})
require.NoError(t, err)
// Setup dependencies
notifyEnq := notificationstest.NewFakeEnqueuer()
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
require.NoError(t, err)
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
userQuietHoursStorePtr.Store(&userQuietHoursStore)
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil)
// Lower the dormancy TTL to ensure the schedule recalculates deadlines and
// triggers notifications.
_, err = templateScheduleStore.Set(dbauthz.AsNotifier(ctx), db, template, agplschedule.TemplateScheduleOptions{
TimeTilDormant: timeTilDormant / 2,
TimeTilDormantAutoDelete: timeTilDormant / 2,
})
require.NoError(t, err)
// We should only receive a notification for the non-deleted dormant workspace.
sent := notifyEnq.Sent()
require.Len(t, sent, 1, "expected exactly 1 notification for the non-deleted workspace")
require.Equal(t, sent[0].UserID, activeDormantWorkspace.OwnerID)
require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceMarkedForDeletion)
require.Contains(t, sent[0].Targets, activeDormantWorkspace.ID)
// Ensure the deleted workspace was NOT notified
for _, notification := range sent {
require.NotContains(t, notification.Targets, deletedDormantWorkspace.ID,
"deleted workspace should not receive notifications")
}
})
}
func TestTemplateTTL(t *testing.T) {
+6 -1
View File
@@ -378,7 +378,12 @@ func (api *API) postInvalidateTemplatePresets(rw http.ResponseWriter, r *http.Re
slog.F("preset_count", len(invalidatedPresets)),
)
invalidated := db2sdk.InvalidatedPresets(invalidatedPresets)
if invalidated == nil {
invalidated = []codersdk.InvalidatedPreset{} // need to avoid nil value
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.InvalidatePresetsResponse{
Invalidated: db2sdk.InvalidatedPresets(invalidatedPresets),
Invalidated: invalidated,
})
}
+120 -1
View File
@@ -3,6 +3,7 @@ package coderd_test
import (
"bytes"
"context"
"errors"
"net/http"
"slices"
"testing"
@@ -2154,7 +2155,7 @@ func TestInvalidateTemplatePrebuilds(t *testing.T) {
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
@@ -2208,3 +2209,121 @@ func TestInvalidateTemplatePrebuilds(t *testing.T) {
require.Equal(t, codersdk.InvalidatedPreset{TemplateName: template.Name, TemplateVersionName: version2.Name, PresetName: presetWithParameters2.Name}, invalidated.Invalidated[0])
require.Equal(t, codersdk.InvalidatedPreset{TemplateName: template.Name, TemplateVersionName: version2.Name, PresetName: presetWithParameters3.Name}, invalidated.Invalidated[1])
}
func TestInvalidateTemplatePrebuilds_RegularUser(t *testing.T) {
t.Parallel()
// Given the following parameters and presets...
templateVersionParameters := []*proto.RichParameter{
{Name: "param1", Type: "string", Required: false, DefaultValue: "default1"},
}
presetWithParameters1 := &proto.Preset{
Name: "Preset With Parameters 1",
Parameters: []*proto.PresetParameter{
{Name: "param1", Value: "value1"},
},
}
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
regularUserClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
// Given
version1 := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Presets: []*proto.Preset{presetWithParameters1},
Parameters: templateVersionParameters,
},
},
},
},
ProvisionApply: echo.ApplyComplete,
})
coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, version1.ID)
template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, version1.ID)
// When
ctx := testutil.Context(t, testutil.WaitShort)
_, err := regularUserClient.InvalidateTemplatePresets(ctx, template.ID)
// Then
require.Error(t, err, "regular user cannot invalidate presets")
var sdkError *codersdk.Error
require.True(t, errors.As(err, &sdkError))
require.ErrorAs(t, err, &sdkError)
require.Equal(t, http.StatusNotFound, sdkError.StatusCode())
}
func TestInvalidateTemplatePrebuilds_NoPresets(t *testing.T) {
t.Parallel()
// Given the template versions and template...
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspacePrebuilds: 1,
},
},
})
templateAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
version1 := coderdtest.CreateTemplateVersion(t, templateAdminClient, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete, ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ApplyComplete,
})
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdminClient, version1.ID)
template := coderdtest.CreateTemplate(t, templateAdminClient, owner.OrganizationID, version1.ID)
// When
ctx := testutil.Context(t, testutil.WaitLong)
invalidated, err := templateAdminClient.InvalidateTemplatePresets(ctx, template.ID)
require.NoError(t, err)
// Then
require.NotNil(t, invalidated.Invalidated)
require.Len(t, invalidated.Invalidated, 0)
}
func TestInvalidateTemplatePrebuilds_LicenseFeatureDisabled(t *testing.T) {
t.Parallel()
// Given the template versions and template...
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{},
})
templateAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin())
version1 := coderdtest.CreateTemplateVersion(t, templateAdminClient, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete, ProvisionPlan: echo.PlanComplete, ProvisionApply: echo.ApplyComplete,
})
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdminClient, version1.ID)
template := coderdtest.CreateTemplate(t, templateAdminClient, owner.OrganizationID, version1.ID)
// When
ctx := testutil.Context(t, testutil.WaitLong)
_, err := templateAdminClient.InvalidateTemplatePresets(ctx, template.ID)
// Then
require.Error(t, err, "license feature prebuilds is required")
var sdkError *codersdk.Error
require.True(t, errors.As(err, &sdkError))
require.ErrorAs(t, err, &sdkError)
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
}
+41 -1
View File
@@ -10,7 +10,7 @@ import { withAuthProvider, withProxyProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { MockUsers } from "pages/UsersPage/storybookData/users";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { getTemplatesQueryKey } from "../../api/queries/templates";
import TasksPage from "./TasksPage";
@@ -145,6 +145,29 @@ export const LoadedTasksWaitingForInputTab: Story = {
spyOn(API, "getTasks").mockResolvedValue([
{
...firstTask,
id: "active-idle-task",
display_name: "Active Idle Task",
status: "active",
current_state: {
...firstTask.current_state,
state: "idle",
},
},
{
...firstTask,
id: "paused-idle-task",
display_name: "Paused Idle Task",
status: "paused",
current_state: {
...firstTask.current_state,
state: "idle",
},
},
{
...firstTask,
id: "error-idle-task",
display_name: "Error Idle Task",
status: "error",
current_state: {
...firstTask.current_state,
state: "idle",
@@ -161,6 +184,23 @@ export const LoadedTasksWaitingForInputTab: Story = {
name: /waiting for input/i,
});
await userEvent.click(waitingForInputTab);
// Wait for the table to update after tab switch
await waitFor(async () => {
const table = canvas.getByRole("table");
const tableContent = within(table);
// Active idle task should be visible
expect(tableContent.getByText("Active Idle Task")).toBeInTheDocument();
// Only active idle tasks should be visible in the table
expect(
tableContent.queryByText("Paused Idle Task"),
).not.toBeInTheDocument();
expect(
tableContent.queryByText("Error Idle Task"),
).not.toBeInTheDocument();
});
});
},
};
+1 -1
View File
@@ -44,7 +44,7 @@ const TasksPage: FC = () => {
refetchInterval: 10_000,
});
const idleTasks = tasksQuery.data?.filter(
(task) => task.current_state?.state === "idle",
(task) => task.status === "active" && task.current_state?.state === "idle",
);
const displayedTasks =
tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data;