diff --git a/agent/agent.go b/agent/agent.go index e142f8662f641..833b4032d491b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -89,9 +89,8 @@ type Options struct { ServiceBannerRefreshInterval time.Duration BlockFileTransfer bool Execer agentexec.Execer - - ExperimentalDevcontainersEnabled bool - ContainerAPIOptions []agentcontainers.Option // Enable ExperimentalDevcontainersEnabled for these to be effective. + Devcontainers bool + DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective. } type Client interface { @@ -190,8 +189,8 @@ func New(options Options) Agent { metrics: newAgentMetrics(prometheusRegistry), execer: options.Execer, - experimentalDevcontainersEnabled: options.ExperimentalDevcontainersEnabled, - containerAPIOptions: options.ContainerAPIOptions, + devcontainers: options.Devcontainers, + containerAPIOptions: options.DevcontainerAPIOptions, } // Initially, we have a closed channel, reflecting the fact that we are not initially connected. // Each time we connect we replace the channel (while holding the closeMutex) with a new one @@ -272,9 +271,9 @@ type agent struct { metrics *agentMetrics execer agentexec.Execer - experimentalDevcontainersEnabled bool - containerAPIOptions []agentcontainers.Option - containerAPI atomic.Pointer[agentcontainers.API] // Set by apiHandler. + devcontainers bool + containerAPIOptions []agentcontainers.Option + containerAPI atomic.Pointer[agentcontainers.API] // Set by apiHandler. } func (a *agent) TailnetConn() *tailnet.Conn { @@ -311,7 +310,7 @@ func (a *agent) init() { return a.reportConnection(id, connectionType, ip) }, - ExperimentalDevContainersEnabled: a.experimentalDevcontainersEnabled, + ExperimentalContainers: a.devcontainers, }) if err != nil { panic(err) @@ -340,7 +339,7 @@ func (a *agent) init() { a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors, a.reconnectingPTYTimeout, func(s *reconnectingpty.Server) { - s.ExperimentalDevcontainersEnabled = a.experimentalDevcontainersEnabled + s.ExperimentalContainers = a.devcontainers }, ) go a.runLoop() @@ -1087,9 +1086,9 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, slog.F("parent_id", manifest.ParentID), slog.F("agent_id", manifest.AgentID), ) - if a.experimentalDevcontainersEnabled { + if a.devcontainers { a.logger.Info(ctx, "devcontainers are not supported on sub agents, disabling feature") - a.experimentalDevcontainersEnabled = false + a.devcontainers = false } } a.client.RewriteDERPMap(manifest.DERPMap) @@ -1145,7 +1144,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, scripts = manifest.Scripts scriptRunnerOpts []agentscripts.InitOption ) - if a.experimentalDevcontainersEnabled { + if a.devcontainers { var dcScripts []codersdk.WorkspaceAgentScript scripts, dcScripts = agentcontainers.ExtractAndInitializeDevcontainerScripts(manifest.Devcontainers, scripts) // See ExtractAndInitializeDevcontainerScripts for motivation diff --git a/agent/agent_test.go b/agent/agent_test.go index 8ee15e563f3ce..1b24520e45cc5 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1954,8 +1954,8 @@ func TestAgent_ReconnectingPTYContainer(t *testing.T) { // nolint: dogsled conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) @@ -2161,9 +2161,9 @@ func TestAgent_DevcontainerAutostart(t *testing.T) { //nolint:dogsled _, agentClient, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append( - o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append( + o.DevcontainerAPIOptions, // Only match this specific dev container. agentcontainers.WithClock(mClock), agentcontainers.WithContainerLabelIncludeFilter("devcontainer.local_folder", tempWorkspaceFolder), @@ -2312,8 +2312,8 @@ func TestAgent_DevcontainerRecreate(t *testing.T) { //nolint:dogsled conn, client, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("devcontainer.local_folder", workspaceFolder), ) }) @@ -2438,8 +2438,7 @@ func TestAgent_DevcontainersDisabledForSubAgent(t *testing.T) { // Setup the agent with devcontainers enabled initially. //nolint:dogsled - conn, _, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true + conn, _, _, _, _ := setupAgent(t, manifest, 0, func(*agenttest.Client, *agent.Options) { }) // Query the containers API endpoint. This should fail because diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 4a8413906e8ce..6d2c46b961122 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -42,7 +42,8 @@ const ( // read-write, which seems sensible for devcontainers. coderPathInsideContainer = "/.coder-agent/coder" - maxAgentNameLength = 64 + maxAgentNameLength = 64 + maxAttemptsToNameAgent = 5 ) // API is responsible for container-related operations in the agent. @@ -71,17 +72,19 @@ type API struct { ownerName string workspaceName string - mu sync.RWMutex - closed bool - containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation. - containersErr error // Error from the last list operation. - devcontainerNames map[string]bool // By devcontainer name. - knownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer // By workspace folder. - configFileModifiedTimes map[string]time.Time // By config file path. - recreateSuccessTimes map[string]time.Time // By workspace folder. - recreateErrorTimes map[string]time.Time // By workspace folder. - injectedSubAgentProcs map[string]subAgentProcess // By workspace folder. - asyncWg sync.WaitGroup + mu sync.RWMutex + closed bool + containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation. + containersErr error // Error from the last list operation. + devcontainerNames map[string]bool // By devcontainer name. + knownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer // By workspace folder. + configFileModifiedTimes map[string]time.Time // By config file path. + recreateSuccessTimes map[string]time.Time // By workspace folder. + recreateErrorTimes map[string]time.Time // By workspace folder. + injectedSubAgentProcs map[string]subAgentProcess // By workspace folder. + usingWorkspaceFolderName map[string]bool // By workspace folder. + ignoredDevcontainers map[string]bool // By workspace folder. Tracks three states (true, false and not checked). + asyncWg sync.WaitGroup devcontainerLogSourceIDs map[string]uuid.UUID // By workspace folder. } @@ -274,10 +277,12 @@ func NewAPI(logger slog.Logger, options ...Option) *API { devcontainerNames: make(map[string]bool), knownDevcontainers: make(map[string]codersdk.WorkspaceAgentDevcontainer), configFileModifiedTimes: make(map[string]time.Time), + ignoredDevcontainers: make(map[string]bool), recreateSuccessTimes: make(map[string]time.Time), recreateErrorTimes: make(map[string]time.Time), scriptLogger: func(uuid.UUID) ScriptLogger { return noopScriptLogger{} }, injectedSubAgentProcs: make(map[string]subAgentProcess), + usingWorkspaceFolderName: make(map[string]bool), } // The ctx and logger must be set before applying options to avoid // nil pointer dereference. @@ -630,7 +635,7 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code // folder's name. If it is not possible to generate a valid // agent name based off of the folder name (i.e. no valid characters), // we will instead fall back to using the container's friendly name. - dc.Name = safeAgentName(path.Base(filepath.ToSlash(dc.WorkspaceFolder)), dc.Container.FriendlyName) + dc.Name, api.usingWorkspaceFolderName[dc.WorkspaceFolder] = api.makeAgentName(dc.WorkspaceFolder, dc.Container.FriendlyName) } } @@ -678,8 +683,10 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code var consecutiveHyphenRegex = regexp.MustCompile("-+") // `safeAgentName` returns a safe agent name derived from a folder name, -// falling back to the container’s friendly name if needed. -func safeAgentName(name string, friendlyName string) string { +// falling back to the container’s friendly name if needed. The second +// return value will be `true` if it succeeded and `false` if it had +// to fallback to the friendly name. +func safeAgentName(name string, friendlyName string) (string, bool) { // Keep only ASCII letters and digits, replacing everything // else with a hyphen. var sb strings.Builder @@ -701,10 +708,10 @@ func safeAgentName(name string, friendlyName string) string { name = name[:min(len(name), maxAgentNameLength)] if provisioner.AgentNameRegex.Match([]byte(name)) { - return name + return name, true } - return safeFriendlyName(friendlyName) + return safeFriendlyName(friendlyName), false } // safeFriendlyName returns a API safe version of the container's @@ -719,6 +726,47 @@ func safeFriendlyName(name string) string { return name } +// expandedAgentName creates an agent name by including parent directories +// from the workspace folder path to avoid name collisions. Like `safeAgentName`, +// the second returned value will be true if using the workspace folder name, +// and false if it fell back to the friendly name. +func expandedAgentName(workspaceFolder string, friendlyName string, depth int) (string, bool) { + var parts []string + for part := range strings.SplitSeq(filepath.ToSlash(workspaceFolder), "/") { + if part = strings.TrimSpace(part); part != "" { + parts = append(parts, part) + } + } + if len(parts) == 0 { + return safeFriendlyName(friendlyName), false + } + + components := parts[max(0, len(parts)-depth-1):] + expanded := strings.Join(components, "-") + + return safeAgentName(expanded, friendlyName) +} + +// makeAgentName attempts to create an agent name. It will first attempt to create an +// agent name based off of the workspace folder, and will eventually fallback to a +// friendly name. Like `safeAgentName`, the second returned value will be true if the +// agent name utilizes the workspace folder, and false if it falls back to the +// friendly name. +func (api *API) makeAgentName(workspaceFolder string, friendlyName string) (string, bool) { + for attempt := 0; attempt <= maxAttemptsToNameAgent; attempt++ { + agentName, usingWorkspaceFolder := expandedAgentName(workspaceFolder, friendlyName, attempt) + if !usingWorkspaceFolder { + return agentName, false + } + + if !api.devcontainerNames[agentName] { + return agentName, true + } + } + + return safeFriendlyName(friendlyName), false +} + // RefreshContainers triggers an immediate update of the container list // and waits for it to complete. func (api *API) RefreshContainers(ctx context.Context) (err error) { @@ -758,6 +806,10 @@ func (api *API) getContainers() (codersdk.WorkspaceAgentListContainersResponse, if len(api.knownDevcontainers) > 0 { devcontainers = make([]codersdk.WorkspaceAgentDevcontainer, 0, len(api.knownDevcontainers)) for _, dc := range api.knownDevcontainers { + if api.ignoredDevcontainers[dc.WorkspaceFolder] { + continue + } + // Include the agent if it's running (we're iterating over // copies, so mutating is fine). if proc := api.injectedSubAgentProcs[dc.WorkspaceFolder]; proc.agent.ID != uuid.Nil { @@ -990,6 +1042,10 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) { logger.Info(api.ctx, "marking devcontainer as dirty") dc.Dirty = true } + if _, ok := api.ignoredDevcontainers[dc.WorkspaceFolder]; ok { + logger.Debug(api.ctx, "clearing devcontainer ignored state") + delete(api.ignoredDevcontainers, dc.WorkspaceFolder) // Allow re-reading config. + } api.knownDevcontainers[dc.WorkspaceFolder] = dc } @@ -1046,6 +1102,10 @@ func (api *API) cleanupSubAgents(ctx context.Context) error { // This method uses an internal timeout to prevent blocking indefinitely // if something goes wrong with the injection. func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc codersdk.WorkspaceAgentDevcontainer) (err error) { + if api.ignoredDevcontainers[dc.WorkspaceFolder] { + return nil + } + ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout) defer cancel() @@ -1067,6 +1127,42 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c maybeRecreateSubAgent := false proc, injected := api.injectedSubAgentProcs[dc.WorkspaceFolder] if injected { + if _, ignoreChecked := api.ignoredDevcontainers[dc.WorkspaceFolder]; !ignoreChecked { + // If ignore status has not yet been checked, or cleared by + // modifications to the devcontainer.json, we must read it + // to determine the current status. This can happen while + // the devcontainer subagent is already running or before + // we've had a chance to inject it. + // + // Note, for simplicity, we do not try to optimize to reduce + // ReadConfig calls here. + config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath, nil) + if err != nil { + return xerrors.Errorf("read devcontainer config: %w", err) + } + + dcIgnored := config.Configuration.Customizations.Coder.Ignore + if dcIgnored { + proc.stop() + if proc.agent.ID != uuid.Nil { + // Unlock while doing the delete operation. + api.mu.Unlock() + client := *api.subAgentClient.Load() + if err := client.Delete(ctx, proc.agent.ID); err != nil { + api.mu.Lock() + return xerrors.Errorf("delete subagent: %w", err) + } + api.mu.Lock() + } + // Reset agent and containerID to force config re-reading if ignore is toggled. + proc.agent = SubAgent{} + proc.containerID = "" + api.injectedSubAgentProcs[dc.WorkspaceFolder] = proc + api.ignoredDevcontainers[dc.WorkspaceFolder] = dcIgnored + return nil + } + } + if proc.containerID == container.ID && proc.ctx.Err() == nil { // Same container and running, no need to reinject. return nil @@ -1085,7 +1181,8 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c // Container ID changed or the subagent process is not running, // stop the existing subagent context to replace it. proc.stop() - } else { + } + if proc.agent.OperatingSystem == "" { // Set SubAgent defaults. proc.agent.OperatingSystem = "linux" // Assuming Linux for devcontainers. } @@ -1104,7 +1201,11 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c ranSubAgent := false // Clean up if injection fails. + var dcIgnored, setDCIgnored bool defer func() { + if setDCIgnored { + api.ignoredDevcontainers[dc.WorkspaceFolder] = dcIgnored + } if !ranSubAgent { proc.stop() if !api.closed { @@ -1142,48 +1243,6 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c proc.agent.Architecture = arch } - agentBinaryPath, err := os.Executable() - if err != nil { - return xerrors.Errorf("get agent binary path: %w", err) - } - agentBinaryPath, err = filepath.EvalSymlinks(agentBinaryPath) - if err != nil { - return xerrors.Errorf("resolve agent binary path: %w", err) - } - - // If we scripted this as a `/bin/sh` script, we could reduce these - // steps to one instruction, speeding up the injection process. - // - // Note: We use `path` instead of `filepath` here because we are - // working with Unix-style paths inside the container. - if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "mkdir", "-p", path.Dir(coderPathInsideContainer)); err != nil { - return xerrors.Errorf("create agent directory in container: %w", err) - } - - if err := api.ccli.Copy(ctx, container.ID, agentBinaryPath, coderPathInsideContainer); err != nil { - return xerrors.Errorf("copy agent binary: %w", err) - } - - logger.Info(ctx, "copied agent binary to container") - - // Make sure the agent binary is executable so we can run it (the - // user doesn't matter since we're making it executable for all). - if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "chmod", "0755", path.Dir(coderPathInsideContainer), coderPathInsideContainer); err != nil { - return xerrors.Errorf("set agent binary executable: %w", err) - } - - // Attempt to add CAP_NET_ADMIN to the binary to improve network - // performance (optional, allow to fail). See `bootstrap_linux.sh`. - // TODO(mafredri): Disable for now until we can figure out why this - // causes the following error on some images: - // - // Image: mcr.microsoft.com/devcontainers/base:ubuntu - // Error: /.coder-agent/coder: Operation not permitted - // - // if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "setcap", "cap_net_admin+ep", coderPathInsideContainer); err != nil { - // logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err)) - // } - subAgentConfig := proc.agent.CloneConfig(dc) if proc.agent.ID == uuid.Nil || maybeRecreateSubAgent { subAgentConfig.Architecture = arch @@ -1223,6 +1282,13 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c return err } + // We only allow ignore to be set in the root customization layer to + // prevent weird interactions with devcontainer features. + dcIgnored, setDCIgnored = config.Configuration.Customizations.Coder.Ignore, true + if dcIgnored { + return nil + } + workspaceFolder = config.Workspace.WorkspaceFolder // NOTE(DanielleMaywood): @@ -1234,6 +1300,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c if provisioner.AgentNameRegex.Match([]byte(name)) { subAgentConfig.Name = name configOutdated = true + delete(api.usingWorkspaceFolderName, dc.WorkspaceFolder) } else { logger.Warn(ctx, "invalid name in devcontainer customization, ignoring", slog.F("name", name), @@ -1270,6 +1337,22 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) } + if dcIgnored { + proc.stop() + if proc.agent.ID != uuid.Nil { + // If we stop the subagent, we also need to delete it. + client := *api.subAgentClient.Load() + if err := client.Delete(ctx, proc.agent.ID); err != nil { + return xerrors.Errorf("delete subagent: %w", err) + } + } + // Reset agent and containerID to force config re-reading if + // ignore is toggled. + proc.agent = SubAgent{} + proc.containerID = "" + return nil + } + displayApps := make([]codersdk.DisplayApp, 0, len(displayAppsMap)) for app, enabled := range displayAppsMap { if enabled { @@ -1302,6 +1385,48 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c subAgentConfig.Directory = workspaceFolder } + agentBinaryPath, err := os.Executable() + if err != nil { + return xerrors.Errorf("get agent binary path: %w", err) + } + agentBinaryPath, err = filepath.EvalSymlinks(agentBinaryPath) + if err != nil { + return xerrors.Errorf("resolve agent binary path: %w", err) + } + + // If we scripted this as a `/bin/sh` script, we could reduce these + // steps to one instruction, speeding up the injection process. + // + // Note: We use `path` instead of `filepath` here because we are + // working with Unix-style paths inside the container. + if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "mkdir", "-p", path.Dir(coderPathInsideContainer)); err != nil { + return xerrors.Errorf("create agent directory in container: %w", err) + } + + if err := api.ccli.Copy(ctx, container.ID, agentBinaryPath, coderPathInsideContainer); err != nil { + return xerrors.Errorf("copy agent binary: %w", err) + } + + logger.Info(ctx, "copied agent binary to container") + + // Make sure the agent binary is executable so we can run it (the + // user doesn't matter since we're making it executable for all). + if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "chmod", "0755", path.Dir(coderPathInsideContainer), coderPathInsideContainer); err != nil { + return xerrors.Errorf("set agent binary executable: %w", err) + } + + // Attempt to add CAP_NET_ADMIN to the binary to improve network + // performance (optional, allow to fail). See `bootstrap_linux.sh`. + // TODO(mafredri): Disable for now until we can figure out why this + // causes the following error on some images: + // + // Image: mcr.microsoft.com/devcontainers/base:ubuntu + // Error: /.coder-agent/coder: Operation not permitted + // + // if _, err := api.ccli.ExecAs(ctx, container.ID, "root", "setcap", "cap_net_admin+ep", coderPathInsideContainer); err != nil { + // logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err)) + // } + deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig) if deleteSubAgent { logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID)) @@ -1320,12 +1445,55 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c ) // Create new subagent record in the database to receive the auth token. + // If we get a unique constraint violation, try with expanded names that + // include parent directories to avoid collisions. client := *api.subAgentClient.Load() - newSubAgent, err := client.Create(ctx, subAgentConfig) - if err != nil { - return xerrors.Errorf("create subagent failed: %w", err) + + originalName := subAgentConfig.Name + + for attempt := 1; attempt <= maxAttemptsToNameAgent; attempt++ { + if proc.agent, err = client.Create(ctx, subAgentConfig); err == nil { + if api.usingWorkspaceFolderName[dc.WorkspaceFolder] { + api.devcontainerNames[dc.Name] = true + delete(api.usingWorkspaceFolderName, dc.WorkspaceFolder) + } + + break + } + + // NOTE(DanielleMaywood): + // Ordinarily we'd use `errors.As` here, but it didn't appear to work. Not + // sure if this is because of the communication protocol? Instead I've opted + // for a slightly more janky string contains approach. + // + // We only care if sub agent creation has failed due to a unique constraint + // violation on the agent name, as we can _possibly_ rectify this. + if !strings.Contains(err.Error(), "workspace agent name") { + return xerrors.Errorf("create subagent failed: %w", err) + } + + // If there has been a unique constraint violation but the user is *not* + // using an auto-generated name, then we should error. This is because + // we do not want to surprise the user with a name they did not ask for. + if usingFolderName := api.usingWorkspaceFolderName[dc.WorkspaceFolder]; !usingFolderName { + return xerrors.Errorf("create subagent failed: %w", err) + } + + if attempt == maxAttemptsToNameAgent { + return xerrors.Errorf("create subagent failed after %d attempts: %w", attempt, err) + } + + // We increase how much of the workspace folder is used for generating + // the agent name. With each iteration there is greater chance of this + // being successful. + subAgentConfig.Name, api.usingWorkspaceFolderName[dc.WorkspaceFolder] = expandedAgentName(dc.WorkspaceFolder, dc.Container.FriendlyName, attempt) + + logger.Debug(ctx, "retrying subagent creation with expanded name", + slog.F("original_name", originalName), + slog.F("expanded_name", subAgentConfig.Name), + slog.F("attempt", attempt+1), + ) } - proc.agent = newSubAgent logger.Info(ctx, "created new subagent", slog.F("agent_id", proc.agent.ID)) } else { diff --git a/agent/agentcontainers/api_internal_test.go b/agent/agentcontainers/api_internal_test.go index bda6371f63e5e..2e049640d74b8 100644 --- a/agent/agentcontainers/api_internal_test.go +++ b/agent/agentcontainers/api_internal_test.go @@ -15,6 +15,7 @@ func TestSafeAgentName(t *testing.T) { name string folderName string expected string + fallback bool }{ // Basic valid names { @@ -110,18 +111,22 @@ func TestSafeAgentName(t *testing.T) { { folderName: "", expected: "friendly-fallback", + fallback: true, }, { folderName: "---", expected: "friendly-fallback", + fallback: true, }, { folderName: "___", expected: "friendly-fallback", + fallback: true, }, { folderName: "@#$", expected: "friendly-fallback", + fallback: true, }, // Additional edge cases @@ -192,10 +197,162 @@ func TestSafeAgentName(t *testing.T) { for _, tt := range tests { t.Run(tt.folderName, func(t *testing.T) { t.Parallel() - name := safeAgentName(tt.folderName, "friendly-fallback") + name, usingWorkspaceFolder := safeAgentName(tt.folderName, "friendly-fallback") assert.Equal(t, tt.expected, name) assert.True(t, provisioner.AgentNameRegex.Match([]byte(name))) + assert.Equal(t, tt.fallback, !usingWorkspaceFolder) + }) + } +} + +func TestExpandedAgentName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + workspaceFolder string + friendlyName string + depth int + expected string + fallback bool + }{ + { + name: "simple path depth 1", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "simple path depth 2", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + { + name: "simple path depth 3", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 2, + expected: "home-coder-project", + }, + { + name: "simple path depth exceeds available", + workspaceFolder: "/home/coder/project", + friendlyName: "friendly-fallback", + depth: 9, + expected: "home-coder-project", + }, + // Cases with special characters that need sanitization + { + name: "path with spaces and special chars", + workspaceFolder: "/home/coder/My Project_v2", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-my-project-v2", + }, + { + name: "path with dots and underscores", + workspaceFolder: "/home/user.name/project_folder.git", + friendlyName: "friendly-fallback", + depth: 1, + expected: "user-name-project-folder-git", + }, + // Edge cases + { + name: "empty path", + workspaceFolder: "", + friendlyName: "friendly-fallback", + depth: 0, + expected: "friendly-fallback", + fallback: true, + }, + { + name: "root path", + workspaceFolder: "/", + friendlyName: "friendly-fallback", + depth: 0, + expected: "friendly-fallback", + fallback: true, + }, + { + name: "single component", + workspaceFolder: "project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "single component with depth 2", + workspaceFolder: "project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "project", + }, + // Collision simulation cases + { + name: "foo/project depth 1", + workspaceFolder: "/home/coder/foo/project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "foo/project depth 2", + workspaceFolder: "/home/coder/foo/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "foo-project", + }, + { + name: "bar/project depth 1", + workspaceFolder: "/home/coder/bar/project", + friendlyName: "friendly-fallback", + depth: 0, + expected: "project", + }, + { + name: "bar/project depth 2", + workspaceFolder: "/home/coder/bar/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "bar-project", + }, + // Path with trailing slashes + { + name: "path with trailing slash", + workspaceFolder: "/home/coder/project/", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + { + name: "path with multiple trailing slashes", + workspaceFolder: "/home/coder/project///", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + // Path with leading slashes + { + name: "path with multiple leading slashes", + workspaceFolder: "///home/coder/project", + friendlyName: "friendly-fallback", + depth: 1, + expected: "coder-project", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + name, usingWorkspaceFolder := expandedAgentName(tt.workspaceFolder, tt.friendlyName, tt.depth) + + assert.Equal(t, tt.expected, name) + assert.True(t, provisioner.AgentNameRegex.Match([]byte(name))) + assert.Equal(t, tt.fallback, !usingWorkspaceFolder) }) } } diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index a5541399f6437..b6bae46c835c9 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3,12 +3,14 @@ package agentcontainers_test import ( "context" "encoding/json" + "fmt" "math/rand" "net/http" "net/http/httptest" "os" "os/exec" "runtime" + "slices" "strings" "sync" "testing" @@ -17,6 +19,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -264,6 +267,16 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S if agent.OperatingSystem == "" { return agentcontainers.SubAgent{}, xerrors.New("operating system must be set") } + + for _, a := range m.agents { + if a.Name == agent.Name { + return agentcontainers.SubAgent{}, &pq.Error{ + Code: "23505", + Message: fmt.Sprintf("workspace agent name %q already exists in this workspace build", agent.Name), + } + } + } + agent.ID = uuid.New() agent.AuthToken = uuid.New() if m.agents == nil { @@ -2040,7 +2053,7 @@ func TestAPI(t *testing.T) { // Verify commands were executed through the custom shell and environment. require.NotEmpty(t, fakeExec.commands, "commands should be executed") - // Want: /bin/custom-shell -c "docker ps --all --quiet --no-trunc" + // Want: /bin/custom-shell -c '"docker" "ps" "--all" "--quiet" "--no-trunc"' require.Equal(t, testShell, fakeExec.commands[0][0], "custom shell should be used") if runtime.GOOS == "windows" { require.Equal(t, "/c", fakeExec.commands[0][1], "shell should be called with /c on Windows") @@ -2049,6 +2062,7 @@ func TestAPI(t *testing.T) { } require.Len(t, fakeExec.commands[0], 3, "command should have 3 arguments") require.GreaterOrEqual(t, strings.Count(fakeExec.commands[0][2], " "), 2, "command/script should have multiple arguments") + require.True(t, strings.HasPrefix(fakeExec.commands[0][2], `"docker" "ps"`), "command should start with \"docker\" \"ps\"") // Verify the environment was set on the command. lastCmd := fakeExec.getLastCommand() @@ -2056,6 +2070,177 @@ func TestAPI(t *testing.T) { require.Equal(t, testDir, lastCmd.Dir, "custom directory should be used") require.Equal(t, testEnv, lastCmd.Env, "custom environment should be used") }) + + t.Run("IgnoreCustomization", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)") + } + + ctx := testutil.Context(t, testutil.WaitShort) + + startTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + configPath := "/workspace/project/.devcontainer/devcontainer.json" + + container := codersdk.WorkspaceAgentContainer{ + ID: "container-id", + FriendlyName: "container-name", + Running: true, + CreatedAt: startTime.Add(-1 * time.Hour), + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project", + agentcontainers.DevcontainerConfigFileLabel: configPath, + }, + } + + fLister := &fakeContainerCLI{ + containers: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{container}, + }, + arch: runtime.GOARCH, + } + + // Start with ignore=true + fDCCLI := &fakeDevcontainerCLI{ + execErrC: make(chan func(string, ...string) error, 1), + readConfig: agentcontainers.DevcontainerConfig{ + Configuration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: agentcontainers.CoderCustomization{Ignore: true}, + }, + }, + Workspace: agentcontainers.DevcontainerWorkspace{WorkspaceFolder: "/workspace/project"}, + }, + } + + fakeSAC := &fakeSubAgentClient{ + logger: slogtest.Make(t, nil).Named("fakeSubAgentClient"), + agents: make(map[uuid.UUID]agentcontainers.SubAgent), + createErrC: make(chan error, 1), + deleteErrC: make(chan error, 1), + } + + mClock := quartz.NewMock(t) + mClock.Set(startTime) + fWatcher := newFakeWatcher(t) + + logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) + api := agentcontainers.NewAPI( + logger, + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithContainerCLI(fLister), + agentcontainers.WithSubAgentClient(fakeSAC), + agentcontainers.WithWatcher(fWatcher), + agentcontainers.WithClock(mClock), + ) + defer func() { + close(fakeSAC.createErrC) + close(fakeSAC.deleteErrC) + api.Close() + }() + + r := chi.NewRouter() + r.Mount("/", api.Routes()) + + t.Log("Phase 1: Test ignore=true filters out devcontainer") + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var response codersdk.WorkspaceAgentListContainersResponse + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Empty(t, response.Devcontainers, "ignored devcontainer should not be in response when ignore=true") + assert.Len(t, response.Containers, 1, "regular container should still be listed") + + t.Log("Phase 2: Change to ignore=false") + fDCCLI.readConfig.Configuration.Customizations.Coder.Ignore = false + var ( + exitSubAgent = make(chan struct{}) + subAgentExited = make(chan struct{}) + exitSubAgentOnce sync.Once + ) + defer func() { + exitSubAgentOnce.Do(func() { + close(exitSubAgent) + }) + }() + execSubAgent := func(cmd string, args ...string) error { + if len(args) != 1 || args[0] != "agent" { + t.Log("execSubAgent called with unexpected arguments", cmd, args) + return nil + } + defer close(subAgentExited) + select { + case <-exitSubAgent: + case <-ctx.Done(): + return ctx.Err() + } + return nil + } + testutil.RequireSend(ctx, t, fDCCLI.execErrC, execSubAgent) + testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) + + fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{ + Name: configPath, + Op: fsnotify.Write, + }) + + err = api.RefreshContainers(ctx) + require.NoError(t, err) + + t.Log("Phase 2: Cont, waiting for sub agent to exit") + exitSubAgentOnce.Do(func() { + close(exitSubAgent) + }) + select { + case <-subAgentExited: + case <-ctx.Done(): + t.Fatal("timeout waiting for sub agent to exit") + } + + req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + err = json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Len(t, response.Devcontainers, 1, "devcontainer should be in response when ignore=false") + assert.Len(t, response.Containers, 1, "regular container should still be listed") + assert.Equal(t, "/workspace/project", response.Devcontainers[0].WorkspaceFolder) + require.Len(t, fakeSAC.created, 1, "sub agent should be created when ignore=false") + createdAgentID := fakeSAC.created[0].ID + + t.Log("Phase 3: Change back to ignore=true and test sub agent deletion") + fDCCLI.readConfig.Configuration.Customizations.Coder.Ignore = true + testutil.RequireSend(ctx, t, fakeSAC.deleteErrC, nil) + + fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{ + Name: configPath, + Op: fsnotify.Write, + }) + + err = api.RefreshContainers(ctx) + require.NoError(t, err) + + req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + err = json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Empty(t, response.Devcontainers, "devcontainer should be filtered out when ignore=true again") + assert.Len(t, response.Containers, 1, "regular container should still be listed") + require.Len(t, fakeSAC.deleted, 1, "sub agent should be deleted when ignore=true") + assert.Equal(t, createdAgentID, fakeSAC.deleted[0], "the same sub agent that was created should be deleted") + }) } // mustFindDevcontainerByPath returns the devcontainer with the given workspace @@ -2073,6 +2258,127 @@ func mustFindDevcontainerByPath(t *testing.T, devcontainers []codersdk.Workspace return codersdk.WorkspaceAgentDevcontainer{} // Unreachable, but required for compilation } +// TestSubAgentCreationWithNameRetry tests the retry logic when unique constraint violations occur +func TestSubAgentCreationWithNameRetry(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows") + } + + tests := []struct { + name string + workspaceFolders []string + expectedNames []string + takenNames []string + }{ + { + name: "SingleCollision", + workspaceFolders: []string{ + "/home/coder/foo/project", + "/home/coder/bar/project", + }, + expectedNames: []string{ + "project", + "bar-project", + }, + }, + { + name: "MultipleCollisions", + workspaceFolders: []string{ + "/home/coder/foo/x/project", + "/home/coder/bar/x/project", + "/home/coder/baz/x/project", + }, + expectedNames: []string{ + "project", + "x-project", + "baz-x-project", + }, + }, + { + name: "NameAlreadyTaken", + takenNames: []string{"project", "x-project"}, + workspaceFolders: []string{ + "/home/coder/foo/x/project", + }, + expectedNames: []string{ + "foo-x-project", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + fSAC = &fakeSubAgentClient{logger: logger, agents: make(map[uuid.UUID]agentcontainers.SubAgent)} + ccli = &fakeContainerCLI{arch: runtime.GOARCH} + ) + + for _, name := range tt.takenNames { + fSAC.agents[uuid.New()] = agentcontainers.SubAgent{Name: name} + } + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(ccli), + agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + defer api.Close() + + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + for i, workspaceFolder := range tt.workspaceFolders { + ccli.containers.Containers = append(ccli.containers.Containers, newFakeContainer( + fmt.Sprintf("container%d", i+1), + fmt.Sprintf("/.devcontainer/devcontainer%d.json", i+1), + workspaceFolder, + )) + + err := api.RefreshContainers(ctx) + require.NoError(t, err) + } + + // Verify that both agents were created with expected names + require.Len(t, fSAC.created, len(tt.workspaceFolders)) + + actualNames := make([]string, len(fSAC.created)) + for i, agent := range fSAC.created { + actualNames[i] = agent.Name + } + + slices.Sort(tt.expectedNames) + slices.Sort(actualNames) + + assert.Equal(t, tt.expectedNames, actualNames) + }) + } +} + +func newFakeContainer(id, configPath, workspaceFolder string) codersdk.WorkspaceAgentContainer { + return codersdk.WorkspaceAgentContainer{ + ID: id, + FriendlyName: "test-friendly", + Image: "test-image:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder, + agentcontainers.DevcontainerConfigFileLabel: configPath, + }, + Running: true, + } +} + func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer { t.Helper() ct := codersdk.WorkspaceAgentContainer{ diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 850dc84d5ac7d..e49c6900facdb 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -44,6 +44,7 @@ type CoderCustomization struct { DisplayApps map[codersdk.DisplayApp]bool `json:"displayApps,omitempty"` Apps []SubAgentApp `json:"apps,omitempty"` Name string `json:"name,omitempty"` + Ignore bool `json:"ignore,omitempty"` } type DevcontainerWorkspace struct { diff --git a/agent/agentcontainers/execer.go b/agent/agentcontainers/execer.go index 8bdb7f24390f3..323401f34ca81 100644 --- a/agent/agentcontainers/execer.go +++ b/agent/agentcontainers/execer.go @@ -2,10 +2,10 @@ package agentcontainers import ( "context" + "fmt" "os/exec" "runtime" - - "github.com/kballard/go-shellquote" + "strings" "cdr.dev/slog" "github.com/coder/coder/v2/agent/agentexec" @@ -56,7 +56,10 @@ func (e *commandEnvExecer) prepare(ctx context.Context, inName string, inArgs .. caller = "/c" } name = shell - args = []string{caller, shellquote.Join(append([]string{inName}, inArgs...)...)} + for _, arg := range append([]string{inName}, inArgs...) { + args = append(args, fmt.Sprintf("%q", arg)) + } + args = []string{caller, strings.Join(args, " ")} return name, args, dir, env } diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index fb4cc055e31da..f49a64924bd36 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -113,9 +113,10 @@ type Config struct { BlockFileTransfer bool // ReportConnection. ReportConnection reportConnectionFunc - // Experimental: allow connecting to running containers if - // CODER_AGENT_DEVCONTAINERS_ENABLE=true. - ExperimentalDevContainersEnabled bool + // Experimental: allow connecting to running containers via Docker exec. + // Note that this is different from the devcontainers feature, which uses + // subagents. + ExperimentalContainers bool } type Server struct { @@ -435,7 +436,7 @@ func (s *Server) sessionHandler(session ssh.Session) { switch ss := session.Subsystem(); ss { case "": case "sftp": - if s.config.ExperimentalDevContainersEnabled && container != "" { + if s.config.ExperimentalContainers && container != "" { closeCause("sftp not yet supported with containers") _ = session.Exit(1) return @@ -549,7 +550,7 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str var ei usershell.EnvInfoer var err error - if s.config.ExperimentalDevContainersEnabled && container != "" { + if s.config.ExperimentalContainers && container != "" { ei, err = agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser) if err != nil { s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1) diff --git a/agent/api.go b/agent/api.go index fa761988b2f21..52c2c0fbb3094 100644 --- a/agent/api.go +++ b/agent/api.go @@ -40,7 +40,7 @@ func (a *agent) apiHandler(aAPI proto.DRPCAgentClient26) (http.Handler, func() e cacheDuration: cacheDuration, } - if a.experimentalDevcontainersEnabled { + if a.devcontainers { containerAPIOpts := []agentcontainers.Option{ agentcontainers.WithExecer(a.execer), agentcontainers.WithCommandEnv(a.sshServer.CommandEnv), diff --git a/agent/reconnectingpty/server.go b/agent/reconnectingpty/server.go index 04bbdc7efb7b2..19a2853c9d47f 100644 --- a/agent/reconnectingpty/server.go +++ b/agent/reconnectingpty/server.go @@ -31,8 +31,10 @@ type Server struct { connCount atomic.Int64 reconnectingPTYs sync.Map timeout time.Duration - - ExperimentalDevcontainersEnabled bool + // Experimental: allow connecting to running containers via Docker exec. + // Note that this is different from the devcontainers feature, which uses + // subagents. + ExperimentalContainers bool } // NewServer returns a new ReconnectingPTY server @@ -187,7 +189,7 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co }() var ei usershell.EnvInfoer - if s.ExperimentalDevcontainersEnabled && msg.Container != "" { + if s.ExperimentalContainers && msg.Container != "" { dei, err := agentcontainers.EnvInfo(ctx, s.commandCreator.Execer, msg.Container, msg.ContainerUser) if err != nil { return xerrors.Errorf("get container env info: %w", err) diff --git a/cli/agent.go b/cli/agent.go index 5d6037f9930ec..2285d44fc3584 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -55,8 +55,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { blockFileTransfer bool agentHeaderCommand string agentHeader []string - - experimentalDevcontainersEnabled bool + devcontainers bool ) cmd := &serpent.Command{ Use: "agent", @@ -321,7 +320,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { return xerrors.Errorf("create agent execer: %w", err) } - if experimentalDevcontainersEnabled { + if devcontainers { logger.Info(ctx, "agent devcontainer detection enabled") } else { logger.Info(ctx, "agent devcontainer detection not enabled") @@ -359,11 +358,11 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { SSHMaxTimeout: sshMaxTimeout, Subsystems: subsystems, - PrometheusRegistry: prometheusRegistry, - BlockFileTransfer: blockFileTransfer, - Execer: execer, - ExperimentalDevcontainersEnabled: experimentalDevcontainersEnabled, - ContainerAPIOptions: []agentcontainers.Option{ + PrometheusRegistry: prometheusRegistry, + BlockFileTransfer: blockFileTransfer, + Execer: execer, + Devcontainers: devcontainers, + DevcontainerAPIOptions: []agentcontainers.Option{ agentcontainers.WithSubAgentURL(r.agentURL.String()), }, }) @@ -506,10 +505,10 @@ func (r *RootCmd) workspaceAgent() *serpent.Command { }, { Flag: "devcontainers-enable", - Default: "false", + Default: "true", Env: "CODER_AGENT_DEVCONTAINERS_ENABLE", Description: "Allow the agent to automatically detect running devcontainers.", - Value: serpent.BoolOf(&experimentalDevcontainersEnabled), + Value: serpent.BoolOf(&devcontainers), }, } diff --git a/cli/exp_rpty_test.go b/cli/exp_rpty_test.go index 04c8798c166af..213764bb40113 100644 --- a/cli/exp_rpty_test.go +++ b/cli/exp_rpty_test.go @@ -116,8 +116,8 @@ func TestExpRpty(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter(wantLabel, "true"), ) }) diff --git a/cli/open_test.go b/cli/open_test.go index 698a4d777984b..b76b603d35b1e 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -334,8 +334,8 @@ func TestOpenVSCodeDevContainer(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mccli), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) @@ -509,8 +509,8 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mccli), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 127d57b22ae75..582f8a3fdf691 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -2029,8 +2029,8 @@ func TestSSH_Container(t *testing.T) { }) _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) @@ -2069,8 +2069,8 @@ func TestSSH_Container(t *testing.T) { Warnings: nil, }, nil).AnyTimes() _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mLister), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) diff --git a/cli/testdata/coder_agent_--help.golden b/cli/testdata/coder_agent_--help.golden index 6548a2fadbe49..3dcbb343149d3 100644 --- a/cli/testdata/coder_agent_--help.golden +++ b/cli/testdata/coder_agent_--help.golden @@ -33,7 +33,7 @@ OPTIONS: --debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113) The bind address to serve a debug HTTP server. - --devcontainers-enable bool, $CODER_AGENT_DEVCONTAINERS_ENABLE (default: false) + --devcontainers-enable bool, $CODER_AGENT_DEVCONTAINERS_ENABLE (default: true) Allow the agent to automatically detect running devcontainers. --log-dir string, $CODER_AGENT_LOG_DIR (default: /tmp) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9e20f3e268f90..647a49e646a88 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12742,11 +12742,9 @@ const docTemplate = `{ "workspace-usage", "web-push", "workspace-prebuilds", - "agentic-chat", - "ai-tasks" + "agentic-chat" ], "x-enum-comments": { - "ExperimentAITasks": "Enables the new AI tasks feature.", "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", @@ -12762,8 +12760,7 @@ const docTemplate = `{ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentWorkspacePrebuilds", - "ExperimentAgenticChat", - "ExperimentAITasks" + "ExperimentAgenticChat" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ddf5fb0d40156..a80d07a165b01 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11435,11 +11435,9 @@ "workspace-usage", "web-push", "workspace-prebuilds", - "agentic-chat", - "ai-tasks" + "agentic-chat" ], "x-enum-comments": { - "ExperimentAITasks": "Enables the new AI tasks feature.", "ExperimentAgenticChat": "Enables the new agentic AI chat feature.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentExample": "This isn't used for anything.", @@ -11455,8 +11453,7 @@ "ExperimentWorkspaceUsage", "ExperimentWebPush", "ExperimentWorkspacePrebuilds", - "ExperimentAgenticChat", - "ExperimentAITasks" + "ExperimentAgenticChat" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/coderd.go b/coderd/coderd.go index bf10573b2888d..97e38047a3d50 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1544,7 +1544,6 @@ func New(options *Options) *API { // Add CSP headers to all static assets and pages. CSP headers only affect // browsers, so these don't make sense on api routes. cspMW := httpmw.CSPHeaders( - api.Experiments, options.Telemetry.Enabled(), func() []*proxyhealth.ProxyHost { if api.DeploymentValues.Dangerous.AllowAllCors { // In this mode, allow all external requests. diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 50f175a69499d..f714a53fd6675 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -151,26 +151,28 @@ func (q *querier) authorizeContext(ctx context.Context, action policy.Action, ob // authorizePrebuiltWorkspace handles authorization for workspace resource types. // prebuilt_workspaces are a subset of workspaces, currently limited to -// supporting delete operations. Therefore, if the action is delete or -// update and the workspace is a prebuild, a prebuilt-specific authorization -// is attempted first. If that fails, it falls back to normal workspace -// authorization. +// supporting delete operations. This function first attempts normal workspace +// authorization. If that fails, the action is delete or update and the workspace +// is a prebuild, a prebuilt-specific authorization is attempted. // Note: Delete operations of workspaces requires both update and delete // permissions. func (q *querier) authorizePrebuiltWorkspace(ctx context.Context, action policy.Action, workspace database.Workspace) error { - var prebuiltErr error - // Special handling for prebuilt_workspace deletion authorization check + // Try default workspace authorization first + var workspaceErr error + if workspaceErr = q.authorizeContext(ctx, action, workspace); workspaceErr == nil { + return nil + } + + // Special handling for prebuilt workspace deletion if (action == policy.ActionUpdate || action == policy.ActionDelete) && workspace.IsPrebuild() { - // Try prebuilt-specific authorization first + var prebuiltErr error if prebuiltErr = q.authorizeContext(ctx, action, workspace.AsPrebuild()); prebuiltErr == nil { return nil } + return xerrors.Errorf("authorize context failed for workspace (%v) and prebuilt (%w)", workspaceErr, prebuiltErr) } - // Fallback to normal workspace authorization check - if err := q.authorizeContext(ctx, action, workspace); err != nil { - return xerrors.Errorf("authorize context: %w", errors.Join(prebuiltErr, err)) - } - return nil + + return xerrors.Errorf("authorize context: %w", workspaceErr) } type authContextKey struct{} @@ -228,6 +230,8 @@ var ( Identifier: rbac.RoleIdentifier{Name: "autostart"}, DisplayName: "Autostart Daemon", Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceOrganizationMember.Type: {policy.ActionRead}, + rbac.ResourceFile.Type: {policy.ActionRead}, // Required to read terraform files rbac.ResourceNotificationMessage.Type: {policy.ActionCreate, policy.ActionRead}, rbac.ResourceSystem.Type: {policy.WildcardSymbol}, rbac.ResourceTemplate.Type: {policy.ActionRead, policy.ActionUpdate}, @@ -443,6 +447,7 @@ var ( }, // Should be able to add the prebuilds system user as a member to any organization that needs prebuilds. rbac.ResourceOrganizationMember.Type: { + policy.ActionRead, policy.ActionCreate, }, // Needs to be able to assign roles to the system user in order to make it a member of an organization. @@ -456,6 +461,10 @@ var ( rbac.ResourceOrganization.Type: { policy.ActionRead, }, + // Required to read the terraform files of a template + rbac.ResourceFile.Type: { + policy.ActionRead, + }, }), }, }), diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a790bcabb3832..df4e1c94c311c 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5650,7 +5650,17 @@ func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() { Reason: database.BuildReasonInitiator, TemplateVersionID: tv.ID, JobID: pj.ID, - }).Asserts(w.AsPrebuild(), policy.ActionDelete) + }). + // Simulate a fallback authorization flow: + // - First, the default workspace authorization fails (simulated by returning an error). + // - Then, authorization is retried using the prebuilt workspace object, which succeeds. + // The test asserts that both authorization attempts occur in the correct order. + WithSuccessAuthorizer(func(ctx context.Context, subject rbac.Subject, action policy.Action, obj rbac.Object) error { + if obj.Type == rbac.ResourceWorkspace.Type { + return xerrors.Errorf("not authorized for workspace type") + } + return nil + }).Asserts(w, policy.ActionDelete, w.AsPrebuild(), policy.ActionDelete) })) s.Run("PrebuildUpdate/InsertWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) @@ -5679,6 +5689,16 @@ func (s *MethodTestSuite) TestAuthorizePrebuiltWorkspace() { }) check.Args(database.InsertWorkspaceBuildParametersParams{ WorkspaceBuildID: wb.ID, - }).Asserts(w.AsPrebuild(), policy.ActionUpdate) + }). + // Simulate a fallback authorization flow: + // - First, the default workspace authorization fails (simulated by returning an error). + // - Then, authorization is retried using the prebuilt workspace object, which succeeds. + // The test asserts that both authorization attempts occur in the correct order. + WithSuccessAuthorizer(func(ctx context.Context, subject rbac.Subject, action policy.Action, obj rbac.Object) error { + if obj.Type == rbac.ResourceWorkspace.Type { + return xerrors.Errorf("not authorized for workspace type") + } + return nil + }).Asserts(w, policy.ActionUpdate, w.AsPrebuild(), policy.ActionUpdate) })) } diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index cb16d8c4995b6..725e45c268d72 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -199,6 +199,13 @@ func (gm GroupMember) RBACObject() rbac.Object { return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String()) } +// PrebuiltWorkspaceResource defines the interface for types that can be identified as prebuilt workspaces +// and converted to their corresponding prebuilt workspace RBAC object. +type PrebuiltWorkspaceResource interface { + IsPrebuild() bool + AsPrebuild() rbac.Object +} + // WorkspaceTable converts a Workspace to it's reduced version. // A more generalized solution is to use json marshaling to // consistently keep these two structs in sync. diff --git a/coderd/dynamicparameters/render.go b/coderd/dynamicparameters/render.go index ed9b2734b259c..05c0f1b6c68c9 100644 --- a/coderd/dynamicparameters/render.go +++ b/coderd/dynamicparameters/render.go @@ -243,24 +243,30 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui return nil // already fetched } - // You only need to be able to read the organization member to get the owner - // data. Only the terraform files can therefore leak more information than the - // caller should have access to. All this info should be public assuming you can - // read the user though. - mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{ - OrganizationID: r.data.templateVersion.OrganizationID, - UserID: ownerID, - IncludeSystem: false, - })) + user, err := r.db.GetUserByID(ctx, ownerID) if err != nil { - return err - } + // If the user failed to read, we also try to read the user from their + // organization member. You only need to be able to read the organization member + // to get the owner data. + // + // Only the terraform files can therefore leak more information than the + // caller should have access to. All this info should be public assuming you can + // read the user though. + mem, err := database.ExpectOne(r.db.OrganizationMembers(ctx, database.OrganizationMembersParams{ + OrganizationID: r.data.templateVersion.OrganizationID, + UserID: ownerID, + IncludeSystem: true, + })) + if err != nil { + return xerrors.Errorf("fetch user: %w", err) + } - // User data is required for the form. Org member is checked above - // nolint:gocritic - user, err := r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID) - if err != nil { - return xerrors.Errorf("fetch user: %w", err) + // Org member fetched, so use the provisioner context to fetch the user. + //nolint:gocritic // Has the correct permissions, and matches the provisioning flow. + user, err = r.db.GetUserByID(dbauthz.AsProvisionerd(ctx), mem.OrganizationMember.UserID) + if err != nil { + return xerrors.Errorf("fetch user: %w", err) + } } // nolint:gocritic // This is kind of the wrong query to use here, but it @@ -293,7 +299,7 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui // unless the template leaks it. // nolint:gocritic key, err := r.db.GetGitSSHKey(dbauthz.AsProvisionerd(ctx), ownerID) - if err != nil { + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { return xerrors.Errorf("ssh key: %w", err) } @@ -314,10 +320,10 @@ func (r *dynamicRenderer) getWorkspaceOwnerData(ctx context.Context, ownerID uui } r.currentOwner = &previewtypes.WorkspaceOwner{ - ID: mem.OrganizationMember.UserID.String(), - Name: mem.Username, - FullName: mem.Name, - Email: mem.Email, + ID: user.ID.String(), + Name: user.Username, + FullName: user.Name, + Email: user.Email, LoginType: string(user.LoginType), RBACRoles: ownerRoles, SSHPublicKey: key.PublicKey, diff --git a/coderd/httpmw/csp.go b/coderd/httpmw/csp.go index 06897a45afd01..f39781ad51b03 100644 --- a/coderd/httpmw/csp.go +++ b/coderd/httpmw/csp.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/coder/coder/v2/coderd/proxyhealth" - "github.com/coder/coder/v2/codersdk" ) // cspDirectives is a map of all csp fetch directives to their values. @@ -59,7 +58,7 @@ const ( // Example: https://github.com/coder/coder/issues/15118 // //nolint:revive -func CSPHeaders(experiments codersdk.Experiments, telemetry bool, proxyHosts func() []*proxyhealth.ProxyHost, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler { +func CSPHeaders(telemetry bool, proxyHosts func() []*proxyhealth.ProxyHost, staticAdditions map[CSPFetchDirective][]string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Content-Security-Policy disables loading certain content types and can prevent XSS injections. @@ -124,9 +123,7 @@ func CSPHeaders(experiments codersdk.Experiments, telemetry bool, proxyHosts fun if len(extraConnect) > 0 { for _, extraHost := range extraConnect { // Allow embedding the app host. - if experiments.Enabled(codersdk.ExperimentAITasks) { - cspSrcs.Append(CSPDirectiveFrameSrc, extraHost.AppHost) - } + cspSrcs.Append(CSPDirectiveFrameSrc, extraHost.AppHost) if extraHost.Host == "*" { // '*' means all cspSrcs.Append(CSPDirectiveConnectSrc, "*") diff --git a/coderd/httpmw/csp_test.go b/coderd/httpmw/csp_test.go index 5fd4b5bbd38aa..7bf8b879ef26f 100644 --- a/coderd/httpmw/csp_test.go +++ b/coderd/httpmw/csp_test.go @@ -10,7 +10,6 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/proxyhealth" - "github.com/coder/coder/v2/codersdk" ) func TestCSP(t *testing.T) { @@ -50,9 +49,7 @@ func TestCSP(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/", nil) rw := httptest.NewRecorder() - httpmw.CSPHeaders(codersdk.Experiments{ - codersdk.ExperimentAITasks, - }, false, func() []*proxyhealth.ProxyHost { + httpmw.CSPHeaders(false, func() []*proxyhealth.ProxyHost { return proxyHosts }, map[httpmw.CSPFetchDirective][]string{ httpmw.CSPDirectiveMediaSrc: expectedMedia, diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index ba67c0bd48835..dfc27418f4862 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -33,6 +33,7 @@ import ( clitelemetry "github.com/coder/coder/v2/cli/telemetry" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" tailnetproto "github.com/coder/coder/v2/tailnet/proto" ) @@ -1090,7 +1091,7 @@ func ConvertTemplate(dbTemplate database.Template) Template { AutostartAllowedDays: codersdk.BitmapToWeekdays(dbTemplate.AutostartAllowedDays()), RequireActiveVersion: dbTemplate.RequireActiveVersion, Deprecated: dbTemplate.Deprecated != "", - UseClassicParameterFlow: dbTemplate.UseClassicParameterFlow, + UseClassicParameterFlow: ptr.Ref(dbTemplate.UseClassicParameterFlow), } } @@ -1397,7 +1398,7 @@ type Template struct { AutostartAllowedDays []string `json:"autostart_allowed_days"` RequireActiveVersion bool `json:"require_active_version"` Deprecated bool `json:"deprecated"` - UseClassicParameterFlow bool `json:"use_classic_parameter_flow"` + UseClassicParameterFlow *bool `json:"use_classic_parameter_flow"` } type TemplateVersion struct { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index b3fb53c228ef8..67bd6ce06b23a 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1250,8 +1250,8 @@ func TestWorkspaceAgentContainers(t *testing.T) { return agents }).Do() _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) }) @@ -1358,8 +1358,8 @@ func TestWorkspaceAgentContainers(t *testing.T) { }).Do() _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { o.Logger = logger.Named("agent") - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append(o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mcl), agentcontainers.WithContainerLabelIncludeFilter("this.label.does.not.exist.ignore.devcontainers", "true"), ) @@ -1432,6 +1432,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }, nil).AnyTimes() // DetectArchitecture always returns "" for this test to disable agent injection. mccli.EXPECT().DetectArchitecture(gomock.Any(), devContainer.ID).Return("", nil).AnyTimes() + mdccli.EXPECT().ReadConfig(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return(agentcontainers.DevcontainerConfig{}, nil).Times(1) mdccli.EXPECT().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return("someid", nil).Times(1) return 0 }, @@ -1473,9 +1474,9 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }).Do() _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { o.Logger = logger.Named("agent") - o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append( - o.ContainerAPIOptions, + o.Devcontainers = true + o.DevcontainerAPIOptions = append( + o.DevcontainerAPIOptions, agentcontainers.WithContainerCLI(mccli), agentcontainers.WithDevcontainerCLI(mdccli), agentcontainers.WithWatcher(watcher.NewNoop()), diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 76e271ff3aee6..6c3321625c9b3 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -391,17 +391,16 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { tx, api.FileCache, func(action policy.Action, object rbac.Objecter) bool { + if auth := api.Authorize(r, action, object); auth { + return true + } // Special handling for prebuilt workspace deletion - if object.RBACObject().Type == rbac.ResourceWorkspace.Type && action == policy.ActionDelete { - if workspaceObj, ok := object.(database.Workspace); ok { - // Try prebuilt-specific authorization first - if auth := api.Authorize(r, action, workspaceObj.AsPrebuild()); auth { - return auth - } + if action == policy.ActionDelete { + if workspaceObj, ok := object.(database.PrebuiltWorkspaceResource); ok && workspaceObj.IsPrebuild() { + return api.Authorize(r, action, workspaceObj.AsPrebuild()) } } - // Fallback to default authorization - return api.Authorize(r, action, object) + return false }, audit.WorkspaceBuildBaggageFromRequest(r), ) diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 0b11240e19db0..bd3677614415e 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -1049,14 +1049,12 @@ func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Obje return BuildError{http.StatusBadRequest, msg, xerrors.New(msg)} } + // Try default workspace authorization first + authorized := authFunc(action, b.workspace) + // Special handling for prebuilt workspace deletion - authorized := false - if action == policy.ActionDelete && b.workspace.IsPrebuild() && authFunc(action, b.workspace.AsPrebuild()) { - authorized = true - } - // Fallback to default authorization - if !authorized && authFunc(action, b.workspace) { - authorized = true + if !authorized && action == policy.ActionDelete && b.workspace.IsPrebuild() { + authorized = authFunc(action, b.workspace.AsPrebuild()) } if !authorized { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ce15ee407a8f3..19ec16c02cb22 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -3369,7 +3369,6 @@ const ( ExperimentWebPush Experiment = "web-push" // Enables web push notifications through the browser. ExperimentWorkspacePrebuilds Experiment = "workspace-prebuilds" // Enables the new workspace prebuilds feature. ExperimentAgenticChat Experiment = "agentic-chat" // Enables the new agentic AI chat feature. - ExperimentAITasks Experiment = "ai-tasks" // Enables the new AI tasks feature. ) // ExperimentsKnown should include all experiments defined above. @@ -3381,7 +3380,6 @@ var ExperimentsKnown = Experiments{ ExperimentWebPush, ExperimentWorkspacePrebuilds, ExperimentAgenticChat, - ExperimentAITasks, } // ExperimentsSafe should include all experiments that are safe for diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c7ea766531e9e..04075bd574d1a 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3513,7 +3513,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `web-push` | | `workspace-prebuilds` | | `agentic-chat` | -| `ai-tasks` | ## codersdk.ExternalAuth diff --git a/enterprise/coderd/prebuilds/id.go b/enterprise/coderd/prebuilds/id.go deleted file mode 100644 index b6513942447c2..0000000000000 --- a/enterprise/coderd/prebuilds/id.go +++ /dev/null @@ -1 +0,0 @@ -package prebuilds diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index ef9d1b977ea00..228b11f485a96 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -287,11 +287,9 @@ func TestCreateUserWorkspace(t *testing.T) { OrganizationID: first.OrganizationID, }) - version := coderdtest.CreateTemplateVersion(t, admin, first.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, admin, version.ID) - template := coderdtest.CreateTemplate(t, admin, first.OrganizationID, version.ID) + template, _ := coderdtest.DynamicParameterTemplate(t, admin, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{}) - ctx = testutil.Context(t, testutil.WaitLong*1000) // Reset the context to avoid timeouts. + ctx = testutil.Context(t, testutil.WaitLong) wrk, err := creator.CreateUserWorkspace(ctx, adminID.ID.String(), codersdk.CreateWorkspaceRequest{ TemplateID: template.ID, @@ -306,6 +304,66 @@ func TestCreateUserWorkspace(t *testing.T) { require.NoError(t, err) }) + t.Run("ForANonOrgMember", func(t *testing.T) { + t.Parallel() + + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + ctx := testutil.Context(t, testutil.WaitShort) + //nolint:gocritic // using owner to setup roles + r, err := owner.CreateOrganizationRole(ctx, codersdk.Role{ + Name: "creator", + OrganizationID: first.OrganizationID.String(), + DisplayName: "Creator", + OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceWorkspace: {codersdk.ActionCreate, codersdk.ActionWorkspaceStart, codersdk.ActionUpdate, codersdk.ActionRead}, + codersdk.ResourceOrganizationMember: {codersdk.ActionRead}, + }), + }) + require.NoError(t, err) + + // user to make the workspace for, **note** the user is not a member of the first org. + // This is strange, but technically valid. The creator can create a workspace for + // this user in this org, even though the user cannot access the workspace. + secondOrg := coderdenttest.CreateOrganization(t, owner, coderdenttest.CreateOrganizationOptions{}) + _, forUser := coderdtest.CreateAnotherUser(t, owner, secondOrg.ID) + + // try the test action with this user & custom role + creator, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.RoleMember(), + rbac.RoleTemplateAdmin(), // Need site wide access to make workspace for non-org + rbac.RoleIdentifier{ + Name: r.Name, + OrganizationID: first.OrganizationID, + }, + ) + + template, _ := coderdtest.DynamicParameterTemplate(t, creator, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{}) + + ctx = testutil.Context(t, testutil.WaitLong) + + wrk, err := creator.CreateUserWorkspace(ctx, forUser.ID.String(), codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "workspace", + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, creator, wrk.LatestBuild.ID) + + _, err = creator.WorkspaceByOwnerAndName(ctx, forUser.Username, wrk.Name, codersdk.WorkspaceOptions{ + IncludeDeleted: false, + }) + require.NoError(t, err) + }) + // Asserting some authz calls when creating a workspace. t.Run("AuthzStory", func(t *testing.T) { t.Parallel() diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 53dcc94831ab0..84174c90b435d 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -178,7 +178,9 @@ func hasAITaskResources(graph *gographviz.Graph) bool { // Check if this node is a coder_ai_task resource if label, exists := node.Attrs["label"]; exists { labelValue := strings.Trim(label, `"`) - if strings.HasPrefix(labelValue, "coder_ai_task.") { + // The first condition is for the case where the resource is in the root module. + // The second condition is for the case where the resource is in a child module. + if strings.HasPrefix(labelValue, "coder_ai_task.") || strings.Contains(labelValue, ".coder_ai_task.") { return true } } diff --git a/site/CLAUDE.md b/site/CLAUDE.md new file mode 100644 index 0000000000000..aded8db19c419 --- /dev/null +++ b/site/CLAUDE.md @@ -0,0 +1,115 @@ +# Frontend Development Guidelines + +## Bash commands + +- `pnpm dev` - Start Vite development server +- `pnpm storybook --no-open` - Run storybook tests +- `pnpm test` - Run jest unit tests +- `pnpm test -- path/to/specific.test.ts` - Run a single test file +- `pnpm lint` - Run complete linting suite (Biome + TypeScript + circular deps + knip) +- `pnpm lint:fix` - Auto-fix linting issues where possible +- `pnpm playwright:test` - Run playwright e2e tests. When running e2e tests, remind the user that a license is required to run all the tests +- `pnpm format` - Format frontend code. Always run before creating a PR + +## Components + +- MUI components are deprecated - migrate away from these when encountered +- Use shadcn/ui components first - check `site/src/components` for existing implementations. +- Do not use shadcn CLI - manually add components to maintain consistency +- The modules folder should contain components with business logic specific to the codebase. +- Create custom components only when shadcn alternatives don't exist + +## Styling + +- Emotion CSS is deprecated. Use Tailwind CSS instead. +- Use custom Tailwind classes in tailwind.config.js. +- Tailwind CSS reset is currently not used to maintain compatibility with MUI +- Responsive design - use Tailwind's responsive prefixes (sm:, md:, lg:, xl:) +- Do not use `dark:` prefix for dark mode + +## Tailwind Best Practices + +- Group related classes +- Use semantic color names from the theme inside `tailwind.config.js` including `content`, `surface`, `border`, `highlight` semantic tokens +- Prefer Tailwind utilities over custom CSS when possible + +## General Code style + +- Use ES modules (import/export) syntax, not CommonJS (require) +- Destructure imports when possible (eg. import { foo } from 'bar') +- Prefer `for...of` over `forEach` for iteration +- **Biome** handles both linting and formatting (not ESLint/Prettier) + +## Workflow + +- Be sure to typecheck when you’re done making a series of code changes +- Prefer running single tests, and not the whole test suite, for performance +- Some e2e tests require a license from the user to execute +- Use pnpm format before creating a PR + +## Pre-PR Checklist + +1. `pnpm check` - Ensure no TypeScript errors +2. `pnpm lint` - Fix linting issues +3. `pnpm format` - Format code consistently +4. `pnpm test` - Run affected unit tests +5. Visual check in Storybook if component changes + +## Migration (MUI → shadcn) (Emotion → Tailwind) + +### Migration Strategy + +- Identify MUI components in current feature +- Find shadcn equivalent in existing components +- Create wrapper if needed for missing functionality +- Update tests to reflect new component structure +- Remove MUI imports once migration complete + +### Migration Guidelines + +- Use Tailwind classes for all new styling +- Replace Emotion `css` prop with Tailwind classes +- Leverage custom color tokens: `content-primary`, `surface-secondary`, etc. +- Use `className` with `clsx` for conditional styling + +## React Rules + +### 1. Purity & Immutability + +- **Components and custom Hooks must be pure and idempotent**—same inputs → same output; move side-effects to event handlers or Effects. +- **Never mutate props, state, or values returned by Hooks.** Always create new objects or use the setter from useState. + +### 2. Rules of Hooks + +- **Only call Hooks at the top level** of a function component or another custom Hook—never in loops, conditions, nested functions, or try / catch. +- **Only call Hooks from React functions.** Regular JS functions, classes, event handlers, useMemo, etc. are off-limits. + +### 3. React orchestrates execution + +- **Don’t call component functions directly; render them via JSX.** This keeps Hook rules intact and lets React optimize reconciliation. +- **Never pass Hooks around as values or mutate them dynamically.** Keep Hook usage static and local to each component. + +### 4. State Management + +- After calling a setter you’ll still read the **previous** state during the same event; updates are queued and batched. +- Use **functional updates** (setX(prev ⇒ …)) whenever next state depends on previous state. +- Pass a function to useState(initialFn) for **lazy initialization**—it runs only on the first render. +- If the next state is Object.is-equal to the current one, React skips the re-render. + +### 5. Effects + +- An Effect takes a **setup** function and optional **cleanup**; React runs setup after commit, cleanup before the next setup or on unmount. +- The **dependency array must list every reactive value** referenced inside the Effect, and its length must stay constant. +- Effects run **only on the client**, never during server rendering. +- Use Effects solely to **synchronize with external systems**; if you’re not “escaping React,” you probably don’t need one. + +### 6. Lists & Keys + +- Every sibling element in a list **needs a stable, unique key prop**. Never use array indexes or Math.random(); prefer data-driven IDs. +- Keys aren’t passed to children and **must not change between renders**; if you return multiple nodes per item, use `` + +### 7. Refs & DOM Access + +- useRef stores a mutable .current **without causing re-renders**. +- **Don’t call Hooks (including useRef) inside loops, conditions, or map().** Extract a child component instead. +- **Avoid reading or mutating refs during render;** access them in event handlers or Effects after commit. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b2c5c562a4dab..4a7280849df18 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -834,7 +834,6 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = - | "ai-tasks" | "agentic-chat" | "auto-fill-parameters" | "example" @@ -844,7 +843,6 @@ export type Experiment = | "workspace-usage"; export const Experiments: Experiment[] = [ - "ai-tasks", "agentic-chat", "auto-fill-parameters", "example", diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index 859aa10d0cf68..1f2c6b3b3416b 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -7,6 +7,8 @@ import { type VariantProps, cva } from "class-variance-authority"; import { forwardRef } from "react"; import { cn } from "utils/cn"; +// Be careful when changing the child styles from the button such as images +// because they can override the styles from other components like Avatar. const buttonVariants = cva( ` inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans @@ -15,8 +17,8 @@ const buttonVariants = cva( focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link disabled:pointer-events-none disabled:text-content-disabled [&:is(a):not([href])]:pointer-events-none [&:is(a):not([href])]:text-content-disabled - [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5 - [&_img]:pointer-events-none [&_img]:shrink-0 [&_img]:p-0.5 + [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:p-0.5 + [&>img]:pointer-events-none [&>img]:shrink-0 [&>img]:p-0.5 `, { variants: { @@ -42,11 +44,11 @@ const buttonVariants = cva( }, size: { - lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg", - sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&_img]:size-icon-sm", + lg: "min-w-20 h-10 px-3 py-2 [&>svg]:size-icon-lg [&>img]:size-icon-lg", + sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&>svg]:size-icon-sm [&>img]:size-icon-sm", xs: "min-w-8 py-1 px-2 text-2xs rounded-md", - icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&_img]:size-icon-sm", - "icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg", + icon: "size-8 px-1.5 [&>svg]:size-icon-sm [&>img]:size-icon-sm", + "icon-lg": "size-10 px-2 [&>svg]:size-icon-lg [&>img]:size-icon-lg", }, }, defaultVariants: { diff --git a/site/src/hooks/useEmbeddedMetadata.test.ts b/site/src/hooks/useEmbeddedMetadata.test.ts index e296e01a34afa..316ab464a78de 100644 --- a/site/src/hooks/useEmbeddedMetadata.test.ts +++ b/site/src/hooks/useEmbeddedMetadata.test.ts @@ -42,7 +42,7 @@ const mockDataForTags = { user: MockUserOwner, userAppearance: MockUserAppearanceSettings, regions: MockRegions, - tasksTabVisible: MockTasksTabVisible, + "tasks-tab-visible": MockTasksTabVisible, } as const satisfies Record; const emptyMetadata: RuntimeHtmlMetadata = { @@ -74,7 +74,7 @@ const emptyMetadata: RuntimeHtmlMetadata = { available: false, value: undefined, }, - tasksTabVisible: { + "tasks-tab-visible": { available: false, value: undefined, }, @@ -109,7 +109,7 @@ const populatedMetadata: RuntimeHtmlMetadata = { available: true, value: MockUserAppearanceSettings, }, - tasksTabVisible: { + "tasks-tab-visible": { available: true, value: MockTasksTabVisible, }, diff --git a/site/src/hooks/useEmbeddedMetadata.ts b/site/src/hooks/useEmbeddedMetadata.ts index 908d89c9590e5..f4a1d59528677 100644 --- a/site/src/hooks/useEmbeddedMetadata.ts +++ b/site/src/hooks/useEmbeddedMetadata.ts @@ -30,7 +30,7 @@ type AvailableMetadata = Readonly<{ entitlements: Entitlements; regions: readonly Region[]; "build-info": BuildInfoResponse; - tasksTabVisible: boolean; + "tasks-tab-visible": boolean; }>; export type MetadataKey = keyof AvailableMetadata; @@ -92,7 +92,7 @@ export class MetadataManager implements MetadataManagerApi { experiments: this.registerValue("experiments"), "build-info": this.registerValue("build-info"), regions: this.registerRegionValue(), - tasksTabVisible: this.registerValue("tasksTabVisible"), + "tasks-tab-visible": this.registerValue("tasks-tab-visible"), }; } diff --git a/site/src/modules/apps/AppStatusStateIcon.tsx b/site/src/modules/apps/AppStatusStateIcon.tsx index f713f49ed24b0..3497773952373 100644 --- a/site/src/modules/apps/AppStatusStateIcon.tsx +++ b/site/src/modules/apps/AppStatusStateIcon.tsx @@ -5,7 +5,7 @@ import { CircleAlertIcon, CircleCheckIcon, HourglassIcon, - SquareIcon, + PauseIcon, TriangleAlertIcon, } from "lucide-react"; import type { FC } from "react"; @@ -29,7 +29,13 @@ export const AppStatusStateIcon: FC = ({ switch (state) { case "idle": return ( - + ); case "complete": return ( diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 7e56c9643c066..8ef245cb13182 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -1,5 +1,4 @@ import { API } from "api/api"; -import { experiments } from "api/queries/experiments"; import type * as TypesGen from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; @@ -10,7 +9,6 @@ import { useWebpushNotifications } from "contexts/useWebpushNotifications"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox"; import type { FC } from "react"; -import { useQuery } from "react-query"; import { NavLink, useLocation } from "react-router-dom"; import { cn } from "utils/cn"; import { DeploymentDropdown } from "./DeploymentDropdown"; @@ -145,7 +143,6 @@ const NavItems: FC = ({ className }) => { const location = useLocation(); const agenticChat = useAgenticChat(); const { metadata } = useEmbeddedMetadata(); - const experimentsQuery = useQuery(experiments(metadata.experiments)); return (