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
}