Ctrl K
ComunidadGuías…

cli/open.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"path"
	"path/filepath"
	"regexp"
	"runtime"
	"slices"
	"strconv"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"github.com/google/uuid"
	"github.com/skratchdot/open-golang/open"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/cli/cliui"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/serpent"
)

func (r *RootCmd) open() *serpent.Command {
	cmd := &serpent.Command{
		Use:   "open",
		Short: "Open a workspace",
		Handler: func(inv *serpent.Invocation) error {
			return inv.Command.HelpHandler(inv)
		},
		Children: []*serpent.Command{
			r.openVSCode(),
			r.openApp(),
		},
	}
	return cmd
}

const vscodeDesktopName = "VS Code Desktop"

// externalSessionTokenPlaceholder is the literal substring in an external
// workspace-app URL that the CLI replaces with the user's session token
// when the app belongs to a top-level agent and its URL is trusted (see
// sessionTokenDestinationTrusted).
const externalSessionTokenPlaceholder = "$SESSION_TOKEN"

func (r *RootCmd) openVSCode() *serpent.Command {
	var (
		generateToken bool
		testOpenError bool
	)

	cmd := &serpent.Command{
		Annotations: workspaceCommand,
		Use:         "vscode <workspace> [<directory in workspace>]",
		Short:       fmt.Sprintf("Open a workspace in %s", vscodeDesktopName),
		Middleware: serpent.Chain(
			serpent.RequireRangeArgs(1, 2),
		),
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}
			ctx, cancel := context.WithCancel(inv.Context())
			defer cancel()
			appearanceConfig := initAppearance(ctx, client)

			// Check if we're inside a workspace, and especially inside _this_
			// workspace so we can perform path resolution/expansion. Generally,
			// we know that if we're inside a workspace, `open` can't be used.
			insideAWorkspace := inv.Environ.Get("CODER") == "true"
			inWorkspaceName := inv.Environ.Get("CODER_WORKSPACE_NAME") + "." + inv.Environ.Get("CODER_WORKSPACE_AGENT_NAME")

			// We need a started workspace to figure out e.g. expanded directory.
			// Pehraps the vscode-coder extension could handle this by accepting
			// default_directory=true, then probing the agent. Then we wouldn't
			// need to wait for the agent to start.
			workspaceQuery := inv.Args[0]
			autostart := true
			workspace, workspaceAgent, otherWorkspaceAgents, err := GetWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
			if err != nil {
				return xerrors.Errorf("get workspace and agent: %w", err)
			}

			workspaceName := workspace.Name + "." + workspaceAgent.Name
			insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName

			// To properly work with devcontainers, VS Code has to connect to
			// parent workspace agent. It will then proceed to enter the
			// container given the correct parameters. There is inherently no
			// dependency on the devcontainer agent in this scenario, but
			// relying on it simplifies the logic and ensures the devcontainer
			// is ready. To eliminate the dependency we would need to know that
			// a sub-agent that hasn't been created yet may be a devcontainer,
			// and thus will be created at a later time as well as expose the
			// container folder on the API response.
			var parentWorkspaceAgent codersdk.WorkspaceAgent
			var devcontainer codersdk.WorkspaceAgentDevcontainer
			if workspaceAgent.ParentID.Valid {
				// This is likely a devcontainer agent, so we need to find the
				// parent workspace agent as well as the devcontainer.
				for _, otherAgent := range otherWorkspaceAgents {
					if otherAgent.ID == workspaceAgent.ParentID.UUID {
						parentWorkspaceAgent = otherAgent
						break
					}
				}
				if parentWorkspaceAgent.ID == uuid.Nil {
					return xerrors.Errorf("parent workspace agent %s not found", workspaceAgent.ParentID.UUID)
				}

				printedWaiting := false
				for {
					resp, err := client.WorkspaceAgentListContainers(ctx, parentWorkspaceAgent.ID, nil)
					if err != nil {
						return xerrors.Errorf("list parent workspace agent containers: %w", err)
					}

					for _, dc := range resp.Devcontainers {
						if dc.Agent.ID == workspaceAgent.ID {
							devcontainer = dc
							break
						}
					}
					if devcontainer.ID == uuid.Nil {
						cliui.Warnf(inv.Stderr, "Devcontainer %q not found, opening as a regular workspace...", workspaceAgent.Name)
						parentWorkspaceAgent = codersdk.WorkspaceAgent{} // Reset to empty, so we don't use it later.
						break
					}

					// Precondition, the devcontainer must be running to enter
					// it. Once running, devcontainer.Container will be set.
					if devcontainer.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning {
						break
					}
					if devcontainer.Status != codersdk.WorkspaceAgentDevcontainerStatusStarting {
						return xerrors.Errorf("devcontainer %q is in unexpected status %q, expected %q or %q",
							devcontainer.Name, devcontainer.Status,
							codersdk.WorkspaceAgentDevcontainerStatusRunning,
							codersdk.WorkspaceAgentDevcontainerStatusStarting,
						)
					}

					if !printedWaiting {
						_, _ = fmt.Fprintf(inv.Stderr, "Waiting for devcontainer %q status to change from %q to %q...\n", devcontainer.Name, devcontainer.Status, codersdk.WorkspaceAgentDevcontainerStatusRunning)
						printedWaiting = true
					}
					time.Sleep(5 * time.Second) // Wait a bit before retrying.
				}
			}

			if !insideThisWorkspace {
				// Wait for the agent to connect, we don't care about readiness
				// otherwise (e.g. wait).
				err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
					Fetch:     client.WorkspaceAgent,
					FetchLogs: nil,
					Wait:      false,
					DocsURL:   appearanceConfig.DocsURL,
				})
				if err != nil {
					if xerrors.Is(err, context.Canceled) {
						return cliui.ErrCanceled
					}
					return xerrors.Errorf("agent: %w", err)
				}

				// The agent will report it's expanded directory before leaving
				// the created state, so we need to wait for that to happen.
				// However, if no directory is set, the expanded directory will
				// not be set either.
				//
				// Note that this is irrelevant for devcontainer sub agents, as
				// they always have a directory set.
				if workspaceAgent.Directory != "" {
					workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(wa codersdk.WorkspaceAgent) bool {
						return wa.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated
					})
					if err != nil {
						return xerrors.Errorf("wait for agent: %w", err)
					}
				}
			}

			var directory string
			if len(inv.Args) > 1 {
				directory = inv.Args[1]
			}

			// If we're opening into a dev container, we should use the directory of the dev container.
			workingDirectory := workspaceAgent.ExpandedDirectory
			if workingDirectory == "" && devcontainer.Agent != nil {
				workingDirectory = devcontainer.Agent.Directory
			}

			directory, err = resolveAgentAbsPath(workingDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace)
			if err != nil {
				return xerrors.Errorf("resolve agent path: %w", err)
			}

			var token string
			// We always set the token if we believe we can open without
			// printing the URI, otherwise the token must be explicitly
			// requested as it will be printed in plain text.
			if !insideAWorkspace || generateToken {
				// Prepare an API key. This is for automagical configuration of
				// VS Code, however, if running on a local machine we could try
				// to probe VS Code settings to see if the current configuration
				// is valid. Future improvement idea.
				apiKey, err := client.CreateAPIKey(ctx, codersdk.Me)
				if err != nil {
					return xerrors.Errorf("create API key: %w", err)
				}
				token = apiKey.Key
			}

			var (
				u  *url.URL
				qp url.Values
			)
			if devcontainer.ID != uuid.Nil {
				u, qp = buildVSCodeWorkspaceDevContainerLink(
					token,
					client.URL.String(),
					workspace,
					parentWorkspaceAgent,
					devcontainer.Container.FriendlyName,
					directory,
					devcontainer.WorkspaceFolder,
					devcontainer.ConfigPath,
				)
			} else {
				u, qp = buildVSCodeWorkspaceLink(
					token,
					client.URL.String(),
					workspace,
					workspaceAgent,
					directory,
				)
			}

			openingPath := workspaceName
			if directory != "" {
				openingPath += ":" + directory
			}

			if insideAWorkspace {
				_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s is not supported inside a workspace, please open the following URI on your local machine instead:\n\n", openingPath, vscodeDesktopName)
				_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
				return nil
			}
			_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s\n", openingPath, vscodeDesktopName)

			if !testOpenError {
				err = open.Run(u.String())
			} else {
				err = xerrors.New("test.open-error")
			}
			if err != nil {
				if !generateToken {
					// This is not an important step, so we don't want
					// to block the user here.
					token := qp.Get("token")
					wait := doAsync(func() {
						// Best effort, we don't care if this fails.
						apiKeyID := strings.SplitN(token, "-", 2)[0]
						_ = client.DeleteAPIKey(ctx, codersdk.Me, apiKeyID)
					})
					defer wait()

					qp.Del("token")
					u.RawQuery = qp.Encode()
				}

				_, _ = fmt.Fprintf(inv.Stderr, "Could not automatically open %s in %s: %s\n", openingPath, vscodeDesktopName, err)
				_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI instead:\n\n")
				_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
				return nil
			}

			return nil
		},
	}

	cmd.Options = serpent.OptionSet{
		{
			Flag: "generate-token",
			Env:  "CODER_OPEN_VSCODE_GENERATE_TOKEN",
			Description: fmt.Sprintf(
				"Generate an auth token and include it in the vscode:// URI. This is for automagical configuration of %s and not needed if already configured. "+
					"This flag does not need to be specified when running this command on a local machine unless automatic open fails.",
				vscodeDesktopName,
			),
			Value: serpent.BoolOf(&generateToken),
		},
		{
			Flag:        "test.open-error",
			Description: "Don't run the open command.",
			Value:       serpent.BoolOf(&testOpenError),
			Hidden:      true, // This is for testing!
		},
	}

	return cmd
}

func (r *RootCmd) openApp() *serpent.Command {
	var (
		regionArg     string
		testOpenError bool
	)

	cmd := &serpent.Command{
		Annotations: workspaceCommand,
		Use:         "app <workspace> <app slug>",
		Short:       "Open a workspace application.",
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}

			ctx, cancel := context.WithCancel(inv.Context())
			defer cancel()

			if len(inv.Args) == 0 || len(inv.Args) > 2 {
				return inv.Command.HelpHandler(inv)
			}

			workspaceName := inv.Args[0]
			ws, agt, _, err := GetWorkspaceAndAgent(ctx, inv, client, false, workspaceName)
			if err != nil {
				var sdkErr *codersdk.Error
				if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
					cliui.Errorf(inv.Stderr, "Workspace %q not found!", workspaceName)
					return sdkErr
				}
				cliui.Errorf(inv.Stderr, "Failed to get workspace and agent: %s", err)
				return err
			}

			allAppSlugs := make([]string, len(agt.Apps))
			for i, app := range agt.Apps {
				allAppSlugs[i] = app.Slug
			}
			slices.Sort(allAppSlugs)

			// If a user doesn't specify an app slug, we'll just list the available
			// apps and exit.
			if len(inv.Args) == 1 {
				cliui.Infof(inv.Stderr, "Available apps in %q: %v", workspaceName, allAppSlugs)
				return nil
			}

			appSlug := inv.Args[1]
			var foundApp codersdk.WorkspaceApp
			appIdx := slices.IndexFunc(agt.Apps, func(a codersdk.WorkspaceApp) bool {
				return a.Slug == appSlug
			})
			if appIdx == -1 {
				cliui.Errorf(inv.Stderr, "App %q not found in workspace %q!\nAvailable apps: %v", appSlug, workspaceName, allAppSlugs)
				return xerrors.Errorf("app not found")
			}
			foundApp = agt.Apps[appIdx]

			// To build the app URL, we need to know the wildcard hostname
			// and path app URL for the region.
			regions, err := client.Regions(ctx)
			if err != nil {
				return xerrors.Errorf("failed to fetch regions: %w", err)
			}
			var region codersdk.Region
			preferredIdx := slices.IndexFunc(regions, func(r codersdk.Region) bool {
				return r.Name == regionArg
			})
			if preferredIdx == -1 {
				allRegions := make([]string, len(regions))
				for i, r := range regions {
					allRegions[i] = r.Name
				}
				cliui.Errorf(inv.Stderr, "Preferred region %q not found!\nAvailable regions: %v", regionArg, allRegions)
				return xerrors.Errorf("region not found")
			}
			region = regions[preferredIdx]

			baseURL, err := url.Parse(region.PathAppURL)
			if err != nil {
				return xerrors.Errorf("failed to parse proxy URL: %w", err)
			}
			baseURL.Path = ""
			pathAppURL := strings.TrimPrefix(region.PathAppURL, baseURL.String())
			appURL := buildAppLinkURL(baseURL, ws, agt, foundApp, region.WildcardHostname, pathAppURL)

			if foundApp.External && strings.Contains(appURL, externalSessionTokenPlaceholder) {
				// Sub-agent external app URLs are set at runtime, so they
				// never receive the session token.
				if agt.ParentID.Valid {
					cliui.Warnf(inv.Stderr,
						"This app was registered from inside the workspace rather than from the workspace template. "+
							"Inspect the URL below carefully and, if you trust the source, substitute the $SESSION_TOKEN placeholder "+
							"with your session token and manually open it:")
					_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
					return nil
				}
				// Template-defined apps only receive the session token when
				// they open a known Coder IDE handler pointed at this
				// deployment, matching the dashboard.
				if !sessionTokenDestinationTrusted(appURL, sessionTokenOrigins(ctx, client)) {
					cliui.Warnf(inv.Stderr,
						"This app's URL isn't trusted to sign you in to Coder, so your session token was not added. "+
							"If this app should sign you in, ask your template administrator to fix its URL:")
					_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
					return nil
				}
				appURL = strings.ReplaceAll(appURL, externalSessionTokenPlaceholder, client.SessionToken())
			}

			// Check if we're inside a workspace.  Generally, we know
			// that if we're inside a workspace, `open` can't be used.
			insideAWorkspace := inv.Environ.Get("CODER") == "true"
			if insideAWorkspace {
				_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI on your local machine:\n\n")
				_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
				return nil
			}
			_, _ = fmt.Fprintf(inv.Stderr, "Opening %s\n", appURL)

			if !testOpenError {
				err = open.Run(appURL)
			} else {
				err = xerrors.New("test.open-error: " + appURL)
			}
			return err
		},
	}

	cmd.Options = serpent.OptionSet{
		{
			Flag: "region",
			Env:  "CODER_OPEN_APP_REGION",
			Description: fmt.Sprintf("Region to use when opening the app." +
				" By default, the app will be opened using the main Coder deployment (a.k.a. \"primary\")."),
			Value:   serpent.StringOf(&regionArg),
			Default: "primary",
		},
		{
			Flag:        "test.open-error",
			Description: "Don't run the open command.",
			Value:       serpent.BoolOf(&testOpenError),
			Hidden:      true, // This is for testing!
		},
	}

	return cmd
}

func buildVSCodeWorkspaceLink(
	token string,
	clientURL string,
	workspace codersdk.Workspace,
	workspaceAgent codersdk.WorkspaceAgent,
	directory string,
) (*url.URL, url.Values) {
	qp := url.Values{}
	qp.Add("url", clientURL)
	qp.Add("owner", workspace.OwnerName)
	qp.Add("workspace", workspace.Name)
	qp.Add("agent", workspaceAgent.Name)

	if directory != "" {
		qp.Add("folder", directory)
	}

	if token != "" {
		qp.Add("token", token)
	}

	return &url.URL{
		Scheme:   "vscode",
		Host:     "coder.coder-remote",
		Path:     "/open",
		RawQuery: qp.Encode(),
	}, qp
}

func buildVSCodeWorkspaceDevContainerLink(
	token string,
	clientURL string,
	workspace codersdk.Workspace,
	workspaceAgent codersdk.WorkspaceAgent,
	containerName string,
	containerFolder string,
	localWorkspaceFolder string,
	localConfigFile string,
) (*url.URL, url.Values) {
	containerFolder = filepath.ToSlash(containerFolder)
	localWorkspaceFolder = filepath.ToSlash(localWorkspaceFolder)
	if localConfigFile != "" {
		localConfigFile = filepath.ToSlash(localConfigFile)
	}

	qp := url.Values{}
	qp.Add("url", clientURL)
	qp.Add("owner", workspace.OwnerName)
	qp.Add("workspace", workspace.Name)
	qp.Add("agent", workspaceAgent.Name)
	qp.Add("devContainerName", containerName)
	qp.Add("devContainerFolder", containerFolder)
	qp.Add("localWorkspaceFolder", localWorkspaceFolder)
	qp.Add("localConfigFile", localConfigFile)

	if token != "" {
		qp.Add("token", token)
	}

	return &url.URL{
		Scheme:   "vscode",
		Host:     "coder.coder-remote",
		Path:     "/openDevContainer",
		RawQuery: qp.Encode(),
	}, qp
}

