diff --git a/cli/exp_mcp.go b/cli/exp_mcp.go
index 6174f0cffbf0e..fb866666daf4a 100644
--- a/cli/exp_mcp.go
+++ b/cli/exp_mcp.go
@@ -255,7 +255,7 @@ func (*RootCmd) mcpConfigureClaudeCode() *serpent.Command {
{
Name: "app-status-slug",
Description: "The app status slug to use when running the Coder MCP server.",
- Env: "CODER_MCP_CLAUDE_APP_STATUS_SLUG",
+ Env: "CODER_MCP_APP_STATUS_SLUG",
Flag: "claude-app-status-slug",
Value: serpent.StringOf(&appStatusSlug),
},
diff --git a/cli/ssh.go b/cli/ssh.go
index 7c5bda073f973..dd0568dc5e14c 100644
--- a/cli/ssh.go
+++ b/cli/ssh.go
@@ -1569,12 +1569,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error
// Converts workspace name input to owner/workspace.agent format
// Possible valid input formats:
// workspace
+// workspace.agent
// owner/workspace
// owner--workspace
// owner/workspace--agent
// owner/workspace.agent
// owner--workspace--agent
// owner--workspace.agent
+// agent.workspace.owner - for parity with Coder Connect
func normalizeWorkspaceInput(input string) string {
// Split on "/", "--", and "."
parts := workspaceNameRe.Split(input, -1)
@@ -1583,8 +1585,15 @@ func normalizeWorkspaceInput(input string) string {
case 1:
return input // "workspace"
case 2:
+ if strings.Contains(input, ".") {
+ return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent"
+ }
return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace"
case 3:
+ // If the only separator is a dot, it's the Coder Connect format
+ if !strings.Contains(input, "/") && !strings.Contains(input, "--") {
+ return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent"
+ }
return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent"
default:
return input // Fallback
diff --git a/cli/ssh_test.go b/cli/ssh_test.go
index 5fcb6205d5e45..9f85652029f50 100644
--- a/cli/ssh_test.go
+++ b/cli/ssh_test.go
@@ -107,12 +107,14 @@ func TestSSH(t *testing.T) {
cases := []string{
"myworkspace",
+ "myworkspace.dev",
"myuser/myworkspace",
"myuser--myworkspace",
"myuser/myworkspace--dev",
"myuser/myworkspace.dev",
"myuser--myworkspace--dev",
"myuser--myworkspace.dev",
+ "dev.myworkspace.myuser",
}
for _, tc := range cases {
diff --git a/coderd/agentapi/manifest.go b/coderd/agentapi/manifest.go
index db8a0af3946a9..6815e8484b286 100644
--- a/coderd/agentapi/manifest.go
+++ b/coderd/agentapi/manifest.go
@@ -47,7 +47,6 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
scripts []database.WorkspaceAgentScript
metadata []database.WorkspaceAgentMetadatum
workspace database.Workspace
- owner database.User
devcontainers []database.WorkspaceAgentDevcontainer
)
@@ -76,10 +75,6 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
if err != nil {
return xerrors.Errorf("getting workspace by id: %w", err)
}
- owner, err = a.Database.GetUserByID(ctx, workspace.OwnerID)
- if err != nil {
- return xerrors.Errorf("getting workspace owner by id: %w", err)
- }
return err
})
eg.Go(func() (err error) {
@@ -98,7 +93,7 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
AppSlugOrPort: "{{port}}",
AgentName: workspaceAgent.Name,
WorkspaceName: workspace.Name,
- Username: owner.Username,
+ Username: workspace.OwnerUsername,
}
vscodeProxyURI := vscodeProxyURI(appSlug, a.AccessURL, a.AppHostname)
@@ -115,7 +110,7 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
}
}
- apps, err := dbAppsToProto(dbApps, workspaceAgent, owner.Username, workspace)
+ apps, err := dbAppsToProto(dbApps, workspaceAgent, workspace.OwnerUsername, workspace)
if err != nil {
return nil, xerrors.Errorf("converting workspace apps: %w", err)
}
@@ -123,7 +118,7 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
return &agentproto.Manifest{
AgentId: workspaceAgent.ID[:],
AgentName: workspaceAgent.Name,
- OwnerUsername: owner.Username,
+ OwnerUsername: workspace.OwnerUsername,
WorkspaceId: workspace.ID[:],
WorkspaceName: workspace.Name,
GitAuthConfigs: gitAuthConfigs,
diff --git a/coderd/agentapi/manifest_test.go b/coderd/agentapi/manifest_test.go
index 98e7ccc8c8b52..178e790ab914d 100644
--- a/coderd/agentapi/manifest_test.go
+++ b/coderd/agentapi/manifest_test.go
@@ -46,9 +46,10 @@ func TestGetManifest(t *testing.T) {
Username: "cool-user",
}
workspace = database.Workspace{
- ID: uuid.New(),
- OwnerID: owner.ID,
- Name: "cool-workspace",
+ ID: uuid.New(),
+ OwnerID: owner.ID,
+ OwnerUsername: owner.Username,
+ Name: "cool-workspace",
}
agent = database.WorkspaceAgent{
ID: uuid.New(),
@@ -329,7 +330,6 @@ func TestGetManifest(t *testing.T) {
}).Return(metadata, nil)
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), agent.ID).Return(devcontainers, nil)
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
- mDB.EXPECT().GetUserByID(gomock.Any(), workspace.OwnerID).Return(owner, nil)
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
@@ -396,7 +396,6 @@ func TestGetManifest(t *testing.T) {
}).Return(metadata, nil)
mDB.EXPECT().GetWorkspaceAgentDevcontainersByAgentID(gomock.Any(), agent.ID).Return(devcontainers, nil)
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
- mDB.EXPECT().GetUserByID(gomock.Any(), workspace.OwnerID).Return(owner, nil)
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go
index b82f8a00dedb4..c7f7d35937198 100644
--- a/coderd/coderdtest/oidctest/idp.go
+++ b/coderd/coderdtest/oidctest/idp.go
@@ -307,7 +307,7 @@ func WithCustomClientAuth(hook func(t testing.TB, req *http.Request) (url.Values
// WithLogging is optional, but will log some HTTP calls made to the IDP.
func WithLogging(t testing.TB, options *slogtest.Options) func(*FakeIDP) {
return func(f *FakeIDP) {
- f.logger = slogtest.Make(t, options)
+ f.logger = slogtest.Make(t, options).Named("fakeidp")
}
}
@@ -794,6 +794,7 @@ func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string
func (f *FakeIDP) newRefreshTokens(email string) string {
refreshToken := uuid.NewString()
f.refreshTokens.Store(refreshToken, email)
+ f.logger.Info(context.Background(), "new refresh token", slog.F("email", email), slog.F("token", refreshToken))
return refreshToken
}
@@ -1003,6 +1004,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
return
}
+ f.logger.Info(r.Context(), "http idp call refresh_token", slog.F("token", refreshToken))
_, ok := f.refreshTokens.Load(refreshToken)
if !assert.True(t, ok, "invalid refresh_token") {
http.Error(rw, "invalid refresh_token", http.StatusBadRequest)
@@ -1026,6 +1028,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
f.refreshTokensUsed.Store(refreshToken, true)
// Always invalidate the refresh token after it is used.
f.refreshTokens.Delete(refreshToken)
+ f.logger.Info(r.Context(), "refresh token invalidated", slog.F("token", refreshToken))
case "urn:ietf:params:oauth:grant-type:device_code":
// Device flow
var resp externalauth.ExchangeDeviceCodeResponse
diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go
index d614b37a3d897..4b92848b773e2 100644
--- a/coderd/httpmw/apikey.go
+++ b/coderd/httpmw/apikey.go
@@ -232,16 +232,21 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
return optionalWrite(http.StatusUnauthorized, resp)
}
- var (
- link database.UserLink
- now = dbtime.Now()
- // Tracks if the API key has properties updated
- changed = false
- )
+ now := dbtime.Now()
+ if key.ExpiresAt.Before(now) {
+ return optionalWrite(http.StatusUnauthorized, codersdk.Response{
+ Message: SignedOutErrorMessage,
+ Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
+ })
+ }
+
+ // We only check OIDC stuff if we have a valid APIKey. An expired key means we don't trust the requestor
+ // really is the user whose key they have, and so we shouldn't be doing anything on their behalf including possibly
+ // refreshing the OIDC token.
if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC {
var err error
//nolint:gocritic // System needs to fetch UserLink to check if it's valid.
- link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
+ link, err := cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
UserID: key.UserID,
LoginType: key.LoginType,
})
@@ -258,7 +263,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}
// Check if the OAuth token is expired
- if link.OAuthExpiry.Before(now) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
+ if !link.OAuthExpiry.IsZero() && link.OAuthExpiry.Before(now) {
if cfg.OAuth2Configs.IsZero() {
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
@@ -267,12 +272,15 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}
+ var friendlyName string
var oauthConfig promoauth.OAuth2Config
switch key.LoginType {
case database.LoginTypeGithub:
oauthConfig = cfg.OAuth2Configs.Github
+ friendlyName = "GitHub"
case database.LoginTypeOIDC:
oauthConfig = cfg.OAuth2Configs.OIDC
+ friendlyName = "OpenID Connect"
default:
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
@@ -292,7 +300,13 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}
- // If it is, let's refresh it from the provided config
+ if link.OAuthRefreshToken == "" {
+ return optionalWrite(http.StatusUnauthorized, codersdk.Response{
+ Message: SignedOutErrorMessage,
+ Detail: fmt.Sprintf("%s session expired at %q. Try signing in again.", friendlyName, link.OAuthExpiry.String()),
+ })
+ }
+ // We have a refresh token, so let's try it
token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{
AccessToken: link.OAuthAccessToken,
RefreshToken: link.OAuthRefreshToken,
@@ -300,28 +314,39 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
}).Token()
if err != nil {
return write(http.StatusUnauthorized, codersdk.Response{
- Message: "Could not refresh expired Oauth token. Try re-authenticating to resolve this issue.",
- Detail: err.Error(),
+ Message: fmt.Sprintf(
+ "Could not refresh expired %s token. Try re-authenticating to resolve this issue.",
+ friendlyName),
+ Detail: err.Error(),
})
}
link.OAuthAccessToken = token.AccessToken
link.OAuthRefreshToken = token.RefreshToken
link.OAuthExpiry = token.Expiry
- key.ExpiresAt = token.Expiry
- changed = true
+ //nolint:gocritic // system needs to update user link
+ link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
+ UserID: link.UserID,
+ LoginType: link.LoginType,
+ OAuthAccessToken: link.OAuthAccessToken,
+ OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
+ OAuthRefreshToken: link.OAuthRefreshToken,
+ OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
+ OAuthExpiry: link.OAuthExpiry,
+ // Refresh should keep the same debug context because we use
+ // the original claims for the group/role sync.
+ Claims: link.Claims,
+ })
+ if err != nil {
+ return write(http.StatusInternalServerError, codersdk.Response{
+ Message: internalErrorMessage,
+ Detail: fmt.Sprintf("update user_link: %s.", err.Error()),
+ })
+ }
}
}
- // Checking if the key is expired.
- // NOTE: The `RequireAuth` React component depends on this `Detail` to detect when
- // the users token has expired. If you change the text here, make sure to update it
- // in site/src/components/RequireAuth/RequireAuth.tsx as well.
- if key.ExpiresAt.Before(now) {
- return optionalWrite(http.StatusUnauthorized, codersdk.Response{
- Message: SignedOutErrorMessage,
- Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
- })
- }
+ // Tracks if the API key has properties updated
+ changed := false
// Only update LastUsed once an hour to prevent database spam.
if now.Sub(key.LastUsed) > time.Hour {
@@ -363,29 +388,6 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
Detail: fmt.Sprintf("API key couldn't update: %s.", err.Error()),
})
}
- // If the API Key is associated with a user_link (e.g. Github/OIDC)
- // then we want to update the relevant oauth fields.
- if link.UserID != uuid.Nil {
- //nolint:gocritic // system needs to update user link
- link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
- UserID: link.UserID,
- LoginType: link.LoginType,
- OAuthAccessToken: link.OAuthAccessToken,
- OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
- OAuthRefreshToken: link.OAuthRefreshToken,
- OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
- OAuthExpiry: link.OAuthExpiry,
- // Refresh should keep the same debug context because we use
- // the original claims for the group/role sync.
- Claims: link.Claims,
- })
- if err != nil {
- return write(http.StatusInternalServerError, codersdk.Response{
- Message: internalErrorMessage,
- Detail: fmt.Sprintf("update user_link: %s.", err.Error()),
- })
- }
- }
// We only want to update this occasionally to reduce DB write
// load. We update alongside the UserLink and APIKey since it's
diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go
index bd979e88235ad..6e2e75ace9825 100644
--- a/coderd/httpmw/apikey_test.go
+++ b/coderd/httpmw/apikey_test.go
@@ -508,6 +508,102 @@ func TestAPIKey(t *testing.T) {
require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
})
+ t.Run("APIKeyExpiredOAuthExpired", func(t *testing.T) {
+ t.Parallel()
+ var (
+ db = dbmem.New()
+ user = dbgen.User(t, db, database.User{})
+ sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{
+ UserID: user.ID,
+ LastUsed: dbtime.Now().AddDate(0, 0, -1),
+ ExpiresAt: dbtime.Now().AddDate(0, 0, -1),
+ LoginType: database.LoginTypeOIDC,
+ })
+ _ = dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ OAuthExpiry: dbtime.Now().AddDate(0, 0, -1),
+ })
+
+ r = httptest.NewRequest("GET", "/", nil)
+ rw = httptest.NewRecorder()
+ )
+ r.Header.Set(codersdk.SessionTokenHeader, token)
+
+ // Include a valid oauth token for refreshing. If this token is invalid,
+ // it is difficult to tell an auth failure from an expired api key, or
+ // an expired oauth key.
+ oauthToken := &oauth2.Token{
+ AccessToken: "wow",
+ RefreshToken: "moo",
+ Expiry: dbtime.Now().AddDate(0, 0, 1),
+ }
+ httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
+ DB: db,
+ OAuth2Configs: &httpmw.OAuth2Configs{
+ OIDC: &testutil.OAuth2Config{
+ Token: oauthToken,
+ },
+ },
+ RedirectToLogin: false,
+ })(successHandler).ServeHTTP(rw, r)
+ res := rw.Result()
+ defer res.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, res.StatusCode)
+
+ gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID)
+ require.NoError(t, err)
+
+ require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
+ })
+
+ t.Run("APIKeyExpiredOAuthNotExpired", func(t *testing.T) {
+ t.Parallel()
+ var (
+ db = dbmem.New()
+ user = dbgen.User(t, db, database.User{})
+ sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{
+ UserID: user.ID,
+ LastUsed: dbtime.Now().AddDate(0, 0, -1),
+ ExpiresAt: dbtime.Now().AddDate(0, 0, -1),
+ LoginType: database.LoginTypeOIDC,
+ })
+ _ = dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+
+ r = httptest.NewRequest("GET", "/", nil)
+ rw = httptest.NewRecorder()
+ )
+ r.Header.Set(codersdk.SessionTokenHeader, token)
+
+ oauthToken := &oauth2.Token{
+ AccessToken: "wow",
+ RefreshToken: "moo",
+ Expiry: dbtime.Now().AddDate(0, 0, 1),
+ }
+ httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
+ DB: db,
+ OAuth2Configs: &httpmw.OAuth2Configs{
+ OIDC: &testutil.OAuth2Config{
+ Token: oauthToken,
+ },
+ },
+ RedirectToLogin: false,
+ })(successHandler).ServeHTTP(rw, r)
+ res := rw.Result()
+ defer res.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, res.StatusCode)
+
+ gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID)
+ require.NoError(t, err)
+
+ require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
+ })
+
t.Run("OAuthRefresh", func(t *testing.T) {
t.Parallel()
var (
@@ -553,7 +649,67 @@ func TestAPIKey(t *testing.T) {
require.NoError(t, err)
require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
- require.Equal(t, oauthToken.Expiry, gotAPIKey.ExpiresAt)
+ // Note that OAuth expiry is independent of APIKey expiry, so an OIDC refresh DOES NOT affect the expiry of the
+ // APIKey
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
+
+ gotLink, err := db.GetUserLinkByUserIDLoginType(r.Context(), database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: user.ID,
+ LoginType: database.LoginTypeGithub,
+ })
+ require.NoError(t, err)
+ require.Equal(t, gotLink.OAuthRefreshToken, "moo")
+ })
+
+ t.Run("OAuthExpiredNoRefresh", func(t *testing.T) {
+ t.Parallel()
+ var (
+ ctx = testutil.Context(t, testutil.WaitShort)
+ db = dbmem.New()
+ user = dbgen.User(t, db, database.User{})
+ sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{
+ UserID: user.ID,
+ LastUsed: dbtime.Now(),
+ ExpiresAt: dbtime.Now().AddDate(0, 0, 1),
+ LoginType: database.LoginTypeGithub,
+ })
+
+ r = httptest.NewRequest("GET", "/", nil)
+ rw = httptest.NewRecorder()
+ )
+ _, err := db.InsertUserLink(ctx, database.InsertUserLinkParams{
+ UserID: user.ID,
+ LoginType: database.LoginTypeGithub,
+ OAuthExpiry: dbtime.Now().AddDate(0, 0, -1),
+ OAuthAccessToken: "letmein",
+ })
+ require.NoError(t, err)
+
+ r.Header.Set(codersdk.SessionTokenHeader, token)
+
+ oauthToken := &oauth2.Token{
+ AccessToken: "wow",
+ RefreshToken: "moo",
+ Expiry: dbtime.Now().AddDate(0, 0, 1),
+ }
+ httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
+ DB: db,
+ OAuth2Configs: &httpmw.OAuth2Configs{
+ Github: &testutil.OAuth2Config{
+ Token: oauthToken,
+ },
+ },
+ RedirectToLogin: false,
+ })(successHandler).ServeHTTP(rw, r)
+ res := rw.Result()
+ defer res.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, res.StatusCode)
+
+ gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID)
+ require.NoError(t, err)
+
+ require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
})
t.Run("RemoteIPUpdates", func(t *testing.T) {
diff --git a/coderd/oauthpki/okidcpki_test.go b/coderd/oauthpki/okidcpki_test.go
index 509da563a9145..7f7dda17bcba8 100644
--- a/coderd/oauthpki/okidcpki_test.go
+++ b/coderd/oauthpki/okidcpki_test.go
@@ -144,6 +144,7 @@ func TestAzureAKPKIWithCoderd(t *testing.T) {
return values, nil
}),
oidctest.WithServing(),
+ oidctest.WithLogging(t, nil),
)
cfg := fake.OIDCConfig(t, scopes, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go
index 10403f1ac00ae..81aa74a2f2fb5 100644
--- a/coderd/workspaceagents_test.go
+++ b/coderd/workspaceagents_test.go
@@ -437,25 +437,55 @@ func TestWorkspaceAgentConnectRPC(t *testing.T) {
t.Run("Connect", func(t *testing.T) {
t.Parallel()
- client, db := coderdtest.NewWithDatabase(t, nil)
- user := coderdtest.CreateFirstUser(t, client)
- r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
- OrganizationID: user.OrganizationID,
- OwnerID: user.UserID,
- }).WithAgent().Do()
- _ = agenttest.New(t, client.URL, r.AgentToken)
- resources := coderdtest.AwaitWorkspaceAgents(t, client, r.Workspace.ID)
+ for _, tc := range []struct {
+ name string
+ apiKeyScope rbac.ScopeName
+ }{
+ {
+ name: "empty (backwards compat)",
+ apiKeyScope: "",
+ },
+ {
+ name: "all",
+ apiKeyScope: rbac.ScopeAll,
+ },
+ {
+ name: "no_user_data",
+ apiKeyScope: rbac.ScopeNoUserData,
+ },
+ {
+ name: "application_connect",
+ apiKeyScope: rbac.ScopeApplicationConnect,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ client, db := coderdtest.NewWithDatabase(t, nil)
+ user := coderdtest.CreateFirstUser(t, client)
+ r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
+ OrganizationID: user.OrganizationID,
+ OwnerID: user.UserID,
+ }).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
+ for _, agent := range agents {
+ agent.ApiKeyScope = string(tc.apiKeyScope)
+ }
- ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
- defer cancel()
+ return agents
+ }).Do()
+ _ = agenttest.New(t, client.URL, r.AgentToken)
+ resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).AgentNames([]string{}).Wait()
- conn, err := workspacesdk.New(client).
- DialAgent(ctx, resources[0].Agents[0].ID, nil)
- require.NoError(t, err)
- defer func() {
- _ = conn.Close()
- }()
- conn.AwaitReachable(ctx)
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
+ defer cancel()
+
+ conn, err := workspacesdk.New(client).
+ DialAgent(ctx, resources[0].Agents[0].ID, nil)
+ require.NoError(t, err)
+ defer func() {
+ _ = conn.Close()
+ }()
+ conn.AwaitReachable(ctx)
+ })
+ }
})
t.Run("FailNonLatestBuild", func(t *testing.T) {
diff --git a/coderd/workspaceagentsrpc.go b/coderd/workspaceagentsrpc.go
index 43da35410f632..2dcf65bd8c7d5 100644
--- a/coderd/workspaceagentsrpc.go
+++ b/coderd/workspaceagentsrpc.go
@@ -76,17 +76,8 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
return
}
- owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
- if err != nil {
- httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
- Message: "Internal error fetching user.",
- Detail: err.Error(),
- })
- return
- }
-
logger = logger.With(
- slog.F("owner", owner.Username),
+ slog.F("owner", workspace.OwnerUsername),
slog.F("workspace_name", workspace.Name),
slog.F("agent_name", workspaceAgent.Name),
)
@@ -170,7 +161,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) {
})
streamID := tailnet.StreamID{
- Name: fmt.Sprintf("%s-%s-%s", owner.Username, workspace.Name, workspaceAgent.Name),
+ Name: fmt.Sprintf("%s-%s-%s", workspace.OwnerUsername, workspace.Name, workspaceAgent.Name),
ID: workspaceAgent.ID,
Auth: tailnet.AgentCoordinateeAuth{ID: workspaceAgent.ID},
}
diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go
index 3f1f1a2b8a764..233c5665310f6 100644
--- a/coderd/workspaceagentsrpc_test.go
+++ b/coderd/workspaceagentsrpc_test.go
@@ -13,6 +13,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "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/testutil"
@@ -22,88 +23,148 @@ import (
func TestWorkspaceAgentReportStats(t *testing.T) {
t.Parallel()
- tickCh := make(chan time.Time)
- flushCh := make(chan int, 1)
- client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
- WorkspaceUsageTrackerFlush: flushCh,
- WorkspaceUsageTrackerTick: tickCh,
- })
- user := coderdtest.CreateFirstUser(t, client)
- r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
- OrganizationID: user.OrganizationID,
- OwnerID: user.UserID,
- }).WithAgent().Do()
+ for _, tc := range []struct {
+ name string
+ apiKeyScope rbac.ScopeName
+ }{
+ {
+ name: "empty (backwards compat)",
+ apiKeyScope: "",
+ },
+ {
+ name: "all",
+ apiKeyScope: rbac.ScopeAll,
+ },
+ {
+ name: "no_user_data",
+ apiKeyScope: rbac.ScopeNoUserData,
+ },
+ {
+ name: "application_connect",
+ apiKeyScope: rbac.ScopeApplicationConnect,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
- ac := agentsdk.New(client.URL)
- ac.SetSessionToken(r.AgentToken)
- conn, err := ac.ConnectRPC(context.Background())
- require.NoError(t, err)
- defer func() {
- _ = conn.Close()
- }()
- agentAPI := agentproto.NewDRPCAgentClient(conn)
+ tickCh := make(chan time.Time)
+ flushCh := make(chan int, 1)
+ client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
+ WorkspaceUsageTrackerFlush: flushCh,
+ WorkspaceUsageTrackerTick: tickCh,
+ })
+ user := coderdtest.CreateFirstUser(t, client)
+ r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
+ OrganizationID: user.OrganizationID,
+ OwnerID: user.UserID,
+ }).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
+ for _, a := range agent {
+ a.ApiKeyScope = string(tc.apiKeyScope)
+ }
- _, err = agentAPI.UpdateStats(context.Background(), &agentproto.UpdateStatsRequest{
- Stats: &agentproto.Stats{
- ConnectionsByProto: map[string]int64{"TCP": 1},
- ConnectionCount: 1,
- RxPackets: 1,
- RxBytes: 1,
- TxPackets: 1,
- TxBytes: 1,
- SessionCountVscode: 1,
- SessionCountJetbrains: 0,
- SessionCountReconnectingPty: 0,
- SessionCountSsh: 0,
- ConnectionMedianLatencyMs: 10,
- },
- })
- require.NoError(t, err)
+ return agent
+ },
+ ).Do()
+
+ ac := agentsdk.New(client.URL)
+ ac.SetSessionToken(r.AgentToken)
+ conn, err := ac.ConnectRPC(context.Background())
+ require.NoError(t, err)
+ defer func() {
+ _ = conn.Close()
+ }()
+ agentAPI := agentproto.NewDRPCAgentClient(conn)
+
+ _, err = agentAPI.UpdateStats(context.Background(), &agentproto.UpdateStatsRequest{
+ Stats: &agentproto.Stats{
+ ConnectionsByProto: map[string]int64{"TCP": 1},
+ ConnectionCount: 1,
+ RxPackets: 1,
+ RxBytes: 1,
+ TxPackets: 1,
+ TxBytes: 1,
+ SessionCountVscode: 1,
+ SessionCountJetbrains: 0,
+ SessionCountReconnectingPty: 0,
+ SessionCountSsh: 0,
+ ConnectionMedianLatencyMs: 10,
+ },
+ })
+ require.NoError(t, err)
- tickCh <- dbtime.Now()
- count := <-flushCh
- require.Equal(t, 1, count, "expected one flush with one id")
+ tickCh <- dbtime.Now()
+ count := <-flushCh
+ require.Equal(t, 1, count, "expected one flush with one id")
- newWorkspace, err := client.Workspace(context.Background(), r.Workspace.ID)
- require.NoError(t, err)
+ newWorkspace, err := client.Workspace(context.Background(), r.Workspace.ID)
+ require.NoError(t, err)
- assert.True(t,
- newWorkspace.LastUsedAt.After(r.Workspace.LastUsedAt),
- "%s is not after %s", newWorkspace.LastUsedAt, r.Workspace.LastUsedAt,
- )
+ assert.True(t,
+ newWorkspace.LastUsedAt.After(r.Workspace.LastUsedAt),
+ "%s is not after %s", newWorkspace.LastUsedAt, r.Workspace.LastUsedAt,
+ )
+ })
+ }
}
func TestAgentAPI_LargeManifest(t *testing.T) {
t.Parallel()
- ctx := testutil.Context(t, testutil.WaitLong)
- client, store := coderdtest.NewWithDatabase(t, nil)
- adminUser := coderdtest.CreateFirstUser(t, client)
- n := 512000
- longScript := make([]byte, n)
- for i := range longScript {
- longScript[i] = 'q'
+
+ for _, tc := range []struct {
+ name string
+ apiKeyScope rbac.ScopeName
+ }{
+ {
+ name: "empty (backwards compat)",
+ apiKeyScope: "",
+ },
+ {
+ name: "all",
+ apiKeyScope: rbac.ScopeAll,
+ },
+ {
+ name: "no_user_data",
+ apiKeyScope: rbac.ScopeNoUserData,
+ },
+ {
+ name: "application_connect",
+ apiKeyScope: rbac.ScopeApplicationConnect,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitLong)
+ client, store := coderdtest.NewWithDatabase(t, nil)
+ adminUser := coderdtest.CreateFirstUser(t, client)
+ n := 512000
+ longScript := make([]byte, n)
+ for i := range longScript {
+ longScript[i] = 'q'
+ }
+ r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
+ OrganizationID: adminUser.OrganizationID,
+ OwnerID: adminUser.UserID,
+ }).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
+ agents[0].Scripts = []*proto.Script{
+ {
+ Script: string(longScript),
+ },
+ }
+ agents[0].ApiKeyScope = string(tc.apiKeyScope)
+ return agents
+ }).Do()
+ ac := agentsdk.New(client.URL)
+ ac.SetSessionToken(r.AgentToken)
+ conn, err := ac.ConnectRPC(ctx)
+ defer func() {
+ _ = conn.Close()
+ }()
+ require.NoError(t, err)
+ agentAPI := agentproto.NewDRPCAgentClient(conn)
+ manifest, err := agentAPI.GetManifest(ctx, &agentproto.GetManifestRequest{})
+ require.NoError(t, err)
+ require.Len(t, manifest.Scripts, 1)
+ require.Len(t, manifest.Scripts[0].Script, n)
+ })
}
- r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
- OrganizationID: adminUser.OrganizationID,
- OwnerID: adminUser.UserID,
- }).WithAgent(func(agents []*proto.Agent) []*proto.Agent {
- agents[0].Scripts = []*proto.Script{
- {
- Script: string(longScript),
- },
- }
- return agents
- }).Do()
- ac := agentsdk.New(client.URL)
- ac.SetSessionToken(r.AgentToken)
- conn, err := ac.ConnectRPC(ctx)
- defer func() {
- _ = conn.Close()
- }()
- require.NoError(t, err)
- agentAPI := agentproto.NewDRPCAgentClient(conn)
- manifest, err := agentAPI.GetManifest(ctx, &agentproto.GetManifestRequest{})
- require.NoError(t, err)
- require.Len(t, manifest.Scripts, 1)
- require.Len(t, manifest.Scripts[0].Script, n)
}
diff --git a/flake.nix b/flake.nix
index bff207662f913..c0f36c3be6e0f 100644
--- a/flake.nix
+++ b/flake.nix
@@ -141,6 +141,7 @@
kubectl
kubectx
kubernetes-helm
+ lazydocker
lazygit
less
mockgen
diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json
index a9307bfc78446..da86c1a26c8b1 100644
--- a/site/src/theme/icons.json
+++ b/site/src/theme/icons.json
@@ -58,6 +58,7 @@
"javascript.svg",
"jax.svg",
"jetbrains-toolbox.svg",
+ "jetbrains.svg",
"jfrog.svg",
"jupyter.svg",
"k8s.png",
@@ -101,6 +102,7 @@
"vault.svg",
"webstorm.svg",
"widgets.svg",
+ "windows.svg",
"windsurf.svg",
"zed.svg"
]
diff --git a/site/static/icon/jetbrains.svg b/site/static/icon/jetbrains.svg
new file mode 100644
index 0000000000000..b281f962fca81
--- /dev/null
+++ b/site/static/icon/jetbrains.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/site/static/icon/windows.svg b/site/static/icon/windows.svg
new file mode 100644
index 0000000000000..8b774a501cdc1
--- /dev/null
+++ b/site/static/icon/windows.svg
@@ -0,0 +1,29 @@
+