From 8c6a9baaf90a1bf7478a1fe2642fa991ac66b1a0 Mon Sep 17 00:00:00 2001 From: renatoathaydes Date: Mon, 19 Oct 2026 10:47:38 +0200 Subject: [PATCH 1/3] Let workspaces use git over https when ssh-agent forwarding is off A forwarded ssh-agent and DevPod's git credential helper both need the user's computer, so agents running unattended in a workspace lose git access when the laptop sleeps. The new GIT_SSH_TO_HTTPS context option (auto by default, which enables it when SSH_AGENT_FORWARDING is false) writes url..insteadOf rules during container setup that rewrite the ssh urls of the workspace's hosts to https. The rules live in /var/devpod/ssh-to-https.gitconfig, included from the system git config, so the repository's .git/config is left alone. GIT_SSH_TO_HTTPS_HOSTS adds hosts and explicit from=to rewrites for servers with different ssh and https paths. Credentials are left to the dev container, so DevPod stays agnostic of git hosts. Removing DevPod's credential helper at the end of a session no longer deletes other helpers in the [credential] section of the global git config, such as one the dev container configured. Documents how to set up a workspace that keeps working unattended. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 + cmd/agent/container/setup.go | 33 +++ cmd/up.go | 2 + .../developing-in-workspaces/credentials.mdx | 4 + .../unattended-workspaces.mdx | 225 ++++++++++++++++++ docs/sidebars.js | 4 + pkg/config/context.go | 38 +++ pkg/config/context_test.go | 52 ++++ pkg/git/ssh_to_https.go | 225 ++++++++++++++++++ pkg/git/ssh_to_https_test.go | 160 +++++++++++++ pkg/gitcredentials/gitcredentials.go | 40 +++- pkg/gitcredentials/gitcredentials_test.go | 61 +++++ pkg/provider/workspace.go | 2 + 13 files changed, 848 insertions(+), 11 deletions(-) create mode 100644 docs/pages/developing-in-workspaces/unattended-workspaces.mdx create mode 100644 pkg/config/context_test.go create mode 100644 pkg/git/ssh_to_https.go create mode 100644 pkg/git/ssh_to_https_test.go create mode 100644 pkg/gitcredentials/gitcredentials_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4faded9b4..92160cd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ 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. + ### 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..a46801045 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -160,6 +160,12 @@ 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) + } + // setup container err = setup.SetupContainer(ctx, setupInfo, workspaceInfo.CLIOptions.WorkspaceEnv, cmd.ChownWorkspace, logger) if err != nil { @@ -522,6 +528,33 @@ 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 not. +func configureGitSSHToHTTPS(ctx context.Context, setupInfo *config.Result, workspaceInfo *provider2.ContainerWorkspaceInfo, log log.Logger) error { + if !command.Exists("git") { + if workspaceInfo.CLIOptions.GitSSHToHTTPS { + log.Warn("GIT_SSH_TO_HTTPS is enabled but git is not installed in the container, skipping") + } + return nil + } + + if !workspaceInfo.CLIOptions.GitSSHToHTTPS { + return git.RemoveSSHToHTTPS(ctx) + } + + remoteURLs := []string{} + if workspaceInfo.Source.GitRepository != "" { + remoteURLs = append(remoteURLs, workspaceInfo.Source.GitRepository) + } + 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..6b3351295 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -81,6 +81,8 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { if devPodConfig.ContextOption(config.ContextOptionSSHStrictHostKeyChecking) == "true" { cmd.StrictHostKeyChecking = true } + cmd.GitSSHToHTTPS = devPodConfig.GitSSHToHTTPS() + cmd.GitSSHToHTTPSHosts = devPodConfig.GitSSHToHTTPSHosts() ctx, cancel := WithSignals(cobraCmd.Context()) defer cancel() 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..a76861209 --- /dev/null +++ b/docs/pages/developing-in-workspaces/unattended-workspaces.mdx @@ -0,0 +1,225 @@ +--- +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 + +On every `devpod up`, 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. +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@:` + - `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. + +### 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. | + +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 `devpod ssh` sessions and, because + `GIT_SSH_TO_HTTPS` is `auto` by default, makes git use https. +- `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/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/git/ssh_to_https.go b/pkg/git/ssh_to_https.go new file mode 100644 index 000000000..23fabfa66 --- /dev/null +++ b/pkg/git/ssh_to_https.go @@ -0,0 +1,225 @@ +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["ssh://git@"+host+"/"] = "https://" + host + "/" + } + + for _, remoteURL := range remoteURLs { + prefix, host, ok := sshPrefix(remoteURL) + if !ok { + continue + } + rewrites[prefix] = "https://" + host + "/" + addHost(host) + } + + explicit := map[string]string{} + for _, extraHost := range extraHosts { + if from, to, ok := strings.Cut(extraHost, "="); ok { + explicit[strings.TrimSpace(from)] = strings.TrimSpace(to) + } else { + addHost(strings.TrimSpace(extraHost)) + } + } + for from, to := range explicit { + rewrites[from] = to + } + + return rewrites +} + +// 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 + } + 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..1900d06e3 --- /dev/null +++ b/pkg/git/ssh_to_https_test.go @@ -0,0 +1,160 @@ +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: "ssh://git@github.com"}, + } + + 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/", + }, + ) + + assert.DeepEqual(t, rewrites, map[string]string{ + "git@github.com:": "https://github.com/", + "ssh://git@github.com/": "https://github.com/", + "git@bitbucket.org:": "https://bitbucket.org/", + "ssh://git@bitbucket.org/": "https://bitbucket.org/", + "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 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") + 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@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", + } { + 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..a01635fa6 100644 --- a/pkg/gitcredentials/gitcredentials.go +++ b/pkg/gitcredentials/gitcredentials.go @@ -274,33 +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") } -// 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 isDevPodCredentialHelper(line string) bool { + key, value, found := strings.Cut(line, "=") + return found && strings.TrimSpace(key) == "helper" && strings.Contains(value, " agent git-credentials") +} + func getGlobalGitConfigPath(userName string) (string, error) { if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { return filepath.Join(xdgConfigHome, "git", "config"), nil 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/workspace.go b/pkg/provider/workspace.go index f3bff8029..55028804c 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -198,6 +198,8 @@ 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 bool `json:"gitSshToHttps,omitempty"` + GitSSHToHTTPSHosts []string `json:"gitSshToHttpsHosts,omitempty"` // build options Repository string `json:"repository,omitempty"` From b298c96148ff88b6a1770df3abab93b4a74c06b6 Mon Sep 17 00:00:00 2001 From: renatoathaydes Date: Mon, 19 Oct 2026 10:58:08 +0200 Subject: [PATCH 2/3] Add an e2e test for GIT_SSH_TO_HTTPS with the docker provider Starts a workspace from a git repo with an ssh remote and an ssh submodule url while SSH_AGENT_FORWARDING is off, checks that git in the container uses https for both and that the repo on the host is untouched, then sets GIT_SSH_TO_HTTPS=false and checks that the rewrites are removed. The test runs in its own context so its options don't leak into other tests. It is labelled up-docker, so CI runs it with the other docker tests, and git-ssh-to-https to run it on its own. Co-Authored-By: Claude Opus 5.5 --- e2e/framework/command.go | 12 ++ e2e/tests/up/git_ssh_to_https.go | 105 ++++++++++++++++++ .../.devcontainer.json | 4 + 3 files changed, 121 insertions(+) create mode 100644 e2e/tests/up/git_ssh_to_https.go create mode 100644 e2e/tests/up/testdata/docker-git-ssh-to-https/.devcontainer.json 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..6e0b238bc --- /dev/null +++ b/e2e/tests/up/git_ssh_to_https.go @@ -0,0 +1,105 @@ +package up + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/loft-sh/devpod/e2e/framework" + "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) + }) + + 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 := func(args ...string) string { + out, err := exec.Command("git", append([]string{"-C", tempDir}, args...)...).CombinedOutput() + framework.ExpectNoError(err, string(out)) + return strings.TrimSpace(string(out)) + } + hostGit("init", "-q") + hostGit("remote", "add", "origin", "git@github.com:curityio/devpod.git") + hostGit("config", "--file", ".gitmodules", "submodule.lib.path", "lib") + hostGit("config", "--file", ".gitmodules", "submodule.lib.url", "git@bitbucket.org:team/lib.git") + + f := framework.NewDefaultFramework(initialDir + "/bin") + + // use a context of our own, so the options don't leak into other tests + 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) + + // GIT_SSH_TO_HTTPS stays at its default, auto + err = f.DevPodContextSetOptions(ctx, contextName, "SSH_AGENT_FORWARDING=false") + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(f.DevPodWorkspaceDelete, context.Background(), tempDir) + + err = f.DevPodUp(ctx, tempDir) + 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, so git in the container doesn't trust it without safe.directory + out, err = f.DevPodSSH(ctx, tempDir, "git -c safe.directory=\"$PWD\" 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("config", "--file", filepath.Join(".git", "config"), "remote.origin.url")). + To(gomega.Equal("git@github.com:curityio/devpod.git")) + + err = f.DevPodContextSetOptions(ctx, contextName, "GIT_SSH_TO_HTTPS=false") + framework.ExpectNoError(err) + + err = f.DevPodUp(ctx, tempDir) + 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 -c safe.directory=\"$PWD\" 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())) + }) +}) 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..e0da5781e --- /dev/null +++ b/e2e/tests/up/testdata/docker-git-ssh-to-https/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Git SSH to HTTPS", + "image": "mcr.microsoft.com/devcontainers/base:bookworm" +} From a209ee2b5ab2a0d07f0b0a586138db0173e3bb56 Mon Sep 17 00:00:00 2001 From: renatoathaydes Date: Mon, 19 Oct 2026 11:34:48 +0200 Subject: [PATCH 3/3] Fix GIT_SSH_TO_HTTPS gaps found in review - Send GIT_SSH_TO_HTTPS as "true"/"false" and leave the rewrites alone when it is empty, so a container started by an older CLI keeps them. devpod ssh now also sends the context's options, so a stopped container that it starts again is set up as devpod up would. - Honour SSH_AGENT_FORWARDING=false for the container: the setup tunnel no longer forwards the ssh-agent into lifecycle commands, and the ssh config entry gets ForwardAgent no. The agent on the machine still gets it to clone the source. - Read the remotes of a local folder source on the host, so worktrees and submodule checkouts, whose git dir isn't mounted, get rewrites too. - Reject GIT_SSH_TO_HTTPS_HOSTS entries that are neither a host nor a from=https://... rewrite, and handle scp-like IPv6 urls and git@host:/path remotes. - Add the workspace folder to the system safe.directory, so git works with folders that aren't chowned (Docker Desktop on macOS and Windows). - Don't save CLI options in the agent's workspace config, as intended. - Extend the e2e test with a container restart, agent forwarding checks, no safe.directory workaround, and a worktree source. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +- cmd/agent/container/setup.go | 21 +++- cmd/up.go | 15 ++- .../unattended-workspaces.mdx | 21 ++-- e2e/tests/up/git_ssh_to_https.go | 119 ++++++++++++++---- .../.devcontainer.json | 3 +- pkg/agent/agent.go | 2 +- pkg/agent/agent_test.go | 26 ++++ pkg/devcontainer/setup.go | 1 + pkg/devcontainer/sshtunnel/sshtunnel.go | 5 +- pkg/devcontainer/sshtunnel/sshtunnel_test.go | 2 +- pkg/git/safe_directory.go | 23 ++++ pkg/git/ssh_to_https.go | 41 +++++- pkg/git/ssh_to_https_test.go | 41 ++++++ pkg/gitcredentials/gitcredentials.go | 2 + pkg/provider/context_options.go | 31 +++++ pkg/provider/context_options_test.go | 84 +++++++++++++ pkg/provider/workspace.go | 8 +- pkg/ssh/config.go | 20 +-- pkg/ssh/config_test.go | 48 +++++-- pkg/tunnel/container.go | 13 +- 21 files changed, 464 insertions(+), 73 deletions(-) create mode 100644 pkg/agent/agent_test.go create mode 100644 pkg/git/safe_directory.go create mode 100644 pkg/provider/context_options.go create mode 100644 pkg/provider/context_options_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 92160cd7a..920e198fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,16 @@ Add new entries to the top of the "Unreleased" section. ### 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. + `[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 diff --git a/cmd/agent/container/setup.go b/cmd/agent/container/setup.go index a46801045..e1e6c5ff0 100644 --- a/cmd/agent/container/setup.go +++ b/cmd/agent/container/setup.go @@ -166,6 +166,14 @@ func (cmd *SetupContainerCmd) Run(ctx context.Context) error { 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 { @@ -529,16 +537,22 @@ func (cmd *SetupContainerCmd) setupOpenVSCode(setupInfo *config.Result, ideOptio } // 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 not. +// 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 workspaceInfo.CLIOptions.GitSSHToHTTPS { + if enabled == "true" { log.Warn("GIT_SSH_TO_HTTPS is enabled but git is not installed in the container, skipping") } return nil } - if !workspaceInfo.CLIOptions.GitSSHToHTTPS { + if enabled != "true" { return git.RemoveSSHToHTTPS(ctx) } @@ -546,6 +560,7 @@ func configureGitSSHToHTTPS(ctx context.Context, setupInfo *config.Result, works 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 { diff --git a/cmd/up.go b/cmd/up.go index 6b3351295..17a3c19d0 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -81,8 +81,6 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { if devPodConfig.ContextOption(config.ContextOptionSSHStrictHostKeyChecking) == "true" { cmd.StrictHostKeyChecking = true } - cmd.GitSSHToHTTPS = devPodConfig.GitSSHToHTTPS() - cmd.GitSSHToHTTPSHosts = devPodConfig.GitSSHToHTTPSHosts() ctx, cancel := WithSignals(cobraCmd.Context()) defer cancel() @@ -93,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) }, } @@ -185,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 } @@ -420,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, @@ -772,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") @@ -786,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/unattended-workspaces.mdx b/docs/pages/developing-in-workspaces/unattended-workspaces.mdx index a76861209..d13b6c28e 100644 --- a/docs/pages/developing-in-workspaces/unattended-workspaces.mdx +++ b/docs/pages/developing-in-workspaces/unattended-workspaces.mdx @@ -39,15 +39,16 @@ service you use, and the credentials don't depend on your computer. ### What DevPod changes -On every `devpod up`, before any of the dev container's lifecycle commands (such as `postCreateCommand`) run, -DevPod: +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. + `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@:` + - `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. @@ -60,14 +61,15 @@ also cover submodules, other remotes and repositories cloned later. You can see git remote get-url --push origin ``` -If the option is turned off again, the next `devpod up` removes the file and the include. +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. | +| `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 @@ -90,8 +92,11 @@ devpod context set-options \ -o GIT_SSH_SIGNATURE_FORWARDING=false ``` -- `SSH_AGENT_FORWARDING=false` stops forwarding your ssh-agent into `devpod ssh` sessions and, because - `GIT_SSH_TO_HTTPS` is `auto` by default, makes git use https. +- `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 diff --git a/e2e/tests/up/git_ssh_to_https.go b/e2e/tests/up/git_ssh_to_https.go index 6e0b238bc..8851e269e 100644 --- a/e2e/tests/up/git_ssh_to_https.go +++ b/e2e/tests/up/git_ssh_to_https.go @@ -2,12 +2,16 @@ 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" ) @@ -24,27 +28,11 @@ var _ = DevPodDescribe("devpod up git ssh to https test suite", func() { framework.ExpectNoError(err) }) - 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 := func(args ...string) string { - out, err := exec.Command("git", append([]string{"-C", tempDir}, args...)...).CombinedOutput() - framework.ExpectNoError(err, string(out)) - return strings.TrimSpace(string(out)) - } - hostGit("init", "-q") - hostGit("remote", "add", "origin", "git@github.com:curityio/devpod.git") - hostGit("config", "--file", ".gitmodules", "submodule.lib.path", "lib") - hostGit("config", "--file", ".gitmodules", "submodule.lib.url", "git@bitbucket.org:team/lib.git") - - f := framework.NewDefaultFramework(initialDir + "/bin") - - // use a context of our own, so the options don't leak into other tests + // 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) + err := f.DevPodContextCreate(ctx, contextName) framework.ExpectNoError(err) ginkgo.DeferCleanup(f.DevPodContextDelete, context.Background(), contextName) err = f.DevPodContextUse(ctx, contextName) @@ -57,21 +45,43 @@ var _ = DevPodDescribe("devpod up git ssh to https test suite", func() { err = f.DevPodProviderUse(ctx, "docker") framework.ExpectNoError(err) - // GIT_SSH_TO_HTTPS stays at its default, auto - err = f.DevPodContextSetOptions(ctx, contextName, "SSH_AGENT_FORWARDING=false") + 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) - err = f.DevPodUp(ctx, 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, so git in the container doesn't trust it without safe.directory - out, err = f.DevPodSSH(ctx, tempDir, "git -c safe.directory=\"$PWD\" remote get-url --push origin") + // 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")) @@ -80,13 +90,36 @@ var _ = DevPodDescribe("devpod up git ssh to https test suite", func() { 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("config", "--file", filepath.Join(".git", "config"), "remote.origin.url")). + 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) + 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") @@ -97,9 +130,41 @@ var _ = DevPodDescribe("devpod up git ssh to https test suite", func() { framework.ExpectNoError(err) gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("absent")) - out, err = f.DevPodSSH(ctx, tempDir, "git -c safe.directory=\"$PWD\" remote get-url --push origin") + 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 index e0da5781e..ed0602dbd 100644 --- a/e2e/tests/up/testdata/docker-git-ssh-to-https/.devcontainer.json +++ b/e2e/tests/up/testdata/docker-git-ssh-to-https/.devcontainer.json @@ -1,4 +1,5 @@ { "name": "Git SSH to HTTPS", - "image": "mcr.microsoft.com/devcontainers/base:bookworm" + "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/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 index 23fabfa66..d5352fd16 100644 --- a/pkg/git/ssh_to_https.go +++ b/pkg/git/ssh_to_https.go @@ -22,6 +22,7 @@ func SSHToHTTPSRewrites(remoteURLs []string, extraHosts []string) map[string]str rewrites := map[string]string{} addHost := func(host string) { rewrites["git@"+host+":"] = "https://" + host + "/" + rewrites["git@"+host+":/"] = "https://" + host + "/" rewrites["ssh://git@"+host+"/"] = "https://" + host + "/" } @@ -31,15 +32,24 @@ func SSHToHTTPSRewrites(remoteURLs []string, extraHosts []string) map[string]str 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(strings.TrimSpace(extraHost)) + addHost(extraHost) } } for from, to := range explicit { @@ -49,6 +59,32 @@ func SSHToHTTPSRewrites(remoteURLs []string, extraHosts []string) map[string]str 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) { @@ -78,6 +114,9 @@ func sshPrefix(remoteURL string) (prefix string, host string, ok bool) { 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 diff --git a/pkg/git/ssh_to_https_test.go b/pkg/git/ssh_to_https_test.go index 1900d06e3..fad738460 100644 --- a/pkg/git/ssh_to_https_test.go +++ b/pkg/git/ssh_to_https_test.go @@ -30,7 +30,12 @@ func TestSSHPrefix(t *testing.T) { {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 { @@ -51,20 +56,53 @@ func TestSSHToHTTPSRewrites(t *testing.T) { []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/", @@ -90,6 +128,7 @@ func TestSSHToHTTPSAppliedByGit(t *testing.T) { 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 @@ -101,6 +140,7 @@ func TestSSHToHTTPSAppliedByGit(t *testing.T) { "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", }) @@ -113,6 +153,7 @@ func TestSSHToHTTPSAppliedByGit(t *testing.T) { "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 diff --git a/pkg/gitcredentials/gitcredentials.go b/pkg/gitcredentials/gitcredentials.go index a01635fa6..5f2752c30 100644 --- a/pkg/gitcredentials/gitcredentials.go +++ b/pkg/gitcredentials/gitcredentials.go @@ -319,6 +319,8 @@ func isDevPodCredentialHelper(line string) bool { 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) { if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { return filepath.Join(xdgConfigHome, "git", "config"), nil 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 55028804c..3356d48f5 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -198,8 +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 bool `json:"gitSshToHttps,omitempty"` - GitSSHToHTTPSHosts []string `json:"gitSshToHttpsHosts,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 }