// waitForAgentCond uses the watch workspace API to update the agent information
// until the condition is met.
func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace, workspaceAgent codersdk.WorkspaceAgent, cond func(codersdk.WorkspaceAgent) bool) (codersdk.Workspace, codersdk.WorkspaceAgent, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	if cond(workspaceAgent) {
		return workspace, workspaceAgent, nil
	}

	wc, err := client.WatchWorkspace(ctx, workspace.ID)
	if err != nil {
		return workspace, workspaceAgent, xerrors.Errorf("watch workspace: %w", err)
	}

	for workspace = range wc {
		workspaceAgent, _, err = getWorkspaceAgent(workspace, workspaceAgent.Name)
		if err != nil {
			return workspace, workspaceAgent, xerrors.Errorf("get workspace agent: %w", err)
		}
		if cond(workspaceAgent) {
			return workspace, workspaceAgent, nil
		}
	}

	return workspace, workspaceAgent, xerrors.New("watch workspace: unexpected closed channel")
}

// isWindowsAbsPath does a simplistic check for if the path is an absolute path
// on Windows. Drive letter or preceding `\` is interpreted as absolute.
func isWindowsAbsPath(p string) bool {
	// Remove the drive letter, if present.
	if len(p) >= 2 && p[1] == ':' {
		p = p[2:]
	}

	switch {
	case len(p) == 0:
		return false
	case p[0] == '\\':
		return true
	default:
		return false
	}
}

