diff --git a/CHANGELOG.md b/CHANGELOG.md index 4faded9b4..920e198fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ Add new entries to the top of the "Unreleased" section. ## Unreleased +### Added + +- Workspaces can keep using git while nobody is connected, e.g. for AI agents. With the new `GIT_SSH_TO_HTTPS` + context option (on by default when `SSH_AGENT_FORWARDING` is `false`), git in the workspace uses https instead + of ssh for the workspace's hosts, with credentials that the dev container provides. `GIT_SSH_TO_HTTPS_HOSTS` + adds hosts and explicit rewrites. See + [Run Workspaces Unattended](https://devpod.curityio.net/developing-in-workspaces/unattended-workspaces). + +### Fixed + +- Ending a session no longer removes credential helpers that the user or the dev container added to the + `[credential]` section of the global git config; only DevPod's own helper is removed. While a session is + active, git asks those helpers before DevPod's. +- `SSH_AGENT_FORWARDING=false` now also stops forwarding the ssh-agent into the dev container's lifecycle + commands and into IDE connections: the workspace's entry in `~/.ssh/config` gets `ForwardAgent no`. + Before, it only applied to `devpod ssh` sessions. +- git in the dev container no longer refuses to use the workspace folder with "detected dubious ownership" when + the folder isn't owned by the container user, e.g. with Docker Desktop on macOS and Windows. DevPod adds the + workspace folder to `safe.directory` in the system git config. +- The workspace config that the agent saves on the machine no longer contains the CLI options of the last + `devpod up`, such as `--workspace-env` values, as was intended. + ### Changed - The Windows e2e tests, which needed Loft's self-hosted Windows runner, are replaced by diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go index a8db5eb6c..e1e6c5ff0 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -160,6 +160,20 @@ func (cmd *SetupContainerCmd) Run(ctx context.Context) error { } } + // make git use https instead of ssh before any lifecycle hook runs + err = configureGitSSHToHTTPS(ctx, setupInfo, workspaceInfo, logger) + if err != nil { + logger.Errorf("Error configuring git to use https instead of ssh: %v", err) + } + + // the workspace folder isn't chowned for docker on macOS and Windows, so git would refuse to use the repository + if command.Exists("git") { + err = git.AddSafeDirectory(ctx, setupInfo.SubstitutionContext.ContainerWorkspaceFolder) + if err != nil { + logger.Errorf("Error adding the workspace folder to git's safe.directory: %v", err) + } + } + // setup container err = setup.SetupContainer(ctx, setupInfo, workspaceInfo.CLIOptions.WorkspaceEnv, cmd.ChownWorkspace, logger) if err != nil { @@ -522,6 +536,40 @@ func (cmd *SetupContainerCmd) setupOpenVSCode(setupInfo *config.Result, ideOptio return openVSCode.Start() } +// configureGitSSHToHTTPS makes git use https instead of ssh for the hosts of the workspace's remotes when +// GIT_SSH_TO_HTTPS is enabled, and removes the rewrites again when it is disabled. When the CLI didn't say, e.g. +// because an older CLI started the container, it leaves the rewrites as they are. +func configureGitSSHToHTTPS(ctx context.Context, setupInfo *config.Result, workspaceInfo *provider2.ContainerWorkspaceInfo, log log.Logger) error { + enabled := workspaceInfo.CLIOptions.GitSSHToHTTPS + if enabled == "" { + return nil + } + + if !command.Exists("git") { + if enabled == "true" { + log.Warn("GIT_SSH_TO_HTTPS is enabled but git is not installed in the container, skipping") + } + return nil + } + + if enabled != "true" { + return git.RemoveSSHToHTTPS(ctx) + } + + remoteURLs := []string{} + if workspaceInfo.Source.GitRepository != "" { + remoteURLs = append(remoteURLs, workspaceInfo.Source.GitRepository) + } + remoteURLs = append(remoteURLs, workspaceInfo.CLIOptions.GitSSHToHTTPSRemotes...) + remoteURLs = append(remoteURLs, git.RemoteURLs(ctx, setupInfo.SubstitutionContext.ContainerWorkspaceFolder)...) + rewrites := git.SSHToHTTPSRewrites(remoteURLs, workspaceInfo.CLIOptions.GitSSHToHTTPSHosts) + if len(rewrites) == 0 { + log.Debug("GIT_SSH_TO_HTTPS is enabled but no ssh hosts were found") + } + log.Debugf("Configuring git to use https instead of ssh: %v", rewrites) + return git.ConfigureSSHToHTTPS(ctx, rewrites) +} + func configureSystemGitCredentials(ctx context.Context, cancel context.CancelFunc, client tunnel.TunnelClient, log log.Logger) (func(), error) { if !command.Exists("git") { return nil, errors.New("git not found") diff --git a/cmd/up.go b/cmd/up.go index 05ef9695a..17a3c19d0 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -91,6 +91,11 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { } telemetry.CollectorCLI.SetClient(client) + err = provider2.ApplyContextOptions(ctx, &cmd.CLIOptions, devPodConfig, client.WorkspaceConfig().Source) + if err != nil { + return err + } + return cmd.Run(ctx, devPodConfig, client, args, logger) }, } @@ -183,7 +188,7 @@ func (cmd *UpCmd) Run( } setupGPGAgentForwarding := cmd.GPGAgentForwarding || devPodConfig.ContextOption(config.ContextOptionGPGAgentForwarding) == "true" - err = configureSSH(client, cmd.SSHConfigPath, user, workdir, setupGPGAgentForwarding, devPodHome) + err = configureSSH(client, cmd.SSHConfigPath, user, workdir, setupGPGAgentForwarding, !cmd.DisableSSHAgentForwarding, devPodHome) if err != nil { return err } @@ -418,6 +423,9 @@ func (cmd *UpCmd) devPodUpMachine( ctx, client, devPodConfig.ContextOption(config.ContextOptionSSHAddPrivateKeys) == "true", + // the agent on the machine may need it to clone the source, it isn't forwarded into the container when + // SSH_AGENT_FORWARDING is false + true, agentInjectFunc, sshTunnelCmd, agentCommand, @@ -770,7 +778,7 @@ func startBrowserTunnel( return nil } -func configureSSH(client client2.BaseWorkspaceClient, sshConfigPath, user, workdir string, gpgagent bool, devPodHome string) error { +func configureSSH(client client2.BaseWorkspaceClient, sshConfigPath, user, workdir string, gpgagent, forwardAgent bool, devPodHome string) error { path, err := devssh.ResolveSSHConfigPath(sshConfigPath) if err != nil { return errors.Wrap(err, "Invalid ssh config path") @@ -784,6 +792,7 @@ func configureSSH(client client2.BaseWorkspaceClient, sshConfigPath, user, workd user, workdir, gpgagent, + forwardAgent, devPodHome, log.Default, ) diff --git a/docs/pages/developing-in-workspaces/credentials.mdx b/docs/pages/developing-in-workspaces/credentials.mdx index fe3c277a4..0cfea03c9 100644 --- a/docs/pages/developing-in-workspaces/credentials.mdx +++ b/docs/pages/developing-in-workspaces/credentials.mdx @@ -16,6 +16,10 @@ If you don't want DevPod to inject the credentials, you can disable that via the devpod context set-options default -o SSH_INJECT_GIT_CREDENTIALS=false ``` +Both only work while you are connected to the workspace, because the credentials stay on your computer. For +workspaces that must keep working without you, such as ones running AI agents, see +[Run Workspaces Unattended](./unattended-workspaces.mdx). + ## Docker credentials DevPod will make docker registry credentials available inside the dev container through a [docker credentials helper](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers). This allows you to pull and push images from and to private registries from within the dev container. diff --git a/docs/pages/developing-in-workspaces/unattended-workspaces.mdx b/docs/pages/developing-in-workspaces/unattended-workspaces.mdx new file mode 100644 index 000000000..d13b6c28e --- /dev/null +++ b/docs/pages/developing-in-workspaces/unattended-workspaces.mdx @@ -0,0 +1,230 @@ +--- +title: Run Workspaces Unattended +sidebar_label: Run Workspaces Unattended +--- + +A workspace can keep working while nobody is connected to it, for example with an AI agent that runs for hours +after you close your laptop. For that to work, everything the workspace needs must be inside the workspace. By +default, DevPod gets git and docker credentials from your computer while you are connected, so they disappear +when your computer goes to sleep or loses the connection. + +This page explains what depends on your computer, and how to set up a workspace that doesn't. + +:::info +This only makes sense with a provider whose workspaces run somewhere other than your computer, such as +Kubernetes, SSH or a cloud machine. With the local `docker` provider, the workspace sleeps with your computer. +::: + +## What depends on your computer + +| Feature | How it works | What happens when your computer is gone | +| --- | --- | --- | +| ssh remotes (`git@github.com:...`) | Your local ssh-agent is forwarded into the workspace over the `devpod ssh` connection. | `git fetch` and `git push` fail with `Permission denied (publickey)`. | +| https remotes | A git credential helper in the workspace asks DevPod on your computer, which runs `git credential fill` there. | git can't get a username or password. | +| Commit signing with ssh keys | Signing requests are sent to your computer. | `git commit` fails if signing is enabled. | +| gpg-agent forwarding | Your local gpg-agent is forwarded into the workspace. | gpg signing and decryption fail. | +| Docker credentials | A docker credential helper in the workspace asks DevPod on your computer. | Pulling from and pushing to private registries fails. | + +ssh keys can't be made to work without your computer: a forwarded ssh-agent is only a connection back to the +agent on your computer, and the private keys never leave it. The alternative to ssh keys is https with a token. +Tokens can be limited to the repositories the workspace needs, can expire and can be revoked, which is what you +want for credentials that stay in a workspace nobody is watching. + +## How DevPod helps + +When ssh-agent forwarding is turned off, DevPod configures git in the workspace to use https instead of ssh for +the hosts the workspace's repositories use. It doesn't provide the token itself: the dev container provides it, +as shown [below](#3-provide-git-credentials-in-the-dev-container). DevPod doesn't need to know which git hosting +service you use, and the credentials don't depend on your computer. + +### What DevPod changes + +Whenever DevPod starts the dev container, with `devpod up` or when `devpod ssh` or an IDE starts a stopped +workspace, and before any of the dev container's lifecycle commands (such as `postCreateCommand`) run, DevPod: + +1. Collects the remote urls it can find: the workspace's git source, and the `remote.*.url` and + `submodule.*.url` entries of the repository in the workspace folder. For a local folder, DevPod reads them on + your computer, so this also works when the folder is a git worktree or a submodule checkout. +2. For each host that uses ssh, adds [`url..insteadOf`](https://git-scm.com/docs/git-config#Documentation/git-config.txt-urlltbasegtinsteadOf) + rules that rewrite these prefixes to `https:///`: + - the prefix of the url itself, e.g. `ssh://git@git.example.com:7999/` + - `git@:` and `git@:/` + - `ssh://git@/` +3. Writes the rules to `/var/devpod/ssh-to-https.gitconfig` and includes that file from the system git config + (`/etc/gitconfig`), so they apply to every user in the container. + +The repository's own `.git/config` is not changed. If the workspace folder is your local checkout, its remotes +still point to ssh on your computer. git in the workspace rewrites the urls whenever it uses them, so the rules +also cover submodules, other remotes and repositories cloned later. You can see the result with: + +```bash +git remote get-url --push origin +``` + +If the option is turned off again, the next `devpod up` removes the file and the include. A container started +by an older DevPod version keeps the rules as they are. + +### Options + +| Context option | Default | Meaning | +| --- | --- | --- | +| `GIT_SSH_TO_HTTPS` | `auto` | `true` always rewrites, `false` never does, and `auto` rewrites when `SSH_AGENT_FORWARDING` is `false`. | +| `GIT_SSH_TO_HTTPS_HOSTS` | | Comma-separated list of extra hosts to rewrite, e.g. `github.com,bitbucket.org`, for repositories the workspace clones later. An entry `from=to` adds an explicit rewrite instead, where `to` starts with `https://`. `devpod up` fails on entries that are neither. | + +The default rewrite assumes that a repository has the same path over ssh and https, which is true for GitHub, +Bitbucket Cloud and GitLab. Servers that use different paths, such as Bitbucket Data Center +(`ssh://git@host:7999/proj/repo.git` but `https://host/scm/proj/repo.git`), need an explicit rewrite: + +```bash +devpod context set-options -o GIT_SSH_TO_HTTPS_HOSTS='ssh://git@git.example.com:7999/=https://git.example.com/scm/' +``` + +An explicit rewrite replaces the default rule for the same prefix. + +## Set up an unattended workspace + +### 1. Turn off everything that depends on your computer + +```bash +devpod context set-options \ + -o SSH_AGENT_FORWARDING=false \ + -o SSH_INJECT_GIT_CREDENTIALS=false \ + -o GIT_SSH_SIGNATURE_FORWARDING=false +``` + +- `SSH_AGENT_FORWARDING=false` stops forwarding your ssh-agent into the dev container: into `devpod ssh` + sessions, into the lifecycle commands, and into IDE and `ssh .devpod` connections, whose entry in + `~/.ssh/config` gets `ForwardAgent no` on the next `devpod up`. Because `GIT_SSH_TO_HTTPS` is `auto` by + default, it also makes git use https. DevPod itself still uses your ssh-agent during `devpod up` to clone the + repository, on your computer or on the provider's machine, but not in the container. +- `SSH_INJECT_GIT_CREDENTIALS=false` stops DevPod from installing its credential helper. When it is on, git tries + DevPod's helper as well, which fails once your computer is gone. +- `GIT_SSH_SIGNATURE_FORWARDING=false` stops DevPod from configuring commit signing through your computer. If you + need signed commits, configure a signing key inside the dev container. + +If agents in the workspace use private docker registries, also set `SSH_INJECT_DOCKER_CREDENTIALS=false` and log in +to the registries from the dev container. Leave `GPG_AGENT_FORWARDING` off (the default). + +These options apply to all workspaces of the context. To keep your other workspaces as they are, create a separate +context for unattended workspaces with `devpod context create unattended`, set the options there with +`devpod context set-options unattended -o ...`, and pass `--context unattended` to `devpod` commands for those +workspaces. + +The options take effect on the next `devpod up` of the workspace. + +:::note +The repository is first cloned during `devpod up`, while you are connected, so it is cloned with your own +credentials as before. The built-in `docker` and `kubernetes` providers clone it on your computer. With a provider +that clones on a remote machine, `SSH_INJECT_GIT_CREDENTIALS=false` also stops DevPod from passing your https +credentials to that clone. Use an ssh url for such workspaces, which still works during `devpod up`. +::: + +### 2. Create tokens + +Create tokens with only the access the workspace needs, and with an expiry date. + +- **GitHub:** a [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) + for the repositories the workspace uses, with *Contents: Read and write* (plus *Pull requests: Read and write* if + agents open pull requests with `gh`). git accepts any username with it. +- **Bitbucket Cloud:** a repository, project or workspace [access token](https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/), + used with the username `x-token-auth`, or an [API token](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/) + with repository read and write scopes, used with the username `x-bitbucket-api-token-auth`. +- **Other hosts:** use the host's access tokens, and check which username it expects with them. + +:::note +An MCP server for your git host (for example for pull requests) doesn't give git access to the repositories. git +needs its own token. +::: + +### 3. Provide git credentials in the dev container + +Pass the tokens to the workspace as environment variables when you start it: + +```bash +devpod up my-workspace --workspace-env-file ~/.config/devpod/unattended.env +``` + +where `~/.config/devpod/unattended.env` contains, for example: + +``` +GH_TOKEN=github_pat_... +BITBUCKET_API_TOKEN=... +``` + +DevPod adds these variables to the environment of every process in the workspace. They are kept until you change +them, so you don't need to pass the file on every `devpod up`. You can also use `--workspace-env NAME=VALUE`, or +`"remoteEnv": { "GH_TOKEN": "${localEnv:GH_TOKEN}" }` in `devcontainer.json`, but `${localEnv:...}` only +reads your computer's environment with providers that run DevPod on your computer, such as `docker` and +`kubernetes`. + +:::warning +The tokens are stored in the workspace and are visible to everything that runs in it, including the agents. +Give them only the access the agents should have. +::: + +Then make git use them with a [credential helper](https://git-scm.com/docs/gitcredentials). Add a script like this +to the repository, e.g. as `.devcontainer/git-credentials.sh`: + +```sh +#!/bin/sh +# git credential helper that answers with tokens from environment variables +[ "$1" = get ] || exit 0 + +host= +while IFS='=' read -r key value; do + [ "$key" = host ] && host=$value +done + +case "$host" in + github.com) + echo username=x-access-token + echo "password=$GH_TOKEN" + ;; + bitbucket.org) + echo username=x-bitbucket-api-token-auth + echo "password=$BITBUCKET_API_TOKEN" + ;; +esac +``` + +and configure it in `devcontainer.json`: + +```json +{ + "postCreateCommand": "git config --global credential.helper '!sh ${containerWorkspaceFolder}/.devcontainer/git-credentials.sh'" +} +``` + +For GitHub, you can use the GitHub CLI instead, which also lets agents use `gh` for pull requests. The +[`github-cli` feature](https://github.com/devcontainers/features/tree/main/src/github-cli) installs it, it reads +`GH_TOKEN` from the environment, and `gh auth setup-git` makes it git's credential helper for GitHub: + +```json +{ + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "postCreateCommand": "gh auth setup-git" +} +``` + +### 4. Keep the workspace running + +If the provider has an inactivity timeout, it stops the workspace when nobody has been connected for that long, +including while an agent is working. Leave the provider's `INACTIVITY_TIMEOUT` option empty (the default for the +built-in providers) or set it longer than the agents run. See [Auto-Inactivity Timeout](./inactivity-timeout.mdx). + +Start long-running agents so that they don't depend on your connection, for example in `tmux` or with `nohup`, +not as a plain `devpod ssh --command`, which ends when the connection does. + +### 5. Check it + +After `devpod up`, check that git uses https and finds the token without your computer's help: + +```bash +devpod ssh my-workspace --command 'git remote get-url --push origin && GIT_TERMINAL_PROMPT=0 git ls-remote origin HEAD' +``` + +The first command prints an `https://` url. The second one prints a commit hash. If it fails with +`could not read Username`, no credential helper answered for that host. If it fails with an authentication error, +the token is wrong or doesn't have access to the repository. diff --git a/docs/sidebars.js b/docs/sidebars.js index 651f8915f..5de5b0d25 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -91,6 +91,10 @@ module.exports = { type: "doc", id: "developing-in-workspaces/credentials", }, + { + type: "doc", + id: "developing-in-workspaces/unattended-workspaces", + }, { type: "doc", id: "developing-in-workspaces/inactivity-timeout", diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 1a967166b..369a05f02 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -355,3 +355,15 @@ func (f *Framework) DevPodContextDelete(ctx context.Context, name string, extraA } return nil } + +func (f *Framework) DevPodContextSetOptions(ctx context.Context, name string, options ...string) error { + baseArgs := []string{"context", "set-options", name} + for _, option := range options { + baseArgs = append(baseArgs, "-o", option) + } + err := f.ExecCommand(ctx, false, true, "", baseArgs) + if err != nil { + return fmt.Errorf("devpod context set-options failed: %s", err.Error()) + } + return nil +} diff --git a/e2e/tests/up/git_ssh_to_https.go b/e2e/tests/up/git_ssh_to_https.go new file mode 100644 index 000000000..8851e269e --- /dev/null +++ b/e2e/tests/up/git_ssh_to_https.go @@ -0,0 +1,170 @@ +package up + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/loft-sh/devpod/e2e/framework" + "github.com/loft-sh/devpod/pkg/devcontainer/config" + docker "github.com/loft-sh/devpod/pkg/docker" + "github.com/loft-sh/log" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const sshToHTTPSConfigPath = "/var/devpod/ssh-to-https.gitconfig" + +var _ = DevPodDescribe("devpod up git ssh to https test suite", func() { + ginkgo.Context("testing GIT_SSH_TO_HTTPS", ginkgo.Label("up-docker", "git-ssh-to-https"), ginkgo.Ordered, func() { + var initialDir string + + ginkgo.BeforeEach(func() { + var err error + initialDir, err = os.Getwd() + framework.ExpectNoError(err) + }) + + // useOwnContext switches to a new context with the docker provider, so the options don't leak into other tests + useOwnContext := func(ctx context.Context, f *framework.Framework, options ...string) string { + contextName := "git-ssh-to-https" + _ = f.DevPodContextDelete(ctx, contextName) + err := f.DevPodContextCreate(ctx, contextName) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(f.DevPodContextDelete, context.Background(), contextName) + err = f.DevPodContextUse(ctx, contextName) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(f.DevPodContextUse, context.Background(), "default") + + _ = f.DevPodProviderDelete(ctx, "docker") + err = f.DevPodProviderAdd(ctx, "docker") + framework.ExpectNoError(err) + err = f.DevPodProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + err = f.DevPodContextSetOptions(ctx, contextName, options...) + framework.ExpectNoError(err) + return contextName + } + + hostGit := func(dir string, args ...string) string { + out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput() + framework.ExpectNoError(err, string(out)) + return strings.TrimSpace(string(out)) + } + + ginkgo.It("should make git use https for ssh remotes when ssh-agent forwarding is off", func(ctx context.Context) { + tempDir, err := framework.CopyToTempDir("tests/up/testdata/docker-git-ssh-to-https") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + hostGit(tempDir, "init", "-q") + hostGit(tempDir, "remote", "add", "origin", "git@github.com:curityio/devpod.git") + hostGit(tempDir, "config", "--file", ".gitmodules", "submodule.lib.path", "lib") + hostGit(tempDir, "config", "--file", ".gitmodules", "submodule.lib.url", "git@bitbucket.org:team/lib.git") + + f := framework.NewDefaultFramework(initialDir + "/bin") + // GIT_SSH_TO_HTTPS stays at its default, auto + contextName := useOwnContext(ctx, f, "SSH_AGENT_FORWARDING=false") + + ginkgo.DeferCleanup(f.DevPodWorkspaceDelete, context.Background(), tempDir) + + sshConfig := filepath.Join(tempDir, "ssh-config") + err = f.DevPodUp(ctx, tempDir, "--ssh-config", sshConfig) + framework.ExpectNoError(err) + + out, err := f.DevPodSSH(ctx, tempDir, "git config --system --get-all include.path") + framework.ExpectNoError(err) + gomega.Expect(out).To(gomega.ContainSubstring(sshToHTTPSConfigPath)) + + // the repository was created on the host and isn't owned by the container user, git trusts it anyway + out, err = f.DevPodSSH(ctx, tempDir, "git remote get-url --push origin") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("https://github.com/curityio/devpod.git")) + + out, err = f.DevPodSSH(ctx, tempDir, "git ls-remote --get-url git@bitbucket.org:team/lib.git") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("https://bitbucket.org/team/lib.git")) + + // the repository on the host keeps its ssh remote + gomega.Expect(hostGit(tempDir, "config", "--file", filepath.Join(".git", "config"), "remote.origin.url")). + To(gomega.Equal("git@github.com:curityio/devpod.git")) + + // the ssh-agent is forwarded neither into the lifecycle commands nor into IDE connections + out, err = f.DevPodSSH(ctx, tempDir, "cat /tmp/post-create-ssh-auth-sock") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("none")) + sshConfigContent, err := os.ReadFile(sshConfig) + framework.ExpectNoError(err) + gomega.Expect(string(sshConfigContent)).To(gomega.ContainSubstring("ForwardAgent no")) + gomega.Expect(string(sshConfigContent)).NotTo(gomega.ContainSubstring("ForwardAgent yes")) + + // when devpod ssh has to start the container again, it keeps the rewrites + workspace, err := f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + dockerHelper := &docker.DockerHelper{DockerCommand: "docker", Log: log.Default} + ids, err := dockerHelper.FindContainer(ctx, []string{fmt.Sprintf("%s=%s", config.DockerIDLabel, workspace.UID)}) + framework.ExpectNoError(err) + gomega.Expect(ids).To(gomega.HaveLen(1)) + err = dockerHelper.Stop(ctx, ids[0]) + framework.ExpectNoError(err) + + out, err = f.DevPodSSH(ctx, tempDir, "git remote get-url --push origin") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("https://github.com/curityio/devpod.git")) + + err = f.DevPodContextSetOptions(ctx, contextName, "GIT_SSH_TO_HTTPS=false") + framework.ExpectNoError(err) + + err = f.DevPodUp(ctx, tempDir, "--ssh-config", sshConfig) + framework.ExpectNoError(err) + + out, err = f.DevPodSSH(ctx, tempDir, "git config --system --get-all include.path || true") + framework.ExpectNoError(err) + gomega.Expect(out).NotTo(gomega.ContainSubstring(sshToHTTPSConfigPath)) + + out, err = f.DevPodSSH(ctx, tempDir, "test -e "+sshToHTTPSConfigPath+" && echo present || echo absent") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("absent")) + + out, err = f.DevPodSSH(ctx, tempDir, "git remote get-url --push origin") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("git@github.com:curityio/devpod.git")) + }, ginkgo.SpecTimeout(framework.GetTimeout())) + + ginkgo.It("should find the remotes of a git worktree", func(ctx context.Context) { + repoDir, err := framework.CopyToTempDir("tests/up/testdata/docker-git-ssh-to-https") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, repoDir) + + hostGit(repoDir, "init", "-q") + hostGit(repoDir, "remote", "add", "origin", "git@github.com:curityio/devpod.git") + hostGit(repoDir, "add", ".devcontainer.json") + hostGit(repoDir, "-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init") + + // the worktree's git dir is in repoDir, which isn't mounted into the container + worktreeParent, err := os.MkdirTemp("", "temp-*") + framework.ExpectNoError(err) + worktreeParent, err = filepath.EvalSymlinks(worktreeParent) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(os.RemoveAll, worktreeParent) + worktree := filepath.Join(worktreeParent, "worktree") + hostGit(repoDir, "worktree", "add", "-q", worktree) + + f := framework.NewDefaultFramework(initialDir + "/bin") + useOwnContext(ctx, f, "SSH_AGENT_FORWARDING=false") + + ginkgo.DeferCleanup(f.DevPodWorkspaceDelete, context.Background(), worktree) + + err = f.DevPodUp(ctx, worktree, "--ssh-config", filepath.Join(worktreeParent, "ssh-config")) + framework.ExpectNoError(err) + + out, err := f.DevPodSSH(ctx, worktree, "cd / && git ls-remote --get-url git@github.com:curityio/devpod.git") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("https://github.com/curityio/devpod.git")) + }, ginkgo.SpecTimeout(framework.GetTimeout())) + }) +}) diff --git a/e2e/tests/up/testdata/docker-git-ssh-to-https/.devcontainer.json b/e2e/tests/up/testdata/docker-git-ssh-to-https/.devcontainer.json new file mode 100644 index 000000000..ed0602dbd --- /dev/null +++ b/e2e/tests/up/testdata/docker-git-ssh-to-https/.devcontainer.json @@ -0,0 +1,5 @@ +{ + "name": "Git SSH to HTTPS", + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "postCreateCommand": "sh -c 'echo ${SSH_AUTH_SOCK:-none} > /tmp/post-create-ssh-auth-sock'" +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index dcac6522a..89dfee2eb 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -238,7 +238,7 @@ func writeWorkspaceInfo(file string, workspaceInfo *provider2.AgentWorkspaceInfo cloned.CLIOptions = provider2.CLIOptions{} // encode workspace info - encoded, err := json.Marshal(workspaceInfo) + encoded, err := json.Marshal(cloned) if err != nil { return err } diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go new file mode 100644 index 000000000..b9b2eb0b5 --- /dev/null +++ b/pkg/agent/agent_test.go @@ -0,0 +1,26 @@ +package agent + +import ( + "path/filepath" + "testing" + + provider2 "github.com/loft-sh/devpod/pkg/provider" + "gotest.tools/assert" +) + +func TestWriteWorkspaceInfoDoesNotSaveCLIOptions(t *testing.T) { + file := filepath.Join(t.TempDir(), provider2.WorkspaceConfigFile) + workspaceInfo := &provider2.AgentWorkspaceInfo{ + Workspace: &provider2.Workspace{ID: "test"}, + CLIOptions: provider2.CLIOptions{WorkspaceEnv: []string{"TOKEN=secret"}, GitSSHToHTTPS: "true"}, + } + + assert.NilError(t, writeWorkspaceInfo(file, workspaceInfo)) + + saved, err := ParseAgentWorkspaceInfo(file) + assert.NilError(t, err) + assert.Equal(t, saved.Workspace.ID, "test") + assert.DeepEqual(t, saved.CLIOptions, provider2.CLIOptions{}) + // the caller's copy is untouched + assert.Equal(t, workspaceInfo.CLIOptions.GitSSHToHTTPS, "true") +} diff --git a/pkg/config/context.go b/pkg/config/context.go index 097614c9d..b2bb87966 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -1,5 +1,7 @@ package config +import "strings" + const ( ContextOptionSSHAddPrivateKeys = "SSH_ADD_PRIVATE_KEYS" ContextOptionGPGAgentForwarding = "GPG_AGENT_FORWARDING" @@ -15,6 +17,8 @@ const ( ContextOptionAgentInjectTimeout = "AGENT_INJECT_TIMEOUT" ContextOptionRegistryCache = "REGISTRY_CACHE" ContextOptionSSHStrictHostKeyChecking = "SSH_STRICT_HOST_KEY_CHECKING" + ContextOptionGitSSHToHTTPS = "GIT_SSH_TO_HTTPS" + ContextOptionGitSSHToHTTPSHosts = "GIT_SSH_TO_HTTPS_HOSTS" ) var ContextOptions = []ContextOption{ @@ -92,4 +96,38 @@ var ContextOptions = []ContextOption{ Default: "false", Enum: []string{"true", "false"}, }, + { + Name: ContextOptionGitSSHToHTTPS, + Description: "Specifies if git inside the workspace should use https instead of ssh for remotes. auto does this when SSH_AGENT_FORWARDING is false", + Default: "auto", + Enum: []string{"auto", "true", "false"}, + }, + { + Name: ContextOptionGitSSHToHTTPSHosts, + Description: "Comma separated list of extra hosts (e.g. git.example.com) or explicit rewrites (e.g. ssh://git@git.example.com:7999/=https://git.example.com/scm/) used by GIT_SSH_TO_HTTPS", + Default: "", + }, +} + +// GitSSHToHTTPS reports if git inside workspaces should use https instead of ssh for remotes. +func (c *Config) GitSSHToHTTPS() bool { + switch c.ContextOption(ContextOptionGitSSHToHTTPS) { + case "true": + return true + case "false": + return false + default: + return c.ContextOption(ContextOptionSSHAgentForwarding) == "false" + } +} + +// GitSSHToHTTPSHosts returns the extra hosts and explicit rewrites configured for GitSSHToHTTPS. +func (c *Config) GitSSHToHTTPSHosts() []string { + hosts := []string{} + for _, host := range strings.Split(c.ContextOption(ContextOptionGitSSHToHTTPSHosts), ",") { + if host = strings.TrimSpace(host); host != "" { + hosts = append(hosts, host) + } + } + return hosts } diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go new file mode 100644 index 000000000..e71e1feae --- /dev/null +++ b/pkg/config/context_test.go @@ -0,0 +1,52 @@ +package config + +import ( + "testing" + + "gotest.tools/assert" +) + +func TestGitSSHToHTTPS(t *testing.T) { + testCases := []struct { + gitSSHToHTTPS string + sshAgentForwarding string + expectedSSHToHTTPS bool + }{ + {expectedSSHToHTTPS: false}, + {sshAgentForwarding: "true", expectedSSHToHTTPS: false}, + {sshAgentForwarding: "false", expectedSSHToHTTPS: true}, + {gitSSHToHTTPS: "auto", sshAgentForwarding: "false", expectedSSHToHTTPS: true}, + {gitSSHToHTTPS: "true", expectedSSHToHTTPS: true}, + {gitSSHToHTTPS: "false", sshAgentForwarding: "false", expectedSSHToHTTPS: false}, + } + + for _, testCase := range testCases { + config := configWithOptions(map[string]string{ + ContextOptionGitSSHToHTTPS: testCase.gitSSHToHTTPS, + ContextOptionSSHAgentForwarding: testCase.sshAgentForwarding, + }) + assert.Equal(t, config.GitSSHToHTTPS(), testCase.expectedSSHToHTTPS, "%+v", testCase) + } +} + +func TestGitSSHToHTTPSHosts(t *testing.T) { + assert.DeepEqual(t, configWithOptions(nil).GitSSHToHTTPSHosts(), []string{}) + + config := configWithOptions(map[string]string{ + ContextOptionGitSSHToHTTPSHosts: " github.com, ,ssh://git@git.example.com:7999/=https://git.example.com/scm/ ", + }) + assert.DeepEqual(t, config.GitSSHToHTTPSHosts(), []string{"github.com", "ssh://git@git.example.com:7999/=https://git.example.com/scm/"}) +} + +func configWithOptions(options map[string]string) *Config { + values := map[string]OptionValue{} + for name, value := range options { + values[name] = OptionValue{Value: value} + } + return &Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{ + DefaultContext: {Options: values}, + }, + } +} diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 396c7431e..82a83afcd 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -136,6 +136,7 @@ func (r *runner) setupContainer( ctx, nil, false, + !r.WorkspaceConfig.CLIOptions.DisableSSHAgentForwarding, agentInjectFunc, sshTunnelCmd, setupCommand, diff --git a/pkg/devcontainer/sshtunnel/sshtunnel.go b/pkg/devcontainer/sshtunnel/sshtunnel.go index 5cc705a0b..19d5c0850 100644 --- a/pkg/devcontainer/sshtunnel/sshtunnel.go +++ b/pkg/devcontainer/sshtunnel/sshtunnel.go @@ -26,6 +26,7 @@ func ExecuteCommand( ctx context.Context, client client2.WorkspaceClient, addPrivateKeys bool, + forwardAgent bool, agentInject AgentInjectFunc, sshCommand, command string, @@ -110,7 +111,9 @@ func ExecuteCommand( log.Debugf("SSH session created") identityAgent := devsshagent.GetSSHAuthSocket() - if identityAgent != "" { + if !forwardAgent { + log.Debugf("Not forwarding ssh-agent, enable via 'devpod context set-options -o SSH_AGENT_FORWARDING=true'") + } else if identityAgent != "" { log.Debugf("Forwarding ssh-agent using %s", identityAgent) err = devsshagent.ForwardToRemote(sshClient, identityAgent) if err != nil { diff --git a/pkg/devcontainer/sshtunnel/sshtunnel_test.go b/pkg/devcontainer/sshtunnel/sshtunnel_test.go index eed9d424e..b0472248d 100644 --- a/pkg/devcontainer/sshtunnel/sshtunnel_test.go +++ b/pkg/devcontainer/sshtunnel/sshtunnel_test.go @@ -35,7 +35,7 @@ func TestExecuteCommand_ReturnsWhenContainerDies(t *testing.T) { } done := make(chan executeResult, 1) go func() { - result, err := ExecuteCommand(context.Background(), nil, false, agentInject, "ssh-server", "setup", log.Discard, tunnelServer) + result, err := ExecuteCommand(context.Background(), nil, false, true, agentInject, "ssh-server", "setup", log.Discard, tunnelServer) done <- executeResult{result: result, err: err} }() diff --git a/pkg/git/safe_directory.go b/pkg/git/safe_directory.go new file mode 100644 index 000000000..3ff4ece0e --- /dev/null +++ b/pkg/git/safe_directory.go @@ -0,0 +1,23 @@ +package git + +import ( + "context" + "fmt" + "strings" +) + +// AddSafeDirectory adds dir to safe.directory in the system git config, so that git uses the repository in dir +// whoever owns it. +func AddSafeDirectory(ctx context.Context, dir string) error { + out, _ := CommandContext(ctx, nil, "config", "--system", "--get-all", "safe.directory").Output() + for _, line := range strings.Split(string(out), "\n") { + if value := strings.TrimSpace(line); value == dir || value == "*" { + return nil + } + } + out, err := CommandContext(ctx, nil, "config", "--system", "--add", "safe.directory", dir).CombinedOutput() + if err != nil { + return fmt.Errorf("add %s to safe.directory: %s: %w", dir, strings.TrimSpace(string(out)), err) + } + return nil +} diff --git a/pkg/git/ssh_to_https.go b/pkg/git/ssh_to_https.go new file mode 100644 index 000000000..d5352fd16 --- /dev/null +++ b/pkg/git/ssh_to_https.go @@ -0,0 +1,264 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// SSHToHTTPSConfigPath is the git config file holding the rewrites, included from the system git config. +const SSHToHTTPSConfigPath = "/var/devpod/ssh-to-https.gitconfig" + +// SSHToHTTPSRewrites returns the `url..insteadOf` rewrites, mapping each ssh prefix to its https base, that +// make git use https for the hosts of the given remote urls and for the given extra hosts. An extra host can also be +// an explicit `from=to` rewrite, which takes precedence over the derived ones. +func SSHToHTTPSRewrites(remoteURLs []string, extraHosts []string) map[string]string { + rewrites := map[string]string{} + addHost := func(host string) { + rewrites["git@"+host+":"] = "https://" + host + "/" + rewrites["git@"+host+":/"] = "https://" + host + "/" + rewrites["ssh://git@"+host+"/"] = "https://" + host + "/" + } + + for _, remoteURL := range remoteURLs { + prefix, host, ok := sshPrefix(remoteURL) + if !ok { + continue + } + rewrites[prefix] = "https://" + host + "/" + if !strings.Contains(prefix, "://") { + // `git@host:/org/repo.git` is the same repository as `git@host:org/repo.git` + rewrites[prefix+"/"] = "https://" + host + "/" + } + addHost(host) + } + + explicit := map[string]string{} + for _, extraHost := range extraHosts { + extraHost = strings.TrimSpace(extraHost) + // the CLI rejects invalid entries, skip them in case an older one sent them + if ValidateSSHToHTTPSHost(extraHost) != nil { + continue + } + if from, to, ok := strings.Cut(extraHost, "="); ok { + explicit[strings.TrimSpace(from)] = strings.TrimSpace(to) + } else { + addHost(extraHost) + } + } + for from, to := range explicit { + rewrites[from] = to + } + + return rewrites +} + +// scpIPv6Pattern matches the start of an scp-like url with an IPv6 address, e.g. `git@[::1]:`. +var scpIPv6Pattern = regexp.MustCompile(`^(?:[^@/\[\]]*@)?(\[[0-9A-Fa-f:.]+\]):`) + +// hostPattern matches a host name or a bracketed IPv6 address. +var hostPattern = regexp.MustCompile(`^(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$`) + +// ValidateSSHToHTTPSHost checks an entry of GIT_SSH_TO_HTTPS_HOSTS: a host, e.g. `git.example.com`, or an explicit +// `from=to` rewrite to an http(s) base, e.g. `ssh://git@git.example.com:7999/=https://git.example.com/scm/`. +func ValidateSSHToHTTPSHost(entry string) error { + entry = strings.TrimSpace(entry) + if from, to, ok := strings.Cut(entry, "="); ok { + from, to = strings.TrimSpace(from), strings.TrimSpace(to) + if from == "" { + return fmt.Errorf("invalid GIT_SSH_TO_HTTPS_HOSTS entry %q: the url prefix before = is empty", entry) + } + if !strings.HasPrefix(to, "https://") && !strings.HasPrefix(to, "http://") { + return fmt.Errorf("invalid GIT_SSH_TO_HTTPS_HOSTS entry %q: the base after = must start with https://", entry) + } + return nil + } + if !hostPattern.MatchString(entry) { + return fmt.Errorf("invalid GIT_SSH_TO_HTTPS_HOSTS entry %q: expected a host such as git.example.com, or an explicit rewrite such as ssh://git@git.example.com:7999/=https://git.example.com/scm/", entry) + } + return nil +} + +// sshPrefix returns the part of an ssh remote url up to and including the separator before the repository path, +// e.g. `git@github.com:` or `ssh://git@example.com:7999/`, and the host it points to. +func sshPrefix(remoteURL string) (prefix string, host string, ok bool) { + for _, scheme := range []string{"ssh://", "git+ssh://", "ssh+git://"} { + rest, found := strings.CutPrefix(remoteURL, scheme) + if !found { + continue + } + authority, _, found := strings.Cut(rest, "/") + if !found { + return "", "", false + } + host = authority + if i := strings.LastIndex(host, "@"); i >= 0 { + host = host[i+1:] + } + if i := strings.LastIndex(host, ":"); i >= 0 && !strings.HasSuffix(host, "]") { + host = host[:i] + } + if host == "" { + return "", "", false + } + return scheme + authority + "/", host, true + } + + // scp-like syntax: [user@]host:path, where the host part has no slash + if strings.Contains(remoteURL, "://") { + return "", "", false + } + if match := scpIPv6Pattern.FindStringSubmatch(remoteURL); match != nil { + return match[0], match[1], true + } + authority, _, found := strings.Cut(remoteURL, ":") + if !found || authority == "" || strings.Contains(authority, "/") { + return "", "", false + } + host = authority + if i := strings.LastIndex(host, "@"); i >= 0 { + host = host[i+1:] + } + // a single letter is a Windows drive, not a host + if len(host) < 2 { + return "", "", false + } + return authority + ":", host, true +} + +// FormatSSHToHTTPSConfig renders the rewrites as a git config file. +func FormatSSHToHTTPSConfig(rewrites map[string]string) string { + byBase := map[string][]string{} + for from, to := range rewrites { + byBase[to] = append(byBase[to], from) + } + bases := make([]string, 0, len(byBase)) + for base := range byBase { + bases = append(bases, base) + } + sort.Strings(bases) + + b := &strings.Builder{} + b.WriteString("# Written by DevPod because GIT_SSH_TO_HTTPS is enabled. Do not edit.\n") + for _, base := range bases { + froms := byBase[base] + sort.Strings(froms) + fmt.Fprintf(b, "[url %q]\n", base) + for _, from := range froms { + fmt.Fprintf(b, "\tinsteadOf = %s\n", from) + } + } + return b.String() +} + +// RemoteURLs returns the remote urls of the repository in dir and the urls of its submodules. It reads the config +// files directly, so it works whoever owns the repository. +func RemoteURLs(ctx context.Context, dir string) []string { + urls := []string{} + if configPath := repositoryConfigPath(dir); configPath != "" { + urls = append(urls, configValues(ctx, configPath, `^remote\..*\.url$`)...) + } + if gitModules := filepath.Join(dir, ".gitmodules"); fileExists(gitModules) { + urls = append(urls, configValues(ctx, gitModules, `^submodule\..*\.url$`)...) + } + return urls +} + +// repositoryConfigPath returns the config file of the repository in dir, following `.git` files of worktrees and +// submodules. It returns an empty string if there is none. +func repositoryConfigPath(dir string) string { + gitDir := filepath.Join(dir, ".git") + info, err := os.Stat(gitDir) + if err != nil { + return "" + } + if !info.IsDir() { + content, err := os.ReadFile(gitDir) + if err != nil { + return "" + } + path, found := strings.CutPrefix(strings.TrimSpace(string(content)), "gitdir:") + if !found { + return "" + } + gitDir = strings.TrimSpace(path) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(dir, gitDir) + } + if commonDir, err := os.ReadFile(filepath.Join(gitDir, "commondir")); err == nil { + path := strings.TrimSpace(string(commonDir)) + if !filepath.IsAbs(path) { + path = filepath.Join(gitDir, path) + } + gitDir = path + } + } + + configPath := filepath.Join(gitDir, "config") + if !fileExists(configPath) { + return "" + } + return configPath +} + +func configValues(ctx context.Context, file, keyPattern string) []string { + out, err := CommandContext(ctx, nil, "config", "--file", file, "--get-regexp", keyPattern).Output() + if err != nil { + return nil + } + values := []string{} + for _, line := range strings.Split(string(out), "\n") { + if _, value, found := strings.Cut(strings.TrimSpace(line), " "); found { + values = append(values, strings.TrimSpace(value)) + } + } + return values +} + +// ConfigureSSHToHTTPS writes the rewrites to SSHToHTTPSConfigPath and includes that file from the system git config. +func ConfigureSSHToHTTPS(ctx context.Context, rewrites map[string]string) error { + if err := os.MkdirAll(filepath.Dir(SSHToHTTPSConfigPath), 0755); err != nil { + return err + } + if err := os.WriteFile(SSHToHTTPSConfigPath, []byte(FormatSSHToHTTPSConfig(rewrites)), 0644); err != nil { + return fmt.Errorf("write %s: %w", SSHToHTTPSConfigPath, err) + } + + out, _ := CommandContext(ctx, nil, "config", "--system", "--get-all", "include.path").Output() + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == SSHToHTTPSConfigPath { + return nil + } + } + out, err := CommandContext(ctx, nil, "config", "--system", "--add", "include.path", SSHToHTTPSConfigPath).CombinedOutput() + if err != nil { + return fmt.Errorf("include %s in system git config: %s: %w", SSHToHTTPSConfigPath, strings.TrimSpace(string(out)), err) + } + return nil +} + +// RemoveSSHToHTTPS undoes ConfigureSSHToHTTPS. +func RemoveSSHToHTTPS(ctx context.Context) error { + if !fileExists(SSHToHTTPSConfigPath) { + return nil + } + + out, err := CommandContext(ctx, nil, "config", "--system", "--unset-all", "include.path", "^"+regexp.QuoteMeta(SSHToHTTPSConfigPath)+"$").CombinedOutput() + // exit code 5 means the include was not there + var exitErr *exec.ExitError + if err != nil && !(errors.As(err, &exitErr) && exitErr.ExitCode() == 5) { + return fmt.Errorf("remove include of %s from system git config: %s: %w", SSHToHTTPSConfigPath, strings.TrimSpace(string(out)), err) + } + return os.Remove(SSHToHTTPSConfigPath) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/pkg/git/ssh_to_https_test.go b/pkg/git/ssh_to_https_test.go new file mode 100644 index 000000000..fad738460 --- /dev/null +++ b/pkg/git/ssh_to_https_test.go @@ -0,0 +1,201 @@ +package git + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gotest.tools/assert" +) + +func TestSSHPrefix(t *testing.T) { + testCases := []struct { + in string + expectedPrefix string + expectedHost string + expectedOK bool + }{ + {in: "git@github.com:curityio/devpod.git", expectedPrefix: "git@github.com:", expectedHost: "github.com", expectedOK: true}, + {in: "github.com:curityio/devpod.git", expectedPrefix: "github.com:", expectedHost: "github.com", expectedOK: true}, + {in: "ssh://git@github.com/curityio/devpod.git", expectedPrefix: "ssh://git@github.com/", expectedHost: "github.com", expectedOK: true}, + {in: "ssh://github.com/curityio/devpod.git", expectedPrefix: "ssh://github.com/", expectedHost: "github.com", expectedOK: true}, + {in: "ssh://git@git.example.com:7999/proj/repo.git", expectedPrefix: "ssh://git@git.example.com:7999/", expectedHost: "git.example.com", expectedOK: true}, + {in: "git+ssh://git@bitbucket.org/team/repo.git", expectedPrefix: "git+ssh://git@bitbucket.org/", expectedHost: "bitbucket.org", expectedOK: true}, + {in: "https://github.com/curityio/devpod.git"}, + {in: "http://github.com/curityio/devpod.git"}, + {in: "file:///tmp/repo"}, + {in: "/tmp/repo"}, + {in: "../repo"}, + {in: "C:/repo"}, + {in: `C:\repo`}, + {in: "ssh://git@github.com"}, + {in: "git@[::1]:team/repo.git", expectedPrefix: "git@[::1]:", expectedHost: "[::1]", expectedOK: true}, + {in: "[fd00::1]:team/repo.git", expectedPrefix: "[fd00::1]:", expectedHost: "[fd00::1]", expectedOK: true}, + {in: "ssh://alice@git.example.com:2222/repo.git", expectedPrefix: "ssh://alice@git.example.com:2222/", expectedHost: "git.example.com", expectedOK: true}, + {in: "ssh://git@[::1]:22/repo.git", expectedPrefix: "ssh://git@[::1]:22/", expectedHost: "[::1]", expectedOK: true}, + } + + for _, testCase := range testCases { + prefix, host, ok := sshPrefix(testCase.in) + assert.Equal(t, ok, testCase.expectedOK, testCase.in) + assert.Equal(t, prefix, testCase.expectedPrefix, testCase.in) + assert.Equal(t, host, testCase.expectedHost, testCase.in) + } +} + +func TestSSHToHTTPSRewrites(t *testing.T) { + rewrites := SSHToHTTPSRewrites( + []string{ + "git@github.com:curityio/devpod.git", + "https://bitbucket.org/team/repo.git", + "ssh://git@git.example.com:7999/proj/repo.git", + }, + []string{ + "bitbucket.org", + "ssh://git@git.example.com:7999/=https://git.example.com/scm/", + // invalid entries are skipped + "broken=", + "https://gitlab.com", + }, + ) + + assert.DeepEqual(t, rewrites, map[string]string{ + "git@github.com:": "https://github.com/", + "git@github.com:/": "https://github.com/", + "ssh://git@github.com/": "https://github.com/", + "git@bitbucket.org:": "https://bitbucket.org/", + "git@bitbucket.org:/": "https://bitbucket.org/", + "ssh://git@bitbucket.org/": "https://bitbucket.org/", + "git@git.example.com:": "https://git.example.com/", + "git@git.example.com:/": "https://git.example.com/", + "ssh://git@git.example.com/": "https://git.example.com/", + "ssh://git@git.example.com:7999/": "https://git.example.com/scm/", + }) +} + +func TestValidateSSHToHTTPSHost(t *testing.T) { + for _, valid := range []string{ + "github.com", + "git.example.com", + "localhost", + "[::1]", + "ssh://git@git.example.com:7999/=https://git.example.com/scm/", + " git@host: = http://host/ ", + } { + assert.NilError(t, ValidateSSHToHTTPSHost(valid), valid) + } + + for _, invalid := range []string{ + "", + "https://github.com", + "github.com/org", + "git@github.com", + "github.com:22", + "host=", + "=https://host/", + "ssh://git@host/=host", + "-host", + } { + assert.Assert(t, ValidateSSHToHTTPSHost(invalid) != nil, invalid) + } +} + +func TestFormatSSHToHTTPSConfig(t *testing.T) { + config := FormatSSHToHTTPSConfig(map[string]string{ + "ssh://git@github.com/": "https://github.com/", + "git@github.com:": "https://github.com/", + "git@bitbucket.org:": "https://bitbucket.org/", + }) + + assert.Equal(t, config, `# Written by DevPod because GIT_SSH_TO_HTTPS is enabled. Do not edit. +[url "https://bitbucket.org/"] + insteadOf = git@bitbucket.org: +[url "https://github.com/"] + insteadOf = git@github.com: + insteadOf = ssh://git@github.com/ +`) +} + +func TestSSHToHTTPSAppliedByGit(t *testing.T) { + requireGit(t) + ctx := context.Background() + dir := t.TempDir() + repo := filepath.Join(dir, "repo") + runGit(t, dir, "init", "-q", repo) + runGit(t, repo, "remote", "add", "origin", "git@github.com:curityio/devpod.git") + runGit(t, repo, "remote", "add", "server", "ssh://git@git.example.com:7999/proj/repo.git") + runGit(t, repo, "remote", "add", "web", "https://bitbucket.org/team/repo.git") + runGit(t, repo, "remote", "add", "slash", "git@github.com:/curityio/devpod.git") + err := os.WriteFile(filepath.Join(repo, ".gitmodules"), []byte(`[submodule "lib"] + path = lib + url = git@bitbucket.org:team/lib.git +`), 0644) + assert.NilError(t, err) + + remoteURLs := RemoteURLs(ctx, repo) + assert.DeepEqual(t, remoteURLs, []string{ + "git@github.com:curityio/devpod.git", + "ssh://git@git.example.com:7999/proj/repo.git", + "https://bitbucket.org/team/repo.git", + "git@github.com:/curityio/devpod.git", + "git@bitbucket.org:team/lib.git", + }) + + rewrites := SSHToHTTPSRewrites(remoteURLs, []string{"ssh://git@git.example.com:7999/=https://git.example.com/scm/"}) + globalConfig := filepath.Join(dir, "gitconfig") + err = os.WriteFile(globalConfig, []byte(FormatSSHToHTTPSConfig(rewrites)), 0644) + assert.NilError(t, err) + + for remote, expected := range map[string]string{ + "origin": "https://github.com/curityio/devpod.git", + "server": "https://git.example.com/scm/proj/repo.git", + "web": "https://bitbucket.org/team/repo.git", + "slash": "https://github.com/curityio/devpod.git", + } { + cmd := exec.Command("git", "remote", "get-url", "--push", remote) + cmd.Dir = repo + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+globalConfig, "GIT_CONFIG_NOSYSTEM=1") + out, err := cmd.Output() + assert.NilError(t, err) + assert.Equal(t, strings.TrimSpace(string(out)), expected, remote) + } +} + +func TestRemoteURLsOfWorktree(t *testing.T) { + requireGit(t) + dir := t.TempDir() + repo := filepath.Join(dir, "repo") + runGit(t, dir, "init", "-q", repo) + runGit(t, repo, "remote", "add", "origin", "git@github.com:curityio/devpod.git") + runGit(t, repo, "-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "--allow-empty", "-m", "init") + worktree := filepath.Join(dir, "worktree") + runGit(t, repo, "worktree", "add", "-q", worktree) + + assert.DeepEqual(t, RemoteURLs(context.Background(), worktree), []string{"git@github.com:curityio/devpod.git"}) +} + +func TestRemoteURLsWithoutRepository(t *testing.T) { + requireGit(t) + assert.DeepEqual(t, RemoteURLs(context.Background(), t.TempDir()), []string{}) +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + emptyConfig := filepath.Join(t.TempDir(), "gitconfig") + assert.NilError(t, os.WriteFile(emptyConfig, nil, 0644)) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+emptyConfig) + out, err := cmd.CombinedOutput() + assert.NilError(t, err, string(out)) +} diff --git a/pkg/gitcredentials/gitcredentials.go b/pkg/gitcredentials/gitcredentials.go index b5ae5a3ab..5f2752c30 100644 --- a/pkg/gitcredentials/gitcredentials.go +++ b/pkg/gitcredentials/gitcredentials.go @@ -274,31 +274,51 @@ func SetupGpgGitKey(gitSignKey string) error { return nil } +// removeCredentialHelper removes DevPod's credential helper from the git config content, keeping any other +// credential settings, and drops [credential] sections left empty by it. func removeCredentialHelper(content string) string { scan := scanner.NewScanner(strings.NewReader(content)) - isCredential := false out := []string{} + // index in out of the current [credential] section header, or -1 outside of one + credentialSection := -1 + sectionHasOtherLines := false + dropEmptySection := func() { + if credentialSection >= 0 && !sectionHasOtherLines { + out = out[:credentialSection] + } + credentialSection = -1 + } for scan.Scan() { line := scan.Text() - if strings.TrimSpace(line) == "[credential]" { - isCredential = true - continue - } else if isCredential { - trimmed := strings.TrimSpace(line) - if len(trimmed) > 0 && trimmed[0] == '[' { - isCredential = false - } else { + trimmed := strings.TrimSpace(line) + if len(trimmed) > 0 && trimmed[0] == '[' { + dropEmptySection() + if trimmed == "[credential]" { + credentialSection = len(out) + sectionHasOtherLines = false + } + } else if credentialSection >= 0 { + if isDevPodCredentialHelper(trimmed) { continue } + if trimmed != "" { + sectionHasOtherLines = true + } } out = append(out, line) } + dropEmptySection() return strings.Join(out, "\n") } +func isDevPodCredentialHelper(line string) bool { + key, value, found := strings.Cut(line, "=") + return found && strings.TrimSpace(key) == "helper" && strings.Contains(value, " agent git-credentials") +} + // getGlobalGitConfigPath resolves the global git config for the specified user according to // https://git-scm.com/docs/git-config/#Documentation/git-config.txt-XDGCONFIGHOMEgitconfig func getGlobalGitConfigPath(userName string) (string, error) { diff --git a/pkg/gitcredentials/gitcredentials_test.go b/pkg/gitcredentials/gitcredentials_test.go new file mode 100644 index 000000000..8e32a4a63 --- /dev/null +++ b/pkg/gitcredentials/gitcredentials_test.go @@ -0,0 +1,61 @@ +package gitcredentials + +import ( + "testing" + + "gotest.tools/assert" +) + +func TestRemoveCredentialHelper(t *testing.T) { + testCases := []struct { + name string + in string + expected string + }{ + { + name: "removes DevPod's section", + in: `[user] + name = Test + +[credential] + helper = !'/usr/local/bin/devpod' agent git-credentials --port 12049 +[core] + editor = vim`, + expected: `[user] + name = Test + +[core] + editor = vim`, + }, + { + name: "keeps other helpers in the same section", + in: `[credential] + helper = store --file /home/vscode/.git-credentials + helper = !'/usr/local/bin/devpod' agent git-credentials --port 12049`, + expected: `[credential] + helper = store --file /home/vscode/.git-credentials`, + }, + { + name: "keeps sections without DevPod's helper", + in: `[credential] + helper = store +[credential "https://github.com"] + helper = !gh auth git-credential +[credential] + helper = !'/usr/local/bin/devpod' agent git-credentials`, + expected: `[credential] + helper = store +[credential "https://github.com"] + helper = !gh auth git-credential`, + }, + { + name: "leaves config without credentials alone", + in: "[user]\n\tname = Test", + expected: "[user]\n\tname = Test", + }, + } + + for _, testCase := range testCases { + assert.Equal(t, removeCredentialHelper(testCase.in), testCase.expected, testCase.name) + } +} diff --git a/pkg/provider/context_options.go b/pkg/provider/context_options.go new file mode 100644 index 000000000..78f5e5c59 --- /dev/null +++ b/pkg/provider/context_options.go @@ -0,0 +1,31 @@ +package provider + +import ( + "context" + "strconv" + + "github.com/loft-sh/devpod/pkg/config" + "github.com/loft-sh/devpod/pkg/git" +) + +// ApplyContextOptions sets the CLI options that the agent needs from the context options, so that every command +// that starts the container, `devpod up` as well as `devpod ssh` on a stopped workspace, sets it up the same way. +// It reads the remote urls of a local folder source on the host, because the repository's git dir can be outside +// of the folder (e.g. for a worktree), where the container can't see it. +func ApplyContextOptions(ctx context.Context, options *CLIOptions, devPodConfig *config.Config, source WorkspaceSource) error { + hosts := devPodConfig.GitSSHToHTTPSHosts() + for _, host := range hosts { + if err := git.ValidateSSHToHTTPSHost(host); err != nil { + return err + } + } + + options.GitSSHToHTTPS = strconv.FormatBool(devPodConfig.GitSSHToHTTPS()) + options.GitSSHToHTTPSHosts = hosts + options.GitSSHToHTTPSRemotes = nil + if devPodConfig.GitSSHToHTTPS() && source.LocalFolder != "" { + options.GitSSHToHTTPSRemotes = git.RemoteURLs(ctx, source.LocalFolder) + } + options.DisableSSHAgentForwarding = devPodConfig.ContextOption(config.ContextOptionSSHAgentForwarding) == "false" + return nil +} diff --git a/pkg/provider/context_options_test.go b/pkg/provider/context_options_test.go new file mode 100644 index 000000000..b8a0c12f5 --- /dev/null +++ b/pkg/provider/context_options_test.go @@ -0,0 +1,84 @@ +package provider + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/loft-sh/devpod/pkg/config" + "gotest.tools/assert" +) + +func TestApplyContextOptions(t *testing.T) { + testCases := []struct { + name string + options map[string]string + expectedEnabled string + expectedNoAgent bool + }{ + {name: "defaults", expectedEnabled: "false"}, + {name: "auto without agent forwarding", options: map[string]string{config.ContextOptionSSHAgentForwarding: "false"}, expectedEnabled: "true", expectedNoAgent: true}, + {name: "explicitly off", options: map[string]string{config.ContextOptionSSHAgentForwarding: "false", config.ContextOptionGitSSHToHTTPS: "false"}, expectedEnabled: "false", expectedNoAgent: true}, + {name: "explicitly on", options: map[string]string{config.ContextOptionGitSSHToHTTPS: "true"}, expectedEnabled: "true"}, + } + + for _, testCase := range testCases { + options := &CLIOptions{} + err := ApplyContextOptions(context.Background(), options, configWithOptions(testCase.options), WorkspaceSource{}) + assert.NilError(t, err, testCase.name) + assert.Equal(t, options.GitSSHToHTTPS, testCase.expectedEnabled, testCase.name) + assert.Equal(t, options.DisableSSHAgentForwarding, testCase.expectedNoAgent, testCase.name) + } +} + +func TestApplyContextOptionsRejectsInvalidHosts(t *testing.T) { + err := ApplyContextOptions(context.Background(), &CLIOptions{}, configWithOptions(map[string]string{ + config.ContextOptionGitSSHToHTTPSHosts: "github.com,https://gitlab.com", + }), WorkspaceSource{}) + assert.ErrorContains(t, err, `"https://gitlab.com"`) +} + +// The git dir of a worktree is outside of the worktree, so the container only sees the remotes that the CLI reads. +func TestApplyContextOptionsReadsRemotesOfWorktreeOnHost(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } + dir := t.TempDir() + repo := filepath.Join(dir, "repo") + worktree := filepath.Join(dir, "worktree") + runGit(t, dir, "init", "-q", repo) + runGit(t, repo, "remote", "add", "origin", "git@github.com:curityio/devpod.git") + runGit(t, repo, "-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "--allow-empty", "-m", "init") + runGit(t, repo, "worktree", "add", "-q", worktree) + + options := &CLIOptions{} + err := ApplyContextOptions(context.Background(), options, configWithOptions(map[string]string{ + config.ContextOptionGitSSHToHTTPS: "true", + }), WorkspaceSource{LocalFolder: worktree}) + assert.NilError(t, err) + assert.DeepEqual(t, options.GitSSHToHTTPSRemotes, []string{"git@github.com:curityio/devpod.git"}) +} + +func configWithOptions(options map[string]string) *config.Config { + values := map[string]config.OptionValue{} + for name, value := range options { + values[name] = config.OptionValue{Value: value} + } + return &config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{config.DefaultContext: {Options: values}}, + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + emptyConfig := filepath.Join(t.TempDir(), "gitconfig") + assert.NilError(t, os.WriteFile(emptyConfig, nil, 0644)) + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+emptyConfig) + out, err := cmd.CombinedOutput() + assert.NilError(t, err, string(out)) +} diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index f3bff8029..3356d48f5 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -198,6 +198,12 @@ type CLIOptions struct { GitSSHSigningKey string `json:"gitSshSigningKey,omitempty"` SSHAuthSockID string `json:"sshAuthSockID,omitempty"` // ID to use when looking for SSH_AUTH_SOCK, defaults to a new random ID if not set (only used for browser IDEs) StrictHostKeyChecking bool `json:"strictHostKeyChecking,omitempty"` + // GitSSHToHTTPS is "true" or "false" to turn GIT_SSH_TO_HTTPS on or off. When it is empty, e.g. when the + // container is started by an older CLI, the agent leaves the current rewrites in place. + GitSSHToHTTPS string `json:"gitSshToHttps,omitempty"` + GitSSHToHTTPSHosts []string `json:"gitSshToHttpsHosts,omitempty"` + GitSSHToHTTPSRemotes []string `json:"gitSshToHttpsRemotes,omitempty"` // remote urls of the local folder source, read on the host + DisableSSHAgentForwarding bool `json:"disableSshAgentForwarding,omitempty"` // build options Repository string `json:"repository,omitempty"` diff --git a/pkg/ssh/config.go b/pkg/ssh/config.go index 3535baf60..4e2f2b5fc 100644 --- a/pkg/ssh/config.go +++ b/pkg/ssh/config.go @@ -24,15 +24,15 @@ var ( MarkerEndPrefix = "# DevPod End " ) -func ConfigureSSHConfig(sshConfigPath, context, workspace, user, workdir string, gpgagent bool, devPodHome string, log log.Logger) error { - return configureSSHConfigSameFile(sshConfigPath, context, workspace, user, workdir, "", gpgagent, devPodHome, log) +func ConfigureSSHConfig(sshConfigPath, context, workspace, user, workdir string, gpgagent, forwardAgent bool, devPodHome string, log log.Logger) error { + return configureSSHConfigSameFile(sshConfigPath, context, workspace, user, workdir, "", gpgagent, forwardAgent, devPodHome, log) } -func configureSSHConfigSameFile(sshConfigPath, context, workspace, user, workdir, command string, gpgagent bool, devPodHome string, log log.Logger) error { +func configureSSHConfigSameFile(sshConfigPath, context, workspace, user, workdir, command string, gpgagent, forwardAgent bool, devPodHome string, log log.Logger) error { configLock.Lock() defer configLock.Unlock() - newFile, err := addHost(sshConfigPath, workspace+"."+"devpod", user, context, workspace, workdir, command, gpgagent, devPodHome) + newFile, err := addHost(sshConfigPath, workspace+"."+"devpod", user, context, workspace, workdir, command, gpgagent, forwardAgent, devPodHome) if err != nil { return errors.Wrap(err, "parse ssh config") } @@ -46,7 +46,7 @@ type DevPodSSHEntry struct { Workspace string } -func addHost(path, host, user, context, workspace, workdir, command string, gpgagent bool, devPodHome string) (string, error) { +func addHost(path, host, user, context, workspace, workdir, command string, gpgagent, forwardAgent bool, devPodHome string) (string, error) { newConfig, err := removeFromConfig(path, host) if err != nil { return "", err @@ -58,17 +58,21 @@ func addHost(path, host, user, context, workspace, workdir, command string, gpga return "", err } - return addHostSection(newConfig, execPath, host, user, context, workspace, workdir, command, gpgagent, devPodHome) + return addHostSection(newConfig, execPath, host, user, context, workspace, workdir, command, gpgagent, forwardAgent, devPodHome) } -func addHostSection(config, execPath, host, user, context, workspace, workdir, command string, gpgagent bool, devPodHome string) (string, error) { +func addHostSection(config, execPath, host, user, context, workspace, workdir, command string, gpgagent, forwardAgent bool, devPodHome string) (string, error) { newLines := []string{} // add new section startMarker := MarkerStartPrefix + host endMarker := MarkerEndPrefix + host newLines = append(newLines, startMarker) newLines = append(newLines, "Host "+host) - newLines = append(newLines, " ForwardAgent yes") + if forwardAgent { + newLines = append(newLines, " ForwardAgent yes") + } else { + newLines = append(newLines, " ForwardAgent no") + } newLines = append(newLines, " LogLevel error") newLines = append(newLines, " StrictHostKeyChecking no") newLines = append(newLines, " UserKnownHostsFile /dev/null") diff --git a/pkg/ssh/config_test.go b/pkg/ssh/config_test.go index c87cbceef..ced975b9d 100644 --- a/pkg/ssh/config_test.go +++ b/pkg/ssh/config_test.go @@ -11,18 +11,20 @@ import ( func TestAddHostSection(t *testing.T) { tests := []struct { - name string - config string - execPath string - host string - user string - context string - workspace string - workdir string - command string - gpgagent bool - devPodHome string - expected string + name string + config string + execPath string + host string + user string + context string + workspace string + workdir string + command string + gpgagent bool + // noAgentForwarding is the inverse of addHostSection's forwardAgent, so that the cases default to forwarding + noAgentForwarding bool + devPodHome string + expected string }{ { name: "Basic host addition", @@ -45,6 +47,26 @@ Host testhost HostKeyAlgorithms rsa-sha2-256,rsa-sha2-512,ssh-rsa ProxyCommand "/path/to/exec" ssh --stdio --context testcontext --user testuser testworkspace User testuser +# DevPod End testhost`, + }, + { + name: "Host addition without agent forwarding", + config: "", + execPath: "/path/to/exec", + host: "testhost", + user: "testuser", + context: "testcontext", + workspace: "testworkspace", + noAgentForwarding: true, + expected: `# DevPod Start testhost +Host testhost + ForwardAgent no + LogLevel error + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + HostKeyAlgorithms rsa-sha2-256,rsa-sha2-512,ssh-rsa + ProxyCommand "/path/to/exec" ssh --stdio --context testcontext --user testuser testworkspace + User testuser # DevPod End testhost`, }, { @@ -253,7 +275,7 @@ Host testhost for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := addHostSection(tt.config, tt.execPath, tt.host, tt.user, tt.context, tt.workspace, tt.workdir, tt.command, tt.gpgagent, tt.devPodHome) + result, err := addHostSection(tt.config, tt.execPath, tt.host, tt.user, tt.context, tt.workspace, tt.workdir, tt.command, tt.gpgagent, !tt.noAgentForwarding, tt.devPodHome) if err != nil { t.Errorf("Failed with err: %v", err) } diff --git a/pkg/tunnel/container.go b/pkg/tunnel/container.go index c00262adf..01d1ac8a0 100644 --- a/pkg/tunnel/container.go +++ b/pkg/tunnel/container.go @@ -38,6 +38,8 @@ type ContainerTunnel struct { client client.WorkspaceClient updateConfigInterval time.Duration log log.Logger + // cliOptions are sent to the agent, which uses them if it has to start the container + cliOptions provider.CLIOptions } // Handler defines what to do once the tunnel has a client established @@ -68,6 +70,13 @@ func (c *ContainerTunnel) Run(ctx context.Context, handler Handler, cfg *config. // Get the timeout from the context options timeout := config.ParseTimeOption(cfg, config.ContextOptionAgentInjectTimeout) + // set the container up as devpod up would if the agent has to start it + err = provider.ApplyContextOptions(ctx, &c.cliOptions, cfg, c.client.WorkspaceConfig().Source) + if err != nil { + c.log.Warnf("Ignoring context options: %v", err) + c.cliOptions = provider.CLIOptions{} + } + // tunnel to host tunnelChan := make(chan error, 1) go func() { @@ -157,7 +166,7 @@ func (c *ContainerTunnel) updateConfig(ctx context.Context, sshClient *ssh.Clien } // compress info - workspaceInfo, agentInfo, err := c.client.AgentInfo(provider.CLIOptions{}) + workspaceInfo, agentInfo, err := c.client.AgentInfo(c.cliOptions) if err != nil { c.log.Errorf("Error compressing workspace info: %v", err) break @@ -184,7 +193,7 @@ func (c *ContainerTunnel) updateConfig(ctx context.Context, sshClient *ssh.Clien // runInContainer uses the connected SSH client to execute handler on the remote func (c *ContainerTunnel) runInContainer(ctx context.Context, sshClient *ssh.Client, handler Handler, envVars map[string]string) error { // compress info - workspaceInfo, _, err := c.client.AgentInfo(provider.CLIOptions{}) + workspaceInfo, _, err := c.client.AgentInfo(c.cliOptions) if err != nil { return err }