diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index 605feeec05a..aee3a685a0a 100644 --- a/agent/agentsocket/server.go +++ b/agent/agentsocket/server.go @@ -56,7 +56,7 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) { return nil, xerrors.Errorf("failed to register drpc service: %w", err) } - server.drpcServer = drpcserver.NewWithOptions(mux, drpcserver.Options{ + server.drpcServer = drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if errors.Is(err, context.Canceled) || diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index 0f5d83a98f9..457da28ebc5 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -78,6 +78,7 @@ func NewClientWithSecrets(t testing.TB, fakeAAPI := NewFakeAgentAPI(t, logger, mp, statsChan) err = agentproto.DRPCRegisterAgent(mux, fakeAAPI) require.NoError(t, err) + // Keep panics unrecovered in this test server so they fail tests loudly. server := drpcserver.NewWithOptions(mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index 2175505921f..efc66baac48 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -59,7 +59,7 @@ type API struct { *SubAgentAPI *BoundaryLogsAPI *ContextAPI - *tailnet.DRPCService + tailnetService *tailnet.DRPCService cachedWorkspaceFields *CachedWorkspaceFields @@ -68,6 +68,28 @@ type API struct { var _ agentproto.DRPCAgentServer = &API{} +// agentTailnetService exposes only Tailnet RPCs intended for workspace agents. +// Other current and future RPCs remain unavailable until explicitly forwarded. +type agentTailnetService struct { + tailnetproto.DRPCTailnetUnimplementedServer + + service *tailnet.DRPCService +} + +func (s *agentTailnetService) PostTelemetry(ctx context.Context, req *tailnetproto.TelemetryRequest) (*tailnetproto.TelemetryResponse, error) { + return s.service.PostTelemetry(ctx, req) +} + +func (s *agentTailnetService) StreamDERPMaps(req *tailnetproto.StreamDERPMapsRequest, stream tailnetproto.DRPCTailnet_StreamDERPMapsStream) error { + return s.service.StreamDERPMaps(req, stream) +} + +func (s *agentTailnetService) Coordinate(stream tailnetproto.DRPCTailnet_CoordinateStream) error { + return s.service.Coordinate(stream) +} + +var _ tailnetproto.DRPCTailnetServer = (*agentTailnetService)(nil) + type Options struct { AgentID uuid.UUID OwnerID uuid.UUID @@ -221,7 +243,7 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge Log: opts.Log, } - api.DRPCService = &tailnet.DRPCService{ + api.tailnetService = &tailnet.DRPCService{ CoordPtr: opts.TailnetCoordinator, Logger: opts.Log, DerpMapUpdateFrequency: opts.DerpMapUpdateFrequency, @@ -273,12 +295,14 @@ func (a *API) Server(ctx context.Context) (*drpcserver.Server, error) { return nil, xerrors.Errorf("register agent API protocol in DRPC mux: %w", err) } - err = tailnetproto.DRPCRegisterTailnet(mux, a) + err = tailnetproto.DRPCRegisterTailnet(mux, &agentTailnetService{ + service: a.tailnetService, + }) if err != nil { return nil, xerrors.Errorf("register tailnet API protocol in DRPC mux: %w", err) } - return drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + return drpcsdk.NewServer(a.opts.Log, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/aibridged.go b/coderd/aibridged.go index 6f946bb6611..c70e20d8c2c 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -84,7 +84,7 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai if err := aibridgedserver.Register(mux, srv); err != nil { return nil, err } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(api.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c3cbeafd52f..49ff9a422cf 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -18840,6 +18840,9 @@ const docTemplate = `{ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "docs_url": { "type": "string" }, @@ -28945,6 +28948,9 @@ const docTemplate = `{ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "logo_url": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0b5c840230e..094a1effedc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16909,6 +16909,9 @@ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "docs_url": { "type": "string" }, @@ -26612,6 +26615,9 @@ "application_name": { "type": "string" }, + "codernauts_enabled": { + "type": "boolean" + }, "logo_url": { "type": "string" }, diff --git a/coderd/appearance/appearance.go b/coderd/appearance/appearance.go index f63cd77a59c..e77edc90b41 100644 --- a/coderd/appearance/appearance.go +++ b/coderd/appearance/appearance.go @@ -3,6 +3,9 @@ package appearance import ( "context" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" ) @@ -11,22 +14,29 @@ type Fetcher interface { } type AGPLFetcher struct { - docsURL string + database database.Store + docsURL string } -func (f AGPLFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) { +func (f AGPLFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) { + codernautsEnabled, err := f.database.GetCodernautsEnabled(ctx) + if err != nil { + return codersdk.AppearanceConfig{}, xerrors.Errorf("get codernauts enabled: %w", err) + } return codersdk.AppearanceConfig{ AnnouncementBanners: []codersdk.BannerConfig{}, SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), DocsURL: f.docsURL, + CodernautsEnabled: codernautsEnabled, }, nil } -func NewDefaultFetcher(docsURL string) Fetcher { +func NewDefaultFetcher(db database.Store, docsURL string) Fetcher { if docsURL == "" { docsURL = codersdk.DefaultDocsURL() } return &AGPLFetcher{ - docsURL: docsURL, + database: db, + docsURL: docsURL, } } diff --git a/coderd/coderd.go b/coderd/coderd.go index ae228b1a416..ca18ecd26cb 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -756,7 +756,7 @@ func New(options *Options) *API { options.AppSigningKeyCache, ) - f := appearance.NewDefaultFetcher(api.DeploymentValues.DocsURL.String()) + f := appearance.NewDefaultFetcher(options.Database, api.DeploymentValues.DocsURL.String()) api.AppearanceFetcher.Store(&f) api.PortSharer.Store(&portsharing.DefaultPortSharer) api.PrebuildsClaimer.Store(&prebuilds.DefaultClaimer) @@ -2582,7 +2582,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n if err != nil { return nil, err } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6ab34639510..0649fac4c54 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -3788,6 +3788,10 @@ func (q *querier) GetChildChatsByParentIDs(ctx context.Context, arg database.Get return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChildChatsByParentIDs)(ctx, arg) } +func (q *querier) GetCodernautsEnabled(ctx context.Context) (bool, error) { + return q.db.GetCodernautsEnabled(ctx) +} + func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { // Just like with the audit logs query, shortcut if the user is an owner. err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog) @@ -9179,6 +9183,13 @@ func (q *querier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl strin return q.db.UpsertChatWorkspaceTTL(ctx, workspaceTtl) } +func (q *querier) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertCodernautsEnabled(ctx, enabled) +} + func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 6fb47988f25..5830c3c7b63 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5490,6 +5490,14 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().UpsertApplicationName(gomock.Any(), "").Return(nil).AnyTimes() check.Args("").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("GetCodernautsEnabled", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetCodernautsEnabled(gomock.Any()).Return(true, nil).AnyTimes() + check.Args().Asserts() + })) + s.Run("UpsertCodernautsEnabled", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertCodernautsEnabled(gomock.Any(), false).Return(nil).AnyTimes() + check.Args(false).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("UpsertBoundaryUsageStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.UpsertBoundaryUsageStatsParams{ReplicaID: uuid.New()} dbm.EXPECT().UpsertBoundaryUsageStats(gomock.Any(), arg).Return(false, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 2a2d2692f3c..c12e13a8082 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1920,6 +1920,14 @@ func (m queryMetricsStore) GetChildChatsByParentIDs(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) GetCodernautsEnabled(ctx context.Context) (bool, error) { + start := time.Now() + r0, r1 := m.s.GetCodernautsEnabled(ctx) + m.queryLatencies.WithLabelValues("GetCodernautsEnabled").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetCodernautsEnabled").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { start := time.Now() r0, r1 := m.s.GetConnectionLogsOffset(ctx, arg) @@ -6432,6 +6440,14 @@ func (m queryMetricsStore) UpsertChatWorkspaceTTL(ctx context.Context, workspace return r0 } +func (m queryMetricsStore) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + start := time.Now() + r0 := m.s.UpsertCodernautsEnabled(ctx, enabled) + m.queryLatencies.WithLabelValues("UpsertCodernautsEnabled").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertCodernautsEnabled").Inc() + return r0 +} + func (m queryMetricsStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { start := time.Now() r0 := m.s.UpsertDefaultProxy(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 251b1b303a1..026bc37889f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3599,6 +3599,21 @@ func (mr *MockStoreMockRecorder) GetChildChatsByParentIDs(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChildChatsByParentIDs", reflect.TypeOf((*MockStore)(nil).GetChildChatsByParentIDs), ctx, arg) } +// GetCodernautsEnabled mocks base method. +func (m *MockStore) GetCodernautsEnabled(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCodernautsEnabled", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCodernautsEnabled indicates an expected call of GetCodernautsEnabled. +func (mr *MockStoreMockRecorder) GetCodernautsEnabled(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCodernautsEnabled", reflect.TypeOf((*MockStore)(nil).GetCodernautsEnabled), ctx) +} + // GetConnectionLogsOffset mocks base method. func (m *MockStore) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) { m.ctrl.T.Helper() @@ -12102,6 +12117,20 @@ func (mr *MockStoreMockRecorder) UpsertChatWorkspaceTTL(ctx, workspaceTtl any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpsertChatWorkspaceTTL), ctx, workspaceTtl) } +// UpsertCodernautsEnabled mocks base method. +func (m *MockStore) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertCodernautsEnabled", ctx, enabled) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertCodernautsEnabled indicates an expected call of UpsertCodernautsEnabled. +func (mr *MockStoreMockRecorder) UpsertCodernautsEnabled(ctx, enabled any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertCodernautsEnabled", reflect.TypeOf((*MockStore)(nil).UpsertCodernautsEnabled), ctx, enabled) +} + // UpsertDefaultProxy mocks base method. func (m *MockStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4ff6615a417..3a149bbee04 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -560,6 +560,7 @@ type sqlcQuerier interface { // invariant (parent archived implies child archived) is enforced // at write time, not here. GetChildChatsByParentIDs(ctx context.Context, arg GetChildChatsByParentIDsParams) ([]GetChildChatsByParentIDsRow, error) + GetCodernautsEnabled(ctx context.Context) (bool, error) GetConnectionLogsOffset(ctx context.Context, arg GetConnectionLogsOffsetParams) ([]GetConnectionLogsOffsetRow, error) GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) @@ -1683,6 +1684,7 @@ type sqlcQuerier interface { UpsertChatSystemPrompt(ctx context.Context, value string) error UpsertChatUserModelOverride(ctx context.Context, arg UpsertChatUserModelOverrideParams) error UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl string) error + UpsertCodernautsEnabled(ctx context.Context, enabled bool) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. // The functional values are immutable and controlled implicitly. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 3689b0daade..02b03eb516f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -25866,6 +25866,18 @@ func (q *sqlQuerier) GetChatWorkspaceTTL(ctx context.Context) (string, error) { return workspace_ttl, err } +const getCodernautsEnabled = `-- name: GetCodernautsEnabled :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'codernauts_enabled'), true) :: boolean AS codernauts_enabled +` + +func (q *sqlQuerier) GetCodernautsEnabled(ctx context.Context) (bool, error) { + row := q.db.QueryRowContext(ctx, getCodernautsEnabled) + var codernauts_enabled bool + err := row.Scan(&codernauts_enabled) + return codernauts_enabled, err +} + const getDERPMeshKey = `-- name: GetDERPMeshKey :one SELECT value FROM site_configs WHERE key = 'derp_mesh_key' ` @@ -26246,6 +26258,28 @@ func (q *sqlQuerier) UpsertChatWorkspaceTTL(ctx context.Context, workspaceTtl st return err } +const upsertCodernautsEnabled = `-- name: UpsertCodernautsEnabled :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'codernauts_enabled', + CASE + WHEN $1::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN $1::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'codernauts_enabled' +` + +func (q *sqlQuerier) UpsertCodernautsEnabled(ctx context.Context, enabled bool) error { + _, err := q.db.ExecContext(ctx, upsertCodernautsEnabled, enabled) + return err +} + const upsertDefaultProxy = `-- name: UpsertDefaultProxy :exec INSERT INTO site_configs (key, value) VALUES diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 52f28e7c5f1..51eadef0eda 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -57,6 +57,26 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'application -- name: GetApplicationName :one SELECT value FROM site_configs WHERE key = 'application_name'; +-- name: GetCodernautsEnabled :one +SELECT + COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'codernauts_enabled'), true) :: boolean AS codernauts_enabled; + +-- name: UpsertCodernautsEnabled :exec +INSERT INTO site_configs (key, value) +VALUES ( + 'codernauts_enabled', + CASE + WHEN @enabled::bool THEN 'true' + ELSE 'false' + END +) +ON CONFLICT (key) DO UPDATE +SET value = CASE + WHEN @enabled::bool THEN 'true' + ELSE 'false' +END +WHERE site_configs.key = 'codernauts_enabled'; + -- name: GetHealthSettings :one SELECT COALESCE((SELECT value FROM site_configs WHERE key = 'health_settings'), '{}') :: text AS health_settings diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index 1595462d191..5d083c131f9 100644 --- a/coderd/workspaceagentsrpc_test.go +++ b/coderd/workspaceagentsrpc_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "storj.io/drpc/drpcerr" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" @@ -17,6 +18,8 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/tailnet" + tailnetproto "github.com/coder/coder/v2/tailnet/proto" "github.com/coder/coder/v2/testutil" ) @@ -109,6 +112,47 @@ func TestWorkspaceAgentReportStats(t *testing.T) { } } +func TestWorkspaceAgentRPC_TailnetMethods(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspace.AgentToken)) + conn, err := agentClient.ConnectRPC(ctx) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + }) + + tailnetClient := tailnetproto.NewDRPCTailnetClient(conn) + _, err = tailnetClient.RefreshResumeToken(ctx, &tailnetproto.RefreshResumeTokenRequest{}) + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := tailnetClient.WorkspaceUpdates(ctx, &tailnetproto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(user.UserID), + }) + if err == nil { + _, err = updates.Recv() + } + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + telemetry, err := tailnetClient.PostTelemetry(ctx, &tailnetproto.TelemetryRequest{}) + require.NoError(t, err) + require.NotNil(t, telemetry) + + agentAPI := agentproto.NewDRPCAgentClient(conn) + _, err = agentAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) + require.NoError(t, err) +} + func TestAgentAPI_LargeManifest(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/stream_sync_poller.go b/coderd/x/chatd/stream_sync_poller.go index 11e9171687e..2a87a9af121 100644 --- a/coderd/x/chatd/stream_sync_poller.go +++ b/coderd/x/chatd/stream_sync_poller.go @@ -101,7 +101,6 @@ func (p *streamSyncPoller) unregister(subscriber *streamSyncPollerSubscriber) { if len(chatSubscribers) == 0 { delete(p.subscribers, subscriber.chatID) } - close(subscriber.hints) } func (p *streamSyncPoller) loop() { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5b7ea5a0610..9700549ebb8 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5280,6 +5280,7 @@ type AppearanceConfig struct { ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` SupportLinks []LinkConfig `json:"support_links,omitempty"` + CodernautsEnabled bool `json:"codernauts_enabled"` } type UpdateAppearanceConfig struct { @@ -5288,6 +5289,7 @@ type UpdateAppearanceConfig struct { // Deprecated: ServiceBanner has been replaced by AnnouncementBanners. ServiceBanner BannerConfig `json:"service_banner"` AnnouncementBanners []BannerConfig `json:"announcement_banners"` + CodernautsEnabled bool `json:"codernauts_enabled"` } // Deprecated: ServiceBannerConfig has been renamed to BannerConfig. diff --git a/codersdk/drpcsdk/server.go b/codersdk/drpcsdk/server.go new file mode 100644 index 00000000000..6fc9a505d93 --- /dev/null +++ b/codersdk/drpcsdk/server.go @@ -0,0 +1,39 @@ +package drpcsdk + +import ( + "runtime/debug" + + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "cdr.dev/slog/v3" +) + +// NewServer constructs a dRPC server that recovers panics from RPC handlers. +func NewServer(logger slog.Logger, handler drpc.Handler, options drpcserver.Options) *drpcserver.Server { + return drpcserver.NewWithOptions(&recoverHandler{ + logger: logger, + handler: handler, + }, options) +} + +type recoverHandler struct { + logger slog.Logger + handler drpc.Handler +} + +func (h *recoverHandler) HandleRPC(stream drpc.Stream, rpc string) (err error) { + defer func() { + if r := recover(); r != nil { + h.logger.Error(stream.Context(), + "panic serving dRPC request (recovered)", + slog.F("rpc", rpc), + slog.F("panic", r), + slog.F("stack", string(debug.Stack())), + ) + err = drpc.InternalError.New("panic serving dRPC request") + } + }() + + return h.handler.HandleRPC(stream, rpc) +} diff --git a/codersdk/drpcsdk/server_internal_test.go b/codersdk/drpcsdk/server_internal_test.go new file mode 100644 index 00000000000..66298af68f5 --- /dev/null +++ b/codersdk/drpcsdk/server_internal_test.go @@ -0,0 +1,85 @@ +package drpcsdk + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/testutil" +) + +func TestRecoverHandler(t *testing.T) { + t.Parallel() + + t.Run("Panic", func(t *testing.T) { + t.Parallel() + + const panicValue = "sensitive panic details" + sink := testutil.NewFakeSink(t) + handler := &recoverHandler{ + logger: sink.Logger(), + handler: handlerFunc(func(drpc.Stream, string) error { + panic(panicValue) + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Panic") + require.Error(t, err) + require.True(t, drpc.InternalError.Has(err)) + require.NotContains(t, err.Error(), panicValue) + + entries := sink.Entries() + require.Len(t, entries, 1) + require.Equal(t, slog.LevelError, entries[0].Level) + require.Equal(t, "panic serving dRPC request (recovered)", entries[0].Message) + require.Equal(t, "/test.Service/Panic", fieldValue(entries[0].Fields, "rpc")) + require.Equal(t, panicValue, fieldValue(entries[0].Fields, "panic")) + stackValue := fieldValue(entries[0].Fields, "stack") + stack, ok := stackValue.(string) + require.True(t, ok, "stack field must be a string, got %T", stackValue) + require.Contains(t, stack, "goroutine ") + }) + + t.Run("Error", func(t *testing.T) { + t.Parallel() + + expected := xerrors.New("handler error") + handler := &recoverHandler{ + handler: handlerFunc(func(drpc.Stream, string) error { + return expected + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Error") + require.ErrorIs(t, err, expected) + }) +} + +type handlerFunc func(drpc.Stream, string) error + +func (f handlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type contextStream struct { + ctx context.Context +} + +func (s contextStream) Context() context.Context { return s.ctx } +func (contextStream) MsgSend(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) MsgRecv(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) CloseSend() error { return nil } +func (contextStream) Close() error { return nil } + +func fieldValue(fields slog.Map, name string) any { + for _, field := range fields { + if field.Name == name { + return field.Value + } + } + return nil +} diff --git a/codersdk/drpcsdk/server_test.go b/codersdk/drpcsdk/server_test.go new file mode 100644 index 00000000000..e906daa82e0 --- /dev/null +++ b/codersdk/drpcsdk/server_test.go @@ -0,0 +1,94 @@ +package drpcsdk_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/coder/v2/testutil" +) + +func TestNewServerRecoversPanics(t *testing.T) { + t.Parallel() + + const ( + panicRPC = "/test.Service/Panic" + echoRPC = "/test.Service/Echo" + panicValue = "sensitive panic details" + ) + + ctx := testutil.Context(t, testutil.WaitShort) + serverCtx, cancel := context.WithCancel(ctx) + defer cancel() + + client, listener := drpcsdk.MemTransportPipe() + defer func() { + _ = client.Close() + _ = listener.Close() + }() + + handler := testHandlerFunc(func(stream drpc.Stream, rpc string) error { + switch rpc { + case panicRPC: + panic(panicValue) + case echoRPC: + var message string + if err := stream.MsgRecv(&message, stringEncoding{}); err != nil { + return err + } + return stream.MsgSend(&message, stringEncoding{}) + default: + return xerrors.Errorf("unexpected RPC %q", rpc) + } + }) + server := drpcsdk.NewServer(testutil.NewFakeSink(t).Logger(), handler, drpcserver.Options{ + Manager: drpcsdk.DefaultDRPCOptions(nil), + }) + serverDone := make(chan error, 1) + go func() { + serverDone <- server.Serve(serverCtx, listener) + }() + + request, response := "request", "" + err := client.Invoke(ctx, panicRPC, stringEncoding{}, &request, &response) + require.EqualError(t, err, "internal error: panic serving dRPC request") + require.NotContains(t, err.Error(), panicValue) + + request, response = "healthy", "" + err = client.Invoke(ctx, echoRPC, stringEncoding{}, &request, &response) + require.NoError(t, err) + require.Equal(t, request, response) + + cancel() + require.NoError(t, testutil.RequireReceive(ctx, t, serverDone)) +} + +type testHandlerFunc func(drpc.Stream, string) error + +func (f testHandlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type stringEncoding struct{} + +func (stringEncoding) Marshal(message drpc.Message) ([]byte, error) { + value, ok := message.(*string) + if !ok { + return nil, xerrors.Errorf("marshal %T: expected *string", message) + } + return []byte(*value), nil +} + +func (stringEncoding) Unmarshal(data []byte, message drpc.Message) error { + value, ok := message.(*string) + if !ok { + return xerrors.Errorf("unmarshal %T: expected *string", message) + } + *value = string(data) + return nil +} diff --git a/docs/ai-coder/agent-relay/cursor.md b/docs/ai-coder/agent-relay/cursor.md new file mode 100644 index 00000000000..8af949037ed --- /dev/null +++ b/docs/ai-coder/agent-relay/cursor.md @@ -0,0 +1,38 @@ +--- +title: Agent Relay for Cursor +--- + +> [!NOTE] +> Agent Relay for Cursor is in [early access](../../install/releases/feature-stages.md#early-access-features) and is currently in closed preview with select customers. + +[Agent Relay](./index.md) connects [Cursor Cloud Agents](https://cursor.com/cloud) to self-hosted Coder workspaces. +Cursor also refers to this self-hosted worker model as [bring-your-own-machine (BYOM)](https://cursor.com/docs/cloud-agent/bring-your-own-machine). +Developers keep using the Cursor client and cloud agent workflow they already know. +The agent's tool calls run inside a Coder workspace on infrastructure you control instead of a Cursor-managed environment. + +Cursor's agent orchestration and AI inference remain cloud-hosted. +Coder doesn't proxy or observe model inference. +Coder provides the workspace where the agent executes, and logs that correlate the Cursor session and user to that workspace. + +## How it works + +1. A developer selects a Cursor worker pool mapped to a Coder organization and workspace template, then starts a Cursor agent session. +1. Agent Relay claims the pending Cursor request and asks the Coder control plane to provision a workspace from the mapped template for that user. +1. A Cursor worker process inside the workspace connects to the corresponding Cursor cloud session and executes the agent's tool calls. +1. When the session ends, Agent Relay manages workspace teardown. + +Each Cursor agent session gets its own ephemeral workspace. + +## Requirements + +- A licensed Coder deployment +- A Cursor Enterprise plan + +## Get started + +Talk to your [Coder account team](https://coder.com/contact) or email [sales@coder.com](mailto:sales@coder.com) to get access to Agent Relay for Cursor. + +## Learn more + +- [Agent Relay](./index.md) +- [Architecture](../../admin/infrastructure/architecture.md) diff --git a/docs/ai-coder/agent-relay/index.md b/docs/ai-coder/agent-relay/index.md new file mode 100644 index 00000000000..848ca6c0b13 --- /dev/null +++ b/docs/ai-coder/agent-relay/index.md @@ -0,0 +1,56 @@ +--- +title: Agent Relay +--- + +Agent Relay connects a cloud-hosted AI coding agent's hosted sessions to self-hosted [Coder workspaces](../../user-guides/workspace-management.md). +Developers keep the cloud agent's client and workflow. +Coder provides the workspace where the agent's tool calls run. + +## What Agent Relay does + +Agent Relay watches for pending agent sessions from a supported provider. +When a session starts, Agent Relay provisions a Coder workspace from a mapped template and connects the session to a worker process inside that workspace. +The worker executes the agent's tool calls, such as reading files, running commands, and using development tools, against the resources available in that workspace. +Agent Relay manages the workspace for the life of the session and tears it down when the session ends. + +Agent Relay architecture diagram + +## What Agent Relay is and isn't + +Agent Relay changes where a cloud agent's tool calls execute. +It doesn't change where the agent's orchestration or AI inference run. +Those stay with the cloud provider. + +Agent Relay is not: + +- A replacement for [Coder Agents](../agents/index.md), Coder's own AI workflow infrastructure that runs its native agent loop inside the Coder control plane and calls out to your configured LLM provider for inference. +- A proxy or observability layer for a provider's AI inference. + Coder has no access to model selection or token usage for sessions that run through Agent Relay. +- A self-hosted deployment of a cloud provider's control plane. + The provider's orchestration stays cloud-hosted. + +## Business value + +- Developers keep using the cloud agent client and workflow they already know. +- Platform and security teams control the infrastructure where agent sessions execute and which internal resources those sessions can reach. +- Agent sessions run in workspaces built from the same templates, networking, and governance controls as the rest of your Coder deployment. +- Each session gets its own workspace, provisioned on demand and deleted when the session ends. + +## Current state + +Agent Relay is in [early access](../../install/releases/feature-stages.md#early-access-features) and is in closed preview with select customers. + +## Supported providers + +[Cursor](./cursor.md) is the first provider Agent Relay supports. +Coder built Agent Relay to support additional cloud-hosted agent providers as they add support for self-hosted execution. + +## Get started + +If you want access to Agent Relay or want updates on the support status for your cloud-hosted agent provider of choice, talk to your [Coder account team](https://coder.com/contact) or email [sales@coder.com](mailto:sales@coder.com). + +## Learn more + +- [Agent Relay for Cursor](./cursor.md) +- [Coder Agents](../agents/index.md) +- [Architecture](../../admin/infrastructure/architecture.md) diff --git a/docs/ai-coder/index.md b/docs/ai-coder/index.md index 541cf536844..89af98020bc 100644 --- a/docs/ai-coder/index.md +++ b/docs/ai-coder/index.md @@ -1,52 +1,69 @@ # Run AI Coding Agents in Coder -Learn how to run & manage coding agents with Coder, both alongside existing -workspaces and for background task execution. +Learn how to run & manage coding agents with Coder, both alongside existing workspaces and for background task execution. -## Agents in the IDE - -Coder [integrates with IDEs](../user-guides/workspace-access/index.md) such as -Cursor, Devin Desktop, and Zed that include built-in coding agents to work alongside -developers. Additionally, template admins can -[pre-install extensions](https://registry.coder.com/modules/coder/vscode-web) -for agents such as GitHub Copilot. +Coder supports several ways to run and [govern](#govern-ai-activity-with-ai-governance) coding agents, depending on how much control you need over execution and orchestration: -These agents work well inside existing Coder workspaces as they can simply be -enabled via an extension or are built-into the editor. +- [Coder Agents](#coder-agents), self-hosted AI workflow infrastructure best suited for headless, automated background tasks and parallel agentic development in a conversational UI. +- [Agent Relay](#agent-relay), best suited for preserving the cloud agent experience developers already know while running execution in self-hosted Coder workspaces. +- [Agents in the IDE](#agents-in-the-ide), best suited for in-editor code assist use cases alongside a developer's existing workflow. +- [Agents in workspace templates](#agents-in-workspace-templates), best suited for developers who want to pair one-on-one with an agent like Claude Code or Codex in a workspace. ## Coder Agents -In cases where the IDE is secondary, such as prototyping, research, or -long-running background jobs, [Coder Agents](./agents/index.md) is the -recommended way to delegate development work to coding agents in your Coder -deployment. +In cases where the IDE is secondary, such as prototyping, research, or long-running background jobs, [Coder Agents](./agents/index.md) is the recommended way to delegate development work to coding agents in your Coder deployment. -Coder Agents is a native AI coding agent built into Coder. The agent loop runs -in the Coder control plane on your infrastructure rather than inside the -workspace, so workspaces can be completely network isolated. Developers -interact with agents through the web UI or the REST API. +Coder Agents is a native AI coding agent built into Coder. +The agent loop runs in the Coder control plane on your infrastructure rather than inside the workspace, so workspaces can be completely network isolated. +Developers interact with agents through the web UI or the REST API. ![Coder Agents chat interface with git diff sidebar](../images/agents-hero-image.png) -[Learn more about Coder Agents](./agents/index.md) for architecture details, -supported LLM providers, and how to get started. +[Learn more about Coder Agents](./agents/index.md) for architecture details, supported LLM providers, and how to get started. + +## Agent Relay + +[Agent Relay](./agent-relay/index.md) connects a supported cloud-hosted AI agent provider's hosted sessions to self-hosted Coder workspaces. +The provider's orchestration and AI inference stay cloud-hosted; a worker process inside the workspace executes the agent's tool calls. +[Cursor Cloud Agents](https://cursor.com/cloud) is the first supported provider. + +Agent Relay is in [early access](../install/releases/feature-stages.md#early-access-features) and is in closed preview with select customers. + +[Learn more about Agent Relay](./agent-relay/index.md) for architecture details and supported providers. + +## Agents in the IDE + +Coder [integrates with IDEs](../user-guides/workspace-access/index.md) such as Cursor, Devin Desktop, and Zed that include built-in coding agents to work alongside developers. +Additionally, template admins can [pre-install extensions](https://registry.coder.com/modules/coder/vscode-web) for agents such as GitHub Copilot. + +These agents work well inside existing Coder workspaces as they can simply be enabled via an extension or are built-into the editor. + +## Agents in workspace templates + +Template admins can install terminal-based coding agents, such as Claude Code or Codex, directly into a workspace template using a [registry module](https://registry.coder.com). +Pick from a curated list of agent modules in the [template builder](../admin/templates/creating-templates.md#template-builder), or add a module directly in Terraform: + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "~> 5.2" + agent_id = coder_agent.main.id +} +``` + +Visit the [Coder Registry](https://registry.coder.com) for the full list of available agent modules. + +[Learn more about extending templates](../admin/templates/extending-templates/index.md). ## Govern AI activity with AI Governance -AI coding tools are quickly becoming core to how engineering teams ship -software. As adoption grows, platform teams want a clear picture of how AI is -being used, consistent guardrails across teams, and predictable cost controls -so they can confidently scale AI tooling to the whole organization. - -[AI Governance](./ai-governance.md) is included with a Premium license and adds -observability, management, and policy controls for AI tooling across your -Coder deployment. It includes: - -- [AI Gateway](./ai-gateway/index.md) for centralized authentication, audit - trails of prompts and tool invocations, and policy enforcement against - upstream LLM providers. -- [Agent Firewall](./agent-firewall/index.md) for process-level network and - command policies that restrict what agents can reach and do inside a - workspace. -[Learn more about AI Governance](./ai-governance.md) for use cases, entitlements, -and how to enable it in your deployment. +AI coding tools are quickly becoming core to how engineering teams ship software. +As adoption grows, platform teams want a clear picture of how AI is being used, consistent guardrails across teams, and predictable cost controls so they can confidently scale AI tooling to the whole organization. + +[AI Governance](./ai-governance.md) is included with a Premium license and adds observability, management, and policy controls for AI tooling across your Coder deployment. +It includes: + +- [AI Gateway](./ai-gateway/index.md) for centralized authentication, audit trails of prompts and tool invocations, and policy enforcement against upstream LLM providers. +- [Agent Firewall](./agent-firewall/index.md) for process-level network and command policies that restrict what agents can reach and do inside a workspace. + +[Learn more about AI Governance](./ai-governance.md) for use cases, entitlements, and how to enable it in your deployment. diff --git a/docs/images/guides/ai-agents/agent-relay-stack.png b/docs/images/guides/ai-agents/agent-relay-stack.png new file mode 100644 index 00000000000..ea4ab90e5ab Binary files /dev/null and b/docs/images/guides/ai-agents/agent-relay-stack.png differ diff --git a/docs/manifest.json b/docs/manifest.json index f34b484f228..74289e51af8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1136,6 +1136,20 @@ } ] }, + { + "title": "Agent Relay", + "description": "Connect supported cloud-hosted AI agents to self-hosted Coder workspaces with Agent Relay.", + "path": "./ai-coder/agent-relay/index.md", + "state": ["early access"], + "children": [ + { + "title": "Agent Relay for Cursor", + "description": "Run Cursor's cloud agent sessions inside self-hosted Coder workspaces with Agent Relay.", + "path": "./ai-coder/agent-relay/cursor.md", + "state": ["early access"] + } + ] + }, { "title": "AI Governance", "description": "Govern AI usage at scale with AI Governance: Agent Firewall, AI Gateway, and reporting.", diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 6fb958ea3c9..70707e53ee0 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -356,6 +356,7 @@ curl -X GET http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "codernauts_enabled": true, "docs_url": "string", "logo_url": "string", "service_banner": { @@ -408,6 +409,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -437,6 +439,7 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \ } ], "application_name": "string", + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4b1467bec02..74018f79d33 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1684,6 +1684,7 @@ None } ], "application_name": "string", + "codernauts_enabled": true, "docs_url": "string", "logo_url": "string", "service_banner": { @@ -1708,6 +1709,7 @@ None |------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| | `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | | `application_name` | string | false | | | +| `codernauts_enabled` | boolean | false | | | | `docs_url` | string | false | | | | `logo_url` | string | false | | | | `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | @@ -15313,6 +15315,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W } ], "application_name": "string", + "codernauts_enabled": true, "logo_url": "string", "service_banner": { "background_color": "string", @@ -15328,6 +15331,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |------------------------|---------------------------------------------------------|----------|--------------|---------------------------------------------------------------------| | `announcement_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | | | `application_name` | string | false | | | +| `codernauts_enabled` | boolean | false | | | | `logo_url` | string | false | | | | `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. | diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 63a67aed3f5..9233b955a9a 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -7,6 +7,7 @@ Each entry gives a short definition and, where it helps, links to the page that > Several Coder terms share the word "agent" but mean different things: > > - [Coder Agents](#coder-agents) is the AI product for delegating development work to coding agents. +> - [Agent Relay](#agent-relay) connects a cloud-hosted AI agent provider's hosted sessions to self-hosted workspaces. It is not Coder Agents. > - A [workspace agent](#workspace-agent) is the process that runs inside a workspace to provide SSH, port forwarding, the web terminal, and other services. > - [`coder_agent`](#coder_agent) is the Terraform resource in a template that declares a workspace agent. @@ -24,6 +25,13 @@ It was previously named Agent Boundaries and uses a sandbox backend, `nsjail` by This feature requires a Premium license. Refer to [Agent Firewall](../ai-coder/agent-firewall/index.md). +### Agent Relay + +A feature that connects a supported cloud-hosted AI agent provider's hosted sessions to self-hosted [workspaces](#workspace). +The provider's orchestration and AI inference stay cloud-hosted; a worker process inside the workspace executes the agent's tool calls. +In [early access](../install/releases/feature-stages.md#early-access-features). +Refer to [Agent Relay](../ai-coder/agent-relay/index.md). + ### AI Gateway An LLM gateway in `coderd` that authenticates users, forwards traffic to providers such as OpenAI and Anthropic, audits prompts and tool invocations, and centralizes MCP administration. diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index 013b0a29ab8..1d2fbc37c19 100644 --- a/docs/user-guides/workspace-management.md +++ b/docs/user-guides/workspace-management.md @@ -1,8 +1,7 @@ # Workspaces -A workspace is the environment that a developer works in. Developers in a team -each work from their own workspace and can use -[multiple IDEs](./workspace-access/index.md). +A workspace is the environment where a developer or a coding agent works. +Developers and agents in a team each work from their own workspace and can use [multiple IDEs](./workspace-access/index.md). A developer creates a workspace from a [shared template](../admin/templates/index.md). This lets an entire team work in diff --git a/enterprise/coderd/aibridgeserve.go b/enterprise/coderd/aibridgeserve.go index 09c48086a6d..44cd4dd16af 100644 --- a/enterprise/coderd/aibridgeserve.go +++ b/enterprise/coderd/aibridgeserve.go @@ -159,7 +159,7 @@ func (api *API) aiGatewayServe(rw http.ResponseWriter, r *http.Request) { return } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/enterprise/coderd/appearance.go b/enterprise/coderd/appearance.go index db845fadea3..3bf28107879 100644 --- a/enterprise/coderd/appearance.go +++ b/enterprise/coderd/appearance.go @@ -66,6 +66,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi applicationName string logoURL string announcementBannersJSON string + codernautsEnabled bool ) eg.Go(func() (err error) { applicationName, err = f.database.GetApplicationName(ctx) @@ -88,6 +89,13 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi } return nil }) + eg.Go(func() (err error) { + codernautsEnabled, err = f.database.GetCodernautsEnabled(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get codernauts enabled: %w", err) + } + return nil + }) err := eg.Wait() if err != nil { return codersdk.AppearanceConfig{}, err @@ -99,6 +107,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi AnnouncementBanners: []codersdk.BannerConfig{}, SupportLinks: codersdk.DefaultSupportLinks(f.docsURL), DocsURL: f.docsURL, + CodernautsEnabled: codernautsEnabled, } if announcementBannersJSON != "" { @@ -206,5 +215,14 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) { return } + err = api.Database.UpsertCodernautsEnabled(ctx, appearance.CodernautsEnabled) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Unable to set Codernauts enabled", + Detail: err.Error(), + }) + return + } + httpapi.Write(r.Context(), rw, http.StatusOK, appearance) } diff --git a/enterprise/coderd/appearance_test.go b/enterprise/coderd/appearance_test.go index 8255dd4c8aa..8bfaae8b9b0 100644 --- a/enterprise/coderd/appearance_test.go +++ b/enterprise/coderd/appearance_test.go @@ -55,6 +55,52 @@ func TestCustomLogoAndCompanyName(t *testing.T) { require.Equal(t, uac.LogoURL, got.LogoURL) } +func TestCodernautsEnabled(t *testing.T) { + t.Parallel() + + adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true}) + basicUserClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // With no stored setting, as after an upgrade, the game defaults to + // enabled. This deployment has no license, so the default appearance + // fetcher serves the value. + got, err := basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.True(t, got.CodernautsEnabled) + + // The setting can be disabled without any license entitlement. + err = adminClient.UpdateAppearance(ctx, codersdk.UpdateAppearanceConfig{ + CodernautsEnabled: false, + }) + require.NoError(t, err) + + got, err = basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.False(t, got.CodernautsEnabled) + + // The stored value survives switching to the licensed fetcher. + coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAppearance: 1, + }, + }) + + got, err = basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.False(t, got.CodernautsEnabled) + + err = adminClient.UpdateAppearance(ctx, codersdk.UpdateAppearanceConfig{ + CodernautsEnabled: true, + }) + require.NoError(t, err) + + got, err = basicUserClient.Appearance(ctx) + require.NoError(t, err) + require.True(t, got.CodernautsEnabled) +} + func TestAnnouncementBanners(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index fcf9b8f09d7..ed40957d39f 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -1162,7 +1162,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { ) api.AGPL.AppearanceFetcher.Store(&f) } else { - f := appearance.NewDefaultFetcher(api.DeploymentValues.DocsURL.String()) + f := appearance.NewDefaultFetcher(api.Database, api.DeploymentValues.DocsURL.String()) api.AGPL.AppearanceFetcher.Store(&f) } } diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index b6d0658433e..3b879bd3995 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -378,7 +378,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) return } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) { diff --git a/provisionersdk/serve.go b/provisionersdk/serve.go index 4afcee96269..013626b22a4 100644 --- a/provisionersdk/serve.go +++ b/provisionersdk/serve.go @@ -92,7 +92,7 @@ func Serve(ctx context.Context, server Server, options *ServeOptions) error { if err != nil { return xerrors.Errorf("register provisioner: %w", err) } - srv := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ + srv := drpcsdk.NewServer(options.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), }) diff --git a/site/site.go b/site/site.go index d607c02ce7a..a24954a7f39 100644 --- a/site/site.go +++ b/site/site.go @@ -89,7 +89,7 @@ type Options struct { func New(opts *Options) (*Handler, error) { if opts.AppearanceFetcher == nil { daf := atomic.Pointer[appearance.Fetcher]{} - f := appearance.NewDefaultFetcher(opts.DocsURL) + f := appearance.NewDefaultFetcher(opts.Database, opts.DocsURL) daf.Store(&f) opts.AppearanceFetcher = &daf } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2c98a77dcdb..7d6eba4b8cd 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2532,6 +2532,7 @@ class ApiMethods { docs_url: "", logo_url: "", announcement_banners: [], + codernauts_enabled: true, service_banner: { enabled: false, }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5239f3fe02e..c12dc3b4bfe 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1590,6 +1590,7 @@ export interface AppearanceConfig { readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; readonly support_links?: readonly LinkConfig[]; + readonly codernauts_enabled: boolean; } // From codersdk/templates.go @@ -9740,6 +9741,7 @@ export interface UpdateAppearanceConfig { */ readonly service_banner: BannerConfig; readonly announcement_banners: readonly BannerConfig[]; + readonly codernauts_enabled: boolean; } // From codersdk/chats.go diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx new file mode 100644 index 00000000000..8740b3af492 --- /dev/null +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type * as Monaco from "monaco-editor"; +import * as monaco from "monaco-editor"; +import { useState } from "react"; +import { expect, userEvent, waitFor } from "storybook/test"; +import { withDashboardProvider } from "#/testHelpers/storybook"; +import { SyntaxHighlighter } from "./SyntaxHighlighter"; + +const original = `resource "coder_agent" "main" { + os = "linux" + arch = "amd64" +} +`; + +const modified = `resource "coder_agent" "main" { + os = "linux" + arch = "arm64" +} +`; + +// The diff editor's gutter menu and occurrence highlighter register delayed +// disposables whose teardown throws when editors unmount in Storybook tests. +// They are irrelevant to model disposal, so we turn them off in stories to keep +// the test runner clean without changing production behavior. +const stableTeardownOptions: Monaco.editor.IStandaloneDiffEditorConstructionOptions = + { + minimap: { enabled: false }, + renderSideBySide: true, + readOnly: true, + renderGutterMenu: false, + occurrencesHighlight: "off", + }; + +const meta: Meta = { + title: "components/SyntaxHighlighter", + component: SyntaxHighlighter, + decorators: [withDashboardProvider], + args: { + language: "hcl", + editorProps: { options: stableTeardownOptions }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Plain: Story = { + args: { + value: original, + }, +}; + +export const Diff: Story = { + args: { + value: modified, + compareWith: original, + }, +}; + +// Reproduces the leak from DEVEX-736: a single SyntaxHighlighter instance that +// stays mounted while a file switches between diff and plain across template +// versions. Each diff editor owns two Monaco models, and they must be disposed +// when the diff goes away. Before the fix the models were only disposed on full +// unmount, so toggling diff -> plain -> diff leaked two models per cycle. +const DiffToggle = () => { + const [showDiff, setShowDiff] = useState(true); + return ( +
+ + +
+ ); +}; + +export const DisposesModelsOnDiffToggle: Story = { + render: () => , + play: async ({ canvas }) => { + const toggle = canvas.getByRole("button", { name: "Toggle diff" }); + + // Wait for the diff editor to mount its original + modified models, then + // record the total as a baseline. Every full toggle cycle must return to + // this number; growth would mean abandoned models are being retained. + let baseline = 0; + await waitFor(() => { + baseline = monaco.editor.getModels().length; + expect(baseline).toBeGreaterThanOrEqual(2); + }); + + for (let cycle = 0; cycle < 3; cycle++) { + // Switch to plain: the diff editor unmounts and must dispose its models. + await userEvent.click(toggle); + await waitFor(() => + expect(monaco.editor.getModels().length).toBeLessThan(baseline), + ); + + // Switch back to diff: a new diff editor mounts and the total must land + // back on the baseline rather than climbing. + await userEvent.click(toggle); + await waitFor(() => + expect(monaco.editor.getModels().length).toBe(baseline), + ); + } + }, +}; diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index ed072a89e5a..05d2f12e675 100644 --- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx @@ -1,7 +1,13 @@ import Editor, { DiffEditor, loader } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; import * as monaco from "monaco-editor"; -import { type ComponentProps, type FC, useCallback } from "react"; +import { + type ComponentProps, + type FC, + useCallback, + useEffect, + useRef, +} from "react"; import { useTheme } from "#/theme/context"; import { useCoderTheme } from "./coderTheme"; @@ -38,40 +44,6 @@ export const SyntaxHighlighter: FC = ({ const theme = useTheme(); const coderTheme = useCoderTheme(); - // Auto-scroll to first diff when the diff editor mounts and diffs are computed. - const handleDiffEditorMount = useCallback( - ( - editor: Monaco.editor.IStandaloneDiffEditor, - monacoInstance: typeof Monaco, - ) => { - // Call any existing onMount handler from editorProps. - editorProps?.onMount?.(editor, monacoInstance); - - // Diffs may already be computed by the time onMount fires, - // so check immediately first. If not ready yet, fall back - // to waiting for the onDidUpdateDiff event. - const scrollToFirstDiff = () => { - editor.goToDiff("next"); - }; - - const changes = editor.getLineChanges(); - if (changes && changes.length > 0) { - scrollToFirstDiff(); - return; - } - - const disposable = editor.onDidUpdateDiff(() => { - const updatedChanges = editor.getLineChanges(); - if (!updatedChanges || updatedChanges.length === 0) { - return; - } - disposable.dispose(); - scrollToFirstDiff(); - }); - }, - [editorProps], - ); - const commonProps = { language, theme: coderTheme.name, @@ -99,20 +71,102 @@ export const SyntaxHighlighter: FC = ({ }} > {hasDiff ? ( - + ) : ( )} ); }; + +type DiffFileProps = CommonEditorProps & { + original: string; + modified: string; +}; + +// Renders the diff editor and owns its model cleanup. Scoping this to its own +// component means the cleanup effect runs whenever the diff editor unmounts, +// including when SyntaxHighlighter stays mounted but switches diff -> plain for +// a file that stopped changing between versions. +// +// keepCurrent{Original,Modified}Model stops @monaco-editor/react from disposing +// the models mid-teardown (which throws), so we dispose them ourselves after +// React has torn the editor down. Without this the models accumulate unbounded +// as users open template versions until the tab runs out of memory. +const DiffFile: FC = ({ + original, + modified, + onMount, + ...editorProps +}) => { + const diffModelsRef = useRef<{ + original: Monaco.editor.ITextModel; + modified: Monaco.editor.ITextModel; + } | null>(null); + + const handleMount = useCallback( + ( + editor: Monaco.editor.IStandaloneDiffEditor, + monacoInstance: typeof Monaco, + ) => { + onMount?.(editor, monacoInstance); + + const diffModel = editor.getModel(); + diffModelsRef.current = diffModel + ? { original: diffModel.original, modified: diffModel.modified } + : null; + + // Auto-scroll to the first diff. Diffs may already be computed by the + // time onMount fires, so check immediately and otherwise wait for the + // onDidUpdateDiff event. + const scrollToFirstDiff = () => { + editor.goToDiff("next"); + }; + + const changes = editor.getLineChanges(); + if (changes && changes.length > 0) { + scrollToFirstDiff(); + return; + } + + const disposable = editor.onDidUpdateDiff(() => { + const updatedChanges = editor.getLineChanges(); + if (!updatedChanges || updatedChanges.length === 0) { + return; + } + disposable.dispose(); + scrollToFirstDiff(); + }); + }, + [onMount], + ); + + useEffect(() => { + return () => { + const models = diffModelsRef.current; + if (!models) { + return; + } + diffModelsRef.current = null; + // Defer disposal until after React's commit finishes. @monaco-editor/ + // react disposes the diff widget in its own unmount cleanup; freeing + // the models in the same synchronous teardown makes the widget throw + // "TextModel got disposed before DiffEditorWidget model got reset". + queueMicrotask(() => { + models.original.dispose(); + models.modified.dispose(); + }); + }; + }, []); + + return ( + + ); +}; diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index 7012b3ab472..9698b928f01 100644 --- a/site/src/modules/apps/apps.test.ts +++ b/site/src/modules/apps/apps.test.ts @@ -97,6 +97,24 @@ describe("getAppHref", () => { expect(href).toBe("vscode://example.com?token=user-session-token"); }); + it("replaces the session token for Antigravity IDE URLs", () => { + const externalApp = { + ...MockWorkspaceApp, + external: true, + url: `antigravity-ide://coder.coder-remote/open?token=${SESSION_TOKEN_PLACEHOLDER}`, + }; + const href = getAppHref(externalApp, { + host: "*.apps-host.tld", + path: "/path-base", + agent: MockWorkspaceAgent, + workspace: MockWorkspace, + token: "user-session-token", + }); + expect(href).toBe( + "antigravity-ide://coder.coder-remote/open?token=user-session-token", + ); + }); + it("doesn't return the URL with the session token replaced when using the HTTP protocol", () => { const externalApp = { ...MockWorkspaceApp, diff --git a/site/src/modules/apps/apps.ts b/site/src/modules/apps/apps.ts index e822eb961a8..235c37c2c66 100644 --- a/site/src/modules/apps/apps.ts +++ b/site/src/modules/apps/apps.ts @@ -27,6 +27,7 @@ const ALLOWED_EXTERNAL_APP_PROTOCOLS = [ "kiro:", "positron:", "antigravity:", + "antigravity-ide:", ]; type GetVSCodeHrefParams = { diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index e21feecc673..3f37a9dac4c 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -61,6 +61,7 @@ export const Navbar: React.FC = () => { user={me} buildInfo={buildInfoQuery.data} supportLinks={Array.from(uniqueLinks.values())} + codernautsEnabled={appearance.codernauts_enabled} onSignOut={signOut} adminPermissions={{ canViewDeployment, diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 7167570c424..e6296a0b255 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -30,6 +30,7 @@ interface NavbarViewProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; + codernautsEnabled?: boolean; onSignOut: () => void; adminPermissions: AdminSettingsPermissions; canCreateChat: boolean; @@ -46,6 +47,7 @@ export const NavbarView: FC = ({ user, buildInfo, supportLinks, + codernautsEnabled, onSignOut, adminPermissions, canCreateChat, @@ -138,6 +140,7 @@ export const NavbarView: FC = ({ user={user} buildInfo={buildInfo} supportLinks={supportLinks?.filter((link) => !isNavbarLink(link))} + codernautsEnabled={codernautsEnabled} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 1879252b6d3..d65dae4fbd0 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -420,4 +420,18 @@ export const InstallCoderDesktopHiddenOniPadOS: Story = { }, }; +export const CodernautsDisabled: Story = { + args: { + codernautsEnabled: false, + }, + play: async ({ canvasElement, step }) => { + await step("hides the Codernauts link", async () => { + await openDropdown(canvasElement); + expect( + screen.queryByRole("menuitem", { name: "Codernauts" }), + ).not.toBeInTheDocument(); + }); + }, +}; + export { Example as UserDropdown }; diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 3a9f52a5c76..8bfdfa2188b 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -36,6 +36,7 @@ interface UserDropdownProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks: readonly TypesGen.LinkConfig[]; + codernautsEnabled?: boolean; onSignOut: () => void; } @@ -43,6 +44,7 @@ export const UserDropdown: FC = ({ buildInfo, user, supportLinks, + codernautsEnabled, onSignOut, }) => { const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); @@ -108,6 +110,7 @@ export const UserDropdown: FC = ({ ) } supportLinks={supportLinks} + codernautsEnabled={codernautsEnabled} onSignOut={onSignOut} /> diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index 12250f6cb64..6bf90c2deb4 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -32,6 +32,7 @@ interface UserDropdownContentProps { * (e.g. AI spend). The consumer supplies its own separator if needed. */ profileExtra?: ReactNode; supportLinks: readonly TypesGen.LinkConfig[]; + codernautsEnabled?: boolean; onSignOut: () => void; } @@ -40,6 +41,7 @@ export const UserDropdownContent: FC = ({ buildInfo, profileExtra, supportLinks, + codernautsEnabled = true, onSignOut, }) => { const { showCopiedSuccess, copyToClipboard } = useClipboard(); @@ -93,28 +95,30 @@ export const UserDropdownContent: FC = ({ ))} )} - - - - - - - - - - - - Codernauts - - {" "} + {codernautsEnabled && ( + + + + + + + + + + + + Codernauts + + + )} diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx index dbdb2aba3ae..3f004fa032c 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { MockPermissions } from "#/testHelpers/entities"; import { docs } from "#/utils/docs"; import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"; @@ -23,9 +23,11 @@ const meta: Meta = { background_color: "#ffaff3", }, ], + codernauts_enabled: true, }, isEntitled: false, canViewPremium: MockPermissions.viewAllLicenses, + onSaveAppearance: fn(), }, }; @@ -84,3 +86,42 @@ export const NotEntitledWithoutLicenseAccess: Story = { ).not.toBeInTheDocument(); }, }; + +export const CodernautsToggle: Story = { + args: { + isEntitled: true, + }, + play: async ({ canvasElement, args, step }) => { + const canvas = within(canvasElement); + await step("switching off saves the game as disabled", async () => { + const switchEl = canvas.getByRole("switch", { + name: "Codernauts game", + }); + expect(switchEl).toBeChecked(); + await userEvent.click(switchEl); + await waitFor(() => + expect(args.onSaveAppearance).toHaveBeenCalledWith({ + codernauts_enabled: false, + }), + ); + }); + }, +}; + +export const CodernautsToggleNotEntitled: Story = { + play: async ({ canvasElement, args, step }) => { + const canvas = within(canvasElement); + await step("the switch saves even without entitlement", async () => { + const switchEl = canvas.getByRole("switch", { + name: "Codernauts game", + }); + expect(switchEl).toBeEnabled(); + await userEvent.click(switchEl); + await waitFor(() => + expect(args.onSaveAppearance).toHaveBeenCalledWith({ + codernauts_enabled: false, + }), + ); + }); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx index f17a113a983..2f10adca5e5 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPageView.tsx @@ -17,6 +17,7 @@ import { SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; +import { Switch } from "#/components/Switch/Switch"; import { PremiumPaywall } from "#/modules/paywall/PremiumPaywall"; import { docs } from "#/utils/docs"; import { getFormHelpers } from "#/utils/formUtils"; @@ -122,6 +123,27 @@ export const AppearanceSettingsPageView: FC< /> )} + +
+
+
+

+ +

+
+ A lunar-lander game where you rescue stranded teammates. Disable + if you're experiencing any productivity loss. +
+
+ + onSaveAppearance({ codernauts_enabled: checked }) + } + /> +
+
); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 184da7338d7..6f374943b27 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3537,6 +3537,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = { }, announcement_banners: [], docs_url: "https://coder.com/docs/@main/", + codernauts_enabled: true, }; export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = { diff --git a/tailnet/service.go b/tailnet/service.go index 0515ece9542..3e1db4dd21e 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/yamux" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "storj.io/drpc/drpcmux" "storj.io/drpc/drpcserver" "tailscale.com/tailcfg" @@ -92,7 +93,7 @@ func NewClientService(options ClientServiceOptions) ( if err != nil { return nil, xerrors.Errorf("register DRPC service: %w", err) } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(options.Logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) || @@ -185,6 +186,13 @@ func (s *DRPCService) StreamDERPMaps(_ *proto.StreamDERPMapsRequest, stream prot } func (s *DRPCService) RefreshResumeToken(ctx context.Context, _ *proto.RefreshResumeTokenRequest) (*proto.RefreshResumeTokenResponse, error) { + if s.ResumeTokenProvider == nil { + return nil, drpcerr.WithCode( + xerrors.New("resume tokens not supported on this connection"), + drpcerr.Unimplemented, + ) + } + streamID, ok := ctx.Value(streamIDContextKey{}).(StreamID) if !ok { return nil, xerrors.New("no Stream ID") @@ -219,6 +227,13 @@ func (s *DRPCService) Coordinate(stream proto.DRPCTailnet_CoordinateStream) erro } func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, stream proto.DRPCTailnet_WorkspaceUpdatesStream) error { + if s.WorkspaceUpdatesProvider == nil { + return drpcerr.WithCode( + xerrors.New("workspace updates not supported on this connection"), + drpcerr.Unimplemented, + ) + } + defer stream.Close() ctx := stream.Context() diff --git a/tailnet/service_test.go b/tailnet/service_test.go index a34f7b65812..4eb26828268 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "tailscale.com/tailcfg" "github.com/coder/coder/v2/tailnet" @@ -178,6 +179,27 @@ func TestClientService_ServeClient_V1(t *testing.T) { require.ErrorIs(t, err, tailnet.ErrUnsupportedVersion) } +func TestClientService_UnsupportedProviders(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + clientID := uuid.New() + _, client := createUpdateService(t, ctx, clientID, nil, nil) + + _, err := client.RefreshResumeToken(ctx, &proto.RefreshResumeTokenRequest{}) + require.ErrorContains(t, err, "resume tokens not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := client.WorkspaceUpdates(ctx, &proto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(clientID), + }) + if err == nil { + _, err = updates.Recv() + } + require.ErrorContains(t, err, "workspace updates not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) +} + func TestNetworkTelemetryBatcher(t *testing.T) { t.Parallel()