diff --git a/client_surface_test.go b/client_surface_test.go index 0b1e602..d02650f 100644 --- a/client_surface_test.go +++ b/client_surface_test.go @@ -497,3 +497,149 @@ 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", `"/"`) + }) +} + +// 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 7e8e9e1..2f64e47 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -165,3 +165,92 @@ 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) + } + }) +} + +// 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 54ee331..72acc68 100644 --- a/unstable/unstable_test.go +++ b/unstable/unstable_test.go @@ -148,6 +148,25 @@ 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) + } + // 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 2e87ba4..fcd2212 100644 --- a/v2/client_test.go +++ b/v2/client_test.go @@ -426,3 +426,69 @@ func TestV2NewSessionUsesStoredInitialization(t *testing.T) { t.Fatalf("session ID = %q", session.SessionID) } } + +// 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() + 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) + } + 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) + } + 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 7180590..06bd3eb 100644 --- a/v2/mcp/mcp_test.go +++ b/v2/mcp/mcp_test.go @@ -66,8 +66,14 @@ 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), }); err != nil { t.Fatal(err) }