From 1069ce6e19399baedbbe900b6d1f27d7c79c1ed6 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 20 Feb 2026 16:27:32 +0400 Subject: [PATCH] feat: add support for agentsock on Windows (#22171) relates to #21335 Adds support for the agentsock and thus `coder exp sync` commands on Windows. This support was initially missing. --- agent/agentsocket/server_test.go | 105 +--------------------------- agent/agentsocket/service_test.go | 28 +++----- agent/agentsocket/socket_windows.go | 53 ++++++++++++-- cli/sync_test.go | 16 +++-- go.mod | 2 +- testutil/unixsocket.go | 10 +++ 6 files changed, 80 insertions(+), 134 deletions(-) diff --git a/agent/agentsocket/server_test.go b/agent/agentsocket/server_test.go index 6f1bc468ae..1c3454b969 100644 --- a/agent/agentsocket/server_test.go +++ b/agent/agentsocket/server_test.go @@ -1,37 +1,22 @@ package agentsocket_test import ( - "context" - "path/filepath" - "runtime" "testing" - "github.com/google/uuid" - "github.com/spf13/afero" "github.com/stretchr/testify/require" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agentsocket" - "github.com/coder/coder/v2/agent/agenttest" - agentproto "github.com/coder/coder/v2/agent/proto" - "github.com/coder/coder/v2/codersdk/agentsdk" - "github.com/coder/coder/v2/tailnet" - "github.com/coder/coder/v2/tailnet/tailnettest" "github.com/coder/coder/v2/testutil" ) func TestServer(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("agentsocket is not supported on Windows") - } - t.Run("StartStop", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(t.TempDir(), "test.sock") + socketPath := testutil.AgentSocketPath(t) logger := slog.Make().Leveled(slog.LevelDebug) server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath)) require.NoError(t, err) @@ -41,7 +26,7 @@ func TestServer(t *testing.T) { t.Run("AlreadyStarted", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(t.TempDir(), "test.sock") + socketPath := testutil.AgentSocketPath(t) logger := slog.Make().Leveled(slog.LevelDebug) server1, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath)) require.NoError(t, err) @@ -49,90 +34,4 @@ func TestServer(t *testing.T) { _, err = agentsocket.NewServer(logger, agentsocket.WithPath(socketPath)) require.ErrorContains(t, err, "create socket") }) - - t.Run("AutoSocketPath", func(t *testing.T) { - t.Parallel() - - socketPath := filepath.Join(t.TempDir(), "test.sock") - logger := slog.Make().Leveled(slog.LevelDebug) - server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath)) - require.NoError(t, err) - require.NoError(t, server.Close()) - }) -} - -func TestServerWindowsNotSupported(t *testing.T) { - t.Parallel() - - if runtime.GOOS != "windows" { - t.Skip("this test only runs on Windows") - } - - t.Run("NewServer", func(t *testing.T) { - t.Parallel() - - socketPath := filepath.Join(t.TempDir(), "test.sock") - logger := slog.Make().Leveled(slog.LevelDebug) - _, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath)) - require.ErrorContains(t, err, "agentsocket is not supported on Windows") - }) - - t.Run("NewClient", func(t *testing.T) { - t.Parallel() - - _, err := agentsocket.NewClient(context.Background(), agentsocket.WithPath("test.sock")) - require.ErrorContains(t, err, "agentsocket is not supported on Windows") - }) -} - -func TestAgentInitializesOnWindowsWithoutSocketServer(t *testing.T) { - t.Parallel() - - if runtime.GOOS != "windows" { - t.Skip("this test only runs on Windows") - } - - ctx := testutil.Context(t, testutil.WaitShort) - logger := testutil.Logger(t).Named("agent") - - derpMap, _ := tailnettest.RunDERPAndSTUN(t) - - coordinator := tailnet.NewCoordinator(logger) - t.Cleanup(func() { - _ = coordinator.Close() - }) - - statsCh := make(chan *agentproto.Stats, 50) - agentID := uuid.New() - manifest := agentsdk.Manifest{ - AgentID: agentID, - AgentName: "test-agent", - WorkspaceName: "test-workspace", - OwnerName: "test-user", - WorkspaceID: uuid.New(), - DERPMap: derpMap, - } - - client := agenttest.NewClient(t, logger.Named("agenttest"), agentID, manifest, statsCh, coordinator) - t.Cleanup(client.Close) - - options := agent.Options{ - Client: client, - Filesystem: afero.NewMemMapFs(), - Logger: logger.Named("agent"), - ReconnectingPTYTimeout: testutil.WaitShort, - EnvironmentVariables: map[string]string{}, - SocketPath: "", - } - - agnt := agent.New(options) - t.Cleanup(func() { - _ = agnt.Close() - }) - - startup := testutil.TryReceive(ctx, t, client.GetStartup()) - require.NotNil(t, startup, "agent should send startup message") - - err := agnt.Close() - require.NoError(t, err, "agent should close cleanly") } diff --git a/agent/agentsocket/service_test.go b/agent/agentsocket/service_test.go index 83c53ee4b8..170250ab1b 100644 --- a/agent/agentsocket/service_test.go +++ b/agent/agentsocket/service_test.go @@ -2,8 +2,6 @@ package agentsocket_test import ( "context" - "path/filepath" - "runtime" "testing" "github.com/stretchr/testify/require" @@ -30,14 +28,10 @@ func newSocketClient(ctx context.Context, t *testing.T, socketPath string) *agen func TestDRPCAgentSocketService(t *testing.T) { t.Parallel() - if runtime.GOOS == "windows" { - t.Skip("agentsocket is not supported on Windows") - } - t.Run("Ping", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -57,7 +51,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("NewUnit", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -79,7 +73,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("UnitAlreadyStarted", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -109,7 +103,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("UnitAlreadyCompleted", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -148,7 +142,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("UnitNotReady", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -178,7 +172,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("NewUnits", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -203,7 +197,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("DependencyAlreadyRegistered", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -238,7 +232,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("DependencyAddedAfterDependentStarted", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -280,7 +274,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("UnregisteredUnit", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -299,7 +293,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("UnitNotReady", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), @@ -323,7 +317,7 @@ func TestDRPCAgentSocketService(t *testing.T) { t.Run("UnitReady", func(t *testing.T) { t.Parallel() - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) ctx := testutil.Context(t, testutil.WaitShort) server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), diff --git a/agent/agentsocket/socket_windows.go b/agent/agentsocket/socket_windows.go index e39c8ae3d9..964106a2fa 100644 --- a/agent/agentsocket/socket_windows.go +++ b/agent/agentsocket/socket_windows.go @@ -4,19 +4,60 @@ package agentsocket import ( "context" + "fmt" "net" + "os" + "os/user" + "strings" + "github.com/Microsoft/go-winio" "golang.org/x/xerrors" ) -func createSocket(_ string) (net.Listener, error) { - return nil, xerrors.New("agentsocket is not supported on Windows") +const defaultSocketPath = `\\.\pipe\com.coder.agentsocket` + +func createSocket(path string) (net.Listener, error) { + if path == "" { + path = defaultSocketPath + } + if !strings.HasPrefix(path, `\\.\pipe\`) { + return nil, xerrors.Errorf("%q is not a valid local socket path", path) + } + + user, err := user.Current() + if err != nil { + return nil, fmt.Errorf("unable to look up current user: %w", err) + } + sid := user.Uid + + // SecurityDescriptor is in SDDL format. c.f. + // https://learn.microsoft.com/en-us/windows/win32/secauthz/security-descriptor-string-format for full details. + // D: indicates this is a Discretionary Access Control List (DACL), which is Windows-speak for ACLs that allow or + // deny access (as opposed to SACL which controls audit logging). + // P indicates that this DACL is "protected" from being modified thru inheritance + // () delimit access control entries (ACEs), here we only have one, which, allows (A) generic all (GA) access to our + // specific user's security ID (SID). + // + // Note that although Microsoft docs at https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes warns that + // named pipes are accessible from remote machines in the general case, the `winio` package sets the flag + // windows.FILE_PIPE_REJECT_REMOTE_CLIENTS when creating pipes, so connections from remote machines are always + // denied. This is important because we sort of expect customers to run the Coder agent under a generic user + // account unless they are very sophisticated. We don't want this socket to cross the boundary of the local machine. + configuration := &winio.PipeConfig{ + SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;%s)", sid), + } + + listener, err := winio.ListenPipe(path, configuration) + if err != nil { + return nil, xerrors.Errorf("failed to open named pipe: %w", err) + } + return listener, nil } -func cleanupSocket(_ string) error { - return nil +func cleanupSocket(path string) error { + return os.Remove(path) } -func dialSocket(_ context.Context, _ string) (net.Conn, error) { - return nil, xerrors.New("agentsocket is not supported on Windows") +func dialSocket(ctx context.Context, path string) (net.Conn, error) { + return winio.DialPipeContext(ctx, path) } diff --git a/cli/sync_test.go b/cli/sync_test.go index 2e4a5b2f2b..caa7b29d91 100644 --- a/cli/sync_test.go +++ b/cli/sync_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package cli_test import ( @@ -7,6 +5,7 @@ import ( "context" "os" "path/filepath" + "runtime" "testing" "time" @@ -25,12 +24,15 @@ func setupSocketServer(t *testing.T) (path string, cleanup func()) { t.Helper() // Use a temporary socket path for each test - socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "test.sock") + socketPath := testutil.AgentSocketPath(t) - // Create parent directory if needed - parentDir := filepath.Dir(socketPath) - err := os.MkdirAll(parentDir, 0o700) - require.NoError(t, err, "create socket directory") + // Create parent directory if needed. Not necessary on Windows because named pipes live in an abstract namespace + // not tied to any real files. + if runtime.GOOS != "windows" { + parentDir := filepath.Dir(socketPath) + err := os.MkdirAll(parentDir, 0o700) + require.NoError(t, err, "create socket directory") + } server, err := agentsocket.NewServer( slog.Make().Leveled(slog.LevelDebug), diff --git a/go.mod b/go.mod index 01297e8858..fceb6db49e 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 require ( cdr.dev/slog/v3 v3.0.0-rc1 cloud.google.com/go/compute/metadata v0.9.0 + github.com/Microsoft/go-winio v0.6.2 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/adrg/xdg v0.5.0 github.com/ammario/tlru v0.4.0 @@ -239,7 +240,6 @@ require ( github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.26.0 // indirect github.com/DataDog/sketches-go v1.4.7 // indirect github.com/KyleBanks/depth v1.2.1 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/agext/levenshtein v1.2.3 // indirect diff --git a/testutil/unixsocket.go b/testutil/unixsocket.go index 008c13ec94..d88847f04c 100644 --- a/testutil/unixsocket.go +++ b/testutil/unixsocket.go @@ -1,7 +1,10 @@ package testutil import ( + "crypto/rand" + "fmt" "os" + "path/filepath" "runtime" "strings" "testing" @@ -37,3 +40,10 @@ func TempDirUnixSocket(t *testing.T) string { }) return dir } + +func AgentSocketPath(t *testing.T) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf(`\\.\pipe\com.coder.agentsocket_test.%s.%s`, t.Name(), rand.Text()) + } + return filepath.Join(TempDirUnixSocket(t), "test.sock") +}