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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. 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))
- **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.context`, `copilot.reasoning_effort`, `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))
Expand Down
2 changes: 2 additions & 0 deletions docs/content/guides/17-copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ grants:

copilot:
model: gpt-5.4
context: long_context
reasoning_effort: high
experimental: false
autopilot: false
```
Expand Down
2 changes: 2 additions & 0 deletions docs/content/reference/01-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ In addition to the command-specific flags below, `moat copilot` accepts all [com
| `-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`. |
| `--context TIER` | Set context window tier (`default` or `long_context`). Overrides `copilot.context`. |
| `--reasoning-effort LEVEL` | Set reasoning effort level (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`). Overrides `copilot.reasoning_effort`. |
| `--experimental` | Enable Copilot CLI experimental features. |
| `--autopilot` | Start Copilot CLI in autopilot mode. |

Expand Down
30 changes: 30 additions & 0 deletions docs/content/reference/02-moat-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ codex:
# GitHub Copilot CLI
copilot:
model: gpt-5.4
context: long_context
reasoning_effort: high
experimental: false
autopilot: false

Expand Down Expand Up @@ -1595,6 +1597,34 @@ copilot:
- Default: Copilot CLI default
- CLI override: `moat copilot --model`

### copilot.context

Context window tier to pass to `copilot --context`.

```yaml
copilot:
context: long_context
```

- Type: `string`
- Values: `default`, `long_context`
- Default: Copilot CLI default
- CLI override: `moat copilot --context`

### copilot.reasoning_effort

Reasoning effort level to pass to `copilot --reasoning-effort`.

```yaml
copilot:
reasoning_effort: high
```

