diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 95690adb25f..6e93dec7ea0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -541,6 +541,22 @@ jobs: gcloud storage cp "./build/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}.asc" done + # Mint a short-lived installation token from the dedicated release + # GitHub App. The default GITHUB_TOKEN (github-actions[bot]) cannot be + # added to the "Auto-imported tag create protections" ruleset bypass + # list, so `gh release create` fails to create the tag with a 403. The + # App is added to that ruleset's bypass list as an Integration actor. + - name: Generate release App token + id: release_app_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + # The App is installed at the org level, so resolve the org + # installation (avoids a 404 on the per-repo installation lookup). + owner: ${{ github.repository_owner }} + repositories: coder + - name: Publish release run: | set -euo pipefail @@ -579,7 +595,9 @@ jobs: --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ "${files[@]}" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Use the dedicated release App token (github-actions[bot] is blocked + # from creating tags by the tag-create protection ruleset). + GITHUB_TOKEN: ${{ steps.release_app_token.outputs.token }} CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} VERSION: ${{ steps.version.outputs.version }} CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 8abc40867e2..54a17275244 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -55,6 +55,13 @@ OPTIONS: the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured. + --disable-workspace-agent-context-sync bool, $CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC + Stop persisting workspace agent context snapshots (instructions, + skills, and MCP state used for pinned chat context). When set, coderd + rejects agent context pushes as unimplemented and agents stop sending + them; chats cannot pin workspace context. Use this to shed the + database write load of context sync on large deployments. + --disable-workspace-sharing bool, $CODER_DISABLE_WORKSPACE_SHARING Disable workspace sharing. Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index a8f3d90eb32..393150a0034 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -566,6 +566,13 @@ disableWorkspaceSharing: false # their chats. # (default: , type: bool) disableChatSharing: false +# Stop persisting workspace agent context snapshots (instructions, skills, and MCP +# state used for pinned chat context). When set, coderd rejects agent context +# pushes as unimplemented and agents stop sending them; chats cannot pin workspace +# context. Use this to shed the database write load of context sync on large +# deployments. +# (default: , type: bool) +disableWorkspaceAgentContextSync: false # These options change the behavior of how clients interact with the Coder. # Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. client: diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index ce697bc4826..c6a77362dec 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -83,7 +83,10 @@ type Options struct { Pubsub pubsub.Pubsub // ContextDirtyMarker is the chatd-backed hydrate/dirty fan-out invoked // from PushContextState. Nil when chatd is disabled. - ContextDirtyMarker ContextDirtyMarker + ContextDirtyMarker ContextDirtyMarker + // ContextSyncDisabled makes PushContextState reject pushes with a dRPC + // Unimplemented code so agents stop sending context snapshots. + ContextSyncDisabled bool ConnectionLogger *atomic.Pointer[connectionlog.ConnectionLogger] DerpMapFn func() *tailcfg.DERPMap TailnetCoordinator *atomic.Pointer[tailnet.Coordinator] @@ -257,6 +260,7 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge Clock: opts.Clock, Database: opts.Database, DirtyMarker: opts.ContextDirtyMarker, + Disabled: opts.ContextSyncDisabled, } // Start background cache refresh loop to handle workspace changes diff --git a/coderd/agentapi/context.go b/coderd/agentapi/context.go index f6dd69e4e38..09766bca87c 100644 --- a/coderd/agentapi/context.go +++ b/coderd/agentapi/context.go @@ -12,6 +12,7 @@ import ( "golang.org/x/xerrors" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" agentproto "github.com/coder/coder/v2/agent/proto" @@ -67,6 +68,13 @@ type ContextAPI struct { // snapshot persisted by a push. It is nil when chatd is not running, // in which case PushContextState stays a pure write path. DirtyMarker ContextDirtyMarker + // Disabled rejects every push with a dRPC Unimplemented code. The + // agent's DRPCPusher translates that code into ErrPushUnimplemented, + // which terminates its RunPush loop for the life of the connection, + // exactly as if coderd predated the v2.10 Agent API. This is the + // deployment-wide kill switch for context sync write load + // (CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC). + Disabled bool } // ContextDirtyMarker hydrates chats from, and marks chats dirty against, a @@ -103,6 +111,14 @@ type ContextDirtyMarker interface { // authorizes the actor (the agent's token subject) against the // workspace that owns the agent. func (a *ContextAPI) PushContextState(ctx context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) { + if a.Disabled { + // The Unimplemented code (not a plain error) is what tells the + // agent to stop pushing instead of retrying with backoff. + return nil, drpcerr.WithCode( + xerrors.New("agentapi: workspace agent context sync is disabled on this deployment"), + drpcerr.Unimplemented, + ) + } if req == nil { return nil, xerrors.New("agentapi: PushContextState request is nil") } diff --git a/coderd/agentapi/context_test.go b/coderd/agentapi/context_test.go index 5c724b93560..f190d0dfb02 100644 --- a/coderd/agentapi/context_test.go +++ b/coderd/agentapi/context_test.go @@ -13,6 +13,7 @@ import ( "github.com/lib/pq" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" @@ -58,6 +59,27 @@ func TestPushContextState(t *testing.T) { ) } + t.Run("DisabledReturnsUnimplemented", func(t *testing.T) { + t.Parallel() + + // No InTx or query expectations: a disabled push must return + // before touching the store. The Unimplemented dRPC code is + // load-bearing; the agent's DRPCPusher translates it into + // ErrPushUnimplemented, which stops its RunPush loop instead + // of retrying with backoff. + api, _ := makeAPI(t) + api.Disabled = true + + resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{ + Version: 1, + AggregateHash: []byte{0x01, 0x02}, + Initial: true, + }) + require.Error(t, err) + require.Nil(t, resp) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + }) + t.Run("AcceptsInitialPush", func(t *testing.T) { t.Parallel() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3021e5b0fd8..df3743c7334 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19876,6 +19876,9 @@ const docTemplate = `{ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_agent_context_sync": { + "type": "boolean" + }, "disable_workspace_sharing": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5a9dd02720f..556ababc495 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18027,6 +18027,9 @@ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_agent_context_sync": { + "type": "boolean" + }, "disable_workspace_sharing": { "type": "boolean" }, diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index f41921c7bb5..50c829349d9 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -38,6 +38,7 @@ import ( "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentcontainers/acmock" "github.com/coder/coder/v2/agent/agentcontainers/watcher" + "github.com/coder/coder/v2/agent/agentcontext" "github.com/coder/coder/v2/agent/agenttest" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentapi/metadatabatcher" @@ -3243,6 +3244,54 @@ func TestWorkspaceAgentPushContextState(t *testing.T) { require.False(t, resp.GetAccepted()) } +// TestWorkspaceAgentPushContextStateDisabled verifies the +// --disable-workspace-agent-context-sync kill switch end to end over a +// real dRPC connection: the handler's Unimplemented code must survive +// the transport and be translated by the agent's DRPCPusher into +// ErrPushUnimplemented, which is what terminates the agent's RunPush +// loop instead of retrying with backoff. Nothing may be persisted. +func TestWorkspaceAgentPushContextStateDisabled(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.DisableWorkspaceAgentContextSync = true + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + user := coderdtest.CreateFirstUser(t, client) + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + require.Len(t, r.Agents, 1) + agentID := r.Agents[0].ID + + ctx := testutil.Context(t, testutil.WaitLong) + + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken)) + aAPI, _, err := agentClient.ConnectRPC210(ctx) + require.NoError(t, err) + defer func() { + cErr := aAPI.DRPCConn().Close() + require.NoError(t, cErr) + }() + + // Push through the same adapter the agent's RunPush loop uses so + // the test breaks if either side of the Unimplemented contract + // changes. + pusher := agentcontext.NewDRPCPusher(aAPI) + resp, err := pusher.PushContextState(ctx, &agentcontext.PushRequest{ + Version: 1, + Initial: true, + }) + require.ErrorIs(t, err, agentcontext.ErrPushUnimplemented) + require.Nil(t, resp) + + // The rejected push must not have persisted anything. + _, err = db.GetLatestWorkspaceAgentContextSnapshot(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // Test assertions read agent-pushed rows directly from the store. + require.ErrorIs(t, err, sql.ErrNoRows) +} + func requireGetManifest(ctx context.Context, t testing.TB, aAPI agentproto.DRPCAgentClient) agentsdk.Manifest { mp, err := aAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) require.NoError(t, err) diff --git a/coderd/workspaceagentsrpc.go b/coderd/workspaceagentsrpc.go index 1c4e14c3552..9e7de438e31 100644 --- a/coderd/workspaceagentsrpc.go +++ b/coderd/workspaceagentsrpc.go @@ -192,6 +192,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) { // Optional: UpdateAgentMetricsFn: api.UpdateAgentMetrics, ContextDirtyMarker: contextDirtyMarker, + ContextSyncDisabled: api.DeploymentValues.DisableWorkspaceAgentContextSync.Value(), }, workspace, workspaceAgent) streamID := tailnet.StreamID{ diff --git a/codersdk/deployment.go b/codersdk/deployment.go index c95d910a0ed..514fa20db95 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -695,6 +695,7 @@ type DeploymentValues struct { DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` DisableWorkspaceSharing serpent.Bool `json:"disable_workspace_sharing,omitempty" typescript:",notnull"` DisableChatSharing serpent.Bool `json:"disable_chat_sharing,omitempty" typescript:",notnull"` + DisableWorkspaceAgentContextSync serpent.Bool `json:"disable_workspace_agent_context_sync,omitempty" typescript:",notnull"` ProxyHealthStatusInterval serpent.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` EnableTerraformDebugMode serpent.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` @@ -3739,6 +3740,15 @@ communicating directly.`, Value: &c.DisableChatSharing, YAML: "disableChatSharing", }, + { + Name: "Disable Workspace Agent Context Sync", + Description: "Stop persisting workspace agent context snapshots (instructions, skills, and MCP state used for pinned chat context). When set, coderd rejects agent context pushes as unimplemented and agents stop sending them; chats cannot pin workspace context. Use this to shed the database write load of context sync on large deployments.", + Flag: "disable-workspace-agent-context-sync", + Env: "CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC", + + Value: &c.DisableWorkspaceAgentContextSync, + YAML: "disableWorkspaceAgentContextSync", + }, { Name: "Session Duration", Description: "The token expiry duration for browser sessions. Sessions may last longer if they are actively making requests, but this functionality can be disabled via --disable-session-expiry-refresh.", diff --git a/docs/admin/setup/configuration-reference.md b/docs/admin/setup/configuration-reference.md index 3636d7cc1ed..0790666d701 100644 --- a/docs/admin/setup/configuration-reference.md +++ b/docs/admin/setup/configuration-reference.md @@ -82,6 +82,14 @@ Disable workspace apps that are not served from subdomains. Path-based apps can - CLI flag: [`--disable-path-apps`](../../reference/cli/server.md#--disable-path-apps) - YAML key: `disablePathApps` +### Disable workspace agent context sync + +Stop persisting workspace agent context snapshots (instructions, skills, and MCP state used for pinned chat context). When set, coderd rejects agent context pushes as unimplemented and agents stop sending them; chats cannot pin workspace context. Use this to shed the database write load of context sync on large deployments. + +- Environment variable: `CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC` +- CLI flag: [`--disable-workspace-agent-context-sync`](../../reference/cli/server.md#--disable-workspace-agent-context-sync) +- YAML key: `disableWorkspaceAgentContextSync` + ### Disable workspace sharing Disable workspace sharing. Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access based on the 'owner' role is also allowed unless disabled via --disable-owner-workspace-access. diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 4715ff82458..9ca81e264d1 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -289,6 +289,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_agent_context_sync": true, "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index aefacff8a52..2d5da928e1d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5857,6 +5857,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_agent_context_sync": true, "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, @@ -6467,6 +6468,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_agent_context_sync": true, "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, @@ -6861,6 +6863,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `disable_owner_workspace_exec` | boolean | false | | | | `disable_password_auth` | boolean | false | | | | `disable_path_apps` | boolean | false | | | +| `disable_workspace_agent_context_sync` | boolean | false | | | | `disable_workspace_sharing` | boolean | false | | | | `docs_url` | [serpent.URL](#serpenturl) | false | | | | `enable_authz_recording` | boolean | false | | | diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index f4ae058d878..61e353573c8 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1201,6 +1201,16 @@ Disable workspace sharing. Workspace ACL checking is disabled and only owners ca Disable chat sharing. Chat ACL checking is disabled and only owners can access their chats. +### --disable-workspace-agent-context-sync + +| | | +|-------------|----------------------------------------------------------| +| Type | bool | +| Environment | $CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC | +| YAML | disableWorkspaceAgentContextSync | + +Stop persisting workspace agent context snapshots (instructions, skills, and MCP state used for pinned chat context). When set, coderd rejects agent context pushes as unimplemented and agents stop sending them; chats cannot pin workspace context. Use this to shed the database write load of context sync on large deployments. + ### --session-duration | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 369b2fe72c8..3fab5d4176a 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -56,6 +56,13 @@ OPTIONS: the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured. + --disable-workspace-agent-context-sync bool, $CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC + Stop persisting workspace agent context snapshots (instructions, + skills, and MCP state used for pinned chat context). When set, coderd + rejects agent context pushes as unimplemented and agents stop sending + them; chats cannot pin workspace context. Use this to shed the + database write load of context sync on large deployments. + --disable-workspace-sharing bool, $CODER_DISABLE_WORKSPACE_SHARING Disable workspace sharing. Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 551a358a6db..d283df627e4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4641,6 +4641,7 @@ export interface DeploymentValues { readonly disable_owner_workspace_exec?: boolean; readonly disable_workspace_sharing?: boolean; readonly disable_chat_sharing?: boolean; + readonly disable_workspace_agent_context_sync?: boolean; readonly proxy_health_status_interval?: number; readonly enable_terraform_debug_mode?: boolean; readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx index d0709b8ba8f..6f36dd8b8e3 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx @@ -105,6 +105,7 @@ export const ChangeWorkspaceVersionDialog: FC< />