Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,13 @@ 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. 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{},
Expand Down
22 changes: 21 additions & 1 deletion coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7841,8 +7841,19 @@ func TestAsChatd(t *testing.T) {
require.NoError(t, err, "workspace %s should be allowed", action)
}

// 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)
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")
Expand Down Expand Up @@ -7874,6 +7885,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 share the workspace grant only; lifecycle
// transitions run under the owner actor.
for _, action := range []policy.Action{
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)
}

// Cannot access users.
err = auth.Authorize(ctx, actor, policy.ActionRead, rbac.ResourceUser)
require.Error(t, err, "user read should be denied")
Expand Down
20 changes: 20 additions & 0 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down Expand Up @@ -626,6 +627,25 @@ 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 {
// 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
Comment thread
ibetitsmike marked this conversation as resolved.
}

if chatSnapshot.AgentID.Valid {
agent, err := c.server.db.GetWorkspaceAgentByID(ctx, chatSnapshot.AgentID.UUID)
if err == nil {
Expand Down
140 changes: 140 additions & 0 deletions coderd/x/chatd/chatd_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1356,6 +1359,7 @@ func TestTurnWorkspaceContextGetWorkspaceConnLazyValidationSwitchesWorkspaceAgen
expectBestEffortContextRepin(db)

workspaceID := uuid.New()
expectLiveWorkspace(db, workspaceID)
staleAgentID := uuid.New()
currentAgentID := uuid.New()
buildID := uuid.New()
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -1498,6 +1503,121 @@ 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: &currentChat,
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 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: &currentChat,
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()

Expand Down Expand Up @@ -1554,6 +1674,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()}
Expand Down Expand Up @@ -2042,6 +2163,7 @@ func TestGetWorkspaceConn_StaleAgentRecovery(t *testing.T) {
expectBestEffortContextRepin(db)

workspaceID := uuid.New()
expectLiveWorkspace(db, workspaceID)
oldAgentID := uuid.New()
newAgentID := uuid.New()
buildID := uuid.New()
Expand Down Expand Up @@ -2168,6 +2290,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).
Expand Down Expand Up @@ -2315,6 +2438,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(),
Expand Down Expand Up @@ -2477,6 +2601,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(),
Expand Down Expand Up @@ -2600,6 +2725,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(),
Expand Down Expand Up @@ -2670,6 +2796,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(),
Expand Down Expand Up @@ -2748,6 +2875,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(),
Expand Down Expand Up @@ -2820,6 +2948,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(),
Expand Down Expand Up @@ -2904,6 +3033,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{
Expand Down Expand Up @@ -2978,6 +3108,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{
Expand Down Expand Up @@ -3048,6 +3179,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{
Expand Down Expand Up @@ -3601,3 +3733,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()
}
10 changes: 10 additions & 0 deletions coderd/x/chatd/chattool/chattool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading