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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Adds HTTP request-body inspection to Keep policies. File- and pack-based `networ

### Added

- **Copilot CLI settings passthrough** — `moat copilot` now carries over user preferences from the host's Copilot settings file (`$COPILOT_HOME/settings.json` when set, otherwise `~/.copilot/settings.json`; contextTier, effortLevel, footer, includeCoAuthoredBy, model, mouse, subagents, tabs, theme). Legacy `colorMode` values are written as the current `theme` setting. An optional `~/.moat/copilot/settings.json` provides moat-specific overrides that win over host settings. Settings that execute commands (`statusLine`) are only allowed from the moat override file. CLI flags and `moat.yaml` fields take precedence over settings.json values. ([#438](https://github.com/majorcontext/moat/pull/438))
- **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))
Expand Down
28 changes: 28 additions & 0 deletions docs/content/guides/17-copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ copilot:

`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.

## User settings

Moat carries these settings from your host's Copilot settings file into the container. By default, that file is `~/.copilot/settings.json`. If `COPILOT_HOME` is set, Moat reads `$COPILOT_HOME/settings.json` instead.

`contextTier`, `effortLevel`, `footer`, `includeCoAuthoredBy`, `model`, `mouse`, `subagents`, `tabs`, `theme`

Other settings stay on the host. Settings that conflict with moat's proxy and network layer (`proxyUrl`, `allowedUrls`, `deniedUrls`), depend on host services the container cannot reach (`notifications`, `keepAlive`, `copyOnSelect`), or execute commands (`hooks`, `statusLine`) are not carried over. `trustedFolders` is always set to `/workspace`. Moat also accepts Copilot's legacy `colorMode` setting and writes it as the current `theme` setting.

### Moat-specific overrides

`~/.moat/copilot/settings.json` is your Copilot settings layer for moat containers. Values in this file win over the host settings, and it is the only place `statusLine` is read from — a status line command executes inside the container, so it requires this explicit opt-in:

```json
{
"theme": "high-contrast",
"statusLine": {
"type": "command",
"command": "~/.copilot/statusline.sh"
}
}
```

### Precedence

CLI flags and `moat.yaml` win over settings.json: when `--model`, `--context`, or `--reasoning-effort` (or the matching `copilot.*` fields) are set, the corresponding `model`, `contextTier`, and `effortLevel` keys are dropped from the container's settings.json. Everything else in settings.json falls back to Copilot's defaults when unset.

Set `MOAT_SKIP_HOST_COPILOT_SETTINGS=1` to disable the passthrough entirely, for example in CI.

## 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`.
Expand Down
9 changes: 9 additions & 0 deletions internal/provider/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ type PrepareOpts struct {
// other agents.
SubscriptionType string
RateLimitTier string

// CopilotModel, CopilotContext, and CopilotReasoningEffort strip the
// corresponding keys (model, contextTier, effortLevel) from the Copilot
// settings.json staged into the container, because the value is pinned by
// moat.yaml copilot.* or a CLI flag. Empty means "no override".
// Copilot-specific; ignored by other agents.
CopilotModel string
CopilotContext string
CopilotReasoningEffort string
}

// MCPServerConfig defines a remote/relay MCP server configuration.
Expand Down
19 changes: 19 additions & 0 deletions internal/providers/copilot/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"

"github.com/majorcontext/moat/internal/log"
"github.com/majorcontext/moat/internal/provider"
)

Expand All @@ -31,6 +32,24 @@ func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOp
return nil, fmt.Errorf("writing copilot config: %w", err)
}

// Merge host settings (COPILOT_HOME/settings.json or
// ~/.copilot/settings.json) with moat overrides.
mergeOpts := MergeOpts{
ModelOverride: opts.CopilotModel,
ContextOverride: opts.CopilotContext,
EffortOverride: opts.CopilotReasoningEffort,
}
if settingsJSON, mergeErr := MergeSettings(mergeOpts); mergeErr != nil {
log.Warn("failed to merge Copilot settings, continuing without",
"error", mergeErr,
)
} else if settingsJSON != nil {
if writeErr := os.WriteFile(filepath.Join(tmpDir, "settings.json"), settingsJSON, 0o600); writeErr != nil {
cleanupFn()
return nil, fmt.Errorf("writing copilot settings: %w", writeErr)
}
}

env := p.ContainerEnv(opts.Credential)
env = append(env,
"MOAT_COPILOT_INIT="+CopilotInitMountPath,
Expand Down
94 changes: 94 additions & 0 deletions internal/providers/copilot/agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package copilot

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/majorcontext/moat/internal/provider"
)

func writeHostSettings(t *testing.T, settings map[string]any) {
t.Helper()
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("MOAT_HOME", t.TempDir())

copilotDir := filepath.Join(fakeHome, ".copilot")
if err := os.MkdirAll(copilotDir, 0o755); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil {
t.Fatal(err)
}
}