- Type: `string`
- Values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`
- Default: Copilot CLI default
- CLI override: `moat copilot --reasoning-effort`

### copilot.experimental

Start Copilot CLI with experimental features enabled.
Expand Down
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,14 @@ type CopilotConfig struct {
// Model optionally selects the model passed to `copilot --model`.
Model string `yaml:"model,omitempty"`

// Context sets the context window tier passed to `copilot --context`.
// Valid values: "default", "long_context".
Context string `yaml:"context,omitempty"`

// ReasoningEffort sets the reasoning effort level passed to `copilot --reasoning-effort`.
// Valid values: "none", "minimal", "low", "medium", "high", "xhigh", "max".
ReasoningEffort string `yaml:"reasoning_effort,omitempty"`

// Experimental starts Copilot CLI with --experimental.
Experimental bool `yaml:"experimental,omitempty"`

Expand Down
20 changes: 20 additions & 0 deletions internal/providers/copilot/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ var (
copilotWtFlag string
copilotAllowAll bool
copilotModelFlag string
copilotContextFlag string
copilotReasoningEffortFlag string
copilotExperimental bool
copilotAutopilot bool
copilotResolvedModel string
copilotResolvedContext string
copilotResolvedEffort string
copilotCredentialConfigured = defaultCopilotCredentialConfigured
)

Expand Down Expand Up @@ -65,6 +69,8 @@ Examples:
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().StringVar(&copilotContextFlag, "context", "", "context window tier: default or long_context (overrides copilot.context)")
copilotCmd.Flags().StringVar(&copilotReasoningEffortFlag, "reasoning-effort", "", "reasoning effort level (overrides copilot.reasoning_effort)")
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")
Expand Down Expand Up @@ -98,11 +104,19 @@ func runCopilot(cmd *cobra.Command, args []string) error {

func resolveCopilotPreflight(cfg *config.Config) error {
copilotResolvedModel = copilotModelFlag
copilotResolvedContext = copilotContextFlag
copilotResolvedEffort = copilotReasoningEffortFlag
if cfg != nil {
cfg.Grants = normalizeCopilotGrants(cfg.Grants, false)
if copilotResolvedModel == "" {
copilotResolvedModel = cfg.Copilot.Model
}
if copilotResolvedContext == "" {
copilotResolvedContext = cfg.Copilot.Context
}
if copilotResolvedEffort == "" {
copilotResolvedEffort = cfg.Copilot.ReasoningEffort
}
if cfg.Copilot.Experimental {
copilotExperimental = true
}
Expand Down Expand Up @@ -167,6 +181,12 @@ func buildCopilotCommand(promptFlag, initialPrompt string) []string {
if copilotResolvedModel != "" {
cmd = append(cmd, "--model", copilotResolvedModel)
}
if copilotResolvedContext != "" {
cmd = append(cmd, "--context", copilotResolvedContext)
}
if copilotResolvedEffort != "" {
cmd = append(cmd, "--reasoning-effort", copilotResolvedEffort)
}
if copilotExperimental {
cmd = append(cmd, "--experimental")
}
Expand Down
68 changes: 61 additions & 7 deletions internal/providers/copilot/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func TestRegisterCLI(t *testing.T) {
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"} {
for _, flag := range []string{"prompt", "allow-all", "model", "context", "reasoning-effort", "experimental", "autopilot", "worktree"} {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("copilot command missing --%s flag", flag)
}
Expand All @@ -140,13 +140,19 @@ func TestRegisterCLI(t *testing.T) {

func TestResolveCopilotPreflight(t *testing.T) {
origModelFlag, origResolvedModel := copilotModelFlag, copilotResolvedModel
origContextFlag, origResolvedContext := copilotContextFlag, copilotResolvedContext
origEffortFlag, origResolvedEffort := copilotReasoningEffortFlag, copilotResolvedEffort
origExperimental, origAutopilot := copilotExperimental, copilotAutopilot
origFlagGrants := copilotFlags.Grants
origDryRun := cli.DryRun
origConfigured := copilotCredentialConfigured
t.Cleanup(func() {
copilotModelFlag = origModelFlag
copilotResolvedModel = origResolvedModel
copilotContextFlag = origContextFlag
copilotResolvedContext = origResolvedContext
copilotReasoningEffortFlag = origEffortFlag
copilotResolvedEffort = origResolvedEffort
copilotExperimental = origExperimental
copilotAutopilot = origAutopilot
copilotFlags.Grants = origFlagGrants
Expand All @@ -155,14 +161,22 @@ func TestResolveCopilotPreflight(t *testing.T) {
})

copilotModelFlag = ""
copilotContextFlag = ""
copilotReasoningEffortFlag = ""
copilotExperimental = false
copilotAutopilot = false
copilotFlags.Grants = []string{"copilot", "ssh:github.com"}
cli.DryRun = false
copilotCredentialConfigured = func() bool { return false }
cfg := &config.Config{
Grants: []string{"github", "copilot"},
Copilot: config.CopilotConfig{Model: "gpt-5.4", Experimental: true, Autopilot: true},
Grants: []string{"github", "copilot"},
Copilot: config.CopilotConfig{
Model: "gpt-5.4",
Context: "long_context",
ReasoningEffort: "high",
Experimental: true,
Autopilot: true,
},
}

if err := resolveCopilotPreflight(cfg); err != nil {
Expand All @@ -171,6 +185,12 @@ 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 copilotResolvedContext != "long_context" {
t.Fatalf("resolved context = %q, want long_context", copilotResolvedContext)
}
if copilotResolvedEffort != "high" {
t.Fatalf("resolved effort = %q, want high", copilotResolvedEffort)
}
if !slices.Equal(cfg.Grants, []string{"github"}) {
t.Fatalf("config grants = %v, want [github]", cfg.Grants)
}
Expand All @@ -179,22 +199,34 @@ func TestResolveCopilotPreflight(t *testing.T) {
}
}

func TestResolveCopilotPreflightModelFlagOverridesConfig(t *testing.T) {
func TestResolveCopilotPreflightFlagOverridesConfig(t *testing.T) {
origModelFlag, origResolvedModel := copilotModelFlag, copilotResolvedModel
origContextFlag, origResolvedContext := copilotContextFlag, copilotResolvedContext
origEffortFlag, origResolvedEffort := copilotReasoningEffortFlag, copilotResolvedEffort
origDryRun := cli.DryRun
origConfigured := copilotCredentialConfigured
t.Cleanup(func() {
copilotModelFlag = origModelFlag
copilotResolvedModel = origResolvedModel
copilotContextFlag = origContextFlag
copilotResolvedContext = origResolvedContext
copilotReasoningEffortFlag = origEffortFlag
copilotResolvedEffort = origResolvedEffort
cli.DryRun = origDryRun
copilotCredentialConfigured = origConfigured
})

copilotModelFlag = "gpt-5.5"
copilotContextFlag = "default"
copilotReasoningEffortFlag = "max"
cli.DryRun = true
copilotCredentialConfigured = func() bool { return true }
cfg := &config.Config{
Copilot: config.CopilotConfig{Model: "claude-sonnet-4"},
Copilot: config.CopilotConfig{
Model: "claude-sonnet-4",
Context: "long_context",
ReasoningEffort: "high",
},
}

if err := resolveCopilotPreflight(cfg); err != nil {
Expand All @@ -203,6 +235,12 @@ func TestResolveCopilotPreflightModelFlagOverridesConfig(t *testing.T) {
if copilotResolvedModel != "gpt-5.5" {
t.Fatalf("CLI --model flag should override config model, got %q", copilotResolvedModel)
}
if copilotResolvedContext != "default" {
t.Fatalf("CLI --context flag should override config context, got %q", copilotResolvedContext)
}
if copilotResolvedEffort != "max" {
t.Fatalf("CLI --reasoning-effort flag should override config effort, got %q", copilotResolvedEffort)
}
}

func TestResolveCopilotPreflightAddsRequiredGrantWhenMissing(t *testing.T) {
Expand Down Expand Up @@ -250,21 +288,26 @@ func TestResolveCopilotPreflightDryRunDoesNotAddMissingGrant(t *testing.T) {
}

func TestBuildCopilotCommand(t *testing.T) {
origModel, origExperimental, origAutopilot, origAllowAll := copilotResolvedModel, copilotExperimental, copilotAutopilot, copilotAllowAll
origModel, origContext, origEffort := copilotResolvedModel, copilotResolvedContext, copilotResolvedEffort
origExperimental, origAutopilot, origAllowAll := copilotExperimental, copilotAutopilot, copilotAllowAll
t.Cleanup(func() {
copilotResolvedModel = origModel
copilotResolvedContext = origContext
copilotResolvedEffort = origEffort
copilotExperimental = origExperimental
copilotAutopilot = origAutopilot
copilotAllowAll = origAllowAll
})

copilotResolvedModel = "gpt-5.4"
copilotResolvedContext = "long_context"
copilotResolvedEffort = "high"
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"}
want := []string{"copilot", "--no-auto-update", "--model", "gpt-5.4", "--context", "long_context", "--reasoning-effort", "high", "--experimental", "--autopilot", "--allow-all", "-p", "fix it"}
if !slices.Equal(got, want) {
t.Errorf("buildCopilotCommand = %v, want %v", got, want)
}
Expand All @@ -276,6 +319,17 @@ func TestBuildCopilotCommand(t *testing.T) {
if !slices.Contains(got, "-i") || !slices.Contains(got, "hello") {
t.Errorf("initial prompt not passed via -i: %v", got)
}

// Companion case: empty context/effort must not produce flags.
copilotResolvedContext = ""
copilotResolvedEffort = ""
got = buildCopilotCommand("fix it", "")
if slices.Contains(got, "--context") {
t.Errorf("empty context should not produce --context flag: %v", got)
}
if slices.Contains(got, "--reasoning-effort") {
t.Errorf("empty effort should not produce --reasoning-effort flag: %v", got)
}
}

func TestGetCredentialName(t *testing.T) {
Expand Down
Loading