From 49bd71a51ea59d9cd2a793034c513f86c78e03f5 Mon Sep 17 00:00:00 2001 From: Adrien Friggeri Date: Tue, 27 Jan 2026 21:55:54 +0000 Subject: [PATCH 1/2] feat(auth): add githubToken and useLoggedInUser options to all SDK clients Enable SDK clients to customize authentication when spawning the CLI server. Node.js: - Add githubToken and useLoggedInUser options to CopilotClientOptions - Set COPILOT_SDK_AUTH_TOKEN env var and pass --auth-token-env flag - Pass --no-auto-login when useLoggedInUser is false - Default useLoggedInUser to false when githubToken is provided Python: - Add github_token and use_logged_in_user options - Same behavior as Node.js SDK Go: - Add GithubToken and UseLoggedInUser fields to ClientOptions - Same behavior as Node.js SDK .NET: - Add GithubToken and UseLoggedInUser properties to CopilotClientOptions - Same behavior as Node.js SDK All SDKs include validation to prevent use with cliUrl (external server) and tests for the new options. --- dotnet/src/Client.cs | 25 +++++++++++++ dotnet/src/Types.cs | 15 ++++++++ dotnet/test/ClientTests.cs | 71 +++++++++++++++++++++++++++++++++++ go/client.go | 33 +++++++++++++++- go/client_test.go | 77 ++++++++++++++++++++++++++++++++++++++ go/types.go | 10 +++++ nodejs/src/client.ts | 31 ++++++++++++++- nodejs/src/types.ts | 15 ++++++++ nodejs/test/client.test.ts | 67 +++++++++++++++++++++++++++++++++ python/copilot/client.py | 33 ++++++++++++++++ python/copilot/types.py | 9 +++++ python/test_client.py | 44 ++++++++++++++++++++++ 12 files changed, 428 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 88946eef..34b45d2c 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -90,6 +90,12 @@ public CopilotClient(CopilotClientOptions? options = null) throw new ArgumentException("CliUrl is mutually exclusive with UseStdio and CliPath"); } + // Validate auth options with external server + if (!string.IsNullOrEmpty(_options.CliUrl) && (!string.IsNullOrEmpty(_options.GithubToken) || _options.UseLoggedInUser != null)) + { + throw new ArgumentException("GithubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)"); + } + _logger = _options.Logger ?? NullLogger.Instance; // Parse CliUrl if provided @@ -657,6 +663,19 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio args.AddRange(["--port", options.Port.ToString()]); } + // Add auth-related flags + if (!string.IsNullOrEmpty(options.GithubToken)) + { + args.AddRange(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]); + } + + // Default UseLoggedInUser to false when GithubToken is provided + var useLoggedInUser = options.UseLoggedInUser ?? (string.IsNullOrEmpty(options.GithubToken) ? true : false); + if (!useLoggedInUser) + { + args.Add("--no-auto-login"); + } + var (fileName, processArgs) = ResolveCliCommand(cliPath, args); var startInfo = new ProcessStartInfo @@ -682,6 +701,12 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio startInfo.Environment.Remove("NODE_DEBUG"); + // Set auth token in environment if provided + if (!string.IsNullOrEmpty(options.GithubToken)) + { + startInfo.Environment["COPILOT_SDK_AUTH_TOKEN"] = options.GithubToken; + } + var cliProcess = new Process { StartInfo = startInfo }; cliProcess.Start(); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 24b4fc2e..cfc9a7c2 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -35,6 +35,21 @@ public class CopilotClientOptions public bool AutoRestart { get; set; } = true; public IReadOnlyDictionary? Environment { get; set; } public ILogger? Logger { get; set; } + + /// + /// GitHub token to use for authentication. + /// When provided, the token is passed to the CLI server via environment variable. + /// This takes priority over other authentication methods. + /// + public string? GithubToken { get; set; } + + /// + /// Whether to use the logged-in user for authentication. + /// When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. + /// When false, only explicit tokens (GithubToken or environment variables) are used. + /// Default: true (but defaults to false when GithubToken is provided). + /// + public bool? UseLoggedInUser { get; set; } } public class ToolBinaryResult diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index 23b0d9d9..f433e677 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -172,4 +172,75 @@ public async Task Should_List_Models_When_Authenticated() await client.ForceStopAsync(); } } + + [Fact] + public void Should_Accept_GithubToken_Option() + { + var options = new CopilotClientOptions + { + CliPath = _cliPath, + GithubToken = "gho_test_token" + }; + + Assert.Equal("gho_test_token", options.GithubToken); + } + + [Fact] + public void Should_Default_UseLoggedInUser_To_Null() + { + var options = new CopilotClientOptions { CliPath = _cliPath }; + + Assert.Null(options.UseLoggedInUser); + } + + [Fact] + public void Should_Allow_Explicit_UseLoggedInUser_False() + { + var options = new CopilotClientOptions + { + CliPath = _cliPath, + UseLoggedInUser = false + }; + + Assert.False(options.UseLoggedInUser); + } + + [Fact] + public void Should_Allow_Explicit_UseLoggedInUser_True_With_GithubToken() + { + var options = new CopilotClientOptions + { + CliPath = _cliPath, + GithubToken = "gho_test_token", + UseLoggedInUser = true + }; + + Assert.True(options.UseLoggedInUser); + } + + [Fact] + public void Should_Throw_When_GithubToken_Used_With_CliUrl() + { + Assert.Throws(() => + { + _ = new CopilotClient(new CopilotClientOptions + { + CliUrl = "localhost:8080", + GithubToken = "gho_test_token" + }); + }); + } + + [Fact] + public void Should_Throw_When_UseLoggedInUser_Used_With_CliUrl() + { + Assert.Throws(() => + { + _ = new CopilotClient(new CopilotClientOptions + { + CliUrl = "localhost:8080", + UseLoggedInUser = false + }); + }); + } } diff --git a/go/client.go b/go/client.go index 95ca7398..afbcf7fa 100644 --- a/go/client.go +++ b/go/client.go @@ -115,6 +115,11 @@ func NewClient(options *ClientOptions) *Client { panic("CLIUrl is mutually exclusive with UseStdio and CLIPath") } + // Validate auth options with external server + if options.CLIUrl != "" && (options.GithubToken != "" || options.UseLoggedInUser != nil) { + panic("GithubToken and UseLoggedInUser cannot be used with CLIUrl (external server manages its own auth)") + } + // Parse CLIUrl if provided if options.CLIUrl != "" { host, port := parseCliUrl(options.CLIUrl) @@ -148,6 +153,12 @@ func NewClient(options *ClientOptions) *Client { if options.AutoRestart != nil { client.autoRestart = *options.AutoRestart } + if options.GithubToken != "" { + opts.GithubToken = options.GithubToken + } + if options.UseLoggedInUser != nil { + opts.UseLoggedInUser = options.UseLoggedInUser + } } // Check environment variable for CLI path @@ -995,6 +1006,21 @@ func (c *Client) startCLIServer() error { args = append(args, "--port", strconv.Itoa(c.options.Port)) } + // Add auth-related flags + if c.options.GithubToken != "" { + args = append(args, "--auth-token-env", "COPILOT_SDK_AUTH_TOKEN") + } + // Default useLoggedInUser to false when GithubToken is provided + useLoggedInUser := true + if c.options.UseLoggedInUser != nil { + useLoggedInUser = *c.options.UseLoggedInUser + } else if c.options.GithubToken != "" { + useLoggedInUser = false + } + if !useLoggedInUser { + args = append(args, "--no-auto-login") + } + // If CLIPath is a .js file, run it with node // Note we can't rely on the shebang as Windows doesn't support it command := c.options.CLIPath @@ -1010,9 +1036,14 @@ func (c *Client) startCLIServer() error { c.process.Dir = c.options.Cwd } - // Set environment if specified + // Set environment if specified, adding auth token if needed if len(c.options.Env) > 0 { c.process.Env = c.options.Env + } else { + c.process.Env = os.Environ() + } + if c.options.GithubToken != "" { + c.process.Env = append(c.process.Env, "COPILOT_SDK_AUTH_TOKEN="+c.options.GithubToken) } if c.options.UseStdio { diff --git a/go/client_test.go b/go/client_test.go index 9ebc51ef..68bc3e20 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -237,6 +237,83 @@ func TestClient_URLParsing(t *testing.T) { }) } +func TestClient_AuthOptions(t *testing.T) { + t.Run("should accept GithubToken option", func(t *testing.T) { + client := NewClient(&ClientOptions{ + GithubToken: "gho_test_token", + }) + + if client.options.GithubToken != "gho_test_token" { + t.Errorf("Expected GithubToken to be 'gho_test_token', got %q", client.options.GithubToken) + } + }) + + t.Run("should default UseLoggedInUser to nil when no GithubToken", func(t *testing.T) { + client := NewClient(&ClientOptions{}) + + if client.options.UseLoggedInUser != nil { + t.Errorf("Expected UseLoggedInUser to be nil, got %v", client.options.UseLoggedInUser) + } + }) + + t.Run("should allow explicit UseLoggedInUser false", func(t *testing.T) { + client := NewClient(&ClientOptions{ + UseLoggedInUser: Bool(false), + }) + + if client.options.UseLoggedInUser == nil || *client.options.UseLoggedInUser != false { + t.Error("Expected UseLoggedInUser to be false") + } + }) + + t.Run("should allow explicit UseLoggedInUser true with GithubToken", func(t *testing.T) { + client := NewClient(&ClientOptions{ + GithubToken: "gho_test_token", + UseLoggedInUser: Bool(true), + }) + + if client.options.UseLoggedInUser == nil || *client.options.UseLoggedInUser != true { + t.Error("Expected UseLoggedInUser to be true") + } + }) + + t.Run("should throw error when GithubToken is used with CLIUrl", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic for auth options with CLIUrl") + } else { + matched, _ := regexp.MatchString("GithubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) + if !matched { + t.Errorf("Expected panic message about auth options, got: %v", r) + } + } + }() + + NewClient(&ClientOptions{ + CLIUrl: "localhost:8080", + GithubToken: "gho_test_token", + }) + }) + + t.Run("should throw error when UseLoggedInUser is used with CLIUrl", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic for auth options with CLIUrl") + } else { + matched, _ := regexp.MatchString("GithubToken and UseLoggedInUser cannot be used with CLIUrl", r.(string)) + if !matched { + t.Errorf("Expected panic message about auth options, got: %v", r) + } + } + }() + + NewClient(&ClientOptions{ + CLIUrl: "localhost:8080", + UseLoggedInUser: Bool(false), + }) + }) +} + func findCLIPathForTest() string { abs, _ := filepath.Abs("../nodejs/node_modules/@github/copilot/index.js") if fileExistsForTest(abs) { diff --git a/go/types.go b/go/types.go index 7a420cd6..4ac5bf9e 100644 --- a/go/types.go +++ b/go/types.go @@ -35,6 +35,16 @@ type ClientOptions struct { AutoRestart *bool // Env is the environment variables for the CLI process (default: inherits from current process) Env []string + // GithubToken is the GitHub token to use for authentication. + // When provided, the token is passed to the CLI server via environment variable. + // This takes priority over other authentication methods. + GithubToken string + // UseLoggedInUser controls whether to use the logged-in user for authentication. + // When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. + // When false, only explicit tokens (GithubToken or environment variables) are used. + // Default: true (but defaults to false when GithubToken is provided). + // Use Bool(false) to explicitly disable. + UseLoggedInUser *bool } // Bool returns a pointer to the given bool value. diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index a698383a..5c162ddb 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -103,7 +103,13 @@ export class CopilotClient { private actualHost: string = "localhost"; private state: ConnectionState = "disconnected"; private sessions: Map = new Map(); - private options: Required> & { cliUrl?: string }; + private options: Required< + Omit + > & { + cliUrl?: string; + githubToken?: string; + useLoggedInUser?: boolean; + }; private isExternalServer: boolean = false; private forceStopping: boolean = false; @@ -134,6 +140,13 @@ export class CopilotClient { throw new Error("cliUrl is mutually exclusive with useStdio and cliPath"); } + // Validate auth options with external server + if (options.cliUrl && (options.githubToken || options.useLoggedInUser !== undefined)) { + throw new Error( + "githubToken and useLoggedInUser cannot be used with cliUrl (external server manages its own auth)" + ); + } + // Parse cliUrl if provided if (options.cliUrl) { const { host, port } = this.parseCliUrl(options.cliUrl); @@ -153,6 +166,9 @@ export class CopilotClient { autoStart: options.autoStart ?? true, autoRestart: options.autoRestart ?? true, env: options.env ?? process.env, + githubToken: options.githubToken, + // Default useLoggedInUser to false when githubToken is provided, otherwise true + useLoggedInUser: options.useLoggedInUser ?? (options.githubToken ? false : true), }; } @@ -758,10 +774,23 @@ export class CopilotClient { args.push("--port", this.options.port.toString()); } + // Add auth-related flags + if (this.options.githubToken) { + args.push("--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"); + } + if (!this.options.useLoggedInUser) { + args.push("--no-auto-login"); + } + // Suppress debug/trace output that might pollute stdout const envWithoutNodeDebug = { ...this.options.env }; delete envWithoutNodeDebug.NODE_DEBUG; + // Set auth token in environment if provided + if (this.options.githubToken) { + envWithoutNodeDebug.COPILOT_SDK_AUTH_TOKEN = this.options.githubToken; + } + // If cliPath is a .js file, spawn it with node // Note that we can't rely on the shebang as Windows doesn't support it const isJsFile = this.options.cliPath.endsWith(".js"); diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 406fe8d5..7fa3f14b 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -74,6 +74,21 @@ export interface CopilotClientOptions { * Environment variables to pass to the CLI process. If not set, inherits process.env. */ env?: Record; + + /** + * GitHub token to use for authentication. + * When provided, the token is passed to the CLI server via environment variable. + * This takes priority over other authentication methods. + */ + githubToken?: string; + + /** + * Whether to use the logged-in user for authentication. + * When true, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. + * When false, only explicit tokens (githubToken or environment variables) are used. + * @default true (but defaults to false when githubToken is provided) + */ + useLoggedInUser?: boolean; } /** diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index b0549b05..364ff382 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -148,4 +148,71 @@ describe("CopilotClient", () => { expect((client as any).isExternalServer).toBe(true); }); }); + + describe("Auth options", () => { + it("should accept githubToken option", () => { + const client = new CopilotClient({ + githubToken: "gho_test_token", + logLevel: "error", + }); + + expect((client as any).options.githubToken).toBe("gho_test_token"); + }); + + it("should default useLoggedInUser to true when no githubToken", () => { + const client = new CopilotClient({ + logLevel: "error", + }); + + expect((client as any).options.useLoggedInUser).toBe(true); + }); + + it("should default useLoggedInUser to false when githubToken is provided", () => { + const client = new CopilotClient({ + githubToken: "gho_test_token", + logLevel: "error", + }); + + expect((client as any).options.useLoggedInUser).toBe(false); + }); + + it("should allow explicit useLoggedInUser: true with githubToken", () => { + const client = new CopilotClient({ + githubToken: "gho_test_token", + useLoggedInUser: true, + logLevel: "error", + }); + + expect((client as any).options.useLoggedInUser).toBe(true); + }); + + it("should allow explicit useLoggedInUser: false without githubToken", () => { + const client = new CopilotClient({ + useLoggedInUser: false, + logLevel: "error", + }); + + expect((client as any).options.useLoggedInUser).toBe(false); + }); + + it("should throw error when githubToken is used with cliUrl", () => { + expect(() => { + new CopilotClient({ + cliUrl: "localhost:8080", + githubToken: "gho_test_token", + logLevel: "error", + }); + }).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/); + }); + + it("should throw error when useLoggedInUser is used with cliUrl", () => { + expect(() => { + new CopilotClient({ + cliUrl: "localhost:8080", + useLoggedInUser: false, + logLevel: "error", + }); + }).toThrow(/githubToken and useLoggedInUser cannot be used with cliUrl/); + }); + }); }); diff --git a/python/copilot/client.py b/python/copilot/client.py index 6870bda4..b03bd773 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -105,6 +105,15 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): if opts.get("cli_url") and (opts.get("use_stdio") or opts.get("cli_path")): raise ValueError("cli_url is mutually exclusive with use_stdio and cli_path") + # Validate auth options with external server + if opts.get("cli_url") and ( + opts.get("github_token") or opts.get("use_logged_in_user") is not None + ): + raise ValueError( + "github_token and use_logged_in_user cannot be used with cli_url " + "(external server manages its own auth)" + ) + # Parse cli_url if provided self._actual_host: str = "localhost" self._is_external_server: bool = False @@ -117,6 +126,13 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): # Check environment variable for CLI path default_cli_path = os.environ.get("COPILOT_CLI_PATH", "copilot") + + # Default use_logged_in_user to False when github_token is provided + github_token = opts.get("github_token") + use_logged_in_user = opts.get("use_logged_in_user") + if use_logged_in_user is None: + use_logged_in_user = False if github_token else True + self.options: CopilotClientOptions = { "cli_path": opts.get("cli_path", default_cli_path), "cwd": opts.get("cwd", os.getcwd()), @@ -125,11 +141,14 @@ def __init__(self, options: Optional[CopilotClientOptions] = None): "log_level": opts.get("log_level", "info"), "auto_start": opts.get("auto_start", True), "auto_restart": opts.get("auto_restart", True), + "use_logged_in_user": use_logged_in_user, } if opts.get("cli_url"): self.options["cli_url"] = opts["cli_url"] if opts.get("env"): self.options["env"] = opts["env"] + if github_token: + self.options["github_token"] = github_token self._process: Optional[subprocess.Popen] = None self._client: Optional[JsonRpcClient] = None @@ -798,6 +817,12 @@ async def _start_cli_server(self) -> None: cli_path = self.options["cli_path"] args = ["--server", "--log-level", self.options["log_level"]] + # Add auth-related flags + if self.options.get("github_token"): + args.extend(["--auth-token-env", "COPILOT_SDK_AUTH_TOKEN"]) + if not self.options.get("use_logged_in_user", True): + args.append("--no-auto-login") + # If cli_path is a .js file, run it with node # Note that we can't rely on the shebang as Windows doesn't support it if cli_path.endswith(".js"): @@ -807,6 +832,14 @@ async def _start_cli_server(self) -> None: # Get environment variables env = self.options.get("env") + if env is None: + env = dict(os.environ) + else: + env = dict(env) + + # Set auth token in environment if provided + if self.options.get("github_token"): + env["COPILOT_SDK_AUTH_TOKEN"] = self.options["github_token"] # Choose transport mode if self.options["use_stdio"]: diff --git a/python/copilot/types.py b/python/copilot/types.py index bb64dd98..67bbdd29 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -49,6 +49,15 @@ class CopilotClientOptions(TypedDict, total=False): # Auto-restart the CLI server if it crashes (default: True) auto_restart: bool env: dict[str, str] # Environment variables for the CLI process + # GitHub token to use for authentication. + # When provided, the token is passed to the CLI server via environment variable. + # This takes priority over other authentication methods. + github_token: str + # Whether to use the logged-in user for authentication. + # When True, the CLI server will attempt to use stored OAuth tokens or gh CLI auth. + # When False, only explicit tokens (github_token or environment variables) are used. + # Default: True (but defaults to False when github_token is provided) + use_logged_in_user: bool ToolResultType = Literal["success", "failure", "rejected", "denied"] diff --git a/python/test_client.py b/python/test_client.py index c53e1494..d0500727 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -92,3 +92,47 @@ def test_use_stdio_false_when_cli_url(self): def test_is_external_server_true(self): client = CopilotClient({"cli_url": "localhost:8080", "log_level": "error"}) assert client._is_external_server + + +class TestAuthOptions: + def test_accepts_github_token(self): + client = CopilotClient({"github_token": "gho_test_token", "log_level": "error"}) + assert client.options.get("github_token") == "gho_test_token" + + def test_default_use_logged_in_user_true_without_token(self): + client = CopilotClient({"log_level": "error"}) + assert client.options.get("use_logged_in_user") is True + + def test_default_use_logged_in_user_false_with_token(self): + client = CopilotClient({"github_token": "gho_test_token", "log_level": "error"}) + assert client.options.get("use_logged_in_user") is False + + def test_explicit_use_logged_in_user_true_with_token(self): + client = CopilotClient( + {"github_token": "gho_test_token", "use_logged_in_user": True, "log_level": "error"} + ) + assert client.options.get("use_logged_in_user") is True + + def test_explicit_use_logged_in_user_false_without_token(self): + client = CopilotClient({"use_logged_in_user": False, "log_level": "error"}) + assert client.options.get("use_logged_in_user") is False + + def test_github_token_with_cli_url_raises(self): + with pytest.raises( + ValueError, match="github_token and use_logged_in_user cannot be used with cli_url" + ): + CopilotClient( + { + "cli_url": "localhost:8080", + "github_token": "gho_test_token", + "log_level": "error", + } + ) + + def test_use_logged_in_user_with_cli_url_raises(self): + with pytest.raises( + ValueError, match="github_token and use_logged_in_user cannot be used with cli_url" + ): + CopilotClient( + {"cli_url": "localhost:8080", "use_logged_in_user": False, "log_level": "error"} + ) From 6165aaec7c2e7713669f8123fe98d6d500b0633f Mon Sep 17 00:00:00 2001 From: Adrien Friggeri Date: Tue, 27 Jan 2026 16:27:47 -0700 Subject: [PATCH 2/2] Potential fix for pull request finding 'Unnecessarily complex Boolean expression' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- dotnet/src/Client.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 34b45d2c..a31bdaf2 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -670,7 +670,7 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio } // Default UseLoggedInUser to false when GithubToken is provided - var useLoggedInUser = options.UseLoggedInUser ?? (string.IsNullOrEmpty(options.GithubToken) ? true : false); + var useLoggedInUser = options.UseLoggedInUser ?? string.IsNullOrEmpty(options.GithubToken); if (!useLoggedInUser) { args.Add("--no-auto-login");