// windowsJoinPath joins the elements into a path, using Windows path separator
// and converting forward slashes to backslashes.
func windowsJoinPath(elem ...string) string {
	if runtime.GOOS == "windows" {
		return filepath.Join(elem...)
	}

	var s string
	for _, e := range elem {
		e = unixToWindowsPath(e)
		if e == "" {
			continue
		}
		if s == "" {
			s = e
			continue
		}
		s += "\\" + strings.TrimSuffix(e, "\\")
	}
	return s
}

func unixToWindowsPath(p string) string {
	return strings.ReplaceAll(p, "/", "\\")
}

// resolveAgentAbsPath resolves the absolute path to a file or directory in the
// workspace. If the path is relative, it will be resolved relative to the
// workspace's expanded directory. If the path is absolute, it will be returned
// as-is. If the path is relative and the workspace directory is not expanded,
// an error will be returned.
//
// If the path is being resolved within the workspace, the path will be resolved
// relative to the current working directory.
func resolveAgentAbsPath(workingDirectory, relOrAbsPath, agentOS string, local bool) (string, error) {
	switch {
	case relOrAbsPath == "":
		return workingDirectory, nil

	case relOrAbsPath == "~" || strings.HasPrefix(relOrAbsPath, "~/"):
		return "", xerrors.Errorf("path %q requires expansion and is not supported, use an absolute path instead", relOrAbsPath)

	case local:
		p, err := filepath.Abs(relOrAbsPath)
		if err != nil {
			return "", xerrors.Errorf("expand path: %w", err)
		}
		return p, nil

	case agentOS == "windows":
		relOrAbsPath = unixToWindowsPath(relOrAbsPath)
		switch {
		case workingDirectory != "" && !isWindowsAbsPath(relOrAbsPath):
			return windowsJoinPath(workingDirectory, relOrAbsPath), nil
		case isWindowsAbsPath(relOrAbsPath):
			return relOrAbsPath, nil
		default:
			return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
		}

	// Note that we use `path` instead of `filepath` since we want Unix behavior.
	case workingDirectory != "" && !path.IsAbs(relOrAbsPath):
		return path.Join(workingDirectory, relOrAbsPath), nil
	case path.IsAbs(relOrAbsPath):
		return relOrAbsPath, nil
	default:
		return "", xerrors.Errorf("path %q not supported, use an absolute path instead", relOrAbsPath)
	}
}

func doAsync(f func()) (wait func()) {
	done := make(chan struct{})
	go func() {
		defer close(done)
		f()
	}()
	return func() {
		<-done
	}
}

