From 1c379a765070858a6912eeecfcbfec87d484c489 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 26 Oct 2026 08:53:01 -0600 Subject: [PATCH 1/2] fix(tunnel): preserve literal host agent paths --- cmd/internal/logs_daemon.go | 11 +- cmd/machine/ssh.go | 6 +- cmd/workspace/logs.go | 11 +- cmd/workspace/up/agent.go | 9 +- .../clientimplementation/workspace_client.go | 13 +- pkg/tunnel/container.go | 17 +- pkg/tunnel/container_host_test.go | 188 ++++++++++++++++++ .../docs/developing-providers/options.mdx | 3 + 8 files changed, 229 insertions(+), 29 deletions(-) create mode 100644 pkg/tunnel/container_host_test.go diff --git a/cmd/internal/logs_daemon.go b/cmd/internal/logs_daemon.go index c85d4c8d4d..025757ebe7 100644 --- a/cmd/internal/logs_daemon.go +++ b/cmd/internal/logs_daemon.go @@ -8,6 +8,7 @@ import ( "os" "time" + "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" @@ -69,13 +70,13 @@ func (cmd *LogsDaemonCmd) Run(ctx context.Context, args []string) error { } command := fmt.Sprintf( - "%q internal agent workspace logs-daemon --context %q --id %q", - workspaceClient.AgentPath(), - workspaceClient.Context(), - workspaceClient.Workspace(), + "%s internal agent workspace logs-daemon --context %s --id %s", + shellescape.Quote(workspaceClient.AgentPath()), + shellescape.Quote(workspaceClient.Context()), + shellescape.Quote(workspaceClient.Workspace()), ) if agentInfo.Agent.DataPath != "" { - command += fmt.Sprintf(" --agent-dir %q", agentInfo.Agent.DataPath) + command += " --agent-dir " + shellescape.Quote(agentInfo.Agent.DataPath) } var stdout bytes.Buffer diff --git a/cmd/machine/ssh.go b/cmd/machine/ssh.go index 8a21dccbee..8ebe3c6fed 100644 --- a/cmd/machine/ssh.go +++ b/cmd/machine/ssh.go @@ -151,7 +151,11 @@ func (cmd *SSHCmd) Run(ctx context.Context, args []string) error { }, Exec: func(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { c := strings.Builder{} - _, _ = fmt.Fprintf(&c, "'%s' internal ssh-server --stdio", machineClient.AgentPath()) + _, _ = fmt.Fprintf( + &c, + "%s internal ssh-server --stdio", + shellescape.Quote(machineClient.AgentPath()), + ) if cmd.Debug { _, _ = c.WriteString(" --debug") } diff --git a/cmd/workspace/logs.go b/cmd/workspace/logs.go index dc664277f3..ffdab3424c 100644 --- a/cmd/workspace/logs.go +++ b/cmd/workspace/logs.go @@ -7,6 +7,7 @@ import ( "os" "time" + "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/cmd/completion" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/agent" @@ -64,7 +65,7 @@ func (cmd *LogsCmd) Run(ctx context.Context, args []string) error { return err } - sshServerCmd := fmt.Sprintf("'%s' internal ssh-server --stdio", client.AgentPath()) + sshServerCmd := shellescape.Quote(client.AgentPath()) + " internal ssh-server --stdio" if log.DebugEnabled() { sshServerCmd += " --debug" } @@ -167,10 +168,10 @@ func runLogsSession(stdout, stdin *os.File, client clientpkg.WorkspaceClient) er defer func() { _ = session.Close() }() agentCommand := fmt.Sprintf( - "%q internal agent workspace logs --context %q --id %q", - client.AgentPath(), - client.Context(), - client.Workspace(), + "%s internal agent workspace logs --context %s --id %s", + shellescape.Quote(client.AgentPath()), + shellescape.Quote(client.Context()), + shellescape.Quote(client.Workspace()), ) if log.DebugEnabled() { agentCommand += " --debug" diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 3980872830..de7de21d5a 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -6,6 +6,7 @@ import ( "io" "os" + "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/pkg/agent" "github.com/devsy-org/devsy/pkg/agent/tunnelserver" client2 "github.com/devsy-org/devsy/pkg/client" @@ -207,15 +208,15 @@ func (cmd *UpCmd) devsyUpMachineSSH( return nil, fmt.Errorf("get agent info: %w", err) } - sshTunnelCmd := fmt.Sprintf("'%s' internal ssh-server --stdio", client.AgentPath()) + sshTunnelCmd := shellescape.Quote(client.AgentPath()) + " internal ssh-server --stdio" if log.DebugEnabled() { sshTunnelCmd += " --debug" //nolint:goconst } agentCommand := fmt.Sprintf( - "%q internal agent workspace up --workspace-info %q", - client.AgentPath(), - workspaceInfo, + "%s internal agent workspace up --workspace-info %s", + shellescape.Quote(client.AgentPath()), + shellescape.Quote(workspaceInfo), ) if log.DebugEnabled() { agentCommand += " --debug" diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 16f96e43aa..84e5721a34 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/pkg/agent" "github.com/devsy-org/devsy/pkg/agent/tunnelserver" "github.com/devsy-org/devsy/pkg/client" @@ -721,10 +722,10 @@ func (s *workspaceClient) agentWorkspaceCommand(subcommand string) (string, erro } return fmt.Sprintf( - "%q internal agent workspace %s --workspace-info %q", - info.Agent.Path, + "%s internal agent workspace %s --workspace-info %s", + shellescape.Quote(info.Agent.Path), subcommand, - compressed, + shellescape.Quote(compressed), ), nil } @@ -1118,10 +1119,10 @@ func buildAgentCommand( agentCommand, workspaceInfo string, ) string { command := fmt.Sprintf( - "%q internal agent workspace %s --workspace-info %q", - workspaceClient.AgentPath(), + "%s internal agent workspace %s --workspace-info %s", + shellescape.Quote(workspaceClient.AgentPath()), agentCommand, - workspaceInfo, + shellescape.Quote(workspaceInfo), ) if log.DebugEnabled() { command += " --debug" diff --git a/pkg/tunnel/container.go b/pkg/tunnel/container.go index 63dc544401..12203532ce 100644 --- a/pkg/tunnel/container.go +++ b/pkg/tunnel/container.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/pkg/agent" "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" @@ -131,7 +132,7 @@ func (c *ContainerTunnel) runHostTunnel( defer closeAndDrain() defer log.Debugf("Tunnel to host closed") - command := fmt.Sprintf("'%s' internal ssh-server --stdio", c.client.AgentPath()) + command := shellescape.Quote(c.client.AgentPath()) + " internal ssh-server --stdio" if log.DebugEnabled() { command += " --debug" } @@ -188,12 +189,12 @@ func (c *ContainerTunnel) updateConfig(ctx context.Context, sshClient *ssh.Clien buf := &bytes.Buffer{} command := fmt.Sprintf( - "%q internal agent workspace update-config --workspace-info %q", - c.client.AgentPath(), - workspaceInfo, + "%s internal agent workspace update-config --workspace-info %s", + shellescape.Quote(c.client.AgentPath()), + shellescape.Quote(workspaceInfo), ) if agentInfo.Agent.DataPath != "" { - command += fmt.Sprintf(" --agent-dir %q", agentInfo.Agent.DataPath) + command += " --agent-dir " + shellescape.Quote(agentInfo.Agent.DataPath) } log.Debugf("Run command in container: %s", command) @@ -228,9 +229,9 @@ func (c *ContainerTunnel) runInContainer( defer closeAndDrain() command := fmt.Sprintf( - "%q internal agent container-tunnel --workspace-info %q", - c.client.AgentPath(), - workspaceInfo, + "%s internal agent container-tunnel --workspace-info %s", + shellescape.Quote(c.client.AgentPath()), + shellescape.Quote(workspaceInfo), ) if log.DebugEnabled() { command += " --debug" diff --git a/pkg/tunnel/container_host_test.go b/pkg/tunnel/container_host_test.go new file mode 100644 index 0000000000..26dbf24948 --- /dev/null +++ b/pkg/tunnel/container_host_test.go @@ -0,0 +1,188 @@ +//go:build unix + +package tunnel + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/shell" + devssh "github.com/devsy-org/devsy/pkg/ssh" + hostssh "github.com/devsy-org/ssh" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" +) + +const hostTunnelStdioFlag = "--stdio" + +type localHostTunnelClient struct { + client.WorkspaceClient + path string + info string + dataPath string + refresh func(context.Context) error +} + +func (c localHostTunnelClient) AgentPath() string { return c.path } +func (localHostTunnelClient) AgentURL() string { return "" } +func (localHostTunnelClient) AgentLocal() bool { return true } + +func (c localHostTunnelClient) AgentInfo( + provider.CLIOptions, +) (string, *provider.AgentWorkspaceInfo, error) { + return c.info, &provider.AgentWorkspaceInfo{ + Agent: provider.ProviderAgentConfig{DataPath: c.dataPath}, + }, nil +} + +func (c localHostTunnelClient) RefreshOptions(ctx context.Context, _ []string, _ bool) error { + return c.refresh(ctx) +} + +func TestHostTunnelExecutesLiteralAgentPath(t *testing.T) { + for _, name := range []string{"agent space", "agent'quote", "agent '\\$-\u03bb;$(false)"} { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), name) + writeHostAgent(t, path, "#!/bin/sh\nprintf '%s\\000' \"$0\" \"$@\"\ncat\n") + var stdout bytes.Buffer + tunnel := NewContainerTunnel(localHostTunnelClient{path: path}) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + require.NoError( + t, + tunnel.runHostTunnel(ctx, strings.NewReader("host-input\n"), &stdout, time.Second), + ) + args := []string{path, "internal", "ssh-server", hostTunnelStdioFlag} + if log.DebugEnabled() { + args = append(args, "--debug") + } + require.Equal(t, strings.Join(args, "\x00")+"\x00host-input\n", stdout.String()) + }) + } +} + +func TestContainerTunnelPreservesLiteralHostArguments(t *testing.T) { + for _, mode := range []string{"container-tunnel", "update-config"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent '$HOME-\\\u03bb") + record := filepath.Join(dir, "arguments") + t.Setenv("HOST_TUNNEL_RECORD", record) + root := writeHostAgent( + t, + path, + "#!/bin/sh\nprintf '%s\\000' \"$0\" \"$@\" > \"$HOST_TUNNEL_RECORD\"\n", + ) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + workspace := localHostTunnelClient{ + path: path, + info: "workspace-info", + dataPath: filepath.Join(dir, "data $HOME"), + } + calls := 0 + workspace.refresh = func(ctx context.Context) error { + calls++ + if calls > 1 { + cancel() + } + return ctx.Err() + } + tunnel := NewContainerTunnel(workspace) + client := localHostShellSSH(t) + args := []string{path, "internal", "agent"} + if mode == "update-config" { + tunnel.updateConfigInterval = time.Millisecond + tunnel.updateConfig(ctx, client) + args = append( + args, + "workspace", + mode, + "--workspace-info", + workspace.info, + "--agent-dir", + workspace.dataPath, + ) + } else { + _ = tunnel.runInContainer(ctx, client, func(context.Context, *ssh.Client) error { + t.Error("fixture does not serve an inner SSH connection") + return nil + }, nil) + args = append(args, mode, "--workspace-info", workspace.info) + if log.DebugEnabled() { + args = append(args, "--debug") + } + } + got, err := root.ReadFile("arguments") + require.NoError(t, err) + require.Equal(t, strings.Join(args, "\x00")+"\x00", string(got)) + }) + } +} + +func writeHostAgent(t *testing.T, path, script string) *os.Root { + t.Helper() + root, err := os.OpenRoot(filepath.Dir(path)) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, root.Close()) }) + name := filepath.Base(path) + require.NoError(t, root.WriteFile(name, []byte(script), 0o600)) + require.NoError(t, root.Chmod(name, 0o700)) + return root +} + +func localHostShellSSH(t *testing.T) *ssh.Client { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + signer, err := ssh.NewSignerFromKey(key) + require.NoError(t, err) + server := &hostssh.Server{Handler: func(session hostssh.Session) { + err := shell.RunEmulatedShell(session.Context(), &shell.CommandRunner{ + Command: session.RawCommand(), + Stdin: session, + Stdout: session, + Stderr: session.Stderr(), + }) + if err != nil { + _ = session.Exit(1) + return + } + _ = session.Exit(0) + }} + server.AddHostKey(signer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + done := make(chan struct{}) + go func() { + defer close(done) + _ = server.Serve(listener) + }() + t.Cleanup(func() { + _ = server.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Error("host SSH fixture did not stop") + } + }) + conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + client, err := devssh.ClientFromConn(conn, "fixture", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + return client +} diff --git a/sites/docs-devsy-sh/content/docs/developing-providers/options.mdx b/sites/docs-devsy-sh/content/docs/developing-providers/options.mdx index 35ed39479c..d539e17398 100644 --- a/sites/docs-devsy-sh/content/docs/developing-providers/options.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-providers/options.mdx @@ -86,3 +86,6 @@ optionGroups: ``` Groups are collapsed unless `defaultVisible` is `true`. + +If your provider exposes `AGENT_PATH`, its value is a literal path, not a shell command. +Host-tunnel startup escapes the resolved path, including spaces, apostrophes and shell metacharacters. From d0857e5ad52ada3b79a48aed0e8a83c77f1b8404 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 26 Oct 2026 11:28:00 -0600 Subject: [PATCH 2/2] fix(tunnel): synchronize refresh command output --- pkg/tunnel/container.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/tunnel/container.go b/pkg/tunnel/container.go index 12203532ce..101421b3a2 100644 --- a/pkg/tunnel/container.go +++ b/pkg/tunnel/container.go @@ -187,7 +187,7 @@ func (c *ContainerTunnel) updateConfig(ctx context.Context, sshClient *ssh.Clien break } - buf := &bytes.Buffer{} + buf := &containerTunnelOutput{} command := fmt.Sprintf( "%s internal agent workspace update-config --workspace-info %s", shellescape.Quote(c.client.AgentPath()), @@ -266,3 +266,20 @@ func (c *ContainerTunnel) runInContainer( }, }) } + +type containerTunnelOutput struct { + mu sync.Mutex + buffer bytes.Buffer +} + +func (b *containerTunnelOutput) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.Write(p) +} + +func (b *containerTunnelOutput) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.String() +}