From 5cc02e935dfc6dfe898705e7a4df1f55a7b92fd7 Mon Sep 17 00:00:00 2001 From: Ryan SVIHLA Date: Wed, 30 Sep 2026 14:34:00 +0200 Subject: [PATCH 1/2] Test every agent-side path validator against either-platform paths Refs #47. --- client_surface_test.go | 71 +++++++++++++++++++++++++++++++++++++++ mcp/mcp_test.go | 43 ++++++++++++++++++++++++ unstable/unstable_test.go | 8 +++++ v2/client_test.go | 33 ++++++++++++++++++ v2/mcp/mcp_test.go | 1 + 5 files changed, 156 insertions(+) diff --git a/client_surface_test.go b/client_surface_test.go index 0b1e602..2ef4b9f 100644 --- a/client_surface_test.go +++ b/client_surface_test.go @@ -497,3 +497,74 @@ func TestNewSessionAcceptsPOSIXAbsoluteAgentPath(t *testing.T) { } assertJSONKey(t, request.Params, "cwd", `"/"`) } + +// TestNewSessionAcceptsWindowsAbsoluteAgentPath covers the other direction of +// issue #47: a POSIX client must be able to address a Windows agent with a +// drive-absolute or UNC path. +func TestNewSessionAcceptsWindowsAbsoluteAgentPath(t *testing.T) { + ctx := context.Background() + for _, cwd := range []string{`C:\agent\workspace`, `C:/agent/workspace`, `\\server\share`} { + c, requests := singleRequestFixture(t, `{"sessionId":"windows-agent"}`) + created, err := c.NewSessionWithOptions(ctx, allCapabilities(), cwd, NewSessionOptions{}) + if err != nil { + t.Fatalf("cwd %q: %v", cwd, err) + } + if created.SessionID != "windows-agent" { + t.Fatalf("cwd %q: session ID = %q", cwd, created.SessionID) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionNewMethodName { + t.Fatalf("cwd %q: method = %s", cwd, request.Method) + } + want, err := json.Marshal(cwd) + if err != nil { + t.Fatal(err) + } + assertJSONKey(t, request.Params, "cwd", string(want)) + } +} + +// TestSessionPathsAcceptPOSIXAbsoluteAgentPaths covers issue #47: every +// agent-side session entry point must accept a path that is absolute on the +// agent's platform. On Windows, filepath.IsAbs rejects "/", so these fail if a +// call site stops delegating to acpvalidate.AbsolutePath. +func TestSessionPathsAcceptPOSIXAbsoluteAgentPaths(t *testing.T) { + ctx := context.Background() + init := allCapabilities() + + t.Run("load", func(t *testing.T) { + c, requests := singleRequestFixture(t, `{}`) + if _, err := c.LoadSession(ctx, init, schema.LoadSessionRequest{SessionID: "old", Cwd: "/"}); err != nil { + t.Fatal(err) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionLoadMethodName { + t.Fatalf("method = %s", request.Method) + } + assertJSONKey(t, request.Params, "cwd", `"/"`) + }) + + t.Run("resume", func(t *testing.T) { + c, requests := singleRequestFixture(t, `{}`) + if _, err := c.ResumeSession(ctx, init, schema.ResumeSessionRequest{SessionID: "old", Cwd: "/"}); err != nil { + t.Fatal(err) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionResumeMethodName { + t.Fatalf("method = %s", request.Method) + } + assertJSONKey(t, request.Params, "cwd", `"/"`) + }) + + t.Run("list", func(t *testing.T) { + c, requests := singleRequestFixture(t, `{"sessions":[]}`) + if _, err := c.ListSessions(ctx, init, schema.ListSessionsRequest{Cwd: strPtr("/")}); err != nil { + t.Fatal(err) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionListMethodName { + t.Fatalf("method = %s", request.Method) + } + assertJSONKey(t, request.Params, "cwd", `"/"`) + }) +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 7e8e9e1..aaf3d0a 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -165,3 +165,46 @@ func TestResumeAndForkGateOnCapabilities(t *testing.T) { t.Fatalf("method = %s", method) } } + +// TestSessionPathsAcceptPOSIXAbsoluteAgentPaths covers issue #47 for the +// unstable v1 MCP session helpers: each one must accept "/" even when the +// client host treats it as relative. +func TestSessionPathsAcceptPOSIXAbsoluteAgentPaths(t *testing.T) { + ctx := context.Background() + initialization := schema.InitializeResponse{AgentCapabilities: &schema.AgentCapabilities{ + SessionCapabilities: &schema.SessionCapabilities{ + Resume: &schema.SessionResumeCapabilities{}, + Fork: &schema.SessionForkCapabilities{}, + }, + }} + + t.Run("new", func(t *testing.T) { + connection, requests := fixture(t, func(string) any { return map[string]any{"sessionId": "s"} }) + if _, err := mcp.NewSession(ctx, connection, initialization, "/", mcp.NewSessionOptions{}); err != nil { + t.Fatal(err) + } + if method := <-requests; method != schema.SessionNewMethodName { + t.Fatalf("method = %s", method) + } + }) + + t.Run("resume", func(t *testing.T) { + connection, requests := fixture(t, func(string) any { return map[string]any{} }) + if _, err := mcp.ResumeSession(ctx, connection, initialization, schema.ResumeSessionRequest{Cwd: "/", SessionID: "old"}); err != nil { + t.Fatal(err) + } + if method := <-requests; method != schema.SessionResumeMethodName { + t.Fatalf("method = %s", method) + } + }) + + t.Run("fork", func(t *testing.T) { + connection, requests := fixture(t, func(string) any { return map[string]any{"sessionId": "forked"} }) + if _, err := mcp.ForkSession(ctx, connection, initialization, schema.ForkSessionRequest{Cwd: "/", SessionID: "old"}); err != nil { + t.Fatal(err) + } + if method := <-requests; method != schema.SessionForkMethodName { + t.Fatalf("method = %s", method) + } + }) +} diff --git a/unstable/unstable_test.go b/unstable/unstable_test.go index 54ee331..da2250d 100644 --- a/unstable/unstable_test.go +++ b/unstable/unstable_test.go @@ -148,6 +148,14 @@ func TestForkSessionGatesAndEncodes(t *testing.T) { if request := <-requests; request.method != schema.SessionForkMethodName { t.Fatalf("method = %s", request.method) } + // Issue #47: a Windows client must be able to fork into a POSIX agent + // working directory even though filepath.IsAbs rejects "/" there. + if _, err := acpunstable.ForkSession(ctx, connection, initialization, schema.ForkSessionRequest{Cwd: "/", SessionID: "old"}); err != nil { + t.Fatal(err) + } + if request := <-requests; request.method != schema.SessionForkMethodName { + t.Fatalf("method = %s", request.method) + } } type testHandler struct{} diff --git a/v2/client_test.go b/v2/client_test.go index 2e87ba4..a442bd8 100644 --- a/v2/client_test.go +++ b/v2/client_test.go @@ -426,3 +426,36 @@ func TestV2NewSessionUsesStoredInitialization(t *testing.T) { t.Fatalf("session ID = %q", session.SessionID) } } + +// TestV2SessionPathsAcceptPOSIXAbsoluteAgentPaths covers issue #47 for the +// draft-v2 session entry points besides NewSession. +func TestV2SessionPathsAcceptPOSIXAbsoluteAgentPaths(t *testing.T) { + ctx := context.Background() + client, _ := pipeClient(t, func(_ context.Context, method string, raw json.RawMessage) (any, error) { + switch method { + case schema.SessionResumeMethodName: + request := decode[schema.ResumeSessionRequest](t, raw) + if request.Cwd != "/" { + t.Errorf("resume cwd = %q", request.Cwd) + } + return schema.ResumeSessionResponse{}, nil + case schema.SessionListMethodName: + request := decode[schema.ListSessionsRequest](t, raw) + if request.Cwd == nil || *request.Cwd != "/" { + t.Errorf("list cwd = %v", request.Cwd) + } + return schema.ListSessionsResponse{}, nil + default: + return nil, &acp.RPCError{Code: -32601} + } + }, nil) + + initialization := sessionInitialization() + if _, err := client.ResumeSession(ctx, initialization, schema.ResumeSessionRequest{SessionID: "old", Cwd: "/"}); err != nil { + t.Fatal(err) + } + cwd := schema.AbsolutePath("/") + if _, err := client.ListSessions(ctx, initialization, schema.ListSessionsRequest{Cwd: &cwd}); err != nil { + t.Fatal(err) + } +} diff --git a/v2/mcp/mcp_test.go b/v2/mcp/mcp_test.go index 7180590..3571569 100644 --- a/v2/mcp/mcp_test.go +++ b/v2/mcp/mcp_test.go @@ -68,6 +68,7 @@ func TestValidatePathsAcceptsAbsolutePathsFromEitherPlatform(t *testing.T) { } if err := validateServers(initialization(true), []schema.McpServer{ NewStdioServer("local", "/opt/mcp", nil, nil), + NewStdioServer("windows", `C:\tools\mcp.exe`, nil, nil), }); err != nil { t.Fatal(err) } From 9a5f1f709fe0ed0a02241f04b7314489e0ffd0da Mon Sep 17 00:00:00 2001 From: Ryan SVIHLA Date: Fri, 2 Oct 2026 09:36:08 +0200 Subject: [PATCH 2/2] Cover either-platform additional directories and both path directions Issue #47 lists the additional-directories loops and asks for the POSIX-to-Windows direction. Add drive-absolute and UNC cases for the v1, unstable, and draft-v2 session entry points, and exercise additional directories with a mixed POSIX/Windows list. --- client_surface_test.go | 75 +++++++++++++++++++++++++++++++++ mcp/mcp_test.go | 46 +++++++++++++++++++++ unstable/unstable_test.go | 11 +++++ v2/client_test.go | 87 +++++++++++++++++++++++++++------------ v2/mcp/mcp_test.go | 5 +++ 5 files changed, 197 insertions(+), 27 deletions(-) diff --git a/client_surface_test.go b/client_surface_test.go index 2ef4b9f..d02650f 100644 --- a/client_surface_test.go +++ b/client_surface_test.go @@ -568,3 +568,78 @@ func TestSessionPathsAcceptPOSIXAbsoluteAgentPaths(t *testing.T) { assertJSONKey(t, request.Params, "cwd", `"/"`) }) } + +// TestSessionPathsAcceptWindowsAbsoluteAgentPaths covers the other direction of +// issue #47 for the v1 load, resume, and list validators: a POSIX client must +// be able to address a Windows agent with a drive-absolute cwd. +func TestSessionPathsAcceptWindowsAbsoluteAgentPaths(t *testing.T) { + ctx := context.Background() + init := allCapabilities() + cwd := `C:\agent\workspace` + want, err := json.Marshal(cwd) + if err != nil { + t.Fatal(err) + } + + t.Run("load", func(t *testing.T) { + c, requests := singleRequestFixture(t, `{}`) + if _, err := c.LoadSession(ctx, init, schema.LoadSessionRequest{SessionID: "old", Cwd: cwd}); err != nil { + t.Fatal(err) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionLoadMethodName { + t.Fatalf("method = %s", request.Method) + } + assertJSONKey(t, request.Params, "cwd", string(want)) + }) + + t.Run("resume", func(t *testing.T) { + c, requests := singleRequestFixture(t, `{}`) + if _, err := c.ResumeSession(ctx, init, schema.ResumeSessionRequest{SessionID: "old", Cwd: cwd}); err != nil { + t.Fatal(err) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionResumeMethodName { + t.Fatalf("method = %s", request.Method) + } + assertJSONKey(t, request.Params, "cwd", string(want)) + }) + + t.Run("list", func(t *testing.T) { + c, requests := singleRequestFixture(t, `{"sessions":[]}`) + if _, err := c.ListSessions(ctx, init, schema.ListSessionsRequest{Cwd: strPtr(cwd)}); err != nil { + t.Fatal(err) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionListMethodName { + t.Fatalf("method = %s", request.Method) + } + assertJSONKey(t, request.Params, "cwd", string(want)) + }) +} + +// TestNewSessionAcceptsEitherPlatformAdditionalDirectories covers the +// additionalDirectories validator shared by session/new, session/load, and +// session/resume: a mixed list must survive the path check even though the +// first entry is absolute only on POSIX and the others only on Windows. +func TestNewSessionAcceptsEitherPlatformAdditionalDirectories(t *testing.T) { + ctx := context.Background() + directories := []string{"/", `C:\agent\extra`, `\\server\share\extra`} + c, requests := singleRequestFixture(t, `{"sessionId":"either-platform"}`) + created, err := c.NewSessionWithOptions(ctx, allCapabilities(), `C:\agent\workspace`, NewSessionOptions{AdditionalDirectories: directories}) + if err != nil { + t.Fatal(err) + } + if created.SessionID != "either-platform" { + t.Fatalf("session ID = %q", created.SessionID) + } + request := receiveRequest(t, requests) + if request.Method != schema.SessionNewMethodName { + t.Fatalf("method = %s", request.Method) + } + want, err := json.Marshal(directories) + if err != nil { + t.Fatal(err) + } + assertJSONKey(t, request.Params, "additionalDirectories", string(want)) +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index aaf3d0a..2f64e47 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -208,3 +208,49 @@ func TestSessionPathsAcceptPOSIXAbsoluteAgentPaths(t *testing.T) { } }) } + +// TestSessionPathsAcceptWindowsAbsoluteAgentPaths covers the other direction of +// issue #47 for the unstable v1 MCP session helpers, including the additional +// directories they share. +func TestSessionPathsAcceptWindowsAbsoluteAgentPaths(t *testing.T) { + ctx := context.Background() + initialization := schema.InitializeResponse{AgentCapabilities: &schema.AgentCapabilities{ + SessionCapabilities: &schema.SessionCapabilities{ + AdditionalDirectories: &schema.SessionAdditionalDirectoriesCapabilities{}, + Resume: &schema.SessionResumeCapabilities{}, + Fork: &schema.SessionForkCapabilities{}, + }, + }} + cwd := `C:\agent\workspace` + additional := []string{"/", `C:\agent\extra`} + + t.Run("new", func(t *testing.T) { + connection, requests := fixture(t, func(string) any { return map[string]any{"sessionId": "s"} }) + if _, err := mcp.NewSession(ctx, connection, initialization, cwd, mcp.NewSessionOptions{AdditionalDirectories: additional}); err != nil { + t.Fatal(err) + } + if method := <-requests; method != schema.SessionNewMethodName { + t.Fatalf("method = %s", method) + } + }) + + t.Run("resume", func(t *testing.T) { + connection, requests := fixture(t, func(string) any { return map[string]any{} }) + if _, err := mcp.ResumeSession(ctx, connection, initialization, schema.ResumeSessionRequest{Cwd: cwd, SessionID: "old", AdditionalDirectories: additional}); err != nil { + t.Fatal(err) + } + if method := <-requests; method != schema.SessionResumeMethodName { + t.Fatalf("method = %s", method) + } + }) + + t.Run("fork", func(t *testing.T) { + connection, requests := fixture(t, func(string) any { return map[string]any{"sessionId": "forked"} }) + if _, err := mcp.ForkSession(ctx, connection, initialization, schema.ForkSessionRequest{Cwd: cwd, SessionID: "old", AdditionalDirectories: additional}); err != nil { + t.Fatal(err) + } + if method := <-requests; method != schema.SessionForkMethodName { + t.Fatalf("method = %s", method) + } + }) +} diff --git a/unstable/unstable_test.go b/unstable/unstable_test.go index da2250d..72acc68 100644 --- a/unstable/unstable_test.go +++ b/unstable/unstable_test.go @@ -156,6 +156,17 @@ func TestForkSessionGatesAndEncodes(t *testing.T) { if request := <-requests; request.method != schema.SessionForkMethodName { t.Fatalf("method = %s", request.method) } + // Issue #47: the POSIX-to-Windows direction and mixed additional + // directories must also pass through the unstable fork facade. + if _, err := acpunstable.ForkSession(ctx, connection, initialization, schema.ForkSessionRequest{ + Cwd: `C:\agent\workspace`, SessionID: "old", + AdditionalDirectories: []string{"/", `C:\agent\extra`}, + }); err != nil { + t.Fatal(err) + } + if request := <-requests; request.method != schema.SessionForkMethodName { + t.Fatalf("method = %s", request.method) + } } type testHandler struct{} diff --git a/v2/client_test.go b/v2/client_test.go index a442bd8..fcd2212 100644 --- a/v2/client_test.go +++ b/v2/client_test.go @@ -427,35 +427,68 @@ func TestV2NewSessionUsesStoredInitialization(t *testing.T) { } } -// TestV2SessionPathsAcceptPOSIXAbsoluteAgentPaths covers issue #47 for the -// draft-v2 session entry points besides NewSession. -func TestV2SessionPathsAcceptPOSIXAbsoluteAgentPaths(t *testing.T) { +// TestV2SessionPathsAcceptEitherPlatformAbsolutePaths covers issue #47 for the +// draft-v2 session entry points: every client-side validator must accept a path +// that is absolute on the agent's platform, whichever platform the client runs +// on, including the additional directories resume carries. +func TestV2SessionPathsAcceptEitherPlatformAbsolutePaths(t *testing.T) { ctx := context.Background() - client, _ := pipeClient(t, func(_ context.Context, method string, raw json.RawMessage) (any, error) { - switch method { - case schema.SessionResumeMethodName: - request := decode[schema.ResumeSessionRequest](t, raw) - if request.Cwd != "/" { - t.Errorf("resume cwd = %q", request.Cwd) + cases := []struct { + name string + cwd string + }{ + {name: "posix root", cwd: "/"}, + {name: "windows drive", cwd: `C:\agent\workspace`}, + {name: "windows unc", cwd: `\\server\share`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, _ := pipeClient(t, func(_ context.Context, method string, raw json.RawMessage) (any, error) { + switch method { + case schema.SessionNewMethodName: + request := decode[schema.NewSessionRequest](t, raw) + if string(request.Cwd) != tc.cwd { + t.Errorf("new cwd = %q", request.Cwd) + } + return schema.NewSessionResponse{SessionID: "v2-session"}, nil + case schema.SessionResumeMethodName: + request := decode[schema.ResumeSessionRequest](t, raw) + if string(request.Cwd) != tc.cwd { + t.Errorf("resume cwd = %q", request.Cwd) + } + if len(request.AdditionalDirectories) != 2 || + string(request.AdditionalDirectories[0]) != "/" || + string(request.AdditionalDirectories[1]) != `C:\agent\extra` { + t.Errorf("additional directories = %v", request.AdditionalDirectories) + } + return schema.ResumeSessionResponse{}, nil + case schema.SessionListMethodName: + request := decode[schema.ListSessionsRequest](t, raw) + if request.Cwd == nil || string(*request.Cwd) != tc.cwd { + t.Errorf("list cwd = %v", request.Cwd) + } + return schema.ListSessionsResponse{}, nil + default: + return nil, &acp.RPCError{Code: -32601} + } + }, nil) + + initialization := sessionInitialization() + initialization.Capabilities.Session.AdditionalDirectories = &schema.SessionAdditionalDirectoriesCapabilities{} + if _, err := client.NewSessionWithOptions(ctx, initialization, tc.cwd, NewSessionOptions{}); err != nil { + t.Fatal(err) } - return schema.ResumeSessionResponse{}, nil - case schema.SessionListMethodName: - request := decode[schema.ListSessionsRequest](t, raw) - if request.Cwd == nil || *request.Cwd != "/" { - t.Errorf("list cwd = %v", request.Cwd) + if _, err := client.ResumeSession(ctx, initialization, schema.ResumeSessionRequest{ + SessionID: "old", + Cwd: schema.AbsolutePath(tc.cwd), + AdditionalDirectories: []schema.AbsolutePath{"/", `C:\agent\extra`}, + }); err != nil { + t.Fatal(err) } - return schema.ListSessionsResponse{}, nil - default: - return nil, &acp.RPCError{Code: -32601} - } - }, nil) - - initialization := sessionInitialization() - if _, err := client.ResumeSession(ctx, initialization, schema.ResumeSessionRequest{SessionID: "old", Cwd: "/"}); err != nil { - t.Fatal(err) - } - cwd := schema.AbsolutePath("/") - if _, err := client.ListSessions(ctx, initialization, schema.ListSessionsRequest{Cwd: &cwd}); err != nil { - t.Fatal(err) + cwd := schema.AbsolutePath(tc.cwd) + if _, err := client.ListSessions(ctx, initialization, schema.ListSessionsRequest{Cwd: &cwd}); err != nil { + t.Fatal(err) + } + }) } } diff --git a/v2/mcp/mcp_test.go b/v2/mcp/mcp_test.go index 3571569..06bd3eb 100644 --- a/v2/mcp/mcp_test.go +++ b/v2/mcp/mcp_test.go @@ -66,6 +66,11 @@ func TestValidatePathsAcceptsAbsolutePathsFromEitherPlatform(t *testing.T) { if err := validatePaths(initialization(true), "/", nil); err != nil { t.Fatal(err) } + withAdditional := initialization(true) + withAdditional.Capabilities.Session.AdditionalDirectories = &schema.SessionAdditionalDirectoriesCapabilities{} + if err := validatePaths(withAdditional, `C:\agent\workspace`, []string{"/", `C:\agent\extra`}); err != nil { + t.Fatal(err) + } if err := validateServers(initialization(true), []schema.McpServer{ NewStdioServer("local", "/opt/mcp", nil, nil), NewStdioServer("windows", `C:\tools\mcp.exe`, nil, nil),