diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e8f16b..61e259d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ ### Added +- **GitHub Copilot CLI agent** — run GitHub Copilot CLI with `moat copilot`. Copilot uses the existing `github` grant: Moat injects that GitHub token for GitHub/Copilot API hosts plus HTTPS git, while the container receives only placeholders. `moat copilot` installs `@github/copilot`, stages Copilot config/context, passes `--allow-all` by default, and supports `copilot.model`, `copilot.experimental`, and `copilot.autopilot` in `moat.yaml`. See [Running GitHub Copilot CLI](https://majorcontext.com/moat/guides/copilot). ([#436](https://github.com/majorcontext/moat/pull/436)) - **Pi packages & safe defaults** — declare Pi extensions/skills/themes in `pi.packages` (remote `npm:`/`git:`/`https:`/`ssh:` sources) and Moat installs them into the image at build time via `pi install`, baked into a reproducible cached layer. Every `moat pi` image also bakes a safe `~/.pi/agent/settings.json` — `defaultProjectTrust: never` (a checked-out repo's own `.pi/` extensions, which are arbitrary code, do not auto-load), telemetry off, quiet startup — that a workspace cannot override. Because Pi config can redirect model traffic to any host, `moat pi` now warns under a permissive network policy (only `network.policy: strict` truly constrains egress). See [Running Pi](https://majorcontext.com/moat/guides/pi). ([#434](https://github.com/majorcontext/moat/pull/434)) - **Pi coding agent** — run the [Pi coding agent](https://github.com/earendil-works/pi) with `moat pi`. Pi has no credential of its own; it runs against your existing `anthropic` or `openai` grant. When exactly one is configured it is used automatically; when both are, choose one with `--provider` or `pi.provider` in `moat.yaml`. Only the `anthropic` and `openai` backends are supported today — any other backend, or a missing/ambiguous grant, fails before a container is created. Configure with the `pi:` block (`provider`, `model`). See [Running Pi](https://majorcontext.com/moat/guides/pi) and `examples/agent-pi`. ([#433](https://github.com/majorcontext/moat/pull/433)) - **`opentofu` and `terragrunt` dependencies** — two new managed cloud tools. `opentofu` installs the OpenTofu CLI as the `tofu` command; `terragrunt` installs the Terragrunt orchestration wrapper. Both install as prebuilt release binaries with no image rebuild cost beyond their own layer. Terragrunt delegates to a Terraform or OpenTofu binary on `PATH`, so pair it with an engine — `dependencies: [terraform, terragrunt]`, or `dependencies: [opentofu, terragrunt]` with `env.TERRAGRUNT_TFPATH: tofu`. See [Dependencies](https://majorcontext.com/moat/reference/dependencies). ([#430](https://github.com/majorcontext/moat/pull/430)) diff --git a/README.md b/README.md index 70c1f000..a8332422 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,15 @@ moat codex # Interactive mode moat codex -p "explain this codebase" # Non-interactive ``` -Both agents run in isolated containers with credentials injected at the network layer. See the [Running Claude Code](docs/content/guides/01-claude-code.md) and [Running Codex](docs/content/guides/02-codex.md) guides for details. +### GitHub Copilot CLI + +```bash +moat grant github # One-time: imports GitHub credentials with Copilot access +moat copilot # Interactive mode +moat copilot -p "fix the failing tests" # Non-interactive +``` + +Agents run in isolated containers with credentials injected at the network layer. See the [Running Claude Code](docs/content/guides/01-claude-code.md), [Running Codex](docs/content/guides/02-codex.md), and [Running GitHub Copilot CLI](docs/content/guides/17-copilot.md) guides for details. ## Configuration @@ -136,6 +144,7 @@ See the [moat.yaml reference](docs/content/reference/02-moat-yaml.md) for all op | Command | Description | |---------|-------------| | `moat claude [workspace]` | Run Claude Code | +| `moat copilot [workspace]` | Run GitHub Copilot CLI | | `moat codex [workspace]` | Run Codex | | `moat run [path] [-- cmd]` | Run an agent | | `moat join ` | Run another agent in a running container | diff --git a/cmd/moat/cli/grant.go b/cmd/moat/cli/grant.go index 14f7fe83..fcb22af1 100644 --- a/cmd/moat/cli/grant.go +++ b/cmd/moat/cli/grant.go @@ -90,6 +90,8 @@ func runGrant(cmd *cobra.Command, args []string) error { // "google" is an alias for "gemini" // "anthropic" and "claude" are separate registered providers; no remapping needed switch providerName { + case "copilot": + return fmt.Errorf("GitHub Copilot CLI uses GitHub credentials.\n\nRun: moat grant github") case "openai": providerName = "codex" case "google": diff --git a/cmd/moat/cli/grant_providers.go b/cmd/moat/cli/grant_providers.go index 4914c137..1f7ff7e1 100644 --- a/cmd/moat/cli/grant_providers.go +++ b/cmd/moat/cli/grant_providers.go @@ -59,6 +59,9 @@ func runGrantProviders(cmd *cobra.Command, args []string) error { // Skip agent-only providers (claude, codex, gemini) from grant listing // if they have a CLI name alias — we show the alias instead name := p.Name() + if name == "copilot" { + continue + } if cliName, ok := goProviderCLINames[name]; ok { name = cliName } diff --git a/cmd/moat/cli/grant_test.go b/cmd/moat/cli/grant_test.go index cc600b96..8d1b4c52 100644 --- a/cmd/moat/cli/grant_test.go +++ b/cmd/moat/cli/grant_test.go @@ -2,6 +2,7 @@ package cli import ( "os" + "strings" "testing" "github.com/majorcontext/moat/internal/credential" @@ -60,3 +61,13 @@ func TestGrantMCP(t *testing.T) { t.Errorf("expected token 'test-api-key-123', got %q", cred.Token) } } + +func TestGrantCopilotUsesGitHub(t *testing.T) { + err := runGrant(grantCmd, []string{"copilot"}) + if err == nil { + t.Fatal("runGrant(copilot) = nil, want error") + } + if !strings.Contains(err.Error(), "moat grant github") { + t.Fatalf("runGrant(copilot) error = %v, want moat grant github guidance", err) + } +} diff --git a/docs/content/guides/17-copilot.md b/docs/content/guides/17-copilot.md new file mode 100644 index 00000000..6a897178 --- /dev/null +++ b/docs/content/guides/17-copilot.md @@ -0,0 +1,80 @@ +--- +title: "Running GitHub Copilot CLI" +navTitle: "Copilot CLI" +description: "Run GitHub Copilot CLI in an isolated container with credential injection." +keywords: ["moat", "copilot", "github copilot", "ai agent", "coding assistant"] +--- + +# Running GitHub Copilot CLI + +This guide covers running GitHub Copilot CLI in a Moat container. + +## Prerequisites + +- Moat installed +- An active GitHub Copilot subscription +- A Copilot-capable GitHub token, either from `gh auth login` or a fine-grained PAT with the **Copilot Requests** permission + +## Granting GitHub credentials + +Run `moat grant github` to configure authentication: + +```bash +moat grant github +``` + +Moat checks `GITHUB_TOKEN` and `GH_TOKEN` first. If none are set, it can use `gh auth token` from GitHub CLI, or prompt for a PAT. + +The token must be able to use Copilot. GitHub CLI OAuth tokens from `gh auth login` work when the account has an active Copilot subscription. Fine-grained PATs must be created for your personal account with the **Copilot Requests** account permission. Classic PATs are not supported by GitHub Copilot CLI. + +Before starting a Copilot container, Moat checks the stored GitHub credential against `api.github.com/copilot_internal/user`. If the token cannot use Copilot, the run fails before building or starting the container. + +## How credentials are injected + +The raw GitHub credential is stored encrypted on the host. Inside the container, Moat sets format-valid placeholders in `COPILOT_GITHUB_TOKEN` and `GH_TOKEN`. The proxy intercepts GitHub and Copilot HTTPS requests and injects the real GitHub token for `api.github.com`, Copilot API hosts, and HTTPS git operations against `github.com`. + +## Running Copilot + +Interactive mode: + +```bash +moat copilot +moat copilot ./my-project +``` + +Interactive mode with an initial prompt: + +```bash +moat copilot -- "explain this codebase" +``` + +Non-interactive mode: + +```bash +moat copilot -p "fix the failing tests" +``` + +Moat passes `--allow-all` to Copilot CLI by default so the agent can complete tasks without approval prompts. The container, workspace mount, and network policy still constrain the run. + +## Configuration + +```yaml +agent: copilot +grants: + - github + +copilot: + model: gpt-5.4 + experimental: false + autopilot: false +``` + +`moat copilot` adds the `github` grant automatically when a GitHub credential is configured, so you do not need to list it unless you use `moat run` directly. + +## Network policy + +`moat copilot` adds the GitHub and Copilot hosts it needs to the run's network rules. Under `network.policy: strict`, add any extra hosts your task needs with `--allow-host` or `network.rules`. + +```bash +moat copilot --allow-host registry.npmjs.org -p "update dependencies" +``` diff --git a/docs/content/reference/01-cli.md b/docs/content/reference/01-cli.md index 96f121d7..f27614da 100644 --- a/docs/content/reference/01-cli.md +++ b/docs/content/reference/01-cli.md @@ -37,7 +37,7 @@ If a name matches multiple runs, batch commands (`stop`, `destroy`) prompt for c ## Common agent flags -The agent commands (`moat claude`, `moat codex`, `moat gemini`, `moat pi`) share the following flags. These flags work identically across `moat claude`, `moat codex`, `moat gemini`, and `moat pi`. +The agent commands (`moat claude`, `moat copilot`, `moat codex`, `moat gemini`, `moat pi`) share the following flags. These flags work identically across `moat claude`, `moat copilot`, `moat codex`, `moat gemini`, and `moat pi`. | Flag | Description | |------|-------------| @@ -62,6 +62,7 @@ All agent commands support passing an initial prompt after `--`. Unlike `-p`, wh ```bash moat claude -- "is this thing on?" +moat copilot -- "explain this codebase" moat codex -- "explain this codebase" ``` @@ -281,6 +282,62 @@ moat claude --noyolo --- +## moat copilot + +Run GitHub Copilot CLI in a container. + +``` +moat copilot [workspace] [flags] [-- initial-prompt] +``` + +In addition to the command-specific flags below, `moat copilot` accepts all [common agent flags](#common-agent-flags). + +`moat copilot` uses the `github` grant. Before creating the run, Moat checks that the stored GitHub credential can use Copilot. The container receives only Copilot/GitHub token placeholders; Moat's proxy injects the real GitHub token for GitHub API, Copilot API, and HTTPS git requests. + +### Arguments + +| Argument | Description | +|----------|-------------| +| `workspace` | Workspace directory (default: current directory) | +| `initial-prompt` | Text after `--` is passed to Copilot as an initial prompt (interactive mode) | + +### Command-specific flags + +| Flag | Description | +|------|-------------| +| `-p`, `--prompt TEXT` | Run non-interactive with prompt | +| `--allow-all` | Allow all Copilot tools, paths, and URLs without prompting. Default: `true`. | +| `--model MODEL` | Select the model to use. Overrides `copilot.model`. | +| `--experimental` | Enable Copilot CLI experimental features. | +| `--autopilot` | Start Copilot CLI in autopilot mode. | + +### Examples + +```bash +# One-time credential setup +moat grant github + +# Interactive Copilot CLI +moat copilot + +# In specific directory +moat copilot ./my-project + +# Interactive with initial prompt +moat copilot -- "explain this codebase" + +# Non-interactive with prompt +moat copilot -p "fix the failing tests" + +# Select a model +moat copilot --model gpt-5.4 + +# Run in a git worktree +moat copilot --worktree=dark-mode --prompt "implement dark mode" +``` + +--- + ## moat codex Run OpenAI Codex CLI in a container. @@ -568,8 +625,8 @@ moat grant [:] GitHub credentials are obtained from multiple sources, in order of preference: -1. **gh CLI** -- Uses token from `gh auth token` if available -2. **Environment variable** -- Falls back to `GITHUB_TOKEN` or `GH_TOKEN` +1. **Environment variable** -- Uses `GITHUB_TOKEN` or `GH_TOKEN` if set +2. **gh CLI** -- Uses token from `gh auth token` if available 3. **Personal Access Token** -- Interactive prompt for manual entry ```bash diff --git a/docs/content/reference/02-moat-yaml.md b/docs/content/reference/02-moat-yaml.md index ae56b613..76875ad2 100644 --- a/docs/content/reference/02-moat-yaml.md +++ b/docs/content/reference/02-moat-yaml.md @@ -126,6 +126,12 @@ codex: grant: openai cwd: /workspace +# GitHub Copilot CLI +copilot: + model: gpt-5.4 + experimental: false + autopilot: false + # Gemini CLI gemini: sync_logs: true @@ -1574,6 +1580,51 @@ When `grant` is specified, the corresponding environment variable is set automat --- +## GitHub Copilot CLI + +### copilot.model + +Model to pass to `copilot --model`. + +```yaml +copilot: + model: gpt-5.4 +``` + +- Type: `string` +- Default: Copilot CLI default +- CLI override: `moat copilot --model` + +### copilot.experimental + +Start Copilot CLI with experimental features enabled. + +```yaml +copilot: + experimental: true +``` + +- Type: `boolean` +- Default: `false` +- CLI override: `moat copilot --experimental` + +### copilot.autopilot + +Start Copilot CLI in autopilot mode. + +```yaml +copilot: + autopilot: true +``` + +- Type: `boolean` +- Default: `false` +- CLI override: `moat copilot --autopilot` + +`moat copilot` uses the `github` grant. Run `moat grant github` before starting a session. See [Running GitHub Copilot CLI](../guides/17-copilot.md). + +--- + ## Gemini ### gemini.sync_logs diff --git a/docs/content/reference/04-grants.md b/docs/content/reference/04-grants.md index ae96aa42..ed3033a0 100644 --- a/docs/content/reference/04-grants.md +++ b/docs/content/reference/04-grants.md @@ -48,8 +48,8 @@ No flags. The command automatically detects your credential source. ### Credential sources (in order of preference) -1. **gh CLI** -- Uses the token from `gh auth token` if the GitHub CLI is installed and authenticated -2. **Environment variable** -- Falls back to `GITHUB_TOKEN` or `GH_TOKEN` if set +1. **Environment variable** -- Uses `GITHUB_TOKEN` or `GH_TOKEN` if set +2. **gh CLI** -- Uses the token from `gh auth token` if the GitHub CLI is installed and authenticated 3. **Personal Access Token** -- Interactive prompt for manual PAT entry ### What it injects @@ -63,9 +63,13 @@ The proxy injects an `Authorization` header, using the scheme each host expects: The container receives `GH_TOKEN` set to a format-valid placeholder so the gh CLI works without prompting. Git is configured with `http.proxyAuthMethod=basic` so it authenticates to the proxy and can establish the HTTPS tunnel. +For `moat copilot` runs, the same GitHub grant is also injected for Copilot API hosts, and the container receives a format-valid `COPILOT_GITHUB_TOKEN` placeholder. The GitHub token must be Copilot-capable: use a GitHub CLI OAuth token from an account with Copilot access, or a fine-grained PAT from your personal account with the **Copilot Requests** account permission. + +Moat validates Copilot access before starting a Copilot run by calling `api.github.com/copilot_internal/user` with the stored GitHub credential. + ### Refresh behavior -Tokens sourced from `gh auth token` or environment variables are refreshed every 30 minutes. PATs entered manually are static. +Tokens sourced from `gh auth token` are refreshed every 30 minutes. Environment variables and PATs entered manually are static. ### moat.yaml @@ -88,6 +92,34 @@ GitHub credential saved $ moat run --grant github ./my-project ``` +## GitHub Copilot + +GitHub Copilot CLI uses the `github` grant. There is no separate Copilot credential to configure. + +```bash +moat grant github +``` + +Classic PATs are not supported by GitHub Copilot CLI. Fine-grained PATs must be created for your personal account, not an organization, and need the **Copilot Requests** account permission. + +### What it injects + +For Copilot runs, the proxy injects the GitHub token for: + +- `api.github.com` and Copilot API hosts -- Copilot CLI authentication, model traffic, and GitHub API calls +- `github.com` -- HTTPS git operations + +The container receives `COPILOT_GITHUB_TOKEN` and `GH_TOKEN` placeholders. Copilot CLI and gh CLI authenticate normally, but the raw token stays on the host. + +### moat.yaml + +```yaml +grants: + - github +``` + +Use this grant with `moat copilot`; the command adds it automatically. + ## Anthropic / Claude Anthropic credentials are split into two separate grant types: diff --git a/internal/config/config.go b/internal/config/config.go index 072f0fe5..95fc0a8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,7 @@ type Config struct { Network NetworkConfig `yaml:"network,omitempty"` Command []string `yaml:"command,omitempty"` Claude ClaudeConfig `yaml:"claude,omitempty"` + Copilot CopilotConfig `yaml:"copilot,omitempty"` Codex CodexConfig `yaml:"codex,omitempty"` Gemini GeminiConfig `yaml:"gemini,omitempty"` Pi PiConfig `yaml:"pi,omitempty"` @@ -333,6 +334,18 @@ type CodexConfig struct { MCP map[string]MCPServerSpec `yaml:"mcp,omitempty"` } +// CopilotConfig configures GitHub Copilot CLI integration options. +type CopilotConfig struct { + // Model optionally selects the model passed to `copilot --model`. + Model string `yaml:"model,omitempty"` + + // Experimental starts Copilot CLI with --experimental. + Experimental bool `yaml:"experimental,omitempty"` + + // Autopilot starts Copilot CLI in autopilot mode. + Autopilot bool `yaml:"autopilot,omitempty"` +} + // GeminiConfig configures Google Gemini CLI integration options. type GeminiConfig struct { // SyncLogs enables mounting Gemini's session logs directory so logs from diff --git a/internal/credential/provider.go b/internal/credential/provider.go index 911c9c00..a26a69fc 100644 --- a/internal/credential/provider.go +++ b/internal/credential/provider.go @@ -34,6 +34,12 @@ const OpenAIAPIKeyPlaceholder = "sk-moat-proxy-injected-placeholder-000000000000 // Authorization headers, so this placeholder never reaches GitHub's servers. const GitHubTokenPlaceholder = "ghp_moatProxyInjectedPlaceholder000000000000" +// CopilotTokenPlaceholder is a placeholder that looks like a GitHub fine-grained +// personal access token. GitHub Copilot CLI rejects classic PAT-shaped tokens +// locally, but accepts fine-grained PATs, GitHub CLI OAuth tokens, and Copilot +// CLI OAuth tokens. The real credential is injected by the proxy. +const CopilotTokenPlaceholder = "github_pat_11MOATPROXYINJECTEDPLACEHOLDER000000000000000000000000000000000000000000" + // AnthropicAPIKeyPlaceholder is a placeholder that looks like a valid Anthropic // API key. // Some tools validate the API key format locally before making requests. diff --git a/internal/credential/types.go b/internal/credential/types.go index e2db59e9..27717b87 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -56,7 +56,9 @@ func RegisterDynamicProvider(p Provider) { // KnownProviders returns a list of all known credential providers. func KnownProviders() []Provider { base := []Provider{ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta} - return append(base, dynamicProviders...) + known := make([]Provider, 0, len(base)+len(dynamicProviders)) + known = append(known, base...) + return append(known, dynamicProviders...) } // IsKnownProvider returns true if the provider is a known credential provider. diff --git a/internal/daemon/api.go b/internal/daemon/api.go index 54b3133f..6e54cde9 100644 --- a/internal/daemon/api.go +++ b/internal/daemon/api.go @@ -73,6 +73,7 @@ type RegisterRequest struct { NetworkAllow []string `json:"network_allow,omitempty"` NetworkRules []netrules.HostRules `json:"network_rules,omitempty"` Grants []string `json:"grants,omitempty"` + CopilotGitHubAuth bool `json:"copilot_github_auth,omitempty"` AWSConfig *AWSConfig `json:"aws_config,omitempty"` ResponseTransformers []TransformerSpec `json:"response_transformers,omitempty"` // CredProfile is the credential profile the run was created under. The @@ -164,6 +165,7 @@ func (req *RegisterRequest) ToRunContext() *RunContext { rc.NetworkRules = req.NetworkRules rc.AWSConfig = req.AWSConfig rc.Grants = req.Grants + rc.CopilotGitHubAuth = req.CopilotGitHubAuth rc.CredProfile = req.CredProfile rc.TransformerSpecs = req.ResponseTransformers rc.HostGateway = req.HostGateway diff --git a/internal/daemon/persist.go b/internal/daemon/persist.go index 615e9651..161d0339 100644 --- a/internal/daemon/persist.go +++ b/internal/daemon/persist.go @@ -24,16 +24,17 @@ import ( // plaintext, protected by file permissions (0600). On restore, provider // credentials are re-resolved from the encrypted credential store. type PersistedRun struct { - AuthToken string `json:"auth_token"` - RunID string `json:"run_id"` - ContainerID string `json:"container_id,omitempty"` - Grants []string `json:"grants,omitempty"` - MCPServers []config.MCPServerConfig `json:"mcp_servers,omitempty"` - NetworkPolicy string `json:"network_policy,omitempty"` - NetworkAllow []string `json:"network_allow,omitempty"` - AWSConfig *AWSConfig `json:"aws_config,omitempty"` - TransformerSpecs []TransformerSpec `json:"transformer_specs,omitempty"` - CredProfile string `json:"cred_profile,omitempty"` + AuthToken string `json:"auth_token"` + RunID string `json:"run_id"` + ContainerID string `json:"container_id,omitempty"` + Grants []string `json:"grants,omitempty"` + MCPServers []config.MCPServerConfig `json:"mcp_servers,omitempty"` + NetworkPolicy string `json:"network_policy,omitempty"` + NetworkAllow []string `json:"network_allow,omitempty"` + AWSConfig *AWSConfig `json:"aws_config,omitempty"` + TransformerSpecs []TransformerSpec `json:"transformer_specs,omitempty"` + CopilotGitHubAuth bool `json:"copilot_github_auth,omitempty"` + CredProfile string `json:"cred_profile,omitempty"` } // persistedFile is the versioned on-disk format. @@ -70,16 +71,17 @@ func (p *RunPersister) Save() error { for _, rc := range entries { rc.mu.RLock() pr := PersistedRun{ - AuthToken: rc.AuthToken, - RunID: rc.RunID, - ContainerID: rc.ContainerID, - Grants: rc.Grants, - MCPServers: rc.MCPServers, - NetworkPolicy: rc.NetworkPolicy, - NetworkAllow: rc.NetworkAllow, - AWSConfig: rc.AWSConfig, - TransformerSpecs: rc.TransformerSpecs, - CredProfile: rc.CredProfile, + AuthToken: rc.AuthToken, + RunID: rc.RunID, + ContainerID: rc.ContainerID, + Grants: rc.Grants, + MCPServers: rc.MCPServers, + NetworkPolicy: rc.NetworkPolicy, + NetworkAllow: rc.NetworkAllow, + AWSConfig: rc.AWSConfig, + TransformerSpecs: rc.TransformerSpecs, + CopilotGitHubAuth: rc.CopilotGitHubAuth, + CredProfile: rc.CredProfile, } rc.mu.RUnlock() runs = append(runs, pr) @@ -212,6 +214,7 @@ func RestoreRuns(ctx context.Context, registry *Registry, runs []PersistedRun) i rc.NetworkAllow = pr.NetworkAllow rc.AWSConfig = pr.AWSConfig rc.TransformerSpecs = pr.TransformerSpecs + rc.CopilotGitHubAuth = pr.CopilotGitHubAuth rc.CredProfile = pr.CredProfile // Open the store scoped to this run's profile — the daemon serves runs @@ -314,6 +317,11 @@ func resolveCredentials(rc *RunContext, grants []string, mcpServers []config.MCP continue } prov.ConfigureProxy(rc, provCred) + if grantName == "github" && rc.CopilotGitHubAuth { + if copilotProv := provider.Get("copilot"); copilotProv != nil { + copilotProv.ConfigureProxy(rc, provCred) + } + } } return nil } diff --git a/internal/daemon/refresh.go b/internal/daemon/refresh.go index a4dcdebb..bca1a23e 100644 --- a/internal/daemon/refresh.go +++ b/internal/daemon/refresh.go @@ -26,6 +26,9 @@ func resolveCredName(grantName, grant string) credential.Provider { if canonical == "oauth" { return credential.Provider(grant) } + if canonical == "copilot" { + return credential.ProviderGitHub + } return credential.Provider(canonical) } @@ -159,6 +162,11 @@ func refreshTokensForRun(ctx context.Context, rc *RunContext, grants []string, s rc.SetCredentialWithGrant(serverHost, mcp.Auth.Header, updated.Token, grant) } } + if grantName == "github" && rc.CopilotGitHubAuth { + if copilotProv := provider.Get("copilot"); copilotProv != nil { + copilotProv.ConfigureProxy(rc, updated) + } + } log.Debug("token refreshed", "provider", credName) } } diff --git a/internal/daemon/runcontext.go b/internal/daemon/runcontext.go index 19e527c2..43a4c4cc 100644 --- a/internal/daemon/runcontext.go +++ b/internal/daemon/runcontext.go @@ -63,12 +63,13 @@ type RunContext struct { NetworkAllow []string `json:"network_allow,omitempty"` NetworkRules []netrules.HostRules `json:"network_rules,omitempty"` - AWSConfig *AWSConfig `json:"aws_config,omitempty"` - TransformerSpecs []TransformerSpec `json:"transformer_specs,omitempty"` - Grants []string `json:"grants,omitempty"` - HostGateway string `json:"host_gateway,omitempty"` - HostGatewayIP string `json:"host_gateway_ip,omitempty"` // actual IP for forwarding allowed host traffic - AllowedHostPorts []int `json:"allowed_host_ports,omitempty"` + AWSConfig *AWSConfig `json:"aws_config,omitempty"` + TransformerSpecs []TransformerSpec `json:"transformer_specs,omitempty"` + Grants []string `json:"grants,omitempty"` + CopilotGitHubAuth bool `json:"copilot_github_auth,omitempty"` + HostGateway string `json:"host_gateway,omitempty"` + HostGatewayIP string `json:"host_gateway_ip,omitempty"` // actual IP for forwarding allowed host traffic + AllowedHostPorts []int `json:"allowed_host_ports,omitempty"` // CredProfile is the credential profile this run was created under (from // the CLI's --profile/MOAT_PROFILE). The daemon is shared across profiles, diff --git a/internal/deps/imagespec.go b/internal/deps/imagespec.go index ff7db127..657ca559 100644 --- a/internal/deps/imagespec.go +++ b/internal/deps/imagespec.go @@ -23,7 +23,7 @@ type ImageSpec struct { // Used only by Dockerfile generation. SSHHosts []string - // InitProviders lists agent provider names (e.g., "claude", "codex", "gemini") + // InitProviders lists agent provider names (e.g., "claude", "copilot", "codex", "gemini") // that need configuration files staged at container startup. Each entry // triggers the moat-init entrypoint and contributes to the image tag hash. InitProviders []string diff --git a/internal/deps/registry.yaml b/internal/deps/registry.yaml index 7925f629..ba70da99 100644 --- a/internal/deps/registry.yaml +++ b/internal/deps/registry.yaml @@ -87,6 +87,12 @@ codex-cli: package: "@openai/codex" requires: [node] +copilot-cli: + description: GitHub Copilot CLI + type: npm + package: "@github/copilot" + requires: [node] + gemini-cli: description: Google Gemini CLI type: npm diff --git a/internal/deps/scripts/moat-init.sh b/internal/deps/scripts/moat-init.sh index f5a0fcb7..eccd7525 100644 --- a/internal/deps/scripts/moat-init.sh +++ b/internal/deps/scripts/moat-init.sh @@ -259,6 +259,36 @@ if [ -n "$MOAT_GEMINI_INIT" ] && [ -d "$MOAT_GEMINI_INIT" ]; then fi fi +# GitHub Copilot CLI Setup +# When MOAT_COPILOT_INIT is set to the staging directory path, copy files +# from the staging area to their final locations (~/.copilot). Runtime context +# stays mounted in the staging directory and is referenced via +# COPILOT_CUSTOM_INSTRUCTIONS_DIRS. +if [ -n "$MOAT_COPILOT_INIT" ] && [ -d "$MOAT_COPILOT_INIT" ]; then + # Determine target home directory + if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then + TARGET_HOME="/home/moatuser" + else + TARGET_HOME="$HOME" + fi + + # Create ~/.copilot directory + mkdir -p "$TARGET_HOME/.copilot" + + # Copy config/state files if present (preserve permissions) + [ -f "$MOAT_COPILOT_INIT/config.json" ] && \ + cp -p "$MOAT_COPILOT_INIT/config.json" "$TARGET_HOME/.copilot/" + [ -f "$MOAT_COPILOT_INIT/settings.json" ] && \ + cp -p "$MOAT_COPILOT_INIT/settings.json" "$TARGET_HOME/.copilot/" + [ -f "$MOAT_COPILOT_INIT/permissions-config.json" ] && \ + cp -p "$MOAT_COPILOT_INIT/permissions-config.json" "$TARGET_HOME/.copilot/" + + # Ensure moatuser owns all the files if we're running as root + if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then + chown -R moatuser:moatuser "$TARGET_HOME/.copilot" 2>/dev/null || true + fi +fi + # Provider Init Files # When MOAT_INIT_FILES is set, it contains tab-delimited records (one per line): # diff --git a/internal/provider/doc.go b/internal/provider/doc.go index 5ef71e2c..ce997778 100644 --- a/internal/provider/doc.go +++ b/internal/provider/doc.go @@ -1,7 +1,7 @@ // Package provider defines interfaces for credential and agent providers. // // All providers implement CredentialProvider for credential acquisition, -// proxy configuration, and container setup. Agent providers (Claude, Codex, Gemini) +// proxy configuration, and container setup. Agent providers (Claude, Copilot, Codex, Gemini) // additionally implement AgentProvider for container preparation and CLI commands. // Endpoint providers (AWS) implement EndpointProvider to expose HTTP endpoints. // diff --git a/internal/provider/interfaces.go b/internal/provider/interfaces.go index 0b1aba64..cf4e7a17 100644 --- a/internal/provider/interfaces.go +++ b/internal/provider/interfaces.go @@ -112,7 +112,7 @@ type JoinableAgent interface { } // AgentProvider extends CredentialProvider for AI agent runtimes. -// Implemented by claude, codex, and gemini providers. +// Implemented by claude, copilot, codex, gemini, and pi providers. type AgentProvider interface { CredentialProvider diff --git a/internal/providers/copilot/agent.go b/internal/providers/copilot/agent.go new file mode 100644 index 00000000..1fc7171e --- /dev/null +++ b/internal/providers/copilot/agent.go @@ -0,0 +1,51 @@ +package copilot + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/majorcontext/moat/internal/provider" +) + +// PrepareContainer stages Copilot config and runtime context. +func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOpts) (*provider.ContainerConfig, error) { + tmpDir, err := os.MkdirTemp("", "moat-copilot-staging-*") + if err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + cleanupFn := func() { os.RemoveAll(tmpDir) } + + if opts.RuntimeContext != "" { + if writeErr := os.WriteFile(filepath.Join(tmpDir, ContextFileName), []byte(opts.RuntimeContext), 0o644); writeErr != nil { + cleanupFn() + return nil, fmt.Errorf("writing context file: %w", writeErr) + } + } + + // Trust /workspace up front so first-run prompts don't block headless runs. + configJSON := []byte(`{"trustedFolders":["/workspace"]}` + "\n") + if err := os.WriteFile(filepath.Join(tmpDir, "config.json"), configJSON, 0o600); err != nil { + cleanupFn() + return nil, fmt.Errorf("writing copilot config: %w", err) + } + + env := p.ContainerEnv(opts.Credential) + env = append(env, + "MOAT_COPILOT_INIT="+CopilotInitMountPath, + "COPILOT_CUSTOM_INSTRUCTIONS_DIRS="+CopilotInitMountPath, + "COPILOT_AUTO_UPDATE=false", + ) + + return &provider.ContainerConfig{ + Env: env, + Mounts: []provider.MountConfig{{ + Source: tmpDir, + Target: CopilotInitMountPath, + ReadOnly: true, + }}, + StagingDir: tmpDir, + Cleanup: cleanupFn, + }, nil +} diff --git a/internal/providers/copilot/cli.go b/internal/providers/copilot/cli.go new file mode 100644 index 00000000..9a79620b --- /dev/null +++ b/internal/providers/copilot/cli.go @@ -0,0 +1,207 @@ +package copilot + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/majorcontext/moat/internal/cli" + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/credential" + "github.com/majorcontext/moat/internal/ui" +) + +var ( + copilotFlags cli.ExecFlags + copilotPromptFlag string + copilotAllowedHosts []string + copilotWtFlag string + copilotAllowAll bool + copilotModelFlag string + copilotExperimental bool + copilotAutopilot bool + copilotResolvedModel string + copilotCredentialConfigured = defaultCopilotCredentialConfigured +) + +func NetworkHosts() []string { + return []string{ + copilotAPIHost, + copilotGitHost, + copilotProxyHost, + copilotChatAPIHost, + copilotBusinessHost, + copilotMCPHost, + copilotTelemetry, + } +} + +func DefaultDependencies() []string { + return []string{"node@22", "git", "gh", "copilot-cli"} +} + +func (p *Provider) RegisterCLI(root *cobra.Command) { + copilotCmd := &cobra.Command{ + Use: "copilot [workspace] [flags] [-- initial-prompt]", + Short: "Run GitHub Copilot CLI in an isolated container", + Long: `Run GitHub Copilot CLI in an isolated container with automatic credential injection. + +Your workspace is mounted at /workspace inside the container. GitHub credentials +are injected transparently via the Moat proxy - Copilot CLI never sees the raw +token stored by Moat. + +Examples: + moat copilot + moat copilot ./my-project + moat copilot -p "explain this codebase" + moat copilot -p "fix the failing tests" --allow-all + moat copilot --model gpt-5.4`, + Args: cobra.ArbitraryArgs, + RunE: runCopilot, + } + + cli.AddExecFlags(copilotCmd, &copilotFlags) + copilotCmd.Flags().StringVarP(&copilotPromptFlag, "prompt", "p", "", "run with prompt (non-interactive mode)") + copilotCmd.Flags().StringSliceVar(&copilotAllowedHosts, "allow-host", nil, "additional hosts to allow network access to") + copilotCmd.Flags().BoolVar(&copilotAllowAll, "allow-all", true, "allow all Copilot tools, paths, and URLs without prompting") + copilotCmd.Flags().StringVar(&copilotModelFlag, "model", "", "model to use (overrides copilot.model)") + copilotCmd.Flags().BoolVar(&copilotExperimental, "experimental", false, "enable Copilot CLI experimental features") + copilotCmd.Flags().BoolVar(&copilotAutopilot, "autopilot", false, "start Copilot CLI in autopilot mode") + copilotCmd.Flags().StringVar(&copilotWtFlag, "worktree", "", "run in a git worktree for this branch") + copilotCmd.Flags().StringVar(&copilotWtFlag, "wt", "", "alias for --worktree") + _ = copilotCmd.Flags().MarkHidden("wt") + + root.AddCommand(copilotCmd) +} + +func runCopilot(cmd *cobra.Command, args []string) error { + return cli.RunProvider(cmd, args, cli.ProviderRunConfig{ + Name: copilotProviderName, + Flags: &copilotFlags, + PromptFlag: copilotPromptFlag, + AllowedHosts: copilotAllowedHosts, + WtFlag: copilotWtFlag, + Preflight: resolveCopilotPreflight, + GetCredentialGrant: GetCredentialName, + Dependencies: DefaultDependencies(), + NetworkHosts: NetworkHosts(), + SupportsInitialPrompt: true, + DryRunNote: "Note: No GitHub credential configured. Run 'moat grant github' first.", + BuildCommand: func(promptFlag, initialPrompt string) ([]string, error) { + return buildCopilotCommand(promptFlag, initialPrompt), nil + }, + ConfigureAgent: func(cfg *config.Config) { + cfg.Agent = copilotProviderName + }, + }) +} + +func resolveCopilotPreflight(cfg *config.Config) error { + copilotResolvedModel = copilotModelFlag + if cfg != nil { + cfg.Grants = normalizeCopilotGrants(cfg.Grants, false) + if copilotResolvedModel == "" { + copilotResolvedModel = cfg.Copilot.Model + } + if cfg.Copilot.Experimental { + copilotExperimental = true + } + if cfg.Copilot.Autopilot { + copilotAutopilot = true + } + } + copilotFlags.Grants = normalizeCopilotGrants(copilotFlags.Grants, true) + // When no stored GitHub credential exists and github isn't already in the + // grant list, add it so validateGrants triggers the inline grant prompt. + // When the credential IS configured, GetCredentialGrant (called by + // RunProvider's buildGrants) returns "github" and handles insertion there. + if !cli.DryRun && !copilotCredentialConfigured() && + !hasBaseGrant(copilotFlags.Grants, "github") && + (cfg == nil || !hasBaseGrant(cfg.Grants, "github")) { + copilotFlags.Grants = append(copilotFlags.Grants, "github") + } + return nil +} + +func normalizeCopilotGrants(grants []string, warn bool) []string { + if len(grants) == 0 { + return grants + } + out := make([]string, 0, len(grants)) + replaced := false + hasGitHub := false + for _, grant := range grants { + switch strings.Split(grant, ":")[0] { + case copilotProviderName: + replaced = true + if !hasGitHub { + out = append(out, "github") + hasGitHub = true + } + continue + case "github": + if hasGitHub { + continue + } + hasGitHub = true + } + out = append(out, grant) + } + if replaced && warn { + ui.Warn("using github grant for moat copilot — Copilot uses the existing GitHub credential") + } + return out +} + +func hasBaseGrant(grants []string, name string) bool { + for _, grant := range grants { + if strings.Split(grant, ":")[0] == name { + return true + } + } + return false +} + +func buildCopilotCommand(promptFlag, initialPrompt string) []string { + cmd := []string{"copilot", "--no-auto-update"} + if copilotResolvedModel != "" { + cmd = append(cmd, "--model", copilotResolvedModel) + } + if copilotExperimental { + cmd = append(cmd, "--experimental") + } + if copilotAutopilot { + cmd = append(cmd, "--autopilot") + } + if copilotAllowAll { + cmd = append(cmd, "--allow-all") + } + if promptFlag != "" { + cmd = append(cmd, "-p", promptFlag) + } else if initialPrompt != "" { + cmd = append(cmd, "-i", initialPrompt) + } + return cmd +} + +func GetCredentialName() string { + if copilotCredentialConfigured() { + return string(credential.ProviderGitHub) + } + return "" +} + +func defaultCopilotCredentialConfigured() bool { + key, err := credential.DefaultEncryptionKey() + if err != nil { + return false + } + store, err := credential.NewFileStore(credential.DefaultStoreDir(), key) + if err != nil { + return false + } + if _, err := store.Get(credential.ProviderGitHub); err == nil { + return true + } + return false +} diff --git a/internal/providers/copilot/constants.go b/internal/providers/copilot/constants.go new file mode 100644 index 00000000..b8822622 --- /dev/null +++ b/internal/providers/copilot/constants.go @@ -0,0 +1,19 @@ +package copilot + +const ( + // CopilotInitMountPath is where the Copilot staging directory is mounted. + CopilotInitMountPath = "/moat/copilot-init" + + // ContextFileName is loaded through COPILOT_CUSTOM_INSTRUCTIONS_DIRS so it + // augments repository instructions without writing into /workspace. + ContextFileName = "moat-context.instructions.md" + + copilotAPIHost = "api.github.com" + copilotGitHost = "github.com" + copilotProxyHost = "copilot-proxy.githubusercontent.com" + copilotChatAPIHost = "api.githubcopilot.com" + copilotBusinessHost = "api.business.githubcopilot.com" + copilotMCPHost = "api.mcp.github.com" + copilotTelemetry = "telemetry.business.githubcopilot.com" + copilotProviderName = "copilot" +) diff --git a/internal/providers/copilot/grant.go b/internal/providers/copilot/grant.go new file mode 100644 index 00000000..6f443ef0 --- /dev/null +++ b/internal/providers/copilot/grant.go @@ -0,0 +1,68 @@ +package copilot + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +var ( + copilotValidationURL = "https://api.github.com/copilot_internal/user" + newCopilotHTTPClient = func() *http.Client { + return &http.Client{Timeout: 10 * time.Second} + } +) + +// ValidateGitHubToken verifies that a GitHub token can authenticate Copilot CLI. +// It is intentionally separate from moat grant github, which remains a general +// GitHub credential setup command and accepts tokens that do not need Copilot. +func ValidateGitHubToken(ctx context.Context, token string) error { + return validateCopilotToken(ctx, token) +} + +func validateCopilotToken(ctx context.Context, token string) error { + client := newCopilotHTTPClient() + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, "GET", copilotValidationURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "moat") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("validating token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + + var body struct { + Message string `json:"message"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + switch resp.StatusCode { + case http.StatusUnauthorized: + return fmt.Errorf("invalid token (401 Unauthorized)") + case http.StatusForbidden: + if body.Message != "" { + return fmt.Errorf("token rejected (403 Forbidden): %s", body.Message) + } + return fmt.Errorf("token rejected (403 Forbidden); the token may lack Copilot Requests permission or Copilot may be disabled") + default: + if body.Message != "" { + return fmt.Errorf("unexpected status validating token: %d: %s", resp.StatusCode, body.Message) + } + return fmt.Errorf("unexpected status validating token: %d", resp.StatusCode) + } +} diff --git a/internal/providers/copilot/grant_test.go b/internal/providers/copilot/grant_test.go new file mode 100644 index 00000000..4c683ec7 --- /dev/null +++ b/internal/providers/copilot/grant_test.go @@ -0,0 +1,104 @@ +package copilot + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func withCopilotValidationServer(t *testing.T, status int, body string, check func(*testing.T, *http.Request)) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if check != nil { + check(t, r) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + + origURL := copilotValidationURL + origClient := newCopilotHTTPClient + copilotValidationURL = server.URL + newCopilotHTTPClient = server.Client + t.Cleanup(func() { + copilotValidationURL = origURL + newCopilotHTTPClient = origClient + }) +} + +func TestValidateCopilotToken(t *testing.T) { + tests := []struct { + name string + status int + body string + wantErr string + }{ + {name: "success", status: http.StatusOK}, + {name: "unauthorized", status: http.StatusUnauthorized, wantErr: "invalid token"}, + {name: "forbidden message", status: http.StatusForbidden, body: `{"message":"missing Copilot Requests"}`, wantErr: "missing Copilot Requests"}, + {name: "forbidden generic", status: http.StatusForbidden, wantErr: "token rejected"}, + {name: "server error message", status: http.StatusInternalServerError, body: `{"message":"boom"}`, wantErr: "500: boom"}, + {name: "server error generic", status: http.StatusInternalServerError, wantErr: "unexpected status"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withCopilotValidationServer(t, tt.status, tt.body, func(t *testing.T, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("Authorization = %q", got) + } + if got := r.Header.Get("Accept"); got != "application/vnd.github+json" { + t.Errorf("Accept = %q", got) + } + if got := r.Header.Get("User-Agent"); got != "moat" { + t.Errorf("User-Agent = %q", got) + } + }) + + err := ValidateGitHubToken(context.Background(), "test-token") + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validateCopilotToken() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validateCopilotToken() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestValidationResponseBodyNeedNotBeJSON(t *testing.T) { + withCopilotValidationServer(t, http.StatusInternalServerError, `not-json`, nil) + err := ValidateGitHubToken(context.Background(), "test-token") + if err == nil || !strings.Contains(err.Error(), "unexpected status validating token: 500") { + t.Fatalf("validateCopilotToken() error = %v", err) + } +} + +func TestValidationMessageJSON(t *testing.T) { + body, err := json.Marshal(map[string]string{"message": "rate limited"}) + if err != nil { + t.Fatal(err) + } + withCopilotValidationServer(t, http.StatusForbidden, string(body), nil) + err = ValidateGitHubToken(context.Background(), "test-token") + if err == nil || !strings.Contains(err.Error(), "rate limited") { + t.Fatalf("validateCopilotToken() error = %v", err) + } +} + +func TestGrantUsesGitHubCredential(t *testing.T) { + _, err := (&Provider{}).Grant(context.Background()) + if err == nil { + t.Fatal("Grant() = nil, want error") + } + if !strings.Contains(err.Error(), "moat grant github") { + t.Fatalf("Grant() error = %v, want moat grant github guidance", err) + } +} diff --git a/internal/providers/copilot/provider.go b/internal/providers/copilot/provider.go new file mode 100644 index 00000000..a66709cc --- /dev/null +++ b/internal/providers/copilot/provider.go @@ -0,0 +1,68 @@ +package copilot + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/majorcontext/moat/internal/credential" + "github.com/majorcontext/moat/internal/provider" +) + +// Provider implements provider.CredentialProvider and provider.AgentProvider +// for GitHub Copilot CLI. +type Provider struct{} + +var ( + _ provider.CredentialProvider = (*Provider)(nil) + _ provider.AgentProvider = (*Provider)(nil) +) + +func init() { + provider.Register(&Provider{}) +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { return copilotProviderName } + +// Grant intentionally does not acquire a separate credential. Copilot CLI uses +// the github grant so there is one GitHub token to rotate and audit. +func (p *Provider) Grant(ctx context.Context) (*provider.Credential, error) { + return nil, fmt.Errorf("GitHub Copilot CLI uses GitHub credentials.\n\nRun: moat grant github") +} + +// ConfigureProxy sets up Copilot credential injection using a GitHub token. +func (p *Provider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *provider.Credential) { + setProxyAuth(proxy, cred.Token) +} + +func setProxyAuth(proxy provider.ProxyConfigurer, token string) { + // copilotProxyHost and copilotTelemetry are excluded: they use session + // tokens obtained via the Copilot token exchange (through api.github.com, + // which does get injection), not the original PAT. + proxy.SetCredentialWithGrant(copilotAPIHost, "Authorization", "Bearer "+token, "github") + proxy.SetCredentialWithGrant(copilotChatAPIHost, "Authorization", "Bearer "+token, "github") + proxy.SetCredentialWithGrant(copilotBusinessHost, "Authorization", "Bearer "+token, "github") + proxy.SetCredentialWithGrant(copilotMCPHost, "Authorization", "Bearer "+token, "github") + basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token)) + proxy.SetCredentialWithGrant(copilotGitHost, "Authorization", "Basic "+basic, "github") +} + +// ContainerEnv returns Copilot auth placeholders. The github grant supplies +// GH_TOKEN and git prompt behavior; Copilot only needs its preferred env var. +func (p *Provider) ContainerEnv(cred *provider.Credential) []string { + return []string{ + "COPILOT_GITHUB_TOKEN=" + credential.CopilotTokenPlaceholder, + } +} + +// ContainerMounts returns none — Copilot uses the staging-directory approach. +func (p *Provider) ContainerMounts(cred *provider.Credential, containerHome string) ([]provider.MountConfig, string, error) { + return nil, "", nil +} + +// Cleanup is a no-op — staging-directory cleanup is handled by PrepareContainer. +func (p *Provider) Cleanup(cleanupPath string) {} + +// ImpliedDependencies returns dependencies useful for Copilot's GitHub workflow. +func (p *Provider) ImpliedDependencies() []string { return []string{"gh", "git"} } diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go new file mode 100644 index 00000000..fdc47f7a --- /dev/null +++ b/internal/providers/copilot/provider_test.go @@ -0,0 +1,322 @@ +package copilot + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/majorcontext/moat/internal/cli" + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/credential" + "github.com/majorcontext/moat/internal/provider" + "github.com/spf13/cobra" +) + +type mockProxyConfigurer struct { + headers map[string]map[string]string + grants map[string]map[string]string +} + +func newMockProxyConfigurer() *mockProxyConfigurer { + return &mockProxyConfigurer{ + headers: make(map[string]map[string]string), + grants: make(map[string]map[string]string), + } +} + +func (m *mockProxyConfigurer) SetCredential(host, value string) {} + +func (m *mockProxyConfigurer) SetCredentialHeader(host, headerName, headerValue string) {} + +func (m *mockProxyConfigurer) SetCredentialWithGrant(host, headerName, headerValue, grant string) { + if m.headers[host] == nil { + m.headers[host] = make(map[string]string) + m.grants[host] = make(map[string]string) + } + m.headers[host][headerName] = headerValue + m.grants[host][headerName] = grant +} + +func (m *mockProxyConfigurer) AddExtraHeader(host, headerName, headerValue string) {} + +func (m *mockProxyConfigurer) AddResponseTransformer(host string, transformer provider.ResponseTransformer) { +} + +func (m *mockProxyConfigurer) RemoveRequestHeader(host, header string) {} + +func (m *mockProxyConfigurer) SetTokenSubstitution(host, placeholder, realToken string) {} + +func TestProviderName(t *testing.T) { + p := &Provider{} + if got := p.Name(); got != "copilot" { + t.Errorf("Name() = %q, want copilot", got) + } +} + +func TestConfigureProxy(t *testing.T) { + p := &Provider{} + proxy := newMockProxyConfigurer() + p.ConfigureProxy(proxy, &provider.Credential{Provider: "copilot", Token: "github_pat_test"}) + + if got := proxy.headers[copilotAPIHost]["Authorization"]; got != "Bearer github_pat_test" { + t.Errorf("api.github.com Authorization = %q", got) + } + if got := proxy.grants[copilotAPIHost]["Authorization"]; got != "github" { + t.Errorf("api.github.com grant = %q, want github", got) + } + if got := proxy.headers[copilotBusinessHost]["Authorization"]; got != "Bearer github_pat_test" { + t.Errorf("api.business.githubcopilot.com Authorization = %q", got) + } + if got := proxy.headers[copilotMCPHost]["Authorization"]; got == "" { + t.Errorf("api.mcp.github.com Authorization was not configured") + } + if got := proxy.headers[copilotGitHost]["Authorization"]; !strings.HasPrefix(got, "Basic ") { + t.Errorf("github.com Authorization = %q, want Basic auth", got) + } + if _, ok := proxy.headers[copilotProxyHost]; ok { + t.Error("copilotProxyHost should NOT have credentials; it uses Copilot session tokens") + } + if _, ok := proxy.headers[copilotTelemetry]; ok { + t.Error("copilotTelemetry should NOT have credentials; it uses Copilot session tokens") + } +} + +func TestContainerEnv(t *testing.T) { + env := (&Provider{}).ContainerEnv(&provider.Credential{Provider: "copilot"}) + for _, want := range []string{ + "COPILOT_GITHUB_TOKEN=" + credential.CopilotTokenPlaceholder, + } { + if !slices.Contains(env, want) { + t.Errorf("ContainerEnv missing %q in %v", want, env) + } + } + for _, unwanted := range []string{"GH_TOKEN=", "GIT_TERMINAL_PROMPT="} { + for _, got := range env { + if strings.HasPrefix(got, unwanted) { + t.Errorf("ContainerEnv contains %q; github grant owns this env: %v", unwanted, env) + } + } + } +} + +func TestProviderNoopMethods(t *testing.T) { + p := &Provider{} + if mounts, cleanupPath, err := p.ContainerMounts(&provider.Credential{}, "/home/moatuser"); err != nil || mounts != nil || cleanupPath != "" { + t.Fatalf("ContainerMounts() = (%v, %q, %v), want nil empty nil", mounts, cleanupPath, err) + } + p.Cleanup("/tmp/unused") + if deps := p.ImpliedDependencies(); !slices.Equal(deps, []string{"gh", "git"}) { + t.Fatalf("ImpliedDependencies() = %v, want [gh git]", deps) + } +} + +func TestDefaultDependenciesAndHosts(t *testing.T) { + if !slices.Contains(DefaultDependencies(), "copilot-cli") { + t.Errorf("DefaultDependencies missing copilot-cli: %v", DefaultDependencies()) + } + if !slices.Contains(NetworkHosts(), copilotAPIHost) || !slices.Contains(NetworkHosts(), copilotBusinessHost) || !slices.Contains(NetworkHosts(), copilotMCPHost) || !slices.Contains(NetworkHosts(), copilotProxyHost) { + t.Errorf("NetworkHosts missing Copilot hosts: %v", NetworkHosts()) + } +} + +func TestRegisterCLI(t *testing.T) { + root := &cobra.Command{Use: "moat"} + (&Provider{}).RegisterCLI(root) + cmd, _, err := root.Find([]string{"copilot"}) + if err != nil { + t.Fatalf("Find(copilot) error = %v", err) + } + if cmd == nil || cmd.Use != "copilot [workspace] [flags] [-- initial-prompt]" { + t.Fatalf("registered command = %#v", cmd) + } + for _, flag := range []string{"prompt", "allow-all", "model", "experimental", "autopilot", "worktree"} { + if cmd.Flags().Lookup(flag) == nil { + t.Fatalf("copilot command missing --%s flag", flag) + } + } +} + +func TestResolveCopilotPreflight(t *testing.T) { + origModelFlag, origResolvedModel := copilotModelFlag, copilotResolvedModel + origExperimental, origAutopilot := copilotExperimental, copilotAutopilot + origFlagGrants := copilotFlags.Grants + origDryRun := cli.DryRun + origConfigured := copilotCredentialConfigured + t.Cleanup(func() { + copilotModelFlag = origModelFlag + copilotResolvedModel = origResolvedModel + copilotExperimental = origExperimental + copilotAutopilot = origAutopilot + copilotFlags.Grants = origFlagGrants + cli.DryRun = origDryRun + copilotCredentialConfigured = origConfigured + }) + + copilotModelFlag = "" + copilotExperimental = false + copilotAutopilot = false + copilotFlags.Grants = []string{"copilot", "ssh:github.com"} + cli.DryRun = false + copilotCredentialConfigured = func() bool { return false } + cfg := &config.Config{ + Grants: []string{"github", "copilot"}, + Copilot: config.CopilotConfig{Model: "gpt-5.4", Experimental: true, Autopilot: true}, + } + + if err := resolveCopilotPreflight(cfg); err != nil { + t.Fatalf("resolveCopilotPreflight() error = %v", err) + } + if copilotResolvedModel != "gpt-5.4" || !copilotExperimental || !copilotAutopilot { + t.Fatalf("resolved state = model:%q experimental:%v autopilot:%v", copilotResolvedModel, copilotExperimental, copilotAutopilot) + } + if !slices.Equal(cfg.Grants, []string{"github"}) { + t.Fatalf("config grants = %v, want [github]", cfg.Grants) + } + if !slices.Equal(copilotFlags.Grants, []string{"github", "ssh:github.com"}) { + t.Fatalf("flag grants = %v, want [github ssh:github.com]", copilotFlags.Grants) + } +} + +func TestResolveCopilotPreflightModelFlagOverridesConfig(t *testing.T) { + origModelFlag, origResolvedModel := copilotModelFlag, copilotResolvedModel + origDryRun := cli.DryRun + origConfigured := copilotCredentialConfigured + t.Cleanup(func() { + copilotModelFlag = origModelFlag + copilotResolvedModel = origResolvedModel + cli.DryRun = origDryRun + copilotCredentialConfigured = origConfigured + }) + + copilotModelFlag = "gpt-5.5" + cli.DryRun = true + copilotCredentialConfigured = func() bool { return true } + cfg := &config.Config{ + Copilot: config.CopilotConfig{Model: "claude-sonnet-4"}, + } + + if err := resolveCopilotPreflight(cfg); err != nil { + t.Fatalf("resolveCopilotPreflight() error = %v", err) + } + if copilotResolvedModel != "gpt-5.5" { + t.Fatalf("CLI --model flag should override config model, got %q", copilotResolvedModel) + } +} + +func TestResolveCopilotPreflightAddsRequiredGrantWhenMissing(t *testing.T) { + origFlagGrants := copilotFlags.Grants + origDryRun := cli.DryRun + origConfigured := copilotCredentialConfigured + t.Cleanup(func() { + copilotFlags.Grants = origFlagGrants + cli.DryRun = origDryRun + copilotCredentialConfigured = origConfigured + }) + + copilotFlags.Grants = nil + cli.DryRun = false + copilotCredentialConfigured = func() bool { return false } + + if err := resolveCopilotPreflight(nil); err != nil { + t.Fatalf("resolveCopilotPreflight(nil) error = %v", err) + } + if !slices.Equal(copilotFlags.Grants, []string{"github"}) { + t.Fatalf("flag grants = %v, want [github]", copilotFlags.Grants) + } +} + +func TestResolveCopilotPreflightDryRunDoesNotAddMissingGrant(t *testing.T) { + origFlagGrants := copilotFlags.Grants + origDryRun := cli.DryRun + origConfigured := copilotCredentialConfigured + t.Cleanup(func() { + copilotFlags.Grants = origFlagGrants + cli.DryRun = origDryRun + copilotCredentialConfigured = origConfigured + }) + + copilotFlags.Grants = nil + cli.DryRun = true + copilotCredentialConfigured = func() bool { return false } + + if err := resolveCopilotPreflight(nil); err != nil { + t.Fatalf("resolveCopilotPreflight(nil) error = %v", err) + } + if len(copilotFlags.Grants) != 0 { + t.Fatalf("dry-run flag grants = %v, want empty", copilotFlags.Grants) + } +} + +func TestBuildCopilotCommand(t *testing.T) { + origModel, origExperimental, origAutopilot, origAllowAll := copilotResolvedModel, copilotExperimental, copilotAutopilot, copilotAllowAll + t.Cleanup(func() { + copilotResolvedModel = origModel + copilotExperimental = origExperimental + copilotAutopilot = origAutopilot + copilotAllowAll = origAllowAll + }) + + copilotResolvedModel = "gpt-5.4" + copilotExperimental = true + copilotAutopilot = true + copilotAllowAll = true + + got := buildCopilotCommand("fix it", "") + want := []string{"copilot", "--no-auto-update", "--model", "gpt-5.4", "--experimental", "--autopilot", "--allow-all", "-p", "fix it"} + if !slices.Equal(got, want) { + t.Errorf("buildCopilotCommand = %v, want %v", got, want) + } + + got = buildCopilotCommand("", "hello") + if !slices.Contains(got, "--allow-all") { + t.Errorf("interactive initial prompt should get --allow-all by default: %v", got) + } + if !slices.Contains(got, "-i") || !slices.Contains(got, "hello") { + t.Errorf("initial prompt not passed via -i: %v", got) + } +} + +func TestGetCredentialName(t *testing.T) { + origConfigured := copilotCredentialConfigured + t.Cleanup(func() { copilotCredentialConfigured = origConfigured }) + copilotCredentialConfigured = func() bool { return true } + if got := GetCredentialName(); got != "github" { + t.Fatalf("GetCredentialName() = %q, want github", got) + } + copilotCredentialConfigured = func() bool { return false } + if got := GetCredentialName(); got != "" { + t.Fatalf("GetCredentialName() = %q, want empty when missing", got) + } +} + +func TestNormalizeCopilotGrants(t *testing.T) { + got := normalizeCopilotGrants([]string{"github", "copilot", "ssh:github.com", "aws"}, false) + want := []string{"github", "ssh:github.com", "aws"} + if !slices.Equal(got, want) { + t.Errorf("normalizeCopilotGrants = %v, want %v", got, want) + } +} + +func TestPrepareContainer(t *testing.T) { + cfg, err := (&Provider{}).PrepareContainer(t.Context(), provider.PrepareOpts{RuntimeContext: "hello"}) + if err != nil { + t.Fatalf("PrepareContainer() error = %v", err) + } + t.Cleanup(cfg.Cleanup) + + if !slices.Contains(cfg.Env, "MOAT_COPILOT_INIT="+CopilotInitMountPath) { + t.Errorf("env missing MOAT_COPILOT_INIT: %v", cfg.Env) + } + if _, err := os.Stat(filepath.Join(cfg.StagingDir, ContextFileName)); err != nil { + t.Fatalf("context file missing: %v", err) + } + data, err := os.ReadFile(filepath.Join(cfg.StagingDir, "config.json")) + if err != nil { + t.Fatalf("reading config.json: %v", err) + } + if !strings.Contains(string(data), "/workspace") { + t.Errorf("config.json should trust /workspace, got %s", data) + } +} diff --git a/internal/providers/register.go b/internal/providers/register.go index 277d210c..6c83e6a5 100644 --- a/internal/providers/register.go +++ b/internal/providers/register.go @@ -9,6 +9,7 @@ import ( _ "github.com/majorcontext/moat/internal/providers/aws" // registers AWS provider _ "github.com/majorcontext/moat/internal/providers/claude" // registers Claude/Anthropic provider _ "github.com/majorcontext/moat/internal/providers/codex" // registers Codex/OpenAI provider + _ "github.com/majorcontext/moat/internal/providers/copilot" // registers GitHub Copilot CLI provider _ "github.com/majorcontext/moat/internal/providers/gemini" // registers Gemini/Google provider _ "github.com/majorcontext/moat/internal/providers/github" // registers GitHub provider _ "github.com/majorcontext/moat/internal/providers/graphite" // registers Graphite provider diff --git a/internal/run/copilot_validation_test.go b/internal/run/copilot_validation_test.go new file mode 100644 index 00000000..be4a693e --- /dev/null +++ b/internal/run/copilot_validation_test.go @@ -0,0 +1,100 @@ +package run + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/credential" +) + +func TestValidateCopilotGitHubGrant(t *testing.T) { + store := newMockStore() + store.Save(credential.Credential{ + Provider: credential.ProviderGitHub, + Token: "github-token", + CreatedAt: time.Now(), + }) + + origValidate := validateCopilotGitHubToken + t.Cleanup(func() { validateCopilotGitHubToken = origValidate }) + + var gotToken string + validateCopilotGitHubToken = func(ctx context.Context, token string) error { + gotToken = token + return nil + } + + err := validateCopilotGitHubGrant(context.Background(), &config.Config{Agent: "copilot"}, []string{"github"}, store) + if err != nil { + t.Fatalf("validateCopilotGitHubGrant() error = %v", err) + } + if gotToken != "github-token" { + t.Fatalf("validated token = %q, want github-token", gotToken) + } +} + +func TestValidateCopilotGitHubGrantFailure(t *testing.T) { + store := newMockStore() + store.Save(credential.Credential{ + Provider: credential.ProviderGitHub, + Token: "github-token", + CreatedAt: time.Now(), + }) + + origValidate := validateCopilotGitHubToken + t.Cleanup(func() { validateCopilotGitHubToken = origValidate }) + validateCopilotGitHubToken = func(ctx context.Context, token string) error { + return errors.New("missing Copilot Requests") + } + + err := validateCopilotGitHubGrant(context.Background(), &config.Config{Agent: "copilot"}, []string{"github"}, store) + if err == nil { + t.Fatal("validateCopilotGitHubGrant() = nil, want error") + } + if !strings.Contains(err.Error(), "moat grant github") || !strings.Contains(err.Error(), "missing Copilot Requests") { + t.Fatalf("validateCopilotGitHubGrant() error = %v, want grant guidance and cause", err) + } +} + +func TestValidateCopilotGitHubGrantSkippedForNonCopilotRun(t *testing.T) { + origValidate := validateCopilotGitHubToken + t.Cleanup(func() { validateCopilotGitHubToken = origValidate }) + validateCopilotGitHubToken = func(ctx context.Context, token string) error { + t.Fatal("validateCopilotGitHubToken should not be called") + return nil + } + + err := validateCopilotGitHubGrant(context.Background(), &config.Config{Agent: "bash"}, []string{"github"}, newMockStore()) + if err != nil { + t.Fatalf("validateCopilotGitHubGrant() error = %v", err) + } +} + +func TestValidateCopilotGitHubGrantRequiresGitHubGrant(t *testing.T) { + origValidate := validateCopilotGitHubToken + t.Cleanup(func() { validateCopilotGitHubToken = origValidate }) + validateCopilotGitHubToken = func(ctx context.Context, token string) error { + t.Fatal("validateCopilotGitHubToken should not be called without github grant") + return nil + } + + err := validateCopilotGitHubGrant(context.Background(), &config.Config{Agent: "copilot"}, nil, newMockStore()) + if err == nil { + t.Fatal("validateCopilotGitHubGrant() = nil, want missing github grant error") + } + if !strings.Contains(err.Error(), "requires the github grant") || !strings.Contains(err.Error(), "moat grant github") { + t.Fatalf("validateCopilotGitHubGrant() error = %v, want github grant guidance", err) + } +} + +func TestNormalizeCopilotGrantNames(t *testing.T) { + got := normalizeCopilotGrantNames([]string{"copilot", "ssh:github.com", "github", "aws", "copilot"}) + want := []string{"github", "ssh:github.com", "aws"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("normalizeCopilotGrantNames() = %v, want %v", got, want) + } +} diff --git a/internal/run/grants_test.go b/internal/run/grants_test.go index 43417930..1bd9c121 100644 --- a/internal/run/grants_test.go +++ b/internal/run/grants_test.go @@ -109,6 +109,22 @@ func TestDetectMissingGrantsMatchesValidators(t *testing.T) { if detected || rejected { t.Fatalf("present case: detector=%v validators=%v — both must report none", detected, rejected) } + + // Legacy copilot grants resolve to the github credential store key. Keep + // that path in the drift guard so a future caller cannot accidentally make + // detector/validator behavior diverge if a copilot grant slips past + // normalization. + copilotGrants := []string{"copilot"} + detected = len(DetectMissingGrants(copilotGrants, nil, store)) > 0 + rejected = validateGrants(copilotGrants, store) != nil + if detected != rejected { + t.Fatalf("copilot missing case: detector=%v validators=%v — they must agree", detected, rejected) + } + detected = len(DetectMissingGrants(copilotGrants, nil, full)) > 0 + rejected = validateGrants(copilotGrants, full) != nil + if detected || rejected { + t.Fatalf("copilot present case: detector=%v validators=%v — both must report none", detected, rejected) + } } func TestClassifyMissingReason(t *testing.T) { diff --git a/internal/run/imageneeds.go b/internal/run/imageneeds.go index e89bafd0..f8b2e851 100644 --- a/internal/run/imageneeds.go +++ b/internal/run/imageneeds.go @@ -100,6 +100,9 @@ func resolveImageNeedsWithStore(grants []string, depList []deps.Dependency, stor if !initSet["claude"] && hasDep(depList, "claude-code") { initSet["claude"] = true } + if !initSet["copilot"] && hasDep(depList, "copilot-cli") { + initSet["copilot"] = true + } if !initSet["gemini"] && hasDep(depList, "gemini-cli") { initSet["gemini"] = true } @@ -136,6 +139,9 @@ func credentialStoreKey(baseName, fullGrant string) credential.Provider { if canonical == providerCodex { return credential.ProviderOpenAI } + if canonical == "copilot" { + return credential.ProviderGitHub + } // OAuth uses the full grant name as store key (oauth:notion → "oauth:notion") // so each integration has its own credential entry. if canonical == "oauth" { diff --git a/internal/run/imageneeds_test.go b/internal/run/imageneeds_test.go index 390626f6..d630bfec 100644 --- a/internal/run/imageneeds_test.go +++ b/internal/run/imageneeds_test.go @@ -145,6 +145,14 @@ func TestResolveImageNeedsGeminiCLIDep(t *testing.T) { } } +func TestResolveImageNeedsCopilotCLIDep(t *testing.T) { + depList := []deps.Dependency{{Name: "copilot-cli"}} + needs := resolveImageNeedsWithStore(nil, depList, nil) + if !contains(needs.initProviders, "copilot") { + t.Error("copilot-cli dep should add copilot to initProviders via fallback") + } +} + func TestResolveImageNeedsPiCLIDep(t *testing.T) { depList := []deps.Dependency{{Name: "pi-cli"}} needs := resolveImageNeedsWithStore(nil, depList, nil) @@ -153,6 +161,13 @@ func TestResolveImageNeedsPiCLIDep(t *testing.T) { } } +func TestResolveImageNeedsNoCopilotWithoutDep(t *testing.T) { + needs := resolveImageNeedsWithStore([]string{"copilot"}, nil, nil) + if contains(needs.initProviders, "copilot") { + t.Error("copilot grant alone should not add copilot init without the copilot-cli dependency") + } +} + // Companion: Pi has no grant of its own, so without the pi-cli dep nothing // should add pi to initProviders (a bare anthropic/openai grant must not). func TestResolveImageNeedsNoPiWithoutDep(t *testing.T) { @@ -233,6 +248,7 @@ func TestCredentialStoreKey(t *testing.T) { want credential.Provider }{ {"github", "github", "github"}, + {"copilot", "copilot", credential.ProviderGitHub}, {"claude", "claude", "claude"}, {"openai", "openai", credential.ProviderOpenAI}, {"oauth", "oauth:notion", "oauth:notion"}, @@ -262,6 +278,7 @@ func TestGrantToCommand(t *testing.T) { want string }{ {"github", "github"}, + {"copilot", "github"}, {"oauth:notion", "oauth notion"}, {"ssh:github.com", "ssh github.com"}, {"mcp:context7", "mcp context7"}, // canonical diff --git a/internal/run/manager_agentinit.go b/internal/run/manager_agentinit.go index 39b1f74f..abe1bf37 100644 --- a/internal/run/manager_agentinit.go +++ b/internal/run/manager_agentinit.go @@ -148,6 +148,27 @@ func (m *Manager) setupPiStaging(ctx context.Context, piProvider provider.AgentP return piConfig, nil } +// setupCopilotStaging builds the GitHub Copilot CLI container config +// (runtime context and first-run config) via the provider interface. +func (m *Manager) setupCopilotStaging(ctx context.Context, copilotProvider provider.AgentProvider, containerHome, renderedContext string, openCredStore func() (*credential.FileStore, error)) (*provider.ContainerConfig, error) { + var copilotCred *provider.Credential + if store, storeErr := openCredStore(); storeErr == nil { + if cred, err := store.Get(credential.ProviderGitHub); err == nil { + copilotCred = provider.FromLegacy(cred) + } + } + + copilotConfig, prepErr := copilotProvider.PrepareContainer(ctx, provider.PrepareOpts{ + Credential: copilotCred, + ContainerHome: containerHome, + RuntimeContext: renderedContext, + }) + if prepErr != nil { + return nil, fmt.Errorf("preparing Copilot container config: %w", prepErr) + } + return copilotConfig, nil +} + // buildClaudeMCPRelayServers builds the .claude.json MCP server map, pointing // each entry at a proxy-relay URL instead of its direct URL. // diff --git a/internal/run/manager_cleanup.go b/internal/run/manager_cleanup.go index 0cc7ca0a..3345894e 100644 --- a/internal/run/manager_cleanup.go +++ b/internal/run/manager_cleanup.go @@ -247,7 +247,7 @@ func (m *Manager) cleanupResources(ctx context.Context, r *Run) { } // Clean up temp directories - for _, dir := range []string{r.awsTempDir, r.ClaudeConfigTempDir, r.CodexConfigTempDir, r.GeminiConfigTempDir, r.PiConfigTempDir} { + for _, dir := range []string{r.awsTempDir, r.ClaudeConfigTempDir, r.CodexConfigTempDir, r.GeminiConfigTempDir, r.CopilotConfigTempDir, r.PiConfigTempDir} { if dir != "" { if err := os.RemoveAll(dir); err != nil { log.Debug("cleanup: failed to remove temp dir", "path", dir, "error", err) diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index 466f340b..cbff94e0 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -40,6 +40,7 @@ import ( "github.com/majorcontext/moat/internal/provider" awsprov "github.com/majorcontext/moat/internal/providers/aws" "github.com/majorcontext/moat/internal/providers/claude" // only for settings types (LoadAllSettings, Settings, MarketplaceConfig) - provider setup uses provider interfaces + copilotprov "github.com/majorcontext/moat/internal/providers/copilot" "github.com/majorcontext/moat/internal/runctx" "github.com/majorcontext/moat/internal/secrets" "github.com/majorcontext/moat/internal/snapshot" @@ -106,6 +107,11 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr } } + opts.Grants = normalizeCopilotGrantNames(opts.Grants) + if opts.Config != nil { + opts.Config.Grants = normalizeCopilotGrantNames(opts.Config.Grants) + } + // Auto-include MCP auth grants so the credential processing loop loads // them into the RunContext. Without this, users would need to duplicate // each mcp[].auth.grant in the top-level grants: list. @@ -138,7 +144,7 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr } // Validate grants before allocating any resources (proxy, container, etc.) - needsGrantValidation := len(opts.Grants) > 0 || (opts.Config != nil && len(opts.Config.MCP) > 0) + needsGrantValidation := len(opts.Grants) > 0 || (opts.Config != nil && len(opts.Config.MCP) > 0) || configUsesCopilotCLI(opts.Config) if needsGrantValidation { store, err := openCredStore() if err != nil { @@ -152,6 +158,9 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr return nil, err } } + if err := validateCopilotGitHubGrant(ctx, opts.Config, opts.Grants, store); err != nil { + return nil, err + } } // Get ports from config @@ -400,6 +409,7 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr // Create a RunContext that implements credential.ProxyConfigurer. // Providers will configure their credentials on this context. runCtx := daemon.NewRunContext(r.ID) + runCtx.CopilotGitHubAuth = configUsesCopilotCLI(opts.Config) && hasGrant(opts.Grants, "github") // Load credentials for granted providers store, err := openCredStore() @@ -458,6 +468,14 @@ func (m *Manager) Create(ctx context.Context, opts Options) (resRun *Run, retErr } // Configure the RunContext (which implements ProxyConfigurer) prov.ConfigureProxy(runCtx, provCred) + if grantName == "github" && runCtx.CopilotGitHubAuth { + copilotProv := provider.Get("copilot") + if copilotProv == nil { + cleanupDaemonRun() + return nil, fmt.Errorf("copilot provider not registered") + } + copilotProv.ConfigureProxy(runCtx, provCred) + } envVars := prov.ContainerEnv(provCred) log.Debug("adding provider env vars", "provider", credName, "vars", envVars) providerEnv = append(providerEnv, envVars...) @@ -1126,6 +1144,9 @@ region = %s imgNeeds := resolveImageNeeds(opts.Grants, depList) needsClaudeInit := slices.Contains(imgNeeds.initProviders, "claude") needsCodexInit := slices.Contains(imgNeeds.initProviders, "codex") + // Copilot uses the github grant, so grant-based image analysis cannot + // distinguish a Copilot run from a generic GitHub-authenticated run. + needsCopilotInit := slices.Contains(imgNeeds.initProviders, "copilot") || (opts.Config != nil && strings.HasPrefix(opts.Config.Agent, "copilot")) needsGeminiInit := slices.Contains(imgNeeds.initProviders, "gemini") needsPiInit := slices.Contains(imgNeeds.initProviders, "pi") @@ -1417,6 +1438,35 @@ region = %s proxyEnv = append(proxyEnv, codexConfig.Env...) } + // Set up GitHub Copilot CLI staging directory using the provider interface. + var copilotConfig *provider.ContainerConfig + if needsCopilotInit { + copilotProvider := provider.GetAgent("copilot") + if copilotProvider == nil { + cleanupDaemonRun() + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + return nil, fmt.Errorf("copilot provider not registered") + } + + cfg, stageErr := m.setupCopilotStaging(ctx, copilotProvider, containerHome, renderedContext, openCredStore) + if stageErr != nil { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + return nil, stageErr + } + copilotConfig = cfg + mounts = append(mounts, copilotConfig.Mounts...) + proxyEnv = append(proxyEnv, copilotConfig.Env...) + defer func() { + if retErr != nil { + cleanupAgentConfig(copilotConfig) + } + }() + } + // Set up Gemini staging directory for init script using the provider interface. // This includes settings.json and optionally oauth_creds.json. var geminiConfig *provider.ContainerConfig @@ -1427,6 +1477,7 @@ region = %s cleanupDaemonRun() cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) return nil, fmt.Errorf("gemini provider not registered") } @@ -1436,6 +1487,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) return nil, stageErr } geminiConfig = cfg @@ -1454,6 +1506,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) cleanupAgentConfig(geminiConfig) return nil, fmt.Errorf("pi provider not registered") } @@ -1464,6 +1517,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) cleanupAgentConfig(geminiConfig) return nil, stageErr } @@ -1546,6 +1600,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) return nil, fmt.Errorf("BuildKit requires Docker runtime (networks not supported by %s)", m.defaultRuntime().Type()) } netID, netErr := netMgr.CreateNetwork(ctx, buildkitCfg.NetworkName) @@ -1554,6 +1609,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) return nil, fmt.Errorf("failed to create Docker network for buildkit sidecar: %w", netErr) } networkID = netID @@ -2003,6 +2059,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(copilotConfig) return nil, fmt.Errorf("creating container: %w", err) } @@ -2025,6 +2082,9 @@ region = %s if geminiConfig != nil { r.GeminiConfigTempDir = geminiConfig.StagingDir } + if copilotConfig != nil { + r.CopilotConfigTempDir = copilotConfig.StagingDir + } if piConfig != nil { r.PiConfigTempDir = piConfig.StagingDir } @@ -2181,7 +2241,7 @@ func replaceHostInEnv(env []string, oldHost, newHost string) []string { } // isAIAgent returns true if the config specifies an AI coding agent -// (claude, codex, or gemini). Used to apply agent-specific defaults +// (claude, codex, copilot, gemini, or pi). Used to apply agent-specific defaults // like the 8 GB memory limit on Apple containers. func isAIAgent(cfg *config.Config) bool { if cfg == nil { @@ -2189,10 +2249,76 @@ func isAIAgent(cfg *config.Config) bool { } return strings.HasPrefix(cfg.Agent, "claude") || strings.HasPrefix(cfg.Agent, "codex") || + strings.HasPrefix(cfg.Agent, "copilot") || strings.HasPrefix(cfg.Agent, "gemini") || strings.HasPrefix(cfg.Agent, "pi") } +func configUsesCopilotCLI(cfg *config.Config) bool { + if cfg == nil { + return false + } + if strings.HasPrefix(cfg.Agent, "copilot") { + return true + } + for _, dep := range cfg.Dependencies { + name := dep + if i := strings.IndexByte(dep, '@'); i >= 0 { + name = dep[:i] + } + if name == "copilot-cli" { + return true + } + } + return false +} + +func normalizeCopilotGrantNames(grants []string) []string { + if len(grants) == 0 { + return grants + } + out := make([]string, 0, len(grants)) + hasGitHub := false + for _, grant := range grants { + if strings.Split(grant, ":")[0] == "github" { + if hasGitHub { + continue + } + hasGitHub = true + out = append(out, grant) + continue + } + if strings.Split(grant, ":")[0] == "copilot" { + if !hasGitHub { + out = append(out, "github") + hasGitHub = true + } + continue + } + out = append(out, grant) + } + return out +} + +var validateCopilotGitHubToken = copilotprov.ValidateGitHubToken + +func validateCopilotGitHubGrant(ctx context.Context, cfg *config.Config, grants []string, store credential.Store) error { + if !configUsesCopilotCLI(cfg) { + return nil + } + if !hasGrant(grants, "github") { + return fmt.Errorf("GitHub Copilot CLI requires the github grant\n\nRun: moat grant github") + } + cred, err := store.Get(credential.ProviderGitHub) + if err != nil { + return fmt.Errorf("github grant: credential not found: %w", err) + } + if err := validateCopilotGitHubToken(ctx, cred.Token); err != nil { + return fmt.Errorf("github grant cannot use GitHub Copilot: %w\n\nRun: moat grant github with a GitHub CLI OAuth token from an account with Copilot access, or a fine-grained PAT with the Copilot Requests permission", err) + } + return nil +} + // resolveContainerHome returns the home directory to use for container mounts. // Most moat runs build a custom image (needsCustomImage=true) which always creates // moatuser and runs as that user, so the home is /home/moatuser. We use this @@ -2464,17 +2590,18 @@ func isMoatOwnedProxyVar(name string) bool { // suitable for sending to the daemon API. func buildRegisterRequest(rc *daemon.RunContext, grants []string) daemon.RegisterRequest { req := daemon.RegisterRequest{ - RunID: rc.RunID, - NetworkPolicy: rc.NetworkPolicy, - NetworkAllow: rc.NetworkAllow, - NetworkRules: rc.NetworkRules, - HostGateway: rc.HostGateway, - HostGatewayIP: rc.HostGatewayIP, - AllowedHostPorts: rc.AllowedHostPorts, - MCPServers: rc.MCPServers, - Grants: grants, - AWSConfig: rc.AWSConfig, - CredProfile: credential.ActiveProfile, + RunID: rc.RunID, + NetworkPolicy: rc.NetworkPolicy, + NetworkAllow: rc.NetworkAllow, + NetworkRules: rc.NetworkRules, + HostGateway: rc.HostGateway, + HostGatewayIP: rc.HostGatewayIP, + AllowedHostPorts: rc.AllowedHostPorts, + MCPServers: rc.MCPServers, + Grants: grants, + CopilotGitHubAuth: rc.CopilotGitHubAuth, + AWSConfig: rc.AWSConfig, + CredProfile: credential.ActiveProfile, } for host, creds := range rc.Credentials { diff --git a/internal/run/manager_resources_test.go b/internal/run/manager_resources_test.go index 60d3cd8f..607b8bc3 100644 --- a/internal/run/manager_resources_test.go +++ b/internal/run/manager_resources_test.go @@ -42,9 +42,13 @@ func TestResolveResourceLimits_FromConfig(t *testing.T) { func TestResolveResourceLimits_AppleAIDefault(t *testing.T) { m := mgrWithRuntime(appleRuntime{&stubRuntime{}}) - mem, _, _, _ := m.resolveResourceLimits(&config.Config{Agent: "claude"}) - if mem != container.DefaultAgentMemoryMB { - t.Fatalf("expected Apple agent default %d, got %d", container.DefaultAgentMemoryMB, mem) + for _, agent := range []string{"claude", "codex", "copilot", "gemini", "pi"} { + t.Run(agent, func(t *testing.T) { + mem, _, _, _ := m.resolveResourceLimits(&config.Config{Agent: agent}) + if mem != container.DefaultAgentMemoryMB { + t.Fatalf("expected Apple agent default %d, got %d", container.DefaultAgentMemoryMB, mem) + } + }) } } diff --git a/internal/run/run.go b/internal/run/run.go index 6840ab30..a6a46ccb 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -134,6 +134,11 @@ type Run struct { // cleaned up when the run is stopped or destroyed. GeminiConfigTempDir string + // CopilotConfigTempDir is the temporary directory containing Copilot configuration files + // and runtime context that are mounted into the container. This should be + // cleaned up when the run is stopped or destroyed. + CopilotConfigTempDir string + // PiConfigTempDir is the temporary directory containing Pi configuration files // (the runtime-context file) that are mounted into the container. This should be // cleaned up when the run is stopped or destroyed. @@ -341,6 +346,9 @@ func grantToCommand(grant string) string { if server, ok := mcpcatalog.GrantName(grant); ok { return "mcp " + server } + if grant == "copilot" { + return "github" + } if parts := strings.SplitN(grant, ":", 2); len(parts) == 2 { return parts[0] + " " + parts[1] }