Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,37 @@ gh copilot-codespace workspaces

# Pass extra copilot flags
gh copilot-codespace --model claude-sonnet-4.5

# Opt in to local GitHub auth forwarding for this session
gh copilot-codespace --github-auth local -c my-codespace-name
```

If you launch without `-c/--codespace` or `--no-codespace`, the interactive picker supports selecting multiple codespaces. Press Enter without toggling any codespaces to start with no codespaces connected, or use `--no-codespace` to skip the picker entirely for non-interactive launches. From there, use `list_available_codespaces`, `create_codespace`, or `connect_codespace` from the agent.

## GitHub auth modes

By default, `gh-copilot-codespace` keeps using the codespace-managed GitHub auth bootstrap from `/workspaces/.codespaces/shared/.env-secrets`. That default path is unchanged, including the refresh/normalization behavior for `GITHUB_TOKEN` / `GH_TOKEN`.

If you want this session to prefer your local GitHub token instead, launch with `--github-auth local`:

```bash
# Prefer the local token for this session
gh copilot-codespace --github-auth local -c my-codespace

# Name and resume a local-auth session
gh copilot-codespace --github-auth local --name my-feature -c my-codespace
gh copilot-codespace --resume my-feature
```

Rules:

- `--github-auth codespace` (default) keeps the current codespace bootstrap behavior.
- `--github-auth local` is explicit and requires a local `GITHUB_TOKEN` or `GH_TOKEN`; the launcher fails early if neither is set.
- Local mode normalizes `GITHUB_TOKEN` / `GH_TOKEN` and forwards local `GITHUB_SERVER_URL` / `GITHUB_API_URL` when present so GitHub Enterprise-style hosts keep working.
- Explicit per-command env overrides (for example rewritten MCP server or hook env values, or `exec --env`) still win over the session-wide auth mode.

Security note: local mode does **not** write your token into workspace manifests, MCP config JSON, or rewritten hook files. The token is resolved from the local environment only at runtime. Because the forwarding path still relies on `gh codespace ssh` / remote process env injection, the token may still briefly exist in descendant process args/env while those local helper processes start.

## What gets fetched from the codespace

The launcher fetches all project-level Copilot CLI components in a single SSH call:
Expand Down Expand Up @@ -102,7 +129,7 @@ The agent can also create, connect to, and delete codespaces on the fly using `c

## Session resume

Workspace sessions are saved to `~/.copilot/workspaces/` with a manifest (`workspace.json`) tracking connected codespaces. Empty sessions are resumable too, which is useful when you want to launch first and create/connect codespaces later from the agent. Use `--resume` to reconnect:
Workspace sessions are saved to `~/.copilot/workspaces/` with a manifest (`workspace.json`) tracking connected codespaces and the selected GitHub auth mode. Empty sessions are resumable too, which is useful when you want to launch first and create/connect codespaces later from the agent. Use `--resume` to reconnect:

