Skip to content

Commit 662f270

Browse files
authored
Add copilotHome option for configurable data directory (#1191)
1 parent 180ca47 commit 662f270

20 files changed

Lines changed: 186 additions & 25 deletions

dotnet/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ new CopilotClient(CopilotClientOptions? options = null)
7575
- `LogLevel` - Log level (default: "info")
7676
- `AutoStart` - Auto-start server (default: true)
7777
- `Cwd` - Working directory for the CLI process
78+
- `CopilotHome` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned CLI process. When not set, the CLI defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when using `CliUrl`.
7879
- `Environment` - Environment variables to pass to the CLI process
7980
- `Logger` - `ILogger` instance for SDK logging
8081
- `GitHubToken` - GitHub token for authentication. When provided, takes priority over other auth methods.

dotnet/src/Client.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,11 @@ private async Task VerifyProtocolVersionAsync(Connection connection, Cancellatio
12611261
startInfo.Environment["COPILOT_CONNECTION_TOKEN"] = connectionToken;
12621262
}
12631263

1264+
if (!string.IsNullOrEmpty(options.CopilotHome))
1265+
{
1266+
startInfo.Environment["COPILOT_HOME"] = options.CopilotHome;
1267+
}
1268+
12641269
// Set telemetry environment variables if configured
12651270
if (options.Telemetry is { } telemetry)
12661271
{

dotnet/src/Types.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ protected CopilotClientOptions(CopilotClientOptions? other)
5959
CliPath = other.CliPath;
6060
CliUrl = other.CliUrl;
6161
Cwd = other.Cwd;
62+
CopilotHome = other.CopilotHome;
6263
Environment = other.Environment;
6364
GitHubToken = other.GitHubToken;
6465
Logger = other.Logger;
@@ -86,6 +87,14 @@ protected CopilotClientOptions(CopilotClientOptions? other)
8687
/// </summary>
8788
public string? Cwd { get; set; }
8889
/// <summary>
90+
/// Base directory for Copilot data (session state, config, etc.).
91+
/// Sets the <c>COPILOT_HOME</c> environment variable on the spawned CLI process.
92+
/// When <see langword="null"/>, the CLI defaults to <c>~/.copilot</c>.
93+
/// This option is only used when the SDK spawns the CLI process; it is ignored
94+
/// when connecting to an external server via <see cref="CliUrl"/>.
95+
/// </summary>
96+
public string? CopilotHome { get; set; }
97+
/// <summary>
8998
/// Port number for the CLI server when not using stdio transport.
9099
/// </summary>
91100
public int Port { get; set; }

dotnet/test/E2E/ClientOptionsE2ETests.cs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,19 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli()
9393
var cliPath = Path.Join(Ctx.WorkDir, $"fake-cli-{Guid.NewGuid():N}.js");
9494
var capturePath = Path.Join(Ctx.WorkDir, $"fake-cli-capture-{Guid.NewGuid():N}.json");
9595
var telemetryPath = Path.Join(Ctx.WorkDir, "telemetry.jsonl");
96+
var copilotHomeFromEnv = Path.Join(Ctx.WorkDir, "copilot-home-from-env");
97+
var copilotHomeFromOption = Path.Join(Ctx.WorkDir, "copilot-home-from-option");
98+
var clientEnv = Ctx.GetEnvironment().ToDictionary(pair => pair.Key, pair => pair.Value);
99+
clientEnv["COPILOT_HOME"] = copilotHomeFromEnv;
96100
await File.WriteAllTextAsync(cliPath, FakeStdioCliScript);
97101

98102
await using var client = Ctx.CreateClient(options: new CopilotClientOptions
99103
{
100104
AutoStart = false,
101105
CliPath = cliPath,
102106
CliArgs = ["--capture-file", capturePath],
107+
CopilotHome = copilotHomeFromOption,
108+
Environment = clientEnv,
103109
GitHubToken = "process-option-token",
104110
LogLevel = "debug",
105111
SessionIdleTimeoutSeconds = 17,
@@ -119,7 +125,7 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli()
119125
using var capture = JsonDocument.Parse(await File.ReadAllTextAsync(capturePath));
120126
var root = capture.RootElement;
121127
var args = root.GetProperty("args").EnumerateArray().Select(e => e.GetString()).ToArray();
122-
var env = root.GetProperty("env");
128+
var capturedEnv = root.GetProperty("env");
123129

124130
AssertArgumentValue(args, "--log-level", "debug");
125131
Assert.Contains("--stdio", args);
@@ -128,13 +134,14 @@ public async Task Should_Propagate_Process_Options_To_Spawned_Cli()
128134
AssertArgumentValue(args, "--session-idle-timeout", "17");
129135
Assert.Equal(Path.GetFullPath(Ctx.WorkDir), root.GetProperty("cwd").GetString());
130136

131-
Assert.Equal("process-option-token", env.GetProperty("COPILOT_SDK_AUTH_TOKEN").GetString());
132-
Assert.Equal("true", env.GetProperty("COPILOT_OTEL_ENABLED").GetString());
133-
Assert.Equal("http://127.0.0.1:4318", env.GetProperty("OTEL_EXPORTER_OTLP_ENDPOINT").GetString());
134-
Assert.Equal(telemetryPath, env.GetProperty("COPILOT_OTEL_FILE_EXPORTER_PATH").GetString());
135-
Assert.Equal("file", env.GetProperty("COPILOT_OTEL_EXPORTER_TYPE").GetString());
136-
Assert.Equal("dotnet-sdk-e2e", env.GetProperty("COPILOT_OTEL_SOURCE_NAME").GetString());
137-
Assert.Equal("true", env.GetProperty("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT").GetString());
137+
Assert.Equal(copilotHomeFromOption, capturedEnv.GetProperty("COPILOT_HOME").GetString());
138+
Assert.Equal("process-option-token", capturedEnv.GetProperty("COPILOT_SDK_AUTH_TOKEN").GetString());
139+
Assert.Equal("true", capturedEnv.GetProperty("COPILOT_OTEL_ENABLED").GetString());
140+
Assert.Equal("http://127.0.0.1:4318", capturedEnv.GetProperty("OTEL_EXPORTER_OTLP_ENDPOINT").GetString());
141+
Assert.Equal(telemetryPath, capturedEnv.GetProperty("COPILOT_OTEL_FILE_EXPORTER_PATH").GetString());
142+
Assert.Equal("file", capturedEnv.GetProperty("COPILOT_OTEL_EXPORTER_TYPE").GetString());
143+
Assert.Equal("dotnet-sdk-e2e", capturedEnv.GetProperty("COPILOT_OTEL_SOURCE_NAME").GetString());
144+
Assert.Equal("true", capturedEnv.GetProperty("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT").GetString());
138145

139146
var session = await client.CreateSessionAsync(new SessionConfig
140147
{
@@ -281,6 +288,7 @@ function saveCapture() {
281288
cwd: process.cwd(),
282289
requests,
283290
env: {
291+
COPILOT_HOME: process.env.COPILOT_HOME,
284292
COPILOT_SDK_AUTH_TOKEN: process.env.COPILOT_SDK_AUTH_TOKEN,
285293
COPILOT_OTEL_ENABLED: process.env.COPILOT_OTEL_ENABLED,
286294
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

dotnet/test/Unit/CloneTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
2626
Environment = new Dictionary<string, string> { ["KEY"] = "value" },
2727
GitHubToken = "ghp_test",
2828
UseLoggedInUser = false,
29+
CopilotHome = "/custom/copilot/home",
2930
SessionIdleTimeoutSeconds = 600,
3031
};
3132

@@ -43,6 +44,7 @@ public void CopilotClientOptions_Clone_CopiesAllProperties()
4344
Assert.Equal(original.Environment, clone.Environment);
4445
Assert.Equal(original.GitHubToken, clone.GitHubToken);
4546
Assert.Equal(original.UseLoggedInUser, clone.UseLoggedInUser);
47+
Assert.Equal(original.CopilotHome, clone.CopilotHome);
4648
Assert.Equal(original.SessionIdleTimeoutSeconds, clone.SessionIdleTimeoutSeconds);
4749
}
4850

go/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
133133
- `CLIPath` (string): Path to CLI executable (default: "copilot" or `COPILOT_CLI_PATH` env var)
134134
- `CLIUrl` (string): URL of existing CLI server (e.g., `"localhost:8080"`, `"http://127.0.0.1:9000"`, or just `"8080"`). When provided, the client will not spawn a CLI process.
135135
- `Cwd` (string): Working directory for CLI process
136+
- `CopilotHome` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned CLI process. When empty, the CLI defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when using `CLIUrl`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location. You can vary `CopilotHome` per client independently of the shared extracted binary location.
136137
- `Port` (int): Server port for TCP mode (default: 0 for random)
137138
- `UseStdio` (bool): Use stdio transport instead of TCP (default: true)
138139
- `LogLevel` (string): Log level (default: "info")

go/client.go

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@ func NewClient(options *ClientOptions) *Client {
231231
if options.Telemetry != nil {
232232
opts.Telemetry = options.Telemetry
233233
}
234+
if options.CopilotHome != "" {
235+
opts.CopilotHome = options.CopilotHome
236+
}
234237
opts.SessionIdleTimeoutSeconds = options.SessionIdleTimeoutSeconds
235238
}
236239

@@ -270,6 +273,19 @@ func getEnvValue(env []string, key string) string {
270273
return ""
271274
}
272275

276+
// setEnvValue returns a copy of env with all existing entries for key removed and
277+
// a single trailing KEY=VALUE entry added so SDK-managed values win deterministically.
278+
func setEnvValue(env []string, key string, value string) []string {
279+
prefix := key + "="
280+
filtered := make([]string, 0, len(env)+1)
281+
for _, entry := range env {
282+
if !strings.HasPrefix(entry, prefix) {
283+
filtered = append(filtered, entry)
284+
}
285+
}
286+
return append(filtered, key+"="+value)
287+
}
288+
273289
// parseCliUrl parses a CLI URL into host and port components.
274290
//
275291
// Supports formats: "host:port", "http://host:port", "https://host:port", or just "port".
@@ -1462,37 +1478,40 @@ func (c *Client) startCLIServer(ctx context.Context) error {
14621478
c.process.Dir = c.options.Cwd
14631479
}
14641480

1465-
// Add auth token if needed.
1466-
c.process.Env = c.options.Env
1481+
c.process.Env = append([]string{}, c.options.Env...)
14671482
if c.options.GitHubToken != "" {
1468-
c.process.Env = append(c.process.Env, "COPILOT_SDK_AUTH_TOKEN="+c.options.GitHubToken)
1483+
c.process.Env = setEnvValue(c.process.Env, "COPILOT_SDK_AUTH_TOKEN", c.options.GitHubToken)
14691484
}
14701485

14711486
if c.effectiveConnectionToken != "" {
1472-
c.process.Env = append(c.process.Env, "COPILOT_CONNECTION_TOKEN="+c.effectiveConnectionToken)
1487+
c.process.Env = setEnvValue(c.process.Env, "COPILOT_CONNECTION_TOKEN", c.effectiveConnectionToken)
1488+
}
1489+
1490+
if c.options.CopilotHome != "" {
1491+
c.process.Env = setEnvValue(c.process.Env, "COPILOT_HOME", c.options.CopilotHome)
14731492
}
14741493

14751494
if c.options.Telemetry != nil {
14761495
t := c.options.Telemetry
1477-
c.process.Env = append(c.process.Env, "COPILOT_OTEL_ENABLED=true")
1496+
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_ENABLED", "true")
14781497
if t.OTLPEndpoint != "" {
1479-
c.process.Env = append(c.process.Env, "OTEL_EXPORTER_OTLP_ENDPOINT="+t.OTLPEndpoint)
1498+
c.process.Env = setEnvValue(c.process.Env, "OTEL_EXPORTER_OTLP_ENDPOINT", t.OTLPEndpoint)
14801499
}
14811500
if t.FilePath != "" {
1482-
c.process.Env = append(c.process.Env, "COPILOT_OTEL_FILE_EXPORTER_PATH="+t.FilePath)
1501+
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_FILE_EXPORTER_PATH", t.FilePath)
14831502
}
14841503
if t.ExporterType != "" {
1485-
c.process.Env = append(c.process.Env, "COPILOT_OTEL_EXPORTER_TYPE="+t.ExporterType)
1504+
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_EXPORTER_TYPE", t.ExporterType)
14861505
}
14871506
if t.SourceName != "" {
1488-
c.process.Env = append(c.process.Env, "COPILOT_OTEL_SOURCE_NAME="+t.SourceName)
1507+
c.process.Env = setEnvValue(c.process.Env, "COPILOT_OTEL_SOURCE_NAME", t.SourceName)
14891508
}
14901509
if t.CaptureContent != nil {
14911510
val := "false"
14921511
if *t.CaptureContent {
14931512
val = "true"
14941513
}
1495-
c.process.Env = append(c.process.Env, "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT="+val)
1514+
c.process.Env = setEnvValue(c.process.Env, "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", val)
14961515
}
14971516
}
14981517

go/client_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,26 @@ func TestClient_AuthOptions(t *testing.T) {
344344
})
345345
}
346346

347+
func TestClient_CopilotHome(t *testing.T) {
348+
t.Run("should accept CopilotHome option", func(t *testing.T) {
349+
client := NewClient(&ClientOptions{
350+
CopilotHome: "/custom/copilot/home",
351+
})
352+
353+
if client.options.CopilotHome != "/custom/copilot/home" {
354+
t.Errorf("Expected CopilotHome to be '/custom/copilot/home', got %q", client.options.CopilotHome)
355+
}
356+
})
357+
358+
t.Run("should default CopilotHome to empty string", func(t *testing.T) {
359+
client := NewClient(&ClientOptions{})
360+
361+
if client.options.CopilotHome != "" {
362+
t.Errorf("Expected CopilotHome to be empty, got %q", client.options.CopilotHome)
363+
}
364+
})
365+
}
366+
347367
func TestClient_EnvOptions(t *testing.T) {
348368
t.Run("should store custom environment variables", func(t *testing.T) {
349369
client := NewClient(&ClientOptions{

go/internal/e2e/client_options_e2e_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ func TestClientOptionsE2E(t *testing.T) {
141141
opts.AutoStart = copilot.Bool(false)
142142
opts.CLIPath = cliPath
143143
opts.CLIArgs = []string{"--capture-file", capturePath}
144+
opts.CopilotHome = filepath.Join(ctx.WorkDir, "copilot-home-from-option")
145+
opts.Env = append([]string{}, opts.Env...)
146+
opts.Env = append(opts.Env, "COPILOT_HOME="+filepath.Join(ctx.WorkDir, "copilot-home-from-env"))
144147
opts.GitHubToken = "process-option-token"
145148
opts.LogLevel = "debug"
146149
opts.SessionIdleTimeoutSeconds = 17
@@ -179,6 +182,7 @@ func TestClientOptionsE2E(t *testing.T) {
179182
}
180183

181184
expectEnv := map[string]string{
185+
"COPILOT_HOME": filepath.Join(ctx.WorkDir, "copilot-home-from-option"),
182186
"COPILOT_SDK_AUTH_TOKEN": "process-option-token",
183187
"COPILOT_OTEL_ENABLED": "true",
184188
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:4318",
@@ -402,6 +406,7 @@ function saveCapture() {
402406
cwd: process.cwd(),
403407
requests,
404408
env: {
409+
COPILOT_HOME: process.env.COPILOT_HOME,
405410
COPILOT_SDK_AUTH_TOKEN: process.env.COPILOT_SDK_AUTH_TOKEN,
406411
COPILOT_OTEL_ENABLED: process.env.COPILOT_OTEL_ENABLED,
407412
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

go/internal/embeddedcli/embeddedcli.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,16 @@ func install() (path string) {
8484
}
8585
installDir := config.Dir
8686
if installDir == "" {
87-
var err error
88-
if installDir, err = os.UserCacheDir(); err != nil {
89-
// Fall back to temp dir if UserCacheDir is unavailable
90-
installDir = os.TempDir()
87+
if copilotHome := os.Getenv("COPILOT_HOME"); copilotHome != "" {
88+
installDir = filepath.Join(copilotHome, "cache", "copilot-sdk")
89+
} else {
90+
var err error
91+
if installDir, err = os.UserCacheDir(); err != nil {
92+
// Fall back to temp dir if UserCacheDir is unavailable
93+
installDir = os.TempDir()
94+
}
95+
installDir = filepath.Join(installDir, "copilot-sdk")
9196
}
92-
installDir = filepath.Join(installDir, "copilot-sdk")
9397
}
9498
path, err := installAt(installDir)
9599
if err != nil {

0 commit comments

Comments
 (0)