From 0725c133b2284b7d744484b8322c72cec24f762f Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 15:06:36 +0200 Subject: [PATCH 1/8] feat(copilot): add GitHub Copilot CLI support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 11 +- docs/content/guides/17-copilot.md | 80 ++++++++++ docs/content/reference/01-cli.md | 61 +++++++- docs/content/reference/02-moat-yaml.md | 51 +++++++ docs/content/reference/04-grants.md | 37 +++++ internal/config/config.go | 13 ++ internal/credential/provider.go | 6 + internal/credential/types.go | 9 +- internal/deps/imagespec.go | 2 +- internal/deps/registry.yaml | 6 + internal/deps/scripts/moat-init.sh | 30 ++++ internal/provider/doc.go | 2 +- internal/provider/interfaces.go | 2 +- internal/providers/copilot/agent.go | 51 +++++++ internal/providers/copilot/cli.go | 156 ++++++++++++++++++++ internal/providers/copilot/constants.go | 18 +++ internal/providers/copilot/grant.go | 146 ++++++++++++++++++ internal/providers/copilot/provider.go | 97 ++++++++++++ internal/providers/copilot/provider_test.go | 153 +++++++++++++++++++ internal/providers/register.go | 1 + internal/run/imageneeds.go | 3 + internal/run/imageneeds_test.go | 15 ++ internal/run/manager_agentinit.go | 13 ++ internal/run/manager_cleanup.go | 2 +- internal/run/manager_create.go | 39 +++++ internal/run/run.go | 5 + 26 files changed, 1000 insertions(+), 9 deletions(-) create mode 100644 docs/content/guides/17-copilot.md create mode 100644 internal/providers/copilot/agent.go create mode 100644 internal/providers/copilot/cli.go create mode 100644 internal/providers/copilot/constants.go create mode 100644 internal/providers/copilot/grant.go create mode 100644 internal/providers/copilot/provider.go create mode 100644 internal/providers/copilot/provider_test.go diff --git a/README.md b/README.md index 70c1f000..1ec1dcd0 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 copilot # One-time: imports Copilot-capable GitHub credentials +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/docs/content/guides/17-copilot.md b/docs/content/guides/17-copilot.md new file mode 100644 index 00000000..5d66658a --- /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 Copilot credentials + +Run `moat grant copilot` to configure authentication: + +```bash +moat grant copilot +``` + +Moat checks `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, and `GITHUB_TOKEN` first. If none are set, it can use `gh auth token` from GitHub CLI, or prompt for a fine-grained PAT. + +Classic PATs are not supported by GitHub Copilot CLI. Fine-grained PATs must be created for your personal account with the **Copilot Requests** account permission. + +## How credentials are injected + +The raw 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 token for `api.github.com`, Copilot API hosts, and HTTPS git operations against `github.com`. + +Do not add a separate `github` grant to `moat copilot` runs. The `copilot` grant already covers GitHub API and HTTPS git auth, and Moat ignores `github` for Copilot runs to avoid ambiguous auth on `api.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: + - copilot + +copilot: + model: gpt-5.4 + experimental: false + autopilot: false +``` + +`moat copilot` adds the `copilot` grant automatically, 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..3d75c63d 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,64 @@ 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` requires a `copilot` grant. The container receives only Copilot/GitHub token placeholders; Moat's proxy injects the real token for GitHub API and HTTPS git requests. + +If `github` is also listed in `moat.yaml` or passed with `--grant`, `moat copilot` ignores it. Both grants target `api.github.com` with the same header, and the `copilot` grant already covers GitHub API and HTTPS git auth for Copilot runs. + +### 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 copilot + +# 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. diff --git a/docs/content/reference/02-moat-yaml.md b/docs/content/reference/02-moat-yaml.md index ae56b613..4577d9a8 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` requires the `copilot` grant. Run `moat grant copilot` 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..cbbc4c1e 100644 --- a/docs/content/reference/04-grants.md +++ b/docs/content/reference/04-grants.md @@ -16,6 +16,7 @@ Store a credential with `moat grant `, then use it in runs with `--gra | Grant | Hosts matched | Header injected | Credential source | |-------|---------------|-----------------|-------------------| | `github` | `api.github.com`, `github.com` | `Authorization: Bearer ...` (`api.github.com`); `Authorization: Basic ...` (`github.com`, for git smart-HTTP) | gh CLI, `GITHUB_TOKEN`/`GH_TOKEN`, or PAT prompt | +| `copilot` | `api.github.com`, Copilot API hosts, `github.com` | `Authorization: Bearer ` (`api.github.com` and Copilot APIs); `Authorization: Basic ...` (`github.com`, for git smart-HTTP) | gh CLI, `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, or Copilot-capable PAT prompt | | `claude` | `api.anthropic.com` | `Authorization: Bearer ...` | `claude setup-token` or imported OAuth | | `anthropic` | `api.anthropic.com` | `x-api-key: ...` | API key from `console.anthropic.com` | | `openai` | `api.openai.com`, `chatgpt.com`, `*.openai.com` | `Authorization: Bearer ...` | `OPENAI_API_KEY` or prompt | @@ -88,6 +89,42 @@ GitHub credential saved $ moat run --grant github ./my-project ``` +## GitHub Copilot + +### CLI command + +```bash +moat grant copilot +``` + +No flags. The command validates that the token can call GitHub Copilot CLI's GitHub API endpoint. + +### Credential sources (in order of preference) + +1. **Environment variable** -- `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` +2. **gh CLI** -- The token from `gh auth token`, if GitHub CLI is installed and authenticated +3. **Copilot-capable token** -- Interactive prompt for a fine-grained PAT from your personal account with the **Copilot Requests** permission + +Classic PATs are not supported by GitHub Copilot CLI. Fine-grained PATs must be created for your personal account, not an organization. + +### What it injects + +The proxy injects the token for: + +- `api.github.com` and Copilot API hosts -- `Authorization: Bearer ` for Copilot CLI authentication, model traffic, and GitHub API calls +- `github.com` -- `Basic ")>` for 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: + - copilot +``` + +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..fee8daea 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -16,6 +16,7 @@ type Provider string const ( ProviderGitHub Provider = "github" + ProviderCopilot Provider = "copilot" ProviderAWS Provider = "aws" ProviderAnthropic Provider = "anthropic" ProviderClaude Provider = "claude" @@ -55,14 +56,16 @@ 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...) + base := []Provider{ProviderGitHub, ProviderCopilot, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta} + 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. func IsKnownProvider(p Provider) bool { switch p { - case ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta: + case ProviderGitHub, ProviderCopilot, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta: return true default: for _, dp := range dynamicProviders { 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..7af6de4b --- /dev/null +++ b/internal/providers/copilot/cli.go @@ -0,0 +1,156 @@ +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 +) + +func NetworkHosts() []string { + return []string{ + "api.github.com", + "github.com", + "copilot-proxy.githubusercontent.com", + "api.githubcopilot.com", + "api.business.githubcopilot.com", + "telemetry.business.githubcopilot.com", + } +} + +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]", + 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. Copilot 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 Copilot credential configured. Run 'moat grant copilot' 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 = filterGitHubGrant(cfg.Grants, false) + if copilotResolvedModel == "" { + copilotResolvedModel = cfg.Copilot.Model + } + if cfg.Copilot.Experimental { + copilotExperimental = true + } + if cfg.Copilot.Autopilot { + copilotAutopilot = true + } + } + copilotFlags.Grants = filterGitHubGrant(copilotFlags.Grants, true) + return nil +} + +func filterGitHubGrant(grants []string, warn bool) []string { + if len(grants) == 0 { + return grants + } + out := grants[:0] + removed := false + for _, grant := range grants { + if strings.Split(grant, ":")[0] == "github" { + removed = true + continue + } + out = append(out, grant) + } + if removed && warn { + ui.Warn("ignoring github grant for moat copilot — the copilot grant already injects GitHub API and HTTPS git auth for this run") + } + return out +} + +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 { return string(credential.ProviderCopilot) } diff --git a/internal/providers/copilot/constants.go b/internal/providers/copilot/constants.go new file mode 100644 index 00000000..697532e2 --- /dev/null +++ b/internal/providers/copilot/constants.go @@ -0,0 +1,18 @@ +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" + 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..24aae418 --- /dev/null +++ b/internal/providers/copilot/grant.go @@ -0,0 +1,146 @@ +package copilot + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + "github.com/majorcontext/moat/internal/provider" + "github.com/majorcontext/moat/internal/provider/util" +) + +// Grant handles Copilot credential acquisition. +type Grant struct{} + +func NewGrant() *Grant { return &Grant{} } + +func (g *Grant) Execute(ctx context.Context) (*provider.Credential, error) { + if token, name := util.CheckEnvVarWithName("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"); token != "" { + fmt.Printf("Using token from %s environment variable\n", name) + return validateAndCreateCredential(ctx, token, SourceEnv) + } + + token, ghErr := getGHCLIToken(ctx) + if ghErr == nil && token != "" { + fmt.Println("Found gh CLI authentication") + confirmed, err := util.Confirm("Use token from gh CLI for Copilot?") + if err != nil { + return nil, fmt.Errorf("reading confirmation: %w", err) + } + if confirmed { + return validateAndCreateCredential(ctx, token, SourceCLI) + } + fmt.Println() + } else if ghErr != nil && isGHCLIInstalled() { + fmt.Printf("Note: gh CLI found but 'gh auth token' failed: %v\n", ghErr) + fmt.Println("You may need to run 'gh auth login' first.") + fmt.Println() + } + + fmt.Println(`Enter a GitHub token that can use Copilot CLI. + +Supported token types: + - GitHub CLI OAuth token from 'gh auth login' + - Fine-grained PAT from your personal account with "Copilot Requests" + +To create a fine-grained PAT: + 1. Visit https://github.com/settings/personal-access-tokens/new + 2. Select your personal account as the resource owner + 3. Add the "Copilot Requests" account permission + 4. Select repository access appropriate for your workflow + 5. Copy the generated token`) + + token, err := util.PromptForToken("Token") + if err != nil { + return nil, fmt.Errorf("reading token: %w", err) + } + if token == "" { + return nil, &provider.GrantError{ + Provider: copilotProviderName, + Cause: fmt.Errorf("no token provided"), + Hint: "Run 'moat grant copilot' and enter a Copilot-capable GitHub token", + } + } + return validateAndCreateCredential(ctx, token, SourcePAT) +} + +func validateAndCreateCredential(ctx context.Context, token, source string) (*provider.Credential, error) { + fmt.Println("Validating Copilot token...") + if err := validateCopilotToken(ctx, token); err != nil { + return nil, &provider.GrantError{ + Provider: copilotProviderName, + Cause: err, + Hint: "Use a GitHub CLI OAuth token or a fine-grained PAT with the Copilot Requests permission", + } + } + fmt.Println("Copilot token validated successfully") + return &provider.Credential{ + Provider: copilotProviderName, + Token: token, + CreatedAt: time.Now(), + Metadata: map[string]string{provider.MetaKeyTokenSource: source}, + }, nil +} + +func validateCopilotToken(ctx context.Context, token string) error { + client := &http.Client{Timeout: 10 * time.Second} + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, "GET", "https://api.github.com/copilot_internal/user", 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) + } +} + +func getGHCLIToken(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "gh", "auth", "token") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("gh auth token: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +func isGHCLIInstalled() bool { + _, err := exec.LookPath("gh") + return err == nil +} diff --git a/internal/providers/copilot/provider.go b/internal/providers/copilot/provider.go new file mode 100644 index 00000000..cbb433a7 --- /dev/null +++ b/internal/providers/copilot/provider.go @@ -0,0 +1,97 @@ +package copilot + +import ( + "context" + "encoding/base64" + "time" + + "github.com/majorcontext/moat/internal/credential" + "github.com/majorcontext/moat/internal/provider" +) + +const ( + SourceCLI = "cli" + SourceEnv = "env" + SourcePAT = "pat" +) + +// Provider implements provider.CredentialProvider and provider.AgentProvider +// for GitHub Copilot CLI. +type Provider struct{} + +var ( + _ provider.CredentialProvider = (*Provider)(nil) + _ provider.AgentProvider = (*Provider)(nil) + _ provider.RefreshableProvider = (*Provider)(nil) +) + +func init() { + provider.Register(&Provider{}) +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { return copilotProviderName } + +// Grant acquires a Copilot-capable GitHub credential. +func (p *Provider) Grant(ctx context.Context) (*provider.Credential, error) { + g := NewGrant() + return g.Execute(ctx) +} + +// ConfigureProxy sets up GitHub/Copilot credential injection. +func (p *Provider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *provider.Credential) { + setProxyAuth(proxy, cred.Token) +} + +func setProxyAuth(proxy provider.ProxyConfigurer, token string) { + proxy.SetCredentialWithGrant(copilotAPIHost, "Authorization", "Bearer "+token, copilotProviderName) + proxy.SetCredentialWithGrant(copilotChatAPIHost, "Authorization", "Bearer "+token, copilotProviderName) + proxy.SetCredentialWithGrant(copilotBusinessHost, "Authorization", "Bearer "+token, copilotProviderName) + basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token)) + proxy.SetCredentialWithGrant(copilotGitHost, "Authorization", "Basic "+basic, copilotProviderName) +} + +// ContainerEnv returns Copilot auth placeholders. Copilot CLI checks +// COPILOT_GITHUB_TOKEN before GH_TOKEN/GITHUB_TOKEN; GH_TOKEN lets gh CLI use +// the same proxy-injected credential for GitHub operations inside the run. +func (p *Provider) ContainerEnv(cred *provider.Credential) []string { + return []string{ + "COPILOT_GITHUB_TOKEN=" + credential.CopilotTokenPlaceholder, + "GH_TOKEN=" + credential.CopilotTokenPlaceholder, + "GIT_TERMINAL_PROMPT=0", + } +} + +// 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"} } + +func (p *Provider) CanRefresh(cred *provider.Credential) bool { + return cred != nil && cred.Metadata != nil && cred.Metadata[provider.MetaKeyTokenSource] == SourceCLI +} + +func (p *Provider) RefreshInterval() time.Duration { return 30 * time.Minute } + +func (p *Provider) Refresh(ctx context.Context, proxy provider.ProxyConfigurer, cred *provider.Credential) (*provider.Credential, error) { + if !p.CanRefresh(cred) { + return nil, provider.ErrRefreshNotSupported + } + token, err := getGHCLIToken(ctx) + if err != nil { + return nil, err + } + if err := validateCopilotToken(ctx, token); err != nil { + return nil, err + } + setProxyAuth(proxy, token) + updated := *cred + updated.Token = token + return &updated, nil +} diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go new file mode 100644 index 00000000..e324ff0b --- /dev/null +++ b/internal/providers/copilot/provider_test.go @@ -0,0 +1,153 @@ +package copilot + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/majorcontext/moat/internal/credential" + "github.com/majorcontext/moat/internal/provider" +) + +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 != "copilot" { + t.Errorf("api.github.com grant = %q, want copilot", 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[copilotGitHost]["Authorization"]; !strings.HasPrefix(got, "Basic ") { + t.Errorf("github.com Authorization = %q, want Basic auth", got) + } +} + +func TestContainerEnv(t *testing.T) { + env := (&Provider{}).ContainerEnv(&provider.Credential{Provider: "copilot"}) + for _, want := range []string{ + "COPILOT_GITHUB_TOKEN=" + credential.CopilotTokenPlaceholder, + "GH_TOKEN=" + credential.CopilotTokenPlaceholder, + "GIT_TERMINAL_PROMPT=0", + } { + if !slices.Contains(env, want) { + t.Errorf("ContainerEnv missing %q in %v", want, env) + } + } +} + +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(), copilotProxyHost) { + t.Errorf("NetworkHosts missing Copilot hosts: %v", NetworkHosts()) + } +} + +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 TestFilterGitHubGrant(t *testing.T) { + got := filterGitHubGrant([]string{"github", "copilot", "ssh:github.com", "aws"}, false) + want := []string{"copilot", "ssh:github.com", "aws"} + if !slices.Equal(got, want) { + t.Errorf("filterGitHubGrant = %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/imageneeds.go b/internal/run/imageneeds.go index e89bafd0..bd718777 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 } diff --git a/internal/run/imageneeds_test.go b/internal/run/imageneeds_test.go index 390626f6..17f32009 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) { diff --git a/internal/run/manager_agentinit.go b/internal/run/manager_agentinit.go index 39b1f74f..13e67c3f 100644 --- a/internal/run/manager_agentinit.go +++ b/internal/run/manager_agentinit.go @@ -148,6 +148,19 @@ 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) (*provider.ContainerConfig, error) { + copilotConfig, prepErr := copilotProvider.PrepareContainer(ctx, provider.PrepareOpts{ + 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..018d05f1 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -1126,6 +1126,7 @@ region = %s imgNeeds := resolveImageNeeds(opts.Grants, depList) needsClaudeInit := slices.Contains(imgNeeds.initProviders, "claude") needsCodexInit := slices.Contains(imgNeeds.initProviders, "codex") + needsCopilotInit := slices.Contains(imgNeeds.initProviders, "copilot") needsGeminiInit := slices.Contains(imgNeeds.initProviders, "gemini") needsPiInit := slices.Contains(imgNeeds.initProviders, "pi") @@ -1417,6 +1418,30 @@ 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) + 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...) + } + // 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 +1452,7 @@ region = %s cleanupDaemonRun() cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) return nil, fmt.Errorf("gemini provider not registered") } @@ -1436,6 +1462,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) return nil, stageErr } geminiConfig = cfg @@ -1454,6 +1481,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) cleanupAgentConfig(geminiConfig) return nil, fmt.Errorf("pi provider not registered") } @@ -1464,6 +1492,7 @@ region = %s cleanupSSH(sshServer) cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) + cleanupAgentConfig(copilotConfig) cleanupAgentConfig(geminiConfig) return nil, stageErr } @@ -1546,6 +1575,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 +1584,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 +2034,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(copilotConfig) return nil, fmt.Errorf("creating container: %w", err) } @@ -2025,6 +2057,9 @@ region = %s if geminiConfig != nil { r.GeminiConfigTempDir = geminiConfig.StagingDir } + if copilotConfig != nil { + r.CopilotConfigTempDir = copilotConfig.StagingDir + } if piConfig != nil { r.PiConfigTempDir = piConfig.StagingDir } @@ -2605,6 +2640,8 @@ func grantToEnvVar(grant string) (string, bool) { return "OPENAI_API_KEY", true case "anthropic": return "ANTHROPIC_API_KEY", true + case "copilot": + return "COPILOT_GITHUB_TOKEN", true case "gemini": return "GEMINI_API_KEY", true default: @@ -2621,6 +2658,8 @@ func grantToPlaceholder(grant string) string { switch grant { case "anthropic": return credential.AnthropicAPIKeyPlaceholder + case "copilot": + return credential.CopilotTokenPlaceholder case "gemini": return credential.GeminiAPIKeyPlaceholder case "github": diff --git a/internal/run/run.go b/internal/run/run.go index 6840ab30..8e64ddbf 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. From e5a70219e7d4a948c10e798cb47de1a18c7526e4 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 17:47:12 +0200 Subject: [PATCH 2/8] test(copilot): cover grant and refresh paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/providers/copilot/grant.go | 14 +- internal/providers/copilot/grant_test.go | 196 ++++++++++++++++++++ internal/providers/copilot/provider_test.go | 97 ++++++++++ 3 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 internal/providers/copilot/grant_test.go diff --git a/internal/providers/copilot/grant.go b/internal/providers/copilot/grant.go index 24aae418..48c3c79d 100644 --- a/internal/providers/copilot/grant.go +++ b/internal/providers/copilot/grant.go @@ -19,6 +19,14 @@ type Grant struct{} func NewGrant() *Grant { return &Grant{} } +var ( + copilotValidationURL = "https://api.github.com/copilot_internal/user" + newCopilotHTTPClient = func() *http.Client { + return &http.Client{Timeout: 10 * time.Second} + } + getGHCLIToken = getGHCLITokenFromCLI +) + func (g *Grant) Execute(ctx context.Context) (*provider.Credential, error) { if token, name := util.CheckEnvVarWithName("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"); token != "" { fmt.Printf("Using token from %s environment variable\n", name) @@ -88,11 +96,11 @@ func validateAndCreateCredential(ctx context.Context, token, source string) (*pr } func validateCopilotToken(ctx context.Context, token string) error { - client := &http.Client{Timeout: 10 * time.Second} + client := newCopilotHTTPClient() reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - req, err := http.NewRequestWithContext(reqCtx, "GET", "https://api.github.com/copilot_internal/user", nil) + req, err := http.NewRequestWithContext(reqCtx, "GET", copilotValidationURL, nil) if err != nil { return fmt.Errorf("creating request: %w", err) } @@ -131,7 +139,7 @@ func validateCopilotToken(ctx context.Context, token string) error { } } -func getGHCLIToken(ctx context.Context) (string, error) { +func getGHCLITokenFromCLI(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "gh", "auth", "token") out, err := cmd.Output() if err != nil { diff --git a/internal/providers/copilot/grant_test.go b/internal/providers/copilot/grant_test.go new file mode 100644 index 00000000..533b3c02 --- /dev/null +++ b/internal/providers/copilot/grant_test.go @@ -0,0 +1,196 @@ +package copilot + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +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 := validateCopilotToken(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 TestValidateAndCreateCredential(t *testing.T) { + withCopilotValidationServer(t, http.StatusOK, `{}`, nil) + + cred, err := validateAndCreateCredential(context.Background(), "token", SourceEnv) + if err != nil { + t.Fatalf("validateAndCreateCredential() error = %v", err) + } + if cred.Provider != copilotProviderName || cred.Token != "token" { + t.Fatalf("credential = %+v", cred) + } + if got := cred.Metadata[provider.MetaKeyTokenSource]; got != SourceEnv { + t.Fatalf("token source = %q, want %q", got, SourceEnv) + } +} + +func TestValidateAndCreateCredentialError(t *testing.T) { + withCopilotValidationServer(t, http.StatusUnauthorized, `{}`, nil) + + _, err := validateAndCreateCredential(context.Background(), "bad-token", SourcePAT) + var grantErr *provider.GrantError + if !errors.As(err, &grantErr) { + t.Fatalf("error = %T %v, want GrantError", err, err) + } + if grantErr.Provider != copilotProviderName { + t.Fatalf("GrantError.Provider = %q", grantErr.Provider) + } +} + +func TestGrantExecuteEnvToken(t *testing.T) { + withCopilotValidationServer(t, http.StatusOK, `{}`, nil) + t.Setenv("COPILOT_GITHUB_TOKEN", "env-token") + + cred, err := NewGrant().Execute(context.Background()) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if cred.Token != "env-token" { + t.Fatalf("token = %q, want env-token", cred.Token) + } + if got := cred.Metadata[provider.MetaKeyTokenSource]; got != SourceEnv { + t.Fatalf("token source = %q, want %q", got, SourceEnv) + } +} + +func TestRefresh(t *testing.T) { + withCopilotValidationServer(t, http.StatusOK, `{}`, nil) + origGetGHCLIToken := getGHCLIToken + getGHCLIToken = func(context.Context) (string, error) { return "fresh-token", nil } + t.Cleanup(func() { getGHCLIToken = origGetGHCLIToken }) + + proxy := newMockProxyConfigurer() + cred := &provider.Credential{ + Provider: copilotProviderName, + Token: "old-token", + Metadata: map[string]string{provider.MetaKeyTokenSource: SourceCLI}, + } + + updated, err := (&Provider{}).Refresh(context.Background(), proxy, cred) + if err != nil { + t.Fatalf("Refresh() error = %v", err) + } + if updated.Token != "fresh-token" { + t.Fatalf("updated token = %q, want fresh-token", updated.Token) + } + if cred.Token != "old-token" { + t.Fatalf("Refresh mutated original credential token = %q", cred.Token) + } + if got := proxy.headers[copilotBusinessHost]["Authorization"]; got != "Bearer fresh-token" { + t.Fatalf("business host auth = %q", got) + } +} + +func TestRefreshUnsupported(t *testing.T) { + _, err := (&Provider{}).Refresh(context.Background(), newMockProxyConfigurer(), &provider.Credential{ + Provider: copilotProviderName, + Token: "token", + Metadata: map[string]string{provider.MetaKeyTokenSource: SourcePAT}, + }) + if !errors.Is(err, provider.ErrRefreshNotSupported) { + t.Fatalf("Refresh() error = %v, want ErrRefreshNotSupported", err) + } +} + +func TestRefreshValidationError(t *testing.T) { + withCopilotValidationServer(t, http.StatusUnauthorized, `{}`, nil) + origGetGHCLIToken := getGHCLIToken + getGHCLIToken = func(context.Context) (string, error) { return "fresh-token", nil } + t.Cleanup(func() { getGHCLIToken = origGetGHCLIToken }) + + _, err := (&Provider{}).Refresh(context.Background(), newMockProxyConfigurer(), &provider.Credential{ + Provider: copilotProviderName, + Token: "old-token", + Metadata: map[string]string{provider.MetaKeyTokenSource: SourceCLI}, + }) + if err == nil || !strings.Contains(err.Error(), "invalid token") { + t.Fatalf("Refresh() error = %v, want invalid token", err) + } +} + +func TestValidationResponseBodyNeedNotBeJSON(t *testing.T) { + withCopilotValidationServer(t, http.StatusInternalServerError, `not-json`, nil) + err := validateCopilotToken(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 = validateCopilotToken(context.Background(), "test-token") + if err == nil || !strings.Contains(err.Error(), "rate limited") { + t.Fatalf("validateCopilotToken() error = %v", err) + } +} diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go index e324ff0b..59351d77 100644 --- a/internal/providers/copilot/provider_test.go +++ b/internal/providers/copilot/provider_test.go @@ -6,9 +6,12 @@ import ( "slices" "strings" "testing" + "time" + "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 { @@ -84,6 +87,42 @@ func TestContainerEnv(t *testing.T) { } } +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) + } + if got := p.RefreshInterval(); got != 30*time.Minute { + t.Fatalf("RefreshInterval() = %v, want 30m", got) + } +} + +func TestCanRefresh(t *testing.T) { + p := &Provider{} + tests := []struct { + name string + cred *provider.Credential + want bool + }{ + {name: "nil", cred: nil}, + {name: "no metadata", cred: &provider.Credential{}}, + {name: "env", cred: &provider.Credential{Metadata: map[string]string{provider.MetaKeyTokenSource: SourceEnv}}}, + {name: "pat", cred: &provider.Credential{Metadata: map[string]string{provider.MetaKeyTokenSource: SourcePAT}}}, + {name: "cli", cred: &provider.Credential{Metadata: map[string]string{provider.MetaKeyTokenSource: SourceCLI}}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := p.CanRefresh(tt.cred); got != tt.want { + t.Fatalf("CanRefresh() = %v, want %v", got, tt.want) + } + }) + } +} + func TestDefaultDependenciesAndHosts(t *testing.T) { if !slices.Contains(DefaultDependencies(), "copilot-cli") { t.Errorf("DefaultDependencies missing copilot-cli: %v", DefaultDependencies()) @@ -93,6 +132,58 @@ func TestDefaultDependenciesAndHosts(t *testing.T) { } } +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]" { + 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 + t.Cleanup(func() { + copilotModelFlag = origModelFlag + copilotResolvedModel = origResolvedModel + copilotExperimental = origExperimental + copilotAutopilot = origAutopilot + copilotFlags.Grants = origFlagGrants + }) + + copilotModelFlag = "" + copilotExperimental = false + copilotAutopilot = false + copilotFlags.Grants = []string{"github", "ssh:github.com"} + 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{"copilot"}) { + t.Fatalf("config grants = %v, want [copilot]", cfg.Grants) + } + if !slices.Equal(copilotFlags.Grants, []string{"ssh:github.com"}) { + t.Fatalf("flag grants = %v, want [ssh:github.com]", copilotFlags.Grants) + } +} + func TestBuildCopilotCommand(t *testing.T) { origModel, origExperimental, origAutopilot, origAllowAll := copilotResolvedModel, copilotExperimental, copilotAutopilot, copilotAllowAll t.Cleanup(func() { @@ -122,6 +213,12 @@ func TestBuildCopilotCommand(t *testing.T) { } } +func TestGetCredentialName(t *testing.T) { + if got := GetCredentialName(); got != "copilot" { + t.Fatalf("GetCredentialName() = %q, want copilot", got) + } +} + func TestFilterGitHubGrant(t *testing.T) { got := filterGitHubGrant([]string{"github", "copilot", "ssh:github.com", "aws"}, false) want := []string{"copilot", "ssh:github.com", "aws"} From 5f07c43be08421c20323499889ce38f84f58bc26 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 18:02:04 +0200 Subject: [PATCH 3/8] fix(copilot): address provider review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/providers/copilot/cli.go | 46 +++++++++++--- internal/providers/copilot/constants.go | 1 + internal/providers/copilot/provider.go | 1 + internal/providers/copilot/provider_test.go | 67 ++++++++++++++++++++- internal/run/manager_agentinit.go | 2 +- internal/run/manager_create.go | 8 ++- internal/run/manager_resources_test.go | 10 ++- 7 files changed, 117 insertions(+), 18 deletions(-) diff --git a/internal/providers/copilot/cli.go b/internal/providers/copilot/cli.go index 7af6de4b..36940a89 100644 --- a/internal/providers/copilot/cli.go +++ b/internal/providers/copilot/cli.go @@ -1,6 +1,7 @@ package copilot import ( + "slices" "strings" "github.com/spf13/cobra" @@ -12,15 +13,16 @@ import ( ) var ( - copilotFlags cli.ExecFlags - copilotPromptFlag string - copilotAllowedHosts []string - copilotWtFlag string - copilotAllowAll bool - copilotModelFlag string - copilotExperimental bool - copilotAutopilot bool - copilotResolvedModel string + copilotFlags cli.ExecFlags + copilotPromptFlag string + copilotAllowedHosts []string + copilotWtFlag string + copilotAllowAll bool + copilotModelFlag string + copilotExperimental bool + copilotAutopilot bool + copilotResolvedModel string + copilotCredentialConfigured = defaultCopilotCredentialConfigured ) func NetworkHosts() []string { @@ -30,6 +32,7 @@ func NetworkHosts() []string { "copilot-proxy.githubusercontent.com", "api.githubcopilot.com", "api.business.githubcopilot.com", + "api.mcp.github.com", "telemetry.business.githubcopilot.com", } } @@ -109,6 +112,9 @@ func resolveCopilotPreflight(cfg *config.Config) error { } } copilotFlags.Grants = filterGitHubGrant(copilotFlags.Grants, true) + if !cli.DryRun && !copilotCredentialConfigured() && !slices.Contains(copilotFlags.Grants, copilotProviderName) { + copilotFlags.Grants = append(copilotFlags.Grants, copilotProviderName) + } return nil } @@ -153,4 +159,24 @@ func buildCopilotCommand(promptFlag, initialPrompt string) []string { return cmd } -func GetCredentialName() string { return string(credential.ProviderCopilot) } +func GetCredentialName() string { + if copilotCredentialConfigured() { + return string(credential.ProviderCopilot) + } + 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.ProviderCopilot); err == nil { + return true + } + return false +} diff --git a/internal/providers/copilot/constants.go b/internal/providers/copilot/constants.go index 697532e2..b8822622 100644 --- a/internal/providers/copilot/constants.go +++ b/internal/providers/copilot/constants.go @@ -13,6 +13,7 @@ const ( 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/provider.go b/internal/providers/copilot/provider.go index cbb433a7..50c504d1 100644 --- a/internal/providers/copilot/provider.go +++ b/internal/providers/copilot/provider.go @@ -47,6 +47,7 @@ func setProxyAuth(proxy provider.ProxyConfigurer, token string) { proxy.SetCredentialWithGrant(copilotAPIHost, "Authorization", "Bearer "+token, copilotProviderName) proxy.SetCredentialWithGrant(copilotChatAPIHost, "Authorization", "Bearer "+token, copilotProviderName) proxy.SetCredentialWithGrant(copilotBusinessHost, "Authorization", "Bearer "+token, copilotProviderName) + proxy.SetCredentialWithGrant(copilotMCPHost, "Authorization", "Bearer "+token, copilotProviderName) basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token)) proxy.SetCredentialWithGrant(copilotGitHost, "Authorization", "Basic "+basic, copilotProviderName) } diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go index 59351d77..df08fa45 100644 --- a/internal/providers/copilot/provider_test.go +++ b/internal/providers/copilot/provider_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/majorcontext/moat/internal/cli" "github.com/majorcontext/moat/internal/config" "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" @@ -69,6 +70,9 @@ func TestConfigureProxy(t *testing.T) { 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) } @@ -127,7 +131,7 @@ 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(), copilotProxyHost) { + 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()) } } @@ -153,18 +157,24 @@ 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{"github", "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}, @@ -179,8 +189,52 @@ func TestResolveCopilotPreflight(t *testing.T) { if !slices.Equal(cfg.Grants, []string{"copilot"}) { t.Fatalf("config grants = %v, want [copilot]", cfg.Grants) } - if !slices.Equal(copilotFlags.Grants, []string{"ssh:github.com"}) { - t.Fatalf("flag grants = %v, want [ssh:github.com]", copilotFlags.Grants) + if !slices.Equal(copilotFlags.Grants, []string{"ssh:github.com", "copilot"}) { + t.Fatalf("flag grants = %v, want [ssh:github.com copilot]", copilotFlags.Grants) + } +} + +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{"copilot"}) { + t.Fatalf("flag grants = %v, want [copilot]", 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) } } @@ -214,9 +268,16 @@ func TestBuildCopilotCommand(t *testing.T) { } func TestGetCredentialName(t *testing.T) { + origConfigured := copilotCredentialConfigured + t.Cleanup(func() { copilotCredentialConfigured = origConfigured }) + copilotCredentialConfigured = func() bool { return true } if got := GetCredentialName(); got != "copilot" { t.Fatalf("GetCredentialName() = %q, want copilot", got) } + copilotCredentialConfigured = func() bool { return false } + if got := GetCredentialName(); got != "" { + t.Fatalf("GetCredentialName() = %q, want empty when missing", got) + } } func TestFilterGitHubGrant(t *testing.T) { diff --git a/internal/run/manager_agentinit.go b/internal/run/manager_agentinit.go index 13e67c3f..f398e5fd 100644 --- a/internal/run/manager_agentinit.go +++ b/internal/run/manager_agentinit.go @@ -35,7 +35,7 @@ func buildLocalMCPConfig(agentName string, specs map[string]config.MCPServerSpec if spec.Grant != "" { v, ok := grantToEnvVar(spec.Grant) if !ok { - return nil, fmt.Errorf("%s.mcp.%s: unknown grant %q (supported: github, openai, anthropic, gemini)", agentName, name, spec.Grant) + return nil, fmt.Errorf("%s.mcp.%s: unknown grant %q (supported: github, copilot, openai, anthropic, gemini)", agentName, name, spec.Grant) } if !hasGrant(grants, spec.Grant) { return nil, fmt.Errorf("%s.mcp.%s: grant %q not declared in top-level grants list — add 'grants: [%s]' to agent.yaml", agentName, name, spec.Grant, spec.Grant) diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index 018d05f1..bca0a57c 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -1440,6 +1440,11 @@ region = %s 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. @@ -2216,7 +2221,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 { @@ -2224,6 +2229,7 @@ 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") } 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) + } + }) } } From 1e5c34ffca787d97ab935041a9b08d7df8cd012e Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 18:06:26 +0200 Subject: [PATCH 4/8] docs(changelog): add Copilot CLI entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e8f16b..583af5e5 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`. The new `copilot` grant validates Copilot-capable GitHub credentials and injects them 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)) From 265f2a008e03a20326b7535dc1e89f69fd361316 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 18:20:02 +0200 Subject: [PATCH 5/8] fix(copilot): address second review round - Document why copilot-proxy and telemetry hosts are excluded from injection - Fix filterGitHubGrant backing-array mutation (use make instead of [:0]) - Use constants in NetworkHosts() instead of string literals - Add companion test for CLI --model flag overriding config - Add comment explaining two-path grant insertion flow - Fix Use string to include [-- initial-prompt] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/providers/copilot/cli.go | 22 +++++++++------- internal/providers/copilot/provider.go | 3 +++ internal/providers/copilot/provider_test.go | 28 ++++++++++++++++++++- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/internal/providers/copilot/cli.go b/internal/providers/copilot/cli.go index 36940a89..1064ef3b 100644 --- a/internal/providers/copilot/cli.go +++ b/internal/providers/copilot/cli.go @@ -27,13 +27,13 @@ var ( func NetworkHosts() []string { return []string{ - "api.github.com", - "github.com", - "copilot-proxy.githubusercontent.com", - "api.githubcopilot.com", - "api.business.githubcopilot.com", - "api.mcp.github.com", - "telemetry.business.githubcopilot.com", + copilotAPIHost, + copilotGitHost, + copilotProxyHost, + copilotChatAPIHost, + copilotBusinessHost, + copilotMCPHost, + copilotTelemetry, } } @@ -43,7 +43,7 @@ func DefaultDependencies() []string { func (p *Provider) RegisterCLI(root *cobra.Command) { copilotCmd := &cobra.Command{ - Use: "copilot [workspace] [flags]", + 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. @@ -112,6 +112,10 @@ func resolveCopilotPreflight(cfg *config.Config) error { } } copilotFlags.Grants = filterGitHubGrant(copilotFlags.Grants, true) + // When no stored credential exists and copilot 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 "copilot" and handles insertion there instead. if !cli.DryRun && !copilotCredentialConfigured() && !slices.Contains(copilotFlags.Grants, copilotProviderName) { copilotFlags.Grants = append(copilotFlags.Grants, copilotProviderName) } @@ -122,7 +126,7 @@ func filterGitHubGrant(grants []string, warn bool) []string { if len(grants) == 0 { return grants } - out := grants[:0] + out := make([]string, 0, len(grants)) removed := false for _, grant := range grants { if strings.Split(grant, ":")[0] == "github" { diff --git a/internal/providers/copilot/provider.go b/internal/providers/copilot/provider.go index 50c504d1..f2c75bf8 100644 --- a/internal/providers/copilot/provider.go +++ b/internal/providers/copilot/provider.go @@ -44,6 +44,9 @@ func (p *Provider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *provider } 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, copilotProviderName) proxy.SetCredentialWithGrant(copilotChatAPIHost, "Authorization", "Bearer "+token, copilotProviderName) proxy.SetCredentialWithGrant(copilotBusinessHost, "Authorization", "Bearer "+token, copilotProviderName) diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go index df08fa45..7c82c583 100644 --- a/internal/providers/copilot/provider_test.go +++ b/internal/providers/copilot/provider_test.go @@ -143,7 +143,7 @@ func TestRegisterCLI(t *testing.T) { if err != nil { t.Fatalf("Find(copilot) error = %v", err) } - if cmd == nil || cmd.Use != "copilot [workspace] [flags]" { + 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"} { @@ -194,6 +194,32 @@ func TestResolveCopilotPreflight(t *testing.T) { } } +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 From ddd1d4e32f187731933af152cee89d8e77eaa4ff Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 17:18:58 +0000 Subject: [PATCH 6/8] fix(copilot): use github grant for auth Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- README.md | 2 +- cmd/moat/cli/grant.go | 2 + cmd/moat/cli/grant_providers.go | 3 + cmd/moat/cli/grant_test.go | 11 ++++ docs/content/guides/17-copilot.md | 18 +++--- docs/content/reference/01-cli.md | 10 ++-- docs/content/reference/02-moat-yaml.md | 2 +- docs/content/reference/04-grants.md | 31 ++++------ internal/credential/types.go | 5 +- internal/daemon/api.go | 2 + internal/daemon/persist.go | 48 ++++++++------- internal/daemon/refresh.go | 8 +++ internal/daemon/runcontext.go | 13 +++-- internal/providers/copilot/cli.go | 59 +++++++++++++------ internal/providers/copilot/grant.go | 5 +- internal/providers/copilot/grant_test.go | 60 ++----------------- internal/providers/copilot/provider.go | 48 ++++----------- internal/providers/copilot/provider_test.go | 65 +++++++-------------- internal/run/imageneeds.go | 3 + internal/run/imageneeds_test.go | 2 + internal/run/manager_agentinit.go | 2 +- internal/run/manager_create.go | 57 +++++++++++++----- internal/run/run.go | 3 + 24 files changed, 219 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 583af5e5..61e259d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +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`. The new `copilot` grant validates Copilot-capable GitHub credentials and injects them 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)) +- **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 1ec1dcd0..a8332422 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ moat codex -p "explain this codebase" # Non-interactive ### GitHub Copilot CLI ```bash -moat grant copilot # One-time: imports Copilot-capable GitHub credentials +moat grant github # One-time: imports GitHub credentials with Copilot access moat copilot # Interactive mode moat copilot -p "fix the failing tests" # Non-interactive ``` 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 index 5d66658a..cec570a4 100644 --- a/docs/content/guides/17-copilot.md +++ b/docs/content/guides/17-copilot.md @@ -15,23 +15,21 @@ This guide covers running GitHub Copilot CLI in a Moat container. - 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 Copilot credentials +## Granting GitHub credentials -Run `moat grant copilot` to configure authentication: +Run `moat grant github` to configure authentication: ```bash -moat grant copilot +moat grant github ``` -Moat checks `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, and `GITHUB_TOKEN` first. If none are set, it can use `gh auth token` from GitHub CLI, or prompt for a fine-grained PAT. +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. -Classic PATs are not supported by GitHub Copilot CLI. Fine-grained PATs must be created for your personal account with the **Copilot Requests** account permission. +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. ## How credentials are injected -The raw 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 token for `api.github.com`, Copilot API hosts, and HTTPS git operations against `github.com`. - -Do not add a separate `github` grant to `moat copilot` runs. The `copilot` grant already covers GitHub API and HTTPS git auth, and Moat ignores `github` for Copilot runs to avoid ambiguous auth on `api.github.com`. +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 @@ -61,7 +59,7 @@ Moat passes `--allow-all` to Copilot CLI by default so the agent can complete ta ```yaml agent: copilot grants: - - copilot + - github copilot: model: gpt-5.4 @@ -69,7 +67,7 @@ copilot: autopilot: false ``` -`moat copilot` adds the `copilot` grant automatically, so you do not need to list it unless you use `moat run` directly. +`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 diff --git a/docs/content/reference/01-cli.md b/docs/content/reference/01-cli.md index 3d75c63d..c62ae72c 100644 --- a/docs/content/reference/01-cli.md +++ b/docs/content/reference/01-cli.md @@ -292,9 +292,7 @@ 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` requires a `copilot` grant. The container receives only Copilot/GitHub token placeholders; Moat's proxy injects the real token for GitHub API and HTTPS git requests. - -If `github` is also listed in `moat.yaml` or passed with `--grant`, `moat copilot` ignores it. Both grants target `api.github.com` with the same header, and the `copilot` grant already covers GitHub API and HTTPS git auth for Copilot runs. +`moat copilot` uses the `github` grant. 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 @@ -317,7 +315,7 @@ If `github` is also listed in `moat.yaml` or passed with `--grant`, `moat copilo ```bash # One-time credential setup -moat grant copilot +moat grant github # Interactive Copilot CLI moat copilot @@ -627,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 4577d9a8..76875ad2 100644 --- a/docs/content/reference/02-moat-yaml.md +++ b/docs/content/reference/02-moat-yaml.md @@ -1621,7 +1621,7 @@ copilot: - Default: `false` - CLI override: `moat copilot --autopilot` -`moat copilot` requires the `copilot` grant. Run `moat grant copilot` before starting a session. See [Running GitHub Copilot CLI](../guides/17-copilot.md). +`moat copilot` uses the `github` grant. Run `moat grant github` before starting a session. See [Running GitHub Copilot CLI](../guides/17-copilot.md). --- diff --git a/docs/content/reference/04-grants.md b/docs/content/reference/04-grants.md index cbbc4c1e..bbc822d1 100644 --- a/docs/content/reference/04-grants.md +++ b/docs/content/reference/04-grants.md @@ -16,7 +16,6 @@ Store a credential with `moat grant `, then use it in runs with `--gra | Grant | Hosts matched | Header injected | Credential source | |-------|---------------|-----------------|-------------------| | `github` | `api.github.com`, `github.com` | `Authorization: Bearer ...` (`api.github.com`); `Authorization: Basic ...` (`github.com`, for git smart-HTTP) | gh CLI, `GITHUB_TOKEN`/`GH_TOKEN`, or PAT prompt | -| `copilot` | `api.github.com`, Copilot API hosts, `github.com` | `Authorization: Bearer ` (`api.github.com` and Copilot APIs); `Authorization: Basic ...` (`github.com`, for git smart-HTTP) | gh CLI, `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, or Copilot-capable PAT prompt | | `claude` | `api.anthropic.com` | `Authorization: Bearer ...` | `claude setup-token` or imported OAuth | | `anthropic` | `api.anthropic.com` | `x-api-key: ...` | API key from `console.anthropic.com` | | `openai` | `api.openai.com`, `chatgpt.com`, `*.openai.com` | `Authorization: Bearer ...` | `OPENAI_API_KEY` or prompt | @@ -49,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 @@ -64,9 +63,11 @@ 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. + ### 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 @@ -91,28 +92,20 @@ $ moat run --grant github ./my-project ## GitHub Copilot -### CLI command +GitHub Copilot CLI uses the `github` grant. There is no separate Copilot credential to configure. ```bash -moat grant copilot +moat grant github ``` -No flags. The command validates that the token can call GitHub Copilot CLI's GitHub API endpoint. - -### Credential sources (in order of preference) - -1. **Environment variable** -- `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, or `GITHUB_TOKEN` -2. **gh CLI** -- The token from `gh auth token`, if GitHub CLI is installed and authenticated -3. **Copilot-capable token** -- Interactive prompt for a fine-grained PAT from your personal account with the **Copilot Requests** permission - -Classic PATs are not supported by GitHub Copilot CLI. Fine-grained PATs must be created for your personal account, not an organization. +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 -The proxy injects the token for: +For Copilot runs, the proxy injects the GitHub token for: -- `api.github.com` and Copilot API hosts -- `Authorization: Bearer ` for Copilot CLI authentication, model traffic, and GitHub API calls -- `github.com` -- `Basic ")>` for HTTPS git operations +- `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. @@ -120,7 +113,7 @@ The container receives `COPILOT_GITHUB_TOKEN` and `GH_TOKEN` placeholders. Copil ```yaml grants: - - copilot + - github ``` Use this grant with `moat copilot`; the command adds it automatically. diff --git a/internal/credential/types.go b/internal/credential/types.go index fee8daea..27717b87 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -16,7 +16,6 @@ type Provider string const ( ProviderGitHub Provider = "github" - ProviderCopilot Provider = "copilot" ProviderAWS Provider = "aws" ProviderAnthropic Provider = "anthropic" ProviderClaude Provider = "claude" @@ -56,7 +55,7 @@ func RegisterDynamicProvider(p Provider) { // KnownProviders returns a list of all known credential providers. func KnownProviders() []Provider { - base := []Provider{ProviderGitHub, ProviderCopilot, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta} + base := []Provider{ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta} known := make([]Provider, 0, len(base)+len(dynamicProviders)) known = append(known, base...) return append(known, dynamicProviders...) @@ -65,7 +64,7 @@ func KnownProviders() []Provider { // IsKnownProvider returns true if the provider is a known credential provider. func IsKnownProvider(p Provider) bool { switch p { - case ProviderGitHub, ProviderCopilot, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta: + case ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta: return true default: for _, dp := range dynamicProviders { 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/providers/copilot/cli.go b/internal/providers/copilot/cli.go index 1064ef3b..9a79620b 100644 --- a/internal/providers/copilot/cli.go +++ b/internal/providers/copilot/cli.go @@ -1,7 +1,6 @@ package copilot import ( - "slices" "strings" "github.com/spf13/cobra" @@ -47,7 +46,7 @@ func (p *Provider) RegisterCLI(root *cobra.Command) { 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. Copilot credentials +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. @@ -87,7 +86,7 @@ func runCopilot(cmd *cobra.Command, args []string) error { Dependencies: DefaultDependencies(), NetworkHosts: NetworkHosts(), SupportsInitialPrompt: true, - DryRunNote: "Note: No Copilot credential configured. Run 'moat grant copilot' first.", + DryRunNote: "Note: No GitHub credential configured. Run 'moat grant github' first.", BuildCommand: func(promptFlag, initialPrompt string) ([]string, error) { return buildCopilotCommand(promptFlag, initialPrompt), nil }, @@ -100,7 +99,7 @@ func runCopilot(cmd *cobra.Command, args []string) error { func resolveCopilotPreflight(cfg *config.Config) error { copilotResolvedModel = copilotModelFlag if cfg != nil { - cfg.Grants = filterGitHubGrant(cfg.Grants, false) + cfg.Grants = normalizeCopilotGrants(cfg.Grants, false) if copilotResolvedModel == "" { copilotResolvedModel = cfg.Copilot.Model } @@ -111,36 +110,58 @@ func resolveCopilotPreflight(cfg *config.Config) error { copilotAutopilot = true } } - copilotFlags.Grants = filterGitHubGrant(copilotFlags.Grants, true) - // When no stored credential exists and copilot 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 "copilot" and handles insertion there instead. - if !cli.DryRun && !copilotCredentialConfigured() && !slices.Contains(copilotFlags.Grants, copilotProviderName) { - copilotFlags.Grants = append(copilotFlags.Grants, copilotProviderName) + 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 filterGitHubGrant(grants []string, warn bool) []string { +func normalizeCopilotGrants(grants []string, warn bool) []string { if len(grants) == 0 { return grants } out := make([]string, 0, len(grants)) - removed := false + replaced := false + hasGitHub := false for _, grant := range grants { - if strings.Split(grant, ":")[0] == "github" { - removed = true + 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 removed && warn { - ui.Warn("ignoring github grant for moat copilot — the copilot grant already injects GitHub API and HTTPS git auth for this run") + 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 != "" { @@ -165,7 +186,7 @@ func buildCopilotCommand(promptFlag, initialPrompt string) []string { func GetCredentialName() string { if copilotCredentialConfigured() { - return string(credential.ProviderCopilot) + return string(credential.ProviderGitHub) } return "" } @@ -179,7 +200,7 @@ func defaultCopilotCredentialConfigured() bool { if err != nil { return false } - if _, err := store.Get(credential.ProviderCopilot); err == nil { + if _, err := store.Get(credential.ProviderGitHub); err == nil { return true } return false diff --git a/internal/providers/copilot/grant.go b/internal/providers/copilot/grant.go index 48c3c79d..6f073035 100644 --- a/internal/providers/copilot/grant.go +++ b/internal/providers/copilot/grant.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" "github.com/majorcontext/moat/internal/provider/util" ) @@ -71,7 +72,7 @@ To create a fine-grained PAT: return nil, &provider.GrantError{ Provider: copilotProviderName, Cause: fmt.Errorf("no token provided"), - Hint: "Run 'moat grant copilot' and enter a Copilot-capable GitHub token", + Hint: "Run 'moat grant github' and enter a Copilot-capable GitHub token", } } return validateAndCreateCredential(ctx, token, SourcePAT) @@ -88,7 +89,7 @@ func validateAndCreateCredential(ctx context.Context, token, source string) (*pr } fmt.Println("Copilot token validated successfully") return &provider.Credential{ - Provider: copilotProviderName, + Provider: string(credential.ProviderGitHub), Token: token, CreatedAt: time.Now(), Metadata: map[string]string{provider.MetaKeyTokenSource: source}, diff --git a/internal/providers/copilot/grant_test.go b/internal/providers/copilot/grant_test.go index 533b3c02..bf6f87d5 100644 --- a/internal/providers/copilot/grant_test.go +++ b/internal/providers/copilot/grant_test.go @@ -83,7 +83,7 @@ func TestValidateAndCreateCredential(t *testing.T) { if err != nil { t.Fatalf("validateAndCreateCredential() error = %v", err) } - if cred.Provider != copilotProviderName || cred.Token != "token" { + if cred.Provider != "github" || cred.Token != "token" { t.Fatalf("credential = %+v", cred) } if got := cred.Metadata[provider.MetaKeyTokenSource]; got != SourceEnv { @@ -115,66 +115,14 @@ func TestGrantExecuteEnvToken(t *testing.T) { if cred.Token != "env-token" { t.Fatalf("token = %q, want env-token", cred.Token) } + if cred.Provider != "github" { + t.Fatalf("provider = %q, want github", cred.Provider) + } if got := cred.Metadata[provider.MetaKeyTokenSource]; got != SourceEnv { t.Fatalf("token source = %q, want %q", got, SourceEnv) } } -func TestRefresh(t *testing.T) { - withCopilotValidationServer(t, http.StatusOK, `{}`, nil) - origGetGHCLIToken := getGHCLIToken - getGHCLIToken = func(context.Context) (string, error) { return "fresh-token", nil } - t.Cleanup(func() { getGHCLIToken = origGetGHCLIToken }) - - proxy := newMockProxyConfigurer() - cred := &provider.Credential{ - Provider: copilotProviderName, - Token: "old-token", - Metadata: map[string]string{provider.MetaKeyTokenSource: SourceCLI}, - } - - updated, err := (&Provider{}).Refresh(context.Background(), proxy, cred) - if err != nil { - t.Fatalf("Refresh() error = %v", err) - } - if updated.Token != "fresh-token" { - t.Fatalf("updated token = %q, want fresh-token", updated.Token) - } - if cred.Token != "old-token" { - t.Fatalf("Refresh mutated original credential token = %q", cred.Token) - } - if got := proxy.headers[copilotBusinessHost]["Authorization"]; got != "Bearer fresh-token" { - t.Fatalf("business host auth = %q", got) - } -} - -func TestRefreshUnsupported(t *testing.T) { - _, err := (&Provider{}).Refresh(context.Background(), newMockProxyConfigurer(), &provider.Credential{ - Provider: copilotProviderName, - Token: "token", - Metadata: map[string]string{provider.MetaKeyTokenSource: SourcePAT}, - }) - if !errors.Is(err, provider.ErrRefreshNotSupported) { - t.Fatalf("Refresh() error = %v, want ErrRefreshNotSupported", err) - } -} - -func TestRefreshValidationError(t *testing.T) { - withCopilotValidationServer(t, http.StatusUnauthorized, `{}`, nil) - origGetGHCLIToken := getGHCLIToken - getGHCLIToken = func(context.Context) (string, error) { return "fresh-token", nil } - t.Cleanup(func() { getGHCLIToken = origGetGHCLIToken }) - - _, err := (&Provider{}).Refresh(context.Background(), newMockProxyConfigurer(), &provider.Credential{ - Provider: copilotProviderName, - Token: "old-token", - Metadata: map[string]string{provider.MetaKeyTokenSource: SourceCLI}, - }) - if err == nil || !strings.Contains(err.Error(), "invalid token") { - t.Fatalf("Refresh() error = %v, want invalid token", err) - } -} - func TestValidationResponseBodyNeedNotBeJSON(t *testing.T) { withCopilotValidationServer(t, http.StatusInternalServerError, `not-json`, nil) err := validateCopilotToken(context.Background(), "test-token") diff --git a/internal/providers/copilot/provider.go b/internal/providers/copilot/provider.go index f2c75bf8..e3b2200a 100644 --- a/internal/providers/copilot/provider.go +++ b/internal/providers/copilot/provider.go @@ -3,7 +3,6 @@ package copilot import ( "context" "encoding/base64" - "time" "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" @@ -20,9 +19,8 @@ const ( type Provider struct{} var ( - _ provider.CredentialProvider = (*Provider)(nil) - _ provider.AgentProvider = (*Provider)(nil) - _ provider.RefreshableProvider = (*Provider)(nil) + _ provider.CredentialProvider = (*Provider)(nil) + _ provider.AgentProvider = (*Provider)(nil) ) func init() { @@ -38,7 +36,7 @@ func (p *Provider) Grant(ctx context.Context) (*provider.Credential, error) { return g.Execute(ctx) } -// ConfigureProxy sets up GitHub/Copilot credential injection. +// ConfigureProxy sets up Copilot credential injection using a GitHub token. func (p *Provider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *provider.Credential) { setProxyAuth(proxy, cred.Token) } @@ -47,22 +45,19 @@ 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, copilotProviderName) - proxy.SetCredentialWithGrant(copilotChatAPIHost, "Authorization", "Bearer "+token, copilotProviderName) - proxy.SetCredentialWithGrant(copilotBusinessHost, "Authorization", "Bearer "+token, copilotProviderName) - proxy.SetCredentialWithGrant(copilotMCPHost, "Authorization", "Bearer "+token, copilotProviderName) + 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, copilotProviderName) + proxy.SetCredentialWithGrant(copilotGitHost, "Authorization", "Basic "+basic, "github") } -// ContainerEnv returns Copilot auth placeholders. Copilot CLI checks -// COPILOT_GITHUB_TOKEN before GH_TOKEN/GITHUB_TOKEN; GH_TOKEN lets gh CLI use -// the same proxy-injected credential for GitHub operations inside the run. +// 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, - "GH_TOKEN=" + credential.CopilotTokenPlaceholder, - "GIT_TERMINAL_PROMPT=0", } } @@ -76,26 +71,3 @@ func (p *Provider) Cleanup(cleanupPath string) {} // ImpliedDependencies returns dependencies useful for Copilot's GitHub workflow. func (p *Provider) ImpliedDependencies() []string { return []string{"gh", "git"} } - -func (p *Provider) CanRefresh(cred *provider.Credential) bool { - return cred != nil && cred.Metadata != nil && cred.Metadata[provider.MetaKeyTokenSource] == SourceCLI -} - -func (p *Provider) RefreshInterval() time.Duration { return 30 * time.Minute } - -func (p *Provider) Refresh(ctx context.Context, proxy provider.ProxyConfigurer, cred *provider.Credential) (*provider.Credential, error) { - if !p.CanRefresh(cred) { - return nil, provider.ErrRefreshNotSupported - } - token, err := getGHCLIToken(ctx) - if err != nil { - return nil, err - } - if err := validateCopilotToken(ctx, token); err != nil { - return nil, err - } - setProxyAuth(proxy, token) - updated := *cred - updated.Token = token - return &updated, nil -} diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go index 7c82c583..6f19bafe 100644 --- a/internal/providers/copilot/provider_test.go +++ b/internal/providers/copilot/provider_test.go @@ -6,7 +6,6 @@ import ( "slices" "strings" "testing" - "time" "github.com/majorcontext/moat/internal/cli" "github.com/majorcontext/moat/internal/config" @@ -64,8 +63,8 @@ func TestConfigureProxy(t *testing.T) { 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 != "copilot" { - t.Errorf("api.github.com grant = %q, want copilot", 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) @@ -82,13 +81,18 @@ func TestContainerEnv(t *testing.T) { env := (&Provider{}).ContainerEnv(&provider.Credential{Provider: "copilot"}) for _, want := range []string{ "COPILOT_GITHUB_TOKEN=" + credential.CopilotTokenPlaceholder, - "GH_TOKEN=" + credential.CopilotTokenPlaceholder, - "GIT_TERMINAL_PROMPT=0", } { 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) { @@ -100,31 +104,6 @@ func TestProviderNoopMethods(t *testing.T) { if deps := p.ImpliedDependencies(); !slices.Equal(deps, []string{"gh", "git"}) { t.Fatalf("ImpliedDependencies() = %v, want [gh git]", deps) } - if got := p.RefreshInterval(); got != 30*time.Minute { - t.Fatalf("RefreshInterval() = %v, want 30m", got) - } -} - -func TestCanRefresh(t *testing.T) { - p := &Provider{} - tests := []struct { - name string - cred *provider.Credential - want bool - }{ - {name: "nil", cred: nil}, - {name: "no metadata", cred: &provider.Credential{}}, - {name: "env", cred: &provider.Credential{Metadata: map[string]string{provider.MetaKeyTokenSource: SourceEnv}}}, - {name: "pat", cred: &provider.Credential{Metadata: map[string]string{provider.MetaKeyTokenSource: SourcePAT}}}, - {name: "cli", cred: &provider.Credential{Metadata: map[string]string{provider.MetaKeyTokenSource: SourceCLI}}, want: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := p.CanRefresh(tt.cred); got != tt.want { - t.Fatalf("CanRefresh() = %v, want %v", got, tt.want) - } - }) - } } func TestDefaultDependenciesAndHosts(t *testing.T) { @@ -172,7 +151,7 @@ func TestResolveCopilotPreflight(t *testing.T) { copilotModelFlag = "" copilotExperimental = false copilotAutopilot = false - copilotFlags.Grants = []string{"github", "ssh:github.com"} + copilotFlags.Grants = []string{"copilot", "ssh:github.com"} cli.DryRun = false copilotCredentialConfigured = func() bool { return false } cfg := &config.Config{ @@ -186,11 +165,11 @@ func TestResolveCopilotPreflight(t *testing.T) { 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{"copilot"}) { - t.Fatalf("config grants = %v, want [copilot]", cfg.Grants) + if !slices.Equal(cfg.Grants, []string{"github"}) { + t.Fatalf("config grants = %v, want [github]", cfg.Grants) } - if !slices.Equal(copilotFlags.Grants, []string{"ssh:github.com", "copilot"}) { - t.Fatalf("flag grants = %v, want [ssh:github.com copilot]", copilotFlags.Grants) + if !slices.Equal(copilotFlags.Grants, []string{"github", "ssh:github.com"}) { + t.Fatalf("flag grants = %v, want [github ssh:github.com]", copilotFlags.Grants) } } @@ -237,8 +216,8 @@ func TestResolveCopilotPreflightAddsRequiredGrantWhenMissing(t *testing.T) { if err := resolveCopilotPreflight(nil); err != nil { t.Fatalf("resolveCopilotPreflight(nil) error = %v", err) } - if !slices.Equal(copilotFlags.Grants, []string{"copilot"}) { - t.Fatalf("flag grants = %v, want [copilot]", copilotFlags.Grants) + if !slices.Equal(copilotFlags.Grants, []string{"github"}) { + t.Fatalf("flag grants = %v, want [github]", copilotFlags.Grants) } } @@ -297,8 +276,8 @@ func TestGetCredentialName(t *testing.T) { origConfigured := copilotCredentialConfigured t.Cleanup(func() { copilotCredentialConfigured = origConfigured }) copilotCredentialConfigured = func() bool { return true } - if got := GetCredentialName(); got != "copilot" { - t.Fatalf("GetCredentialName() = %q, want copilot", got) + if got := GetCredentialName(); got != "github" { + t.Fatalf("GetCredentialName() = %q, want github", got) } copilotCredentialConfigured = func() bool { return false } if got := GetCredentialName(); got != "" { @@ -306,11 +285,11 @@ func TestGetCredentialName(t *testing.T) { } } -func TestFilterGitHubGrant(t *testing.T) { - got := filterGitHubGrant([]string{"github", "copilot", "ssh:github.com", "aws"}, false) - want := []string{"copilot", "ssh:github.com", "aws"} +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("filterGitHubGrant = %v, want %v", got, want) + t.Errorf("normalizeCopilotGrants = %v, want %v", got, want) } } diff --git a/internal/run/imageneeds.go b/internal/run/imageneeds.go index bd718777..f8b2e851 100644 --- a/internal/run/imageneeds.go +++ b/internal/run/imageneeds.go @@ -139,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 17f32009..d630bfec 100644 --- a/internal/run/imageneeds_test.go +++ b/internal/run/imageneeds_test.go @@ -248,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"}, @@ -277,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 f398e5fd..13e67c3f 100644 --- a/internal/run/manager_agentinit.go +++ b/internal/run/manager_agentinit.go @@ -35,7 +35,7 @@ func buildLocalMCPConfig(agentName string, specs map[string]config.MCPServerSpec if spec.Grant != "" { v, ok := grantToEnvVar(spec.Grant) if !ok { - return nil, fmt.Errorf("%s.mcp.%s: unknown grant %q (supported: github, copilot, openai, anthropic, gemini)", agentName, name, spec.Grant) + return nil, fmt.Errorf("%s.mcp.%s: unknown grant %q (supported: github, openai, anthropic, gemini)", agentName, name, spec.Grant) } if !hasGrant(grants, spec.Grant) { return nil, fmt.Errorf("%s.mcp.%s: grant %q not declared in top-level grants list — add 'grants: [%s]' to agent.yaml", agentName, name, spec.Grant, spec.Grant) diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index bca0a57c..12b23d4a 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -400,6 +400,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 +459,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,7 +1135,7 @@ region = %s imgNeeds := resolveImageNeeds(opts.Grants, depList) needsClaudeInit := slices.Contains(imgNeeds.initProviders, "claude") needsCodexInit := slices.Contains(imgNeeds.initProviders, "codex") - needsCopilotInit := slices.Contains(imgNeeds.initProviders, "copilot") + 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") @@ -2234,6 +2243,25 @@ func isAIAgent(cfg *config.Config) bool { 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 +} + // 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 @@ -2505,17 +2533,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 { @@ -2646,8 +2675,6 @@ func grantToEnvVar(grant string) (string, bool) { return "OPENAI_API_KEY", true case "anthropic": return "ANTHROPIC_API_KEY", true - case "copilot": - return "COPILOT_GITHUB_TOKEN", true case "gemini": return "GEMINI_API_KEY", true default: @@ -2664,8 +2691,6 @@ func grantToPlaceholder(grant string) string { switch grant { case "anthropic": return credential.AnthropicAPIKeyPlaceholder - case "copilot": - return credential.CopilotTokenPlaceholder case "gemini": return credential.GeminiAPIKeyPlaceholder case "github": diff --git a/internal/run/run.go b/internal/run/run.go index 8e64ddbf..a6a46ccb 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -346,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] } From dd48e50839b90ec68d36866880aa34bf9c591893 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 17:37:09 +0000 Subject: [PATCH 7/8] fix(copilot): validate github grant access Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/content/guides/17-copilot.md | 2 + docs/content/reference/01-cli.md | 2 +- docs/content/reference/04-grants.md | 2 + internal/providers/copilot/grant.go | 97 ++---------------------- internal/providers/copilot/grant_test.go | 66 ++++------------ internal/providers/copilot/provider.go | 13 +--- internal/run/copilot_validation_test.go | 83 ++++++++++++++++++++ internal/run/manager_create.go | 54 +++++++++++++ 8 files changed, 164 insertions(+), 155 deletions(-) create mode 100644 internal/run/copilot_validation_test.go diff --git a/docs/content/guides/17-copilot.md b/docs/content/guides/17-copilot.md index cec570a4..6a897178 100644 --- a/docs/content/guides/17-copilot.md +++ b/docs/content/guides/17-copilot.md @@ -27,6 +27,8 @@ Moat checks `GITHUB_TOKEN` and `GH_TOKEN` first. If none are set, it can use `gh 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`. diff --git a/docs/content/reference/01-cli.md b/docs/content/reference/01-cli.md index c62ae72c..f27614da 100644 --- a/docs/content/reference/01-cli.md +++ b/docs/content/reference/01-cli.md @@ -292,7 +292,7 @@ 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. 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. +`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 diff --git a/docs/content/reference/04-grants.md b/docs/content/reference/04-grants.md index bbc822d1..ed3033a0 100644 --- a/docs/content/reference/04-grants.md +++ b/docs/content/reference/04-grants.md @@ -65,6 +65,8 @@ The container receives `GH_TOKEN` set to a format-valid placeholder so the gh CL 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` are refreshed every 30 minutes. Environment variables and PATs entered manually are static. diff --git a/internal/providers/copilot/grant.go b/internal/providers/copilot/grant.go index 6f073035..6f443ef0 100644 --- a/internal/providers/copilot/grant.go +++ b/internal/providers/copilot/grant.go @@ -6,94 +6,21 @@ import ( "fmt" "io" "net/http" - "os/exec" - "strings" "time" - - "github.com/majorcontext/moat/internal/credential" - "github.com/majorcontext/moat/internal/provider" - "github.com/majorcontext/moat/internal/provider/util" ) -// Grant handles Copilot credential acquisition. -type Grant struct{} - -func NewGrant() *Grant { return &Grant{} } - var ( copilotValidationURL = "https://api.github.com/copilot_internal/user" newCopilotHTTPClient = func() *http.Client { return &http.Client{Timeout: 10 * time.Second} } - getGHCLIToken = getGHCLITokenFromCLI ) -func (g *Grant) Execute(ctx context.Context) (*provider.Credential, error) { - if token, name := util.CheckEnvVarWithName("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"); token != "" { - fmt.Printf("Using token from %s environment variable\n", name) - return validateAndCreateCredential(ctx, token, SourceEnv) - } - - token, ghErr := getGHCLIToken(ctx) - if ghErr == nil && token != "" { - fmt.Println("Found gh CLI authentication") - confirmed, err := util.Confirm("Use token from gh CLI for Copilot?") - if err != nil { - return nil, fmt.Errorf("reading confirmation: %w", err) - } - if confirmed { - return validateAndCreateCredential(ctx, token, SourceCLI) - } - fmt.Println() - } else if ghErr != nil && isGHCLIInstalled() { - fmt.Printf("Note: gh CLI found but 'gh auth token' failed: %v\n", ghErr) - fmt.Println("You may need to run 'gh auth login' first.") - fmt.Println() - } - - fmt.Println(`Enter a GitHub token that can use Copilot CLI. - -Supported token types: - - GitHub CLI OAuth token from 'gh auth login' - - Fine-grained PAT from your personal account with "Copilot Requests" - -To create a fine-grained PAT: - 1. Visit https://github.com/settings/personal-access-tokens/new - 2. Select your personal account as the resource owner - 3. Add the "Copilot Requests" account permission - 4. Select repository access appropriate for your workflow - 5. Copy the generated token`) - - token, err := util.PromptForToken("Token") - if err != nil { - return nil, fmt.Errorf("reading token: %w", err) - } - if token == "" { - return nil, &provider.GrantError{ - Provider: copilotProviderName, - Cause: fmt.Errorf("no token provided"), - Hint: "Run 'moat grant github' and enter a Copilot-capable GitHub token", - } - } - return validateAndCreateCredential(ctx, token, SourcePAT) -} - -func validateAndCreateCredential(ctx context.Context, token, source string) (*provider.Credential, error) { - fmt.Println("Validating Copilot token...") - if err := validateCopilotToken(ctx, token); err != nil { - return nil, &provider.GrantError{ - Provider: copilotProviderName, - Cause: err, - Hint: "Use a GitHub CLI OAuth token or a fine-grained PAT with the Copilot Requests permission", - } - } - fmt.Println("Copilot token validated successfully") - return &provider.Credential{ - Provider: string(credential.ProviderGitHub), - Token: token, - CreatedAt: time.Now(), - Metadata: map[string]string{provider.MetaKeyTokenSource: source}, - }, nil +// 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 { @@ -139,17 +66,3 @@ func validateCopilotToken(ctx context.Context, token string) error { return fmt.Errorf("unexpected status validating token: %d", resp.StatusCode) } } - -func getGHCLITokenFromCLI(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "gh", "auth", "token") - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("gh auth token: %w", err) - } - return strings.TrimSpace(string(out)), nil -} - -func isGHCLIInstalled() bool { - _, err := exec.LookPath("gh") - return err == nil -} diff --git a/internal/providers/copilot/grant_test.go b/internal/providers/copilot/grant_test.go index bf6f87d5..4c683ec7 100644 --- a/internal/providers/copilot/grant_test.go +++ b/internal/providers/copilot/grant_test.go @@ -3,13 +3,10 @@ package copilot import ( "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "strings" "testing" - - "github.com/majorcontext/moat/internal/provider" ) func withCopilotValidationServer(t *testing.T, status int, body string, check func(*testing.T, *http.Request)) { @@ -62,7 +59,7 @@ func TestValidateCopilotToken(t *testing.T) { } }) - err := validateCopilotToken(context.Background(), "test-token") + err := ValidateGitHubToken(context.Background(), "test-token") if tt.wantErr == "" { if err != nil { t.Fatalf("validateCopilotToken() error = %v", err) @@ -76,56 +73,9 @@ func TestValidateCopilotToken(t *testing.T) { } } -func TestValidateAndCreateCredential(t *testing.T) { - withCopilotValidationServer(t, http.StatusOK, `{}`, nil) - - cred, err := validateAndCreateCredential(context.Background(), "token", SourceEnv) - if err != nil { - t.Fatalf("validateAndCreateCredential() error = %v", err) - } - if cred.Provider != "github" || cred.Token != "token" { - t.Fatalf("credential = %+v", cred) - } - if got := cred.Metadata[provider.MetaKeyTokenSource]; got != SourceEnv { - t.Fatalf("token source = %q, want %q", got, SourceEnv) - } -} - -func TestValidateAndCreateCredentialError(t *testing.T) { - withCopilotValidationServer(t, http.StatusUnauthorized, `{}`, nil) - - _, err := validateAndCreateCredential(context.Background(), "bad-token", SourcePAT) - var grantErr *provider.GrantError - if !errors.As(err, &grantErr) { - t.Fatalf("error = %T %v, want GrantError", err, err) - } - if grantErr.Provider != copilotProviderName { - t.Fatalf("GrantError.Provider = %q", grantErr.Provider) - } -} - -func TestGrantExecuteEnvToken(t *testing.T) { - withCopilotValidationServer(t, http.StatusOK, `{}`, nil) - t.Setenv("COPILOT_GITHUB_TOKEN", "env-token") - - cred, err := NewGrant().Execute(context.Background()) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if cred.Token != "env-token" { - t.Fatalf("token = %q, want env-token", cred.Token) - } - if cred.Provider != "github" { - t.Fatalf("provider = %q, want github", cred.Provider) - } - if got := cred.Metadata[provider.MetaKeyTokenSource]; got != SourceEnv { - t.Fatalf("token source = %q, want %q", got, SourceEnv) - } -} - func TestValidationResponseBodyNeedNotBeJSON(t *testing.T) { withCopilotValidationServer(t, http.StatusInternalServerError, `not-json`, nil) - err := validateCopilotToken(context.Background(), "test-token") + err := ValidateGitHubToken(context.Background(), "test-token") if err == nil || !strings.Contains(err.Error(), "unexpected status validating token: 500") { t.Fatalf("validateCopilotToken() error = %v", err) } @@ -137,8 +87,18 @@ func TestValidationMessageJSON(t *testing.T) { t.Fatal(err) } withCopilotValidationServer(t, http.StatusForbidden, string(body), nil) - err = validateCopilotToken(context.Background(), "test-token") + 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 index e3b2200a..a66709cc 100644 --- a/internal/providers/copilot/provider.go +++ b/internal/providers/copilot/provider.go @@ -3,17 +3,12 @@ package copilot import ( "context" "encoding/base64" + "fmt" "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" ) -const ( - SourceCLI = "cli" - SourceEnv = "env" - SourcePAT = "pat" -) - // Provider implements provider.CredentialProvider and provider.AgentProvider // for GitHub Copilot CLI. type Provider struct{} @@ -30,10 +25,10 @@ func init() { // Name returns the provider identifier. func (p *Provider) Name() string { return copilotProviderName } -// Grant acquires a Copilot-capable GitHub credential. +// 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) { - g := NewGrant() - return g.Execute(ctx) + 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. diff --git a/internal/run/copilot_validation_test.go b/internal/run/copilot_validation_test.go new file mode 100644 index 00000000..73223ba9 --- /dev/null +++ b/internal/run/copilot_validation_test.go @@ -0,0 +1,83 @@ +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 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/manager_create.go b/internal/run/manager_create.go index 12b23d4a..0f6f89c7 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. @@ -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 @@ -1135,6 +1144,8 @@ 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") @@ -2262,6 +2273,49 @@ func configUsesCopilotCLI(cfg *config.Config) bool { 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) || !hasGrant(grants, "github") { + return nil + } + 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 From f8b2e3240c500a596d7c1681ea9f4a51ea6de099 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Wed, 8 Jul 2026 17:56:32 +0000 Subject: [PATCH 8/8] test(copilot): cover grant edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/providers/copilot/provider_test.go | 6 ++++++ internal/run/copilot_validation_test.go | 17 +++++++++++++++++ internal/run/grants_test.go | 16 ++++++++++++++++ internal/run/manager_agentinit.go | 10 +++++++++- internal/run/manager_create.go | 9 ++++++--- 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go index 6f19bafe..fdc47f7a 100644 --- a/internal/providers/copilot/provider_test.go +++ b/internal/providers/copilot/provider_test.go @@ -75,6 +75,12 @@ func TestConfigureProxy(t *testing.T) { 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) { diff --git a/internal/run/copilot_validation_test.go b/internal/run/copilot_validation_test.go index 73223ba9..be4a693e 100644 --- a/internal/run/copilot_validation_test.go +++ b/internal/run/copilot_validation_test.go @@ -74,6 +74,23 @@ func TestValidateCopilotGitHubGrantSkippedForNonCopilotRun(t *testing.T) { } } +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"} 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/manager_agentinit.go b/internal/run/manager_agentinit.go index 13e67c3f..abe1bf37 100644 --- a/internal/run/manager_agentinit.go +++ b/internal/run/manager_agentinit.go @@ -150,8 +150,16 @@ func (m *Manager) setupPiStaging(ctx context.Context, piProvider provider.AgentP // 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) (*provider.ContainerConfig, error) { +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, }) diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index 0f6f89c7..cbff94e0 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -144,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 { @@ -1449,7 +1449,7 @@ region = %s return nil, fmt.Errorf("copilot provider not registered") } - cfg, stageErr := m.setupCopilotStaging(ctx, copilotProvider, containerHome, renderedContext) + cfg, stageErr := m.setupCopilotStaging(ctx, copilotProvider, containerHome, renderedContext, openCredStore) if stageErr != nil { cleanupDaemonRun() cleanupSSH(sshServer) @@ -2303,9 +2303,12 @@ func normalizeCopilotGrantNames(grants []string) []string { var validateCopilotGitHubToken = copilotprov.ValidateGitHubToken func validateCopilotGitHubGrant(ctx context.Context, cfg *config.Config, grants []string, store credential.Store) error { - if !configUsesCopilotCLI(cfg) || !hasGrant(grants, "github") { + 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)