```bash
# First session
Expand Down Expand Up @@ -187,4 +214,5 @@ To promote a dev pre-release to `latest` (for mise users), run the "Promote to L
|---|---|---|
| `CODESPACE_NAME` | Codespace name | Launcher → MCP server |
| `CODESPACE_WORKDIR` | Working directory on codespace | Launcher → MCP server |
| `CODESPACE_GITHUB_AUTH` | Session GitHub auth mode (`codespace` or `local`) | Launcher → MCP server / local proxy helper |
| `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` | Temp dir with fetched instruction files | Launcher → copilot |
138 changes: 82 additions & 56 deletions cmd/gh-copilot-codespace/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"strings"
"testing"

"github.com/ekroon/gh-copilot-codespace/internal/codespaceenv"
)

func TestRewriteMCPServerForSSH_WithRemoteBinary(t *testing.T) {
Expand All @@ -18,7 +20,9 @@ func TestRewriteMCPServerForSSH_WithRemoteBinary(t *testing.T) {
},
}

result := rewriteMCPServerForSSH(server, "my-cs", "/workspaces/repo", "/tmp/gh-copilot-codespace-bin/gh-copilot-codespace")
t.Setenv("GITHUB_TOKEN", "local-token")

result := rewriteMCPServerForSSH("/usr/local/bin/self", codespaceenv.GitHubAuthLocal, server, "my-cs", "/workspaces/repo", "/tmp/gh-copilot-codespace-bin/gh-copilot-codespace")

if result == nil {
t.Fatal("rewriteMCPServerForSSH returned nil")
Expand All @@ -29,9 +33,9 @@ func TestRewriteMCPServerForSSH_WithRemoteBinary(t *testing.T) {
t.Fatal("args not []string")
}

// Should use structured exec, not bash -c
if args[0] != "codespace" || args[1] != "ssh" {
t.Errorf("args should start with [codespace ssh], got %v", args[:2])
// Rewritten server should invoke the local proxy helper, not gh directly.
if args[0] != "proxy" {
t.Errorf("args should start with [proxy], got %v", args[0])
}

// Should contain the remote binary path
Expand All @@ -46,22 +50,13 @@ func TestRewriteMCPServerForSSH_WithRemoteBinary(t *testing.T) {
t.Errorf("args should contain remote binary path, got %v", args)
}

// Should contain "exec" subcommand
foundExec := false
for _, a := range args {
if a == "exec" {
foundExec = true
break
}
}
if !foundExec {
t.Errorf("args should contain 'exec', got %v", args)
if got := result["command"]; got != "/usr/local/bin/self" {
t.Fatalf("command = %v, want /usr/local/bin/self", got)
}

// Should NOT contain "bash -c" (that's the old pattern)
for i, a := range args {
if a == "bash" && i+1 < len(args) && args[i+1] == "-c" {
t.Errorf("should not use 'bash -c' with remote binary, got %v", args)
for _, forbidden := range []string{"local-token", "GITHUB_TOKEN=local-token"} {
if contains(strings.Join(args, " "), forbidden) {
t.Fatalf("rewritten args should not serialize local token %q: %v", forbidden, args)
}
}

Expand All @@ -77,17 +72,12 @@ func TestRewriteMCPServerForSSH_WithRemoteBinary(t *testing.T) {
t.Errorf("args should contain '--workdir /workspaces/repo', got %v", args)
}

// Should contain --env for API_KEY
foundEnv := false
for i, a := range args {
if a == "--env" && i+1 < len(args) && args[i+1] == "API_KEY=secret" {
foundEnv = true
break
// Should contain mode and env forwarding through the helper
for _, want := range []string{"--github-auth", "local", "--env", "API_KEY=secret"} {
if !contains(strings.Join(args, "\n"), want) {
t.Errorf("args should contain %q, got %v", want, args)
}
}
if !foundEnv {
t.Errorf("args should contain '--env API_KEY=secret', got %v", args)
}

// Command should appear after --
foundSeparator := false
Expand All @@ -111,31 +101,19 @@ func TestRewriteMCPServerForSSH_FallbackWithoutBinary(t *testing.T) {
"args": []any{"server.py"},
}

result := rewriteMCPServerForSSH(server, "cs", "/workspaces/repo", "")
result := rewriteMCPServerForSSH("/usr/local/bin/self", codespaceenv.GitHubAuthCodespace, server, "cs", "/workspaces/repo", "")

if result == nil {
t.Fatal("rewriteMCPServerForSSH returned nil")
}

args := result["args"].([]string)

// Should use bash -c fallback with quoted command
foundBash := false
for i, a := range args {
if a == "bash" && i+1 < len(args) && args[i+1] == "-c" {
foundBash = true
// The command after -c should be shell-quoted (single quotes)
if i+2 < len(args) && !strings.HasPrefix(args[i+2], "'") {
t.Errorf("bash -c command should be shell-quoted, got %q", args[i+2])
}
break
}
}
if !foundBash {
t.Errorf("should use 'bash -c' fallback without remote binary, got %v", args)
if got := result["command"]; got != "/usr/local/bin/self" {
t.Fatalf("command = %v, want /usr/local/bin/self", got)
}
if bashCmd := args[len(args)-1]; !contains(bashCmd, ".env-secrets") {
t.Errorf("fallback command should bootstrap codespace auth env, got %q", bashCmd)
if !contains(strings.Join(args, " "), "proxy") {
t.Errorf("fallback rewrite should still use proxy helper, got %v", args)
}
}

Expand All @@ -154,7 +132,9 @@ func TestRewriteHooksForSSH_WithRemoteBinary(t *testing.T) {
}
}`

