From ad6c6c64baf15dcc94590ccd0bea5967084f312f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:58:27 +0000 Subject: [PATCH 1/3] fix(coderd): stop surfacing RBAC errors for dormant chat workspaces Chat tools run as the chatd subject, which could read workspaces but not workspace_dormant. A workspace's RBAC object switches to workspace_dormant while dormant_at is set, and dormancy auto-delete leaves it set on the soft-deleted row, so every tool lookup of a dormant or dormant-deleted chat workspace failed with "unauthorized: rbac: forbidden" before the existing deleted-workspace handling could run. The model read that as a permission problem and gave up, including on create_workspace. Grant chatd read on workspace_dormant, report a deleted workspace from the shared workspace-connection path with an explicit pointer to create_workspace, and attach the same recovery hint to the remaining load failures in start_workspace and stop_workspace. Fixes CODAGT-1024 --- coderd/database/dbauthz/dbauthz.go | 6 + coderd/database/dbauthz/dbauthz_test.go | 17 ++- coderd/x/chatd/chatd.go | 13 ++ coderd/x/chatd/chatd_internal_test.go | 82 +++++++++++++ coderd/x/chatd/chattool/chattool.go | 10 ++ .../x/chatd/chattool/createworkspace_test.go | 112 ++++++++++++++++++ coderd/x/chatd/chattool/startworkspace.go | 4 +- .../x/chatd/chattool/startworkspace_test.go | 105 ++++++++++++++++ coderd/x/chatd/chattool/stopworkspace.go | 8 +- coderd/x/chatd/chattool/stopworkspace_test.go | 61 ++++++++++ 10 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 coderd/x/chatd/chattool/createworkspace_test.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4b281f074c8b6..f9a3d74d8080c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -828,6 +828,12 @@ var ( // Organization reads support organization-scoped model // resolution and user-global settings in the default org. rbac.ResourceOrganization.Type: {policy.ActionRead}, + // A workspace's RBAC object is workspace_dormant while + // dormant_at is set, and dormancy auto-delete leaves it set + // on the soft-deleted row. Without this read chatd cannot + // tell a dormant or deleted chat workspace apart from a + // permission failure. Starting one runs as the owner. + rbac.ResourceWorkspaceDormant.Type: {policy.ActionRead}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 9f87cfec00850..c8b5591275ae4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7841,8 +7841,14 @@ func TestAsChatd(t *testing.T) { require.NoError(t, err, "workspace %s should be allowed", action) } + // Dormant (including dormancy-deleted) chat workspaces must stay + // readable so tools can report their state instead of a + // permission failure. + err := auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceWorkspaceDormant) + require.NoError(t, err, "dormant workspace read should be allowed") + // DeploymentConfig reads are allowed, but writes are not. - err := auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceDeploymentConfig) + err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceDeploymentConfig) require.NoError(t, err, "deployment config read should be allowed") err = auth.Authorize(ctx, actor, policy.ActionUpdate, rbac.ResourceDeploymentConfig) require.Error(t, err, "deployment config update should not be allowed") @@ -7874,6 +7880,15 @@ func TestAsChatd(t *testing.T) { err := auth.Authorize(ctx, actor, policy.ActionDelete, rbac.ResourceWorkspace) require.Error(t, err, "workspace delete should be denied") + // Dormant workspaces are read-only for chatd; starting one runs + // under the owner actor. + for _, action := range []policy.Action{ + policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop, + } { + err = auth.Authorize(ctx, actor, action, rbac.ResourceWorkspaceDormant) + require.Error(t, err, "dormant workspace %s should be denied", action) + } + // Cannot access users. err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceUser) require.Error(t, err, "user read should be denied") diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index ee17c59825720..3b9fbf57087ea 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -120,6 +120,7 @@ const ( var ( errChatHasNoWorkspaceAgent = xerrors.New("workspace has no running agent: the workspace is likely stopped. Use the start_workspace tool to start it") + errChatWorkspaceDeleted = xerrors.New("the chat's workspace was deleted (for example by dormancy cleanup) and cannot execute tools. Use the create_workspace tool to create a new one") errChatAgentDisconnected = xerrors.New( "workspace agent has been disconnected for at least 90 seconds " + "and cannot execute tools. To recover, call stop_workspace " + @@ -626,6 +627,18 @@ func (c *turnWorkspaceContext) loadWorkspaceAgentLocked( return chatSnapshot, database.WorkspaceAgent{}, xerrors.New("no workspace is associated with this chat. Use the create_workspace tool to create one") } + // A soft-deleted workspace keeps its agent rows, so the bound agent + // would otherwise be dialed and fail as merely unreachable. + ws, err := c.server.db.GetWorkspaceByID(ctx, chatSnapshot.WorkspaceID.UUID) + if err != nil { + return chatSnapshot, database.WorkspaceAgent{}, xerrors.Errorf( + "load workspace: %w. %s", err, chattool.WorkspaceUnavailableHint, + ) + } + if ws.Deleted { + return chatSnapshot, database.WorkspaceAgent{}, errChatWorkspaceDeleted + } + if chatSnapshot.AgentID.Valid { agent, err := c.server.db.GetWorkspaceAgentByID(ctx, chatSnapshot.AgentID.UUID) if err == nil { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 186977f4a7e8c..9db54903b7004 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -1187,6 +1187,7 @@ func TestTurnWorkspaceContext_BindingFirstPath(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -1232,6 +1233,7 @@ func TestTurnWorkspaceContext_NullBindingLazyBind(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) buildID := uuid.New() agentID := uuid.New() chat := database.Chat{ @@ -1300,6 +1302,7 @@ func TestTurnWorkspaceContext_StaleBindingRepair(t *testing.T) { expectBestEffortContextRepin(db) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) staleAgentID := uuid.New() buildID := uuid.New() currentAgentID := uuid.New() @@ -1356,6 +1359,7 @@ func TestTurnWorkspaceContextGetWorkspaceConnLazyValidationSwitchesWorkspaceAgen expectBestEffortContextRepin(db) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) staleAgentID := uuid.New() currentAgentID := uuid.New() buildID := uuid.New() @@ -1435,6 +1439,7 @@ func TestTurnWorkspaceContextGetWorkspaceConnFastFailsWithoutCurrentAgent(t *tes db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) staleAgentID := uuid.New() resourceID := uuid.New() chat := database.Chat{ @@ -1498,6 +1503,63 @@ func TestTurnWorkspaceContextGetWorkspaceConnFastFailsWithoutCurrentAgent(t *tes require.Equal(t, uuid.NullUUID{}, workspaceCtx.cachedWorkspaceID) } +func TestTurnWorkspaceContextGetWorkspaceConnDeletedWorkspace(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + workspaceID := uuid.New() + staleAgentID := uuid.New() + chat := database.Chat{ + ID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + AgentID: uuid.NullUUID{UUID: staleAgentID, Valid: true}, + } + + // Dormancy auto-delete soft-deletes the row but keeps dormant_at and + // the agent rows, so the bound agent must not be dialed. + db.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID). + Return(database.Workspace{ + ID: workspaceID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }, nil). + Times(1) + + server := &Server{ + db: db, + clock: quartz.NewReal(), + dialTimeout: 30 * time.Second, + } + server.agentConnFn = func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { + t.Fatal("agentConnFn must not be called for a deleted workspace") + return nil, nil, nil + } + + chatStateMu := &sync.Mutex{} + currentChat := chat + workspaceCtx := turnWorkspaceContext{ + server: server, + chatStateMu: chatStateMu, + currentChat: ¤tChat, + loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil }, + } + defer workspaceCtx.close() + + gotConn, err := workspaceCtx.getWorkspaceConn(ctx) + require.Nil(t, gotConn) + require.ErrorIs(t, err, errChatWorkspaceDeleted) + require.Contains(t, err.Error(), "create_workspace") + require.NotContains(t, err.Error(), "forbidden") + + workspaceCtx.mu.Lock() + defer workspaceCtx.mu.Unlock() + require.False(t, workspaceCtx.agentLoaded) + require.Nil(t, workspaceCtx.conn) +} + func TestTurnWorkspaceContext_SelectWorkspaceClearsCachedState(t *testing.T) { t.Parallel() @@ -1554,6 +1616,7 @@ func TestTurnWorkspaceContext_EnsureWorkspaceAgentIgnoresCachedAgentForDifferent workspaceOneID := uuid.New() workspaceTwoID := uuid.New() + expectLiveWorkspace(db, workspaceTwoID) buildID := uuid.New() cachedAgent := database.WorkspaceAgent{ID: uuid.New()} resolvedAgent := database.WorkspaceAgent{ID: uuid.New()} @@ -2042,6 +2105,7 @@ func TestGetWorkspaceConn_StaleAgentRecovery(t *testing.T) { expectBestEffortContextRepin(db) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) oldAgentID := uuid.New() newAgentID := uuid.New() buildID := uuid.New() @@ -2168,6 +2232,7 @@ func TestGetWorkspaceConn_SameBuildAgentCrash(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() // Agent: disconnected (crashed on current build). @@ -2315,6 +2380,7 @@ func TestGetWorkspaceConn_StatusCheck(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2477,6 +2543,7 @@ func TestGetWorkspaceConn_DialTimeoutDisconnectedRecoveryThreshold(t *testing.T) db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2600,6 +2667,7 @@ func TestGetWorkspaceConn_DisconnectedStatusDialSuccessDoesNotEscalate(t *testin db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2670,6 +2738,7 @@ func TestGetWorkspaceConn_CacheHitDisconnectedRetriesDialBeforeEscalating(t *tes db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2748,6 +2817,7 @@ func TestGetWorkspaceConn_DialTimeout(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2820,6 +2890,7 @@ func TestGetWorkspaceConn_DialTimeoutParentCanceled(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() chat := database.Chat{ ID: uuid.New(), @@ -2904,6 +2975,7 @@ func TestGetWorkspaceConn_PreflightExternalAgentTimedOut(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() resourceID := uuid.New() agent := database.WorkspaceAgent{ @@ -2978,6 +3050,7 @@ func TestGetWorkspaceConn_PreflightExternalAgentConnectingDials(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() resourceID := uuid.New() agent := database.WorkspaceAgent{ @@ -3048,6 +3121,7 @@ func TestGetWorkspaceConn_DialErrorNotMisclassifiedAsTimeout(t *testing.T) { db := dbmock.NewMockStore(ctrl) workspaceID := uuid.New() + expectLiveWorkspace(db, workspaceID) agentID := uuid.New() resourceID := uuid.New() chat := database.Chat{ @@ -3601,3 +3675,11 @@ func TestResolveFallbackModelConfigID(t *testing.T) { require.ErrorIs(t, err, ErrInvalidModelConfigID) }) } + +// expectLiveWorkspace satisfies the workspace liveness read in +// loadWorkspaceAgentLocked for a workspace that still exists. +func expectLiveWorkspace(db *dbmock.MockStore, workspaceID uuid.UUID) { + db.EXPECT().GetWorkspaceByID(gomock.Any(), workspaceID). + Return(database.Workspace{ID: workspaceID}, nil). + AnyTimes() +} diff --git a/coderd/x/chatd/chattool/chattool.go b/coderd/x/chatd/chattool/chattool.go index dc99c384d795f..100314510fc0e 100644 --- a/coderd/x/chatd/chattool/chattool.go +++ b/coderd/x/chatd/chattool/chattool.go @@ -16,6 +16,16 @@ import ( const templateNotAvailableMessage = "template not available for chat workspaces; use list_templates to find allowed templates" +// WorkspaceUnavailableHint follows errors that keep a tool from loading the +// chat's workspace so the model recovers instead of concluding it is blocked. +const WorkspaceUnavailableHint = "The workspace is probably gone; use the create_workspace tool to make a new one" + +func workspaceLoadErrorResponse(err error) fantasy.ToolResponse { + return fantasy.NewTextErrorResponse( + xerrors.Errorf("load workspace: %w. %s", err, WorkspaceUnavailableHint).Error(), + ) +} + func marshalToolResponse(result any) fantasy.ToolResponse { data, err := json.Marshal(result) if err != nil { diff --git a/coderd/x/chatd/chattool/createworkspace_test.go b/coderd/x/chatd/chattool/createworkspace_test.go new file mode 100644 index 0000000000000..7ef75d47d11aa --- /dev/null +++ b/coderd/x/chatd/chattool/createworkspace_test.go @@ -0,0 +1,112 @@ +package chattool_test + +import ( + "context" + "database/sql" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "charm.land/fantasy" + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// Dormancy auto-delete leaves dormant_at set, which switches the row's RBAC +// object to workspace_dormant. The chatd actor must still read it so the +// deleted binding is skipped and a replacement workspace gets created. +func TestCreateWorkspace_DormantDeletedWorkspace(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + ActiveVersionID: version.ID, + AgentsAllowed: true, + }) + // Link the version back so the owner's version read authorizes through + // the template, as in production. + require.NoError(t, db.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ + ID: version.ID, + TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, + UpdatedAt: version.UpdatedAt, + Name: version.Name, + Message: version.Message, + })) + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionDelete, + }).Do() + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: wsResp.Workspace.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-create-dormant-deleted-workspace", + }) + + authzDB := dbauthz.New( + db, + rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), + slogtest.Make(t, nil), + testAccessControlStorePointer(), + ) + var createCalled atomic.Bool + tool := chattool.CreateWorkspace(authzDB, org.ID, chat.ID, chattool.CreateWorkspaceOptions{ + OwnerID: user.ID, + CreateFn: func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { + createCalled.Store(true) + require.Equal(t, template.ID, req.TemplateID) + return codersdk.Workspace{}, xerrors.New("creation stopped by test") + }, + WorkspaceMu: &sync.Mutex{}, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + }) + + resp, err := tool.Run( + dbauthz.AsChatd(ctx), + fantasy.ToolCall{ + ID: "call-1", + Name: "create_workspace", + Input: fmt.Sprintf(`{"template_id": %q}`, template.ID), + }, + ) + require.NoError(t, err) + require.True(t, createCalled.Load(), "the deleted binding must not block creation: %s", resp.Content) + require.Contains(t, resp.Content, "creation stopped by test") + require.NotContains(t, resp.Content, "forbidden") +} diff --git a/coderd/x/chatd/chattool/startworkspace.go b/coderd/x/chatd/chattool/startworkspace.go index 650513d4beb13..a2efc9d93ad1d 100644 --- a/coderd/x/chatd/chattool/startworkspace.go +++ b/coderd/x/chatd/chattool/startworkspace.go @@ -76,9 +76,7 @@ func StartWorkspace(db database.Store, chatID uuid.UUID, options StartWorkspaceO ws, err := db.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID) if err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("load workspace: %w", err).Error(), - ), nil + return workspaceLoadErrorResponse(err), nil } if ws.Deleted { return fantasy.NewTextErrorResponse( diff --git a/coderd/x/chatd/chattool/startworkspace_test.go b/coderd/x/chatd/chattool/startworkspace_test.go index 8955760e2274f..5629f41db303d 100644 --- a/coderd/x/chatd/chattool/startworkspace_test.go +++ b/coderd/x/chatd/chattool/startworkspace_test.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" @@ -941,6 +942,110 @@ func TestStartWorkspace(t *testing.T) { require.NoError(t, err) require.Contains(t, resp.Content, "workspace was deleted") }) + + // Dormancy auto-delete leaves dormant_at set, which switches the row's + // RBAC object to workspace_dormant. The chatd actor must still read it + // so the deleted-workspace guidance is reached instead of a permission + // failure. + t.Run("DormantDeletedWorkspace", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionDelete, + }).Do() + ws := wsResp.Workspace + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-dormant-deleted-workspace", + }) + + authzDB := dbauthz.New( + db, + rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), + slogtest.Make(t, nil), + testAccessControlStorePointer(), + ) + tool := chattool.StartWorkspace(authzDB, chat.ID, chattool.StartWorkspaceOptions{ + OwnerID: user.ID, + StartFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StartFn should not be called for deleted workspace") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + }) + + resp, err := tool.Run( + dbauthz.AsChatd(ctx), + fantasy.ToolCall{ID: "call-1", Name: "start_workspace", Input: "{}"}, + ) + require.NoError(t, err) + require.Contains(t, resp.Content, "workspace was deleted") + require.Contains(t, resp.Content, "create_workspace") + require.NotContains(t, resp.Content, "forbidden") + }) + + t.Run("LoadWorkspaceErrorGuidance", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + ws := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + }).Do().Workspace + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-load-workspace-error", + }) + + tool := chattool.StartWorkspace(&failingWorkspaceStore{Store: db}, chat.ID, chattool.StartWorkspaceOptions{ + StartFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StartFn should not be called when the workspace cannot be loaded") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + }) + + resp, err := tool.Run(ctx, fantasy.ToolCall{ID: "call-1", Name: "start_workspace", Input: "{}"}) + require.NoError(t, err) + require.True(t, resp.IsError) + require.Contains(t, resp.Content, "load workspace: workspace store offline") + require.Contains(t, resp.Content, chattool.WorkspaceUnavailableHint) + }) +} + +// failingWorkspaceStore fails every workspace lookup so tests can observe +// the recovery guidance attached to load errors. +type failingWorkspaceStore struct { + database.Store +} + +func (*failingWorkspaceStore) GetWorkspaceByID(context.Context, uuid.UUID) (database.Workspace, error) { + return database.Workspace{}, xerrors.New("workspace store offline") } // seedModelConfig inserts a provider and model config for testing. diff --git a/coderd/x/chatd/chattool/stopworkspace.go b/coderd/x/chatd/chattool/stopworkspace.go index 1aea9ad8369e0..6cf34b1e14d8a 100644 --- a/coderd/x/chatd/chattool/stopworkspace.go +++ b/coderd/x/chatd/chattool/stopworkspace.go @@ -77,9 +77,7 @@ func StopWorkspace(db database.Store, chatID uuid.UUID, options StopWorkspaceOpt ws, err := db.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID) if err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("load workspace: %w", err).Error(), - ), nil + return workspaceLoadErrorResponse(err), nil } if ws.Deleted { return fantasy.NewTextErrorResponse( @@ -105,9 +103,7 @@ func StopWorkspace(db database.Store, chatID uuid.UUID, options StopWorkspaceOpt // have completed while this tool was blocked. ws, err = db.GetWorkspaceByID(ctx, ws.ID) if err != nil { - return fantasy.NewTextErrorResponse( - xerrors.Errorf("load workspace: %w", err).Error(), - ), nil + return workspaceLoadErrorResponse(err), nil } if ws.Deleted { return fantasy.NewTextErrorResponse( diff --git a/coderd/x/chatd/chattool/stopworkspace_test.go b/coderd/x/chatd/chattool/stopworkspace_test.go index 4133ba223da24..dc88a4426d124 100644 --- a/coderd/x/chatd/chattool/stopworkspace_test.go +++ b/coderd/x/chatd/chattool/stopworkspace_test.go @@ -11,13 +11,16 @@ import ( "charm.land/fantasy" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -101,6 +104,64 @@ func TestStopWorkspace(t *testing.T) { require.Contains(t, resp.Content, "create_workspace") }) + // Dormancy auto-delete leaves dormant_at set, which switches the row's + // RBAC object to workspace_dormant. The chatd actor must still read it + // so the deleted-workspace guidance is reached instead of a permission + // failure. + t.Run("DormantDeletedWorkspace", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + + user := dbgen.User(t, db, database.User{}) + modelCfg := seedModelConfig(t, db) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + wsResp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + Deleted: true, + DormantAt: sql.NullTime{Time: time.Now(), Valid: true}, + }).Seed(database.WorkspaceBuild{ + Transition: database.WorkspaceTransitionDelete, + }).Do() + ws := wsResp.Workspace + + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: org.ID, + OwnerID: user.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + LastModelConfigID: modelCfg.ID, + Title: "test-stop-dormant-deleted-workspace", + }) + + authzDB := dbauthz.New( + db, + rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry()), + slogtest.Make(t, nil), + testAccessControlStorePointer(), + ) + tool := chattool.StopWorkspace(authzDB, chat.ID, chattool.StopWorkspaceOptions{ + StopFn: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ codersdk.CreateWorkspaceBuildRequest) (codersdk.WorkspaceBuild, error) { + t.Fatal("StopFn should not be called for deleted workspace") + return codersdk.WorkspaceBuild{}, nil + }, + WorkspaceMu: &sync.Mutex{}, + }) + + resp, err := tool.Run( + dbauthz.AsChatd(ctx), + fantasy.ToolCall{ID: "call-1", Name: "stop_workspace", Input: "{}"}, + ) + require.NoError(t, err) + require.Contains(t, resp.Content, "workspace was deleted") + require.Contains(t, resp.Content, "create_workspace") + require.NotContains(t, resp.Content, "forbidden") + }) + t.Run("AlreadyStopped", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) From c3438244048b5b2ecebfa35aef0ed08671433972 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:53:55 +0000 Subject: [PATCH 2/3] fix(coderd/database/dbauthz): mirror the workspace grant for dormant rows Read alone let chatd see dormant chat workspaces, but the heartbeat activity bump still logged "rbac: forbidden" for them (seen in dogfood UAT). Grant the same read and update the chatd subject already has on active workspaces so a dormant workspace behaves like any other one for chatd. --- coderd/database/dbauthz/dbauthz.go | 9 +++++---- coderd/database/dbauthz/dbauthz_test.go | 21 +++++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f9a3d74d8080c..a4c5e8500d8da 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -830,10 +830,11 @@ var ( rbac.ResourceOrganization.Type: {policy.ActionRead}, // A workspace's RBAC object is workspace_dormant while // dormant_at is set, and dormancy auto-delete leaves it set - // on the soft-deleted row. Without this read chatd cannot - // tell a dormant or deleted chat workspace apart from a - // permission failure. Starting one runs as the owner. - rbac.ResourceWorkspaceDormant.Type: {policy.ActionRead}, + // on the soft-deleted row. Mirror the workspace grant so + // chatd can tell a dormant or deleted chat workspace apart + // from a permission failure and heartbeat activity bumps + // keep working. Starting one runs as the owner. + rbac.ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionUpdate}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index c8b5591275ae4..fb80b3f473f63 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -7841,11 +7841,16 @@ func TestAsChatd(t *testing.T) { require.NoError(t, err, "workspace %s should be allowed", action) } - // Dormant (including dormancy-deleted) chat workspaces must stay - // readable so tools can report their state instead of a - // permission failure. - err := auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceWorkspaceDormant) - require.NoError(t, err, "dormant workspace read should be allowed") + // Dormant (including dormancy-deleted) chat workspaces get the + // same grant so tools report their state instead of a permission + // failure and activity bumps keep working. + var err error + for _, action := range []policy.Action{ + policy.ActionRead, policy.ActionUpdate, + } { + err = auth.Authorize(ctx, actor, action, rbac.ResourceWorkspaceDormant) + require.NoError(t, err, "dormant workspace %s should be allowed", action) + } // DeploymentConfig reads are allowed, but writes are not. err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceDeploymentConfig) @@ -7880,10 +7885,10 @@ func TestAsChatd(t *testing.T) { err := auth.Authorize(ctx, actor, policy.ActionDelete, rbac.ResourceWorkspace) require.Error(t, err, "workspace delete should be denied") - // Dormant workspaces are read-only for chatd; starting one runs - // under the owner actor. + // Dormant workspaces share the workspace grant only; lifecycle + // transitions run under the owner actor. for _, action := range []policy.Action{ - policy.ActionUpdate, policy.ActionDelete, policy.ActionWorkspaceStop, + policy.ActionDelete, policy.ActionWorkspaceStop, policy.ActionCreate, } { err = auth.Authorize(ctx, actor, action, rbac.ResourceWorkspaceDormant) require.Error(t, err, "dormant workspace %s should be denied", action) From c54454b449a9f51a2b78d30d8c04301d8eab554e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:09:22 +0000 Subject: [PATCH 3/3] fix(coderd/x/chatd): recheck the chat binding before reporting a deleted workspace A create_workspace running alongside another workspace tool can rebind the chat while the deleted row is being read. Follow the agent lookup below and retry with the replacement binding instead of returning the deleted error from the stale snapshot. --- coderd/x/chatd/chatd.go | 7 ++++ coderd/x/chatd/chatd_internal_test.go | 58 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 3b9fbf57087ea..65eec37b27c1c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -636,6 +636,13 @@ func (c *turnWorkspaceContext) loadWorkspaceAgentLocked( ) } if ws.Deleted { + // A concurrent create_workspace may have rebound the chat + // while the row was read; resolve the replacement instead. + latestChat, workspaceMatches := c.currentWorkspaceMatches(chatSnapshot.WorkspaceID) + if !workspaceMatches { + chatSnapshot = latestChat + continue + } return chatSnapshot, database.WorkspaceAgent{}, errChatWorkspaceDeleted } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 9db54903b7004..e8674cb284e19 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -1560,6 +1560,64 @@ func TestTurnWorkspaceContextGetWorkspaceConnDeletedWorkspace(t *testing.T) { require.Nil(t, workspaceCtx.conn) } +func TestTurnWorkspaceContextEnsureWorkspaceAgentRebindsFromDeletedWorkspace(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + + deletedWorkspaceID := uuid.New() + replacementWorkspaceID := uuid.New() + buildID := uuid.New() + replacementAgent := database.WorkspaceAgent{ID: uuid.New()} + chat := database.Chat{ + ID: uuid.New(), + WorkspaceID: uuid.NullUUID{UUID: deletedWorkspaceID, Valid: true}, + AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + } + rebound := chat + rebound.WorkspaceID = uuid.NullUUID{UUID: replacementWorkspaceID, Valid: true} + rebound.AgentID = uuid.NullUUID{} + updatedChat := rebound + updatedChat.BuildID = uuid.NullUUID{UUID: buildID, Valid: true} + updatedChat.AgentID = uuid.NullUUID{UUID: replacementAgent.ID, Valid: true} + + chatStateMu := &sync.Mutex{} + currentChat := chat + workspaceCtx := turnWorkspaceContext{ + server: &Server{db: db}, + chatStateMu: chatStateMu, + currentChat: ¤tChat, + loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil }, + } + defer workspaceCtx.close() + + // A concurrent create_workspace publishes the replacement binding while + // the deleted row is being read. + db.EXPECT().GetWorkspaceByID(gomock.Any(), deletedWorkspaceID). + DoAndReturn(func(context.Context, uuid.UUID) (database.Workspace, error) { + workspaceCtx.setCurrentChat(rebound) + return database.Workspace{ID: deletedWorkspaceID, Deleted: true}, nil + }). + Times(1) + expectLiveWorkspace(db, replacementWorkspaceID) + gomock.InOrder( + db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), replacementWorkspaceID).Return([]database.WorkspaceAgent{replacementAgent}, nil), + db.EXPECT().GetLatestWorkspaceBuildByWorkspaceID(gomock.Any(), replacementWorkspaceID).Return(database.WorkspaceBuild{ID: buildID}, nil), + db.EXPECT().UpdateChatBuildAgentBinding(gomock.Any(), database.UpdateChatBuildAgentBindingParams{ + ID: chat.ID, + BuildID: uuid.NullUUID{UUID: buildID, Valid: true}, + AgentID: uuid.NullUUID{UUID: replacementAgent.ID, Valid: true}, + }).Return(updatedChat, nil), + ) + + chatSnapshot, agent, err := workspaceCtx.ensureWorkspaceAgent(ctx) + require.NoError(t, err) + require.Equal(t, updatedChat, chatSnapshot) + require.Equal(t, replacementAgent, agent) +} + func TestTurnWorkspaceContext_SelectWorkspaceClearsCachedState(t *testing.T) { t.Parallel()