// buildAppLinkURL returns the URL to open the app in the browser.
// It follows similar logic to the TypeScript implementation in site/src/utils/app.ts
// except that all URLs returned are absolute and based on the provided base URL.
func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent codersdk.WorkspaceAgent, app codersdk.WorkspaceApp, appsHost, preferredPathBase string) string {
	// If app is external, return the URL directly
	if app.External {
		return app.URL
	}

	var u url.URL
	u.Scheme = baseURL.Scheme
	u.Host = baseURL.Host
	// We redirect if we don't include a trailing slash, so we always include one to avoid extra roundtrips.
	u.Path = fmt.Sprintf(
		"%s/@%s/%s.%s/apps/%s/",
		preferredPathBase,
		workspace.OwnerName,
		workspace.Name,
		agent.Name,
		url.PathEscape(app.Slug),
	)
	if app.Command != "" {
		u.Path = fmt.Sprintf(
			"%s/@%s/%s.%s/terminal",
			preferredPathBase,
			workspace.OwnerName,
			workspace.Name,
			agent.Name,
		)
		q := u.Query()
		q.Set("app", app.Slug)
		u.RawQuery = q.Encode()
	}

	if appsHost != "" && app.Subdomain && app.SubdomainName != "" {
		u.Host = strings.Replace(appsHost, "*", app.SubdomainName, 1)
		u.Path = "/"
	}
	return u.String()
}

type sessionTokenHandler struct {
	// base is the scheme and authority, matched case-insensitively.
	base string
	// path runs through the first required param, matched exactly.
	path string
}

// sessionTokenHandlers are the only deep-link handlers that may receive the
// session token. Keep in sync with SESSION_TOKEN_HANDLERS in
// site/src/modules/apps/apps.ts.
var sessionTokenHandlers = func() []sessionTokenHandler {
	var handlers []sessionTokenHandler
	for _, scheme := range []string{
		"vscode",
		"vscode-insiders",
		"windsurf",
		"devin",
		"cursor",
		"kiro",
		"positron",
		"antigravity",
		"antigravity-ide",
	} {
		for _, p := range []string{"/open?", "/openDevContainer?"} {
			handlers = append(handlers, sessionTokenHandler{base: scheme + "://coder.coder-remote", path: p})
		}
	}
	return append(handlers,
		sessionTokenHandler{base: "jetbrains://gateway", path: "/coder?"},
		sessionTokenHandler{base: "jetbrains://gateway", path: "/com.coder.toolbox?"},
		// Gateway also handles non-Coder providers, such as `type=ssh`.
		sessionTokenHandler{base: "jetbrains-gateway://connect", path: "#type=coder&"},
	)
}()

// sessionTokenOrigins returns the origins that external app links may send
// the session token to: the URL the CLI is logged in to and, when the server
// reports one, the deployment's dashboard URL.
func sessionTokenOrigins(ctx context.Context, client *codersdk.Client) []string {
	origins := []string{urlOrigin(client.URL)}
	// Without build info, only links to the URL the CLI uses are trusted.
	buildInfo, err := client.BuildInfo(ctx)
	if err != nil {
		return origins
	}
	if u, err := url.Parse(buildInfo.DashboardURL); err == nil && u.Host != "" {
		origins = append(origins, urlOrigin(u))
	}
	return origins
}

// sessionTokenDestinationTrusted reports whether appURL opens a known Coder
// handler, its only `url` param is one of origins, and the placeholder is its
// only `token`. It mirrors isSessionTokenDestinationTrusted in
// site/src/modules/apps/apps.ts, which documents each check. Both test suites
// run the cases in site/src/modules/apps/sessionTokenDestinationCases.json, so
// add new cases there.
func sessionTokenDestinationTrusted(appURL string, origins []string) bool {
	// IDEs and browsers strip some control characters, so `ur\tl` could
	// become a second `url` param after this check.
	if !utf8.ValidString(appURL) ||
		strings.IndexFunc(appURL, unicode.IsControl) != -1 ||
		strings.Count(appURL, externalSessionTokenPlaceholder) != 1 {
		return false
	}
	handlerIdx := slices.IndexFunc(sessionTokenHandlers, func(h sessionTokenHandler) bool {
		return len(appURL) >= len(h.base) &&
			asciiLower(appURL[:len(h.base)]) == h.base &&
			strings.HasPrefix(appURL[len(h.base):], h.path)
	})
	if handlerIdx == -1 {
		return false
	}
	handler := sessionTokenHandlers[handlerIdx]
	params := appURL[len(handler.base)+strings.IndexAny(handler.path, "?#")+1:]
	// A later `#` would hide params from IDEs that read only the query.
	if strings.Contains(params, "#") {
		return false
	}
	// The placeholder must be the raw `token` entry, and names must be
	// literal because JetBrains Gateway doesn't decode them.
	entries := strings.Split(params, "&")
	if !slices.Contains(entries, "token="+externalSessionTokenPlaceholder) {
		return false
	}
	for _, entry := range entries {
		name, _, _ := strings.Cut(entry, "=")
		if strings.Contains(name, "%") {
			return false
		}
	}
	// IDEs decode values twice, so check that view too and reject leftovers.
	decoded, err := url.PathUnescape(params)
	if err != nil || !utf8.ValidString(decoded) || strings.Contains(decoded, "%") {
		return false
	}
	return hasTrustedSessionTokenParams(params, origins) &&
		hasTrustedSessionTokenParams(decoded, origins)
}