result := rewriteHooksForSSH([]byte(hooksJSON), "my-cs", "/workspaces/repo", "/tmp/gh-copilot-codespace-bin/gh-copilot-codespace")
t.Setenv("GITHUB_TOKEN", "local-token")

result := rewriteHooksForSSH("/usr/local/bin/self", codespaceenv.GitHubAuthLocal, []byte(hooksJSON), "my-cs", "/workspaces/repo", "/tmp/gh-copilot-codespace-bin/gh-copilot-codespace")
if result == nil {
t.Fatal("rewriteHooksForSSH returned nil")
}
Expand All @@ -169,15 +149,21 @@ func TestRewriteHooksForSSH_WithRemoteBinary(t *testing.T) {
hook := preToolUse[0].(map[string]any)
bash := hook["bash"].(string)

// Should contain the remote binary path and exec subcommand
// Should contain the proxy helper, mode, and remote binary path.
if !contains(bash, "/usr/local/bin/self") {
t.Errorf("should contain self binary path, got %q", bash)
}
if !contains(bash, "proxy") {
t.Errorf("should contain proxy subcommand, got %q", bash)
}
if !contains(bash, "/tmp/gh-copilot-codespace-bin/gh-copilot-codespace") {
t.Errorf("should contain remote binary path, got %q", bash)
}
if !contains(bash, "exec") {
t.Errorf("should contain 'exec', got %q", bash)
if !contains(bash, "--github-auth") || !contains(bash, "local") {
t.Errorf("should contain auth mode, got %q", bash)
}
if !contains(bash, "--workdir") {
t.Errorf("should contain '--workdir', got %q", bash)
if contains(bash, "local-token") {
t.Errorf("hook rewrite should not serialize local token, got %q", bash)
}

// cwd and env should be removed from the hook object
Expand All @@ -192,7 +178,7 @@ func TestRewriteHooksForSSH_WithRemoteBinary(t *testing.T) {
func TestRewriteHooksForSSH_FallbackWithoutBinary(t *testing.T) {
hooksJSON := `{"version":1,"hooks":{"sessionStart":[{"type":"command","bash":"echo hi","cwd":"."}]}}`

result := rewriteHooksForSSH([]byte(hooksJSON), "cs", "/workspaces/repo", "")
result := rewriteHooksForSSH("/usr/local/bin/self", codespaceenv.GitHubAuthCodespace, []byte(hooksJSON), "cs", "/workspaces/repo", "")
if result == nil {
t.Fatal("rewriteHooksForSSH returned nil")
}
Expand All @@ -204,12 +190,11 @@ func TestRewriteHooksForSSH_FallbackWithoutBinary(t *testing.T) {
hook := ss[0].(map[string]any)
bash := hook["bash"].(string)

// Fallback should use bash -c
if !contains(bash, "bash -c") {
t.Errorf("fallback should use 'bash -c', got %q", bash)
if !contains(bash, "proxy") {
t.Errorf("fallback should use proxy helper, got %q", bash)
}
if !contains(bash, ".env-secrets") {
t.Errorf("fallback hook should bootstrap codespace auth env, got %q", bash)
if contains(bash, ".env-secrets") {
t.Errorf("fallback hook should not bake bootstrap snippet into the hook file, got %q", bash)
}
}

Expand Down Expand Up @@ -271,6 +256,47 @@ func TestRunExecExplicitEnvOverridesBootstrap(t *testing.T) {
}
}

func TestRunExecExplicitEnvOverridesBootstrapHostValues(t *testing.T) {
originalApply := applyCodespaceEnv
originalExec := execProcess
t.Cleanup(func() {
applyCodespaceEnv = originalApply
execProcess = originalExec
})

applyCodespaceEnv = func() {
_ = os.Setenv("GITHUB_TOKEN", "bootstrap-token")
_ = os.Setenv("GITHUB_API_URL", "https://api.github.com")
_ = os.Setenv("GITHUB_SERVER_URL", "https://github.com")
}

var gotEnv map[string]string
execProcess = func(_ string, _ []string, env []string) error {
gotEnv = envSliceToMap(env)
return errors.New("stop exec")
}

err := runExec([]string{
"--env", "GITHUB_TOKEN=flag-token",
"--env", "GH_TOKEN=flag-token",
"--env", "GITHUB_API_URL=https://ghe.example.com/api/v3",
"--env", "GITHUB_SERVER_URL=https://ghe.example.com",
"--", "sh",
})
if err == nil || err.Error() != "stop exec" {
t.Fatalf("runExec() error = %v, want stop exec", err)
}
if gotEnv["GITHUB_TOKEN"] != "flag-token" || gotEnv["GH_TOKEN"] != "flag-token" {
t.Fatalf("token env = %#v, want explicit flag-token overrides", gotEnv)
}
if gotEnv["GITHUB_API_URL"] != "https://ghe.example.com/api/v3" {
t.Fatalf("GITHUB_API_URL = %q, want https://ghe.example.com/api/v3", gotEnv["GITHUB_API_URL"])
}
if gotEnv["GITHUB_SERVER_URL"] != "https://ghe.example.com" {
t.Fatalf("GITHUB_SERVER_URL = %q, want https://ghe.example.com", gotEnv["GITHUB_SERVER_URL"])
}
}

func envSliceToMap(env []string) map[string]string {
result := make(map[string]string, len(env))
for _, kv := range env {
Expand Down
5 changes: 3 additions & 2 deletions cmd/gh-copilot-codespace/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"testing"
"time"

"github.com/ekroon/gh-copilot-codespace/internal/codespaceenv"
"github.com/ekroon/gh-copilot-codespace/internal/mcp"
"github.com/ekroon/gh-copilot-codespace/internal/registry"
"github.com/ekroon/gh-copilot-codespace/internal/ssh"
Expand Down Expand Up @@ -186,7 +187,7 @@ func TestIntegration_MCPConfigRewriting(t *testing.T) {
}

// Verify buildMCPConfig rewrites it to use gh
mcpConfig := buildMCPConfig("/usr/local/bin/self", cs, wd, remoteMCP, "")
mcpConfig := buildMCPConfig("/usr/local/bin/self", codespaceenv.GitHubAuthCodespace, cs, wd, remoteMCP, "")
var parsed map[string]any
if err := json.Unmarshal([]byte(mcpConfig), &parsed); err != nil {
t.Fatalf("invalid merged MCP config JSON: %v", err)
Expand Down Expand Up @@ -550,7 +551,7 @@ func TestIntegration_MCPForwardingEndToEnd_VSCode(t *testing.T) {
if !ok {
t.Fatal("vscode-test-server config should be a map")
}
rewritten := rewriteMCPServerForSSH(serverConfig, cs, wd, "")
rewritten := rewriteMCPServerForSSH("/usr/local/bin/self", codespaceenv.GitHubAuthCodespace, serverConfig, cs, wd, "")
if rewritten == nil {
t.Fatal("rewriteMCPServerForSSH returned nil for vscode-test-server")
}
Expand Down
Loading