func stagedSettings(t *testing.T, cfg *provider.ContainerConfig) map[string]json.RawMessage {
t.Helper()
data, err := os.ReadFile(filepath.Join(cfg.StagingDir, "settings.json"))
if err != nil {
t.Fatalf("reading staged settings.json: %v", err)
}
var got map[string]json.RawMessage
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal staged settings: %v", err)
}
return got
}

func TestPrepareContainerStripsSettingsFromOpts(t *testing.T) {
writeHostSettings(t, map[string]any{
"model": "gpt-5.4",
"contextTier": "long_context",
"effortLevel": "high",
"theme": "dim",
})

cfg, err := (&Provider{}).PrepareContainer(context.Background(), provider.PrepareOpts{
CopilotModel: "claude-opus-4.6",
CopilotContext: "default",
CopilotReasoningEffort: "low",
})
if err != nil {
t.Fatalf("PrepareContainer: %v", err)
}
t.Cleanup(cfg.Cleanup)

got := stagedSettings(t, cfg)
for _, key := range []string{"model", "contextTier", "effortLevel"} {
if _, ok := got[key]; ok {
t.Errorf("%s should be stripped when the PrepareOpts override is set", key)
}
}
if _, ok := got["theme"]; !ok {
t.Error("expected theme to remain")
}
}

func TestPrepareContainerPreservesSettingsWithoutOverrides(t *testing.T) {
writeHostSettings(t, map[string]any{
"model": "gpt-5.4",
"contextTier": "long_context",
"effortLevel": "high",
"theme": "dim",
})

cfg, err := (&Provider{}).PrepareContainer(context.Background(), provider.PrepareOpts{})
if err != nil {
t.Fatalf("PrepareContainer: %v", err)
}
t.Cleanup(cfg.Cleanup)

got := stagedSettings(t, cfg)
for _, key := range []string{"model", "contextTier", "effortLevel", "theme"} {
if _, ok := got[key]; !ok {
t.Errorf("%s should be preserved when no override is set", key)
}
}
}
16 changes: 13 additions & 3 deletions internal/providers/copilot/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,22 @@ func runCopilot(cmd *cobra.Command, args []string) error {
BuildCommand: func(promptFlag, initialPrompt string) ([]string, error) {
return buildCopilotCommand(promptFlag, initialPrompt), nil
},
ConfigureAgent: func(cfg *config.Config) {
cfg.Agent = copilotProviderName
},
ConfigureAgent: configureCopilotAgent,
})
}

// configureCopilotAgent writes the flag-resolved copilot values back into the
// config so downstream consumers (e.g. the run manager populating
// provider.PrepareOpts) see the same precedence the CLI applied: flag over
// moat.yaml. RunProvider calls this after guaranteeing cfg is non-nil, unlike
// Preflight which may receive nil when no moat.yaml exists.
func configureCopilotAgent(cfg *config.Config) {
cfg.Agent = copilotProviderName
cfg.Copilot.Model = copilotResolvedModel
cfg.Copilot.Context = copilotResolvedContext
cfg.Copilot.ReasoningEffort = copilotResolvedEffort
}

func resolveCopilotPreflight(cfg *config.Config) error {
copilotResolvedModel = copilotModelFlag
copilotResolvedContext = copilotContextFlag
Expand Down
31 changes: 31 additions & 0 deletions internal/providers/copilot/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,37 @@ func TestResolveCopilotPreflightFlagOverridesConfig(t *testing.T) {
}
}

func TestConfigureCopilotAgentWritesResolvedValues(t *testing.T) {
origModel := copilotResolvedModel
origContext := copilotResolvedContext
origEffort := copilotResolvedEffort
t.Cleanup(func() {
copilotResolvedModel = origModel
copilotResolvedContext = origContext
copilotResolvedEffort = origEffort
})

copilotResolvedModel = "claude-opus-4.6"
copilotResolvedContext = "long_context"
copilotResolvedEffort = "high"

cfg := &config.Config{}
configureCopilotAgent(cfg)

if cfg.Agent != copilotProviderName {
t.Errorf("cfg.Agent = %q, want %q", cfg.Agent, copilotProviderName)
}
if cfg.Copilot.Model != "claude-opus-4.6" {
t.Errorf("cfg.Copilot.Model = %q, want claude-opus-4.6", cfg.Copilot.Model)
}
if cfg.Copilot.Context != "long_context" {
t.Errorf("cfg.Copilot.Context = %q, want long_context", cfg.Copilot.Context)
}
if cfg.Copilot.ReasoningEffort != "high" {
t.Errorf("cfg.Copilot.ReasoningEffort = %q, want high", cfg.Copilot.ReasoningEffort)
}
}

func TestResolveCopilotPreflightAddsRequiredGrantWhenMissing(t *testing.T) {
origFlagGrants := copilotFlags.Grants
origDryRun := cli.DryRun
Expand Down
Loading
Loading