// hasTrustedSessionTokenParams reports whether every name in rawQuery appears
// once, `token` is the placeholder, and `url` is one of origins.
func hasTrustedSessionTokenParams(rawQuery string, origins []string) bool {
	values, ok := parseSearchParams(rawQuery)
	if !ok {
		return false
	}
	for _, v := range values {
		if len(v) != 1 {
			return false
		}
	}
	return values.Get("token") == externalSessionTokenPlaceholder &&
		isTrustedBareOrigin(values.Get("url"), origins)
}

// parseSearchParams parses rawQuery like URLSearchParams, which IDEs use. It
// drops one leading `?`, so `??url=a&url=b` can't hide the duplicate `url`
// behind a `?url` name, and splits on `&` only, so a `;` in a value is kept
// rather than rejected as url.ParseQuery does. Unlike URLSearchParams, it
// rejects invalid percent escapes.
func parseSearchParams(rawQuery string) (url.Values, bool) {
	values := url.Values{}
	for _, entry := range strings.Split(strings.TrimPrefix(rawQuery, "?"), "&") {
		if entry == "" {
			continue
		}
		rawName, rawValue, _ := strings.Cut(entry, "=")
		name, err := url.QueryUnescape(rawName)
		if err != nil {
			return nil, false
		}
		value, err := url.QueryUnescape(rawValue)
		if err != nil {
			return nil, false
		}
		values.Add(name, value)
	}
	return values, true
}

// bareOriginRegex matches a URL that is only an origin, optionally with a
// trailing slash. A path could reach a workspace app on this deployment, and
// userinfo can hide a different host from IDEs that decode it again.
var bareOriginRegex = regexp.MustCompile(`(?i)^[a-z][a-z\d+.-]*://[^/\\?#@\s]+/?$`)

func isTrustedBareOrigin(rawURL string, origins []string) bool {
	if !bareOriginRegex.MatchString(rawURL) {
		return false
	}
	u, err := url.Parse(rawURL)
	if err != nil {
		return false
	}
	// Browsers map non-ASCII hosts through IDNA, which this comparison doesn't
	// model, so only ASCII hosts are trusted.
	if strings.IndexFunc(u.Hostname(), func(r rune) bool { return r > unicode.MaxASCII }) != -1 {
		return false
	}
	origin := urlOrigin(u)
	return origin != "" && slices.Contains(origins, origin)
}

// urlOrigin returns the scheme, host, and non-default port of u, with ASCII
// letters lowercased and the port in canonical form, like a browser origin.
// Unicode case folding would turn hosts such as `codİng.com` into a
// different, trusted host. It returns "" when the port is out of range.
func urlOrigin(u *url.URL) string {
	scheme := asciiLower(u.Scheme)
	host := asciiLower(u.Hostname())
	port := u.Port()
	if port != "" {
		n, err := strconv.ParseUint(port, 10, 16)
		if err != nil {
			return ""
		}
		port = strconv.FormatUint(n, 10)
	}
	if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
		port = ""
	}
	if strings.Contains(host, ":") {
		host = "[" + host + "]"
	}
	if port != "" {
		host += ":" + port
	}
	return scheme + "://" + host
}

// asciiLower lowercases ASCII letters only, so non-ASCII characters that
// Unicode folds to ASCII, such as the Kelvin sign, never match a handler.
func asciiLower(s string) string {
	b := []byte(s)
	for i, c := range b {
		if 'A' <= c && c <= 'Z' {
			b[i] = c + ('a' - 'A')
		}
	}
	return string(b)
}