From e779f9e5a9e7407861c8af990130da2d865190c5 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Thu, 9 Jul 2026 16:55:02 +0200 Subject: [PATCH 1/3] feat(copilot): carry over host settings into container Read ~/.copilot/settings.json and optional ~/.moat/copilot/settings.json overrides, filter through an allowlist of safe/useful settings, and write the merged result to the container staging directory. Carried over: statusLine, theme, model, effortLevel, contextTier, mouse, beep, beepOnSchedule, renderMarkdown, scrollbar, compactPaste, copyOnSelect, stream, terminalProgress, updateTerminalTitle, banner, showTipsOnStartup, keepAlive, memory, includeCoAuthoredBy, subagents, tabs, screenReader, stayInAutopilot, continueOnAutoMode. Excluded (unsafe/nonfunctional in containers): proxyUrl, allowedUrls, deniedUrls, notifications, hooks, trustedFolders, ide.*. Precedence: CLI flag / moat.yaml > settings.json > Copilot default. Model/effortLevel/contextTier are stripped from settings.json when the corresponding CLI flag or moat.yaml field is set (they become --flags which naturally override). MergeSettings accepts MergeOpts to avoid coupling to mutable package-level state. MOAT_SKIP_HOST_COPILOT_SETTINGS=1 disables the feature for tests/CI. --- CHANGELOG.md | 1 + internal/providers/copilot/agent.go | 16 + internal/providers/copilot/settings.go | 161 +++++++ internal/providers/copilot/settings_test.go | 499 ++++++++++++++++++++ 4 files changed, 677 insertions(+) create mode 100644 internal/providers/copilot/settings.go create mode 100644 internal/providers/copilot/settings_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d646a42d..70f88bb1 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 +- **Copilot CLI settings passthrough** — `moat copilot` now carries over user preferences from the host's `~/.copilot/settings.json` (contextTier, effortLevel, footer, includeCoAuthoredBy, model, mouse, subagents, tabs, theme). 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)) diff --git a/internal/providers/copilot/agent.go b/internal/providers/copilot/agent.go index 1fc7171e..a013644b 100644 --- a/internal/providers/copilot/agent.go +++ b/internal/providers/copilot/agent.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/majorcontext/moat/internal/log" "github.com/majorcontext/moat/internal/provider" ) @@ -31,6 +32,21 @@ func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOp return nil, fmt.Errorf("writing copilot config: %w", err) } + // Merge host settings (~/.copilot/settings.json) with moat overrides. + mergeOpts := MergeOpts{ + ModelOverride: copilotResolvedModel, + } + 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, diff --git a/internal/providers/copilot/settings.go b/internal/providers/copilot/settings.go new file mode 100644 index 00000000..44df61d4 --- /dev/null +++ b/internal/providers/copilot/settings.go @@ -0,0 +1,161 @@ +package copilot + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/log" +) + +// allowedSettings lists the settings keys that are safe and useful to carry +// over from the host into a moat container. Settings that conflict with moat's +// proxy/network layer, don't work inside containers, or pose security concerns +// are excluded. +// +// Note: statusLine is intentionally excluded from host settings because +// statusLine.command executes arbitrary commands inside the container. It is +// only allowed from ~/.moat/copilot/settings.json (explicit user opt-in). +// +// Excluded after analysis: +// - keepAlive: requires host-level caffeinate/power API — no-op in container +// - copyOnSelect: requires OSC 52 clipboard write — moat uses its own +// clipboard bridging (xclip/Ctrl+V), OSC 52 does not reach host clipboard +// - notifications: requires host notification daemon — unreachable from container +// - hooks: security concern — arbitrary command execution +// - proxyUrl, allowedUrls, deniedUrls: moat manages network/proxy +// - trustedFolders: hardcoded to /workspace +// - ide.*: no IDE in container +var allowedSettings = map[string]bool{ + "contextTier": true, + "effortLevel": true, + "footer": true, + "includeCoAuthoredBy": true, + "model": true, + "mouse": true, + "subagents": true, + "tabs": true, + "theme": true, +} + +// moatOnlySettings lists settings keys that are allowed only from the +// moat-specific override file (~/.moat/copilot/settings.json), not from the +// host's ~/.copilot/settings.json. These settings can execute arbitrary +// commands or have security implications that require explicit user opt-in. +var moatOnlySettings = map[string]bool{ + "statusLine": true, +} + +// loadHostSettings reads ~/.copilot/settings.json from the host. +func loadHostSettings() (map[string]json.RawMessage, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Debug("cannot determine home directory, skipping host Copilot settings", + "error", err, + ) + return nil, nil + } + return loadSettingsFile(filepath.Join(homeDir, ".copilot", "settings.json")) +} + +// loadMoatSettings reads /copilot/settings.json for moat-specific +// user overrides. +func loadMoatSettings() (map[string]json.RawMessage, error) { + return loadSettingsFile(filepath.Join(config.GlobalConfigDir(), "copilot", "settings.json")) +} + +// loadSettingsFile reads a JSON settings file into a raw map. +// Returns nil, nil if the file does not exist. +func loadSettingsFile(path string) (map[string]json.RawMessage, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + log.Warn("ignoring malformed Copilot settings", + "path", path, + "error", err, + ) + return nil, nil + } + return raw, nil +} + +// MergeOpts provides override values that take precedence over settings.json. +// These correspond to CLI flags or moat.yaml copilot.* fields that are passed +// as --flags to the Copilot CLI (which naturally override settings.json). +type MergeOpts struct { + ModelOverride string +} + +// MergeSettings builds the container settings.json by: +// 1. Reading ~/.copilot/settings.json (host defaults) +// 2. Reading /copilot/settings.json (moat user overrides) +// 3. Filtering to the allowlist +// 4. Stripping settings overridden by moat.yaml/CLI flags +// +// Returns nil, nil when no settings need to be written. +func MergeSettings(opts MergeOpts) ([]byte, error) { + if os.Getenv("MOAT_SKIP_HOST_COPILOT_SETTINGS") == "1" { + return nil, nil + } + + host, err := loadHostSettings() + if err != nil { + return nil, err + } + + moat, err := loadMoatSettings() + if err != nil { + return nil, err + } + + if host == nil && moat == nil { + return nil, nil + } + + // Start with host, overlay moat overrides + merged := make(map[string]json.RawMessage) + for k, v := range host { + merged[k] = v + } + for k, v := range moat { + merged[k] = v + } + + // Filter to allowlist + result := make(map[string]json.RawMessage) + for k, v := range merged { + if !allowedSettings[k] { + continue + } + result[k] = v + } + + // Add moat-only settings (only from ~/.moat/copilot/settings.json) + for k, v := range moat { + if moatOnlySettings[k] { + result[k] = v + } + } + + // Strip settings that are overridden by moat.yaml or CLI flags. + if opts.ModelOverride != "" { + delete(result, "model") + } + + if len(result) == 0 { + return nil, nil + } + + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} diff --git a/internal/providers/copilot/settings_test.go b/internal/providers/copilot/settings_test.go new file mode 100644 index 00000000..ec8d8c45 --- /dev/null +++ b/internal/providers/copilot/settings_test.go @@ -0,0 +1,499 @@ +package copilot + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestMergeSettings_HostSettingsCarriedOver(t *testing.T) { + // Create a fake home with settings.json + 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) + } + + hostSettings := map[string]any{ + "theme": "dim", + "mouse": false, + "tabs": map[string]any{"enabled": true}, + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + if result == nil { + t.Fatal("expected non-nil settings output") + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // All allowed keys should be present + for _, key := range []string{"theme", "mouse", "tabs"} { + if _, ok := got[key]; !ok { + t.Errorf("expected key %q in merged settings", key) + } + } +} + +func TestMergeSettings_DisallowedKeysFiltered(t *testing.T) { + 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) + } + + hostSettings := map[string]any{ + "theme": "dim", + "proxyUrl": "http://evil.proxy:8080", + "allowedUrls": []string{"https://example.com"}, + "deniedUrls": []string{"https://bad.com"}, + "notifications": true, + "hooks": map[string]any{"pre-commit": "echo pwned"}, + "trustedFolders": []string{"/home/user/secrets"}, + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + if result == nil { + t.Fatal("expected non-nil settings (theme is allowed)") + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // theme should be present + if _, ok := got["theme"]; !ok { + t.Error("expected theme in result") + } + + // Disallowed keys must not appear + for _, key := range []string{"proxyUrl", "allowedUrls", "deniedUrls", "notifications", "hooks", "trustedFolders"} { + if _, ok := got[key]; ok { + t.Errorf("disallowed key %q should not be in merged settings", key) + } + } +} + +func TestMergeSettings_MoatOverridesHost(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + + moatHome := t.TempDir() + t.Setenv("MOAT_HOME", moatHome) + + // Host settings + copilotDir := filepath.Join(fakeHome, ".copilot") + if err := os.MkdirAll(copilotDir, 0o755); err != nil { + t.Fatal(err) + } + hostData, _ := json.Marshal(map[string]any{"theme": "github", "mouse": true}) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), hostData, 0o600); err != nil { + t.Fatal(err) + } + + // Moat overrides + moatCopilotDir := filepath.Join(moatHome, "copilot") + if err := os.MkdirAll(moatCopilotDir, 0o755); err != nil { + t.Fatal(err) + } + moatData, _ := json.Marshal(map[string]any{"theme": "high-contrast"}) + if err := os.WriteFile(filepath.Join(moatCopilotDir, "settings.json"), moatData, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // Moat override should win + var theme string + if err := json.Unmarshal(got["theme"], &theme); err != nil { + t.Fatalf("unmarshal theme: %v", err) + } + if theme != "high-contrast" { + t.Errorf("theme = %q, want %q", theme, "high-contrast") + } + + // Host-only key should also be present + if _, ok := got["mouse"]; !ok { + t.Error("expected mouse from host settings") + } +} + +func TestMergeSettings_CLIFlagStripsModelFields(t *testing.T) { + 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) + } + + hostSettings := map[string]any{ + "model": "gpt-5.4", + "theme": "dim", + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{ + ModelOverride: "claude-opus-4.6", + }) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // model should be stripped (CLI flag wins) + if _, ok := got["model"]; ok { + t.Error("model should be stripped when ModelOverride is set") + } + + // Non-model settings should remain + if _, ok := got["theme"]; !ok { + t.Error("expected theme to remain") + } +} + +func TestMergeSettings_ModelPreservedWhenNoCLIOverride(t *testing.T) { + 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) + } + + hostSettings := map[string]any{ + "model": "gpt-5.4", + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + // No CLI overrides + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + if _, ok := got["model"]; !ok { + t.Error("model should be preserved when no CLI override") + } +} + +func TestMergeSettings_NilWhenNoFiles(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("MOAT_HOME", t.TempDir()) + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + if result != nil { + t.Errorf("expected nil when no settings files exist, got %s", result) + } +} + +func TestMergeSettings_SkipEnvVar(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("MOAT_HOME", t.TempDir()) + t.Setenv("MOAT_SKIP_HOST_COPILOT_SETTINGS", "1") + + copilotDir := filepath.Join(fakeHome, ".copilot") + if err := os.MkdirAll(copilotDir, 0o755); err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(map[string]any{"theme": "dim"}) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + if result != nil { + t.Error("expected nil when MOAT_SKIP_HOST_COPILOT_SETTINGS=1") + } +} + +func TestMergeSettings_MalformedJSONIgnored(t *testing.T) { + 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) + } + + // Write invalid JSON + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), []byte("{not valid json!!!"), 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings should not error on malformed JSON: %v", err) + } + if result != nil { + t.Errorf("expected nil for malformed JSON, got %s", result) + } +} + +func TestMergeSettings_ModelStrippedOthersPreserved(t *testing.T) { + 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) + } + + hostSettings := map[string]any{ + "model": "gpt-5.4", + "effortLevel": "high", + "contextTier": "long_context", + "theme": "dim", + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{ + ModelOverride: "claude-opus-4.6", + }) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // model should be stripped (CLI override) + if _, ok := got["model"]; ok { + t.Error("model should be stripped when ModelOverride is set") + } + + // effortLevel and contextTier pass through (no CLI override exists for them yet) + if _, ok := got["effortLevel"]; !ok { + t.Error("effortLevel should be preserved") + } + if _, ok := got["contextTier"]; !ok { + t.Error("contextTier should be preserved") + } + + // theme should always be present + if _, ok := got["theme"]; !ok { + t.Error("expected theme to remain") + } +} + +func TestMergeSettings_HostStatusLineDropped(t *testing.T) { + 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) + } + + hostSettings := map[string]any{ + "statusLine": map[string]any{ + "type": "command", + "command": "echo pwned", + }, + "theme": "dim", + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // statusLine from host must NOT appear + if _, ok := got["statusLine"]; ok { + t.Error("statusLine from host settings should be dropped (security: executes commands in container)") + } + + // theme should still be present + if _, ok := got["theme"]; !ok { + t.Error("expected theme from host settings") + } +} + +func TestMergeSettings_MoatStatusLinePreserved(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + + moatHome := t.TempDir() + t.Setenv("MOAT_HOME", moatHome) + + // Host has no statusLine + copilotDir := filepath.Join(fakeHome, ".copilot") + if err := os.MkdirAll(copilotDir, 0o755); err != nil { + t.Fatal(err) + } + hostData, _ := json.Marshal(map[string]any{"theme": "dim"}) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), hostData, 0o600); err != nil { + t.Fatal(err) + } + + // Moat settings have statusLine (explicit user opt-in) + moatCopilotDir := filepath.Join(moatHome, "copilot") + if err := os.MkdirAll(moatCopilotDir, 0o755); err != nil { + t.Fatal(err) + } + moatData, _ := json.Marshal(map[string]any{ + "statusLine": map[string]any{ + "type": "command", + "command": "/home/user/.moat/statusline.sh", + }, + }) + if err := os.WriteFile(filepath.Join(moatCopilotDir, "settings.json"), moatData, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // statusLine from moat settings SHOULD be present + if _, ok := got["statusLine"]; !ok { + t.Error("statusLine from ~/.moat/copilot/settings.json should be preserved") + } + + // theme from host should also be present + if _, ok := got["theme"]; !ok { + t.Error("expected theme from host settings") + } +} + +func TestMergeSettings_StatusLineConflictMoatWins(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + + moatHome := t.TempDir() + t.Setenv("MOAT_HOME", moatHome) + + // Host has statusLine + copilotDir := filepath.Join(fakeHome, ".copilot") + if err := os.MkdirAll(copilotDir, 0o755); err != nil { + t.Fatal(err) + } + hostData, _ := json.Marshal(map[string]any{ + "statusLine": map[string]any{ + "type": "command", + "command": "echo host-version", + }, + "theme": "dim", + }) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), hostData, 0o600); err != nil { + t.Fatal(err) + } + + // Moat also has statusLine + moatCopilotDir := filepath.Join(moatHome, "copilot") + if err := os.MkdirAll(moatCopilotDir, 0o755); err != nil { + t.Fatal(err) + } + moatData, _ := json.Marshal(map[string]any{ + "statusLine": map[string]any{ + "type": "command", + "command": "echo moat-version", + }, + }) + if err := os.WriteFile(filepath.Join(moatCopilotDir, "settings.json"), moatData, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // statusLine should be moat's version (host's is never considered) + raw, ok := got["statusLine"] + if !ok { + t.Fatal("expected statusLine from moat settings") + } + var sl map[string]any + if err := json.Unmarshal(raw, &sl); err != nil { + t.Fatalf("unmarshal statusLine: %v", err) + } + if sl["command"] != "echo moat-version" { + t.Errorf("statusLine.command = %q, want %q", sl["command"], "echo moat-version") + } +} From 8a577d0df04569000f28c374cf94b9c22baa1fac Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Tue, 14 Jul 2026 10:32:19 +0200 Subject: [PATCH 2/3] feat(copilot): honor context/effort overrides in settings passthrough Strip contextTier/effortLevel from the staged settings.json when --context/--reasoning-effort or copilot.context/copilot.reasoning_effort (added in #437) pin them, matching the existing model behavior. PrepareContainer now reads the overrides from provider.PrepareOpts instead of the copilot CLI's package globals, so 'moat run' with agent: copilot applies moat.yaml precedence too. The copilot command's ConfigureAgent writes flag-resolved values back into the config, which the run manager threads through setupCopilotStaging. Document the settings passthrough in the Copilot guide. --- docs/content/guides/17-copilot.md | 28 ++++++ internal/provider/credential.go | 9 ++ internal/providers/copilot/agent.go | 4 +- internal/providers/copilot/agent_test.go | 94 +++++++++++++++++++++ internal/providers/copilot/cli.go | 16 +++- internal/providers/copilot/provider_test.go | 31 +++++++ internal/providers/copilot/settings.go | 10 ++- internal/providers/copilot/settings_test.go | 80 +++++++++++++++++- internal/run/manager_agentinit.go | 21 ++++- internal/run/manager_agentinit_test.go | 56 ++++++++++++ internal/run/manager_create.go | 2 +- 11 files changed, 340 insertions(+), 11 deletions(-) create mode 100644 internal/providers/copilot/agent_test.go diff --git a/docs/content/guides/17-copilot.md b/docs/content/guides/17-copilot.md index c569d062..5ae01c24 100644 --- a/docs/content/guides/17-copilot.md +++ b/docs/content/guides/17-copilot.md @@ -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.json` into the container: + +`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-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`. diff --git a/internal/provider/credential.go b/internal/provider/credential.go index e354c46c..11fd7204 100644 --- a/internal/provider/credential.go +++ b/internal/provider/credential.go @@ -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. diff --git a/internal/providers/copilot/agent.go b/internal/providers/copilot/agent.go index a013644b..3c7b0f62 100644 --- a/internal/providers/copilot/agent.go +++ b/internal/providers/copilot/agent.go @@ -34,7 +34,9 @@ func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOp // Merge host settings (~/.copilot/settings.json) with moat overrides. mergeOpts := MergeOpts{ - ModelOverride: copilotResolvedModel, + 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", diff --git a/internal/providers/copilot/agent_test.go b/internal/providers/copilot/agent_test.go new file mode 100644 index 00000000..384bdd1c --- /dev/null +++ b/internal/providers/copilot/agent_test.go @@ -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) + } + } +} diff --git a/internal/providers/copilot/cli.go b/internal/providers/copilot/cli.go index d66913b1..5d70df4d 100644 --- a/internal/providers/copilot/cli.go +++ b/internal/providers/copilot/cli.go @@ -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 diff --git a/internal/providers/copilot/provider_test.go b/internal/providers/copilot/provider_test.go index 419c3567..092c7201 100644 --- a/internal/providers/copilot/provider_test.go +++ b/internal/providers/copilot/provider_test.go @@ -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 diff --git a/internal/providers/copilot/settings.go b/internal/providers/copilot/settings.go index 44df61d4..55720a1e 100644 --- a/internal/providers/copilot/settings.go +++ b/internal/providers/copilot/settings.go @@ -90,7 +90,9 @@ func loadSettingsFile(path string) (map[string]json.RawMessage, error) { // These correspond to CLI flags or moat.yaml copilot.* fields that are passed // as --flags to the Copilot CLI (which naturally override settings.json). type MergeOpts struct { - ModelOverride string + ModelOverride string + ContextOverride string + EffortOverride string } // MergeSettings builds the container settings.json by: @@ -148,6 +150,12 @@ func MergeSettings(opts MergeOpts) ([]byte, error) { if opts.ModelOverride != "" { delete(result, "model") } + if opts.ContextOverride != "" { + delete(result, "contextTier") + } + if opts.EffortOverride != "" { + delete(result, "effortLevel") + } if len(result) == 0 { return nil, nil diff --git a/internal/providers/copilot/settings_test.go b/internal/providers/copilot/settings_test.go index ec8d8c45..fd8967b5 100644 --- a/internal/providers/copilot/settings_test.go +++ b/internal/providers/copilot/settings_test.go @@ -326,7 +326,7 @@ func TestMergeSettings_ModelStrippedOthersPreserved(t *testing.T) { t.Error("model should be stripped when ModelOverride is set") } - // effortLevel and contextTier pass through (no CLI override exists for them yet) + // effortLevel and contextTier pass through when their overrides are unset if _, ok := got["effortLevel"]; !ok { t.Error("effortLevel should be preserved") } @@ -340,6 +340,84 @@ func TestMergeSettings_ModelStrippedOthersPreserved(t *testing.T) { } } +func TestMergeSettings_ContextTierStrippedWhenOverridden(t *testing.T) { + 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) + } + + hostSettings := map[string]any{ + "contextTier": "long_context", + "theme": "dim", + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{ + ContextOverride: "default", + }) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + if _, ok := got["contextTier"]; ok { + t.Error("contextTier should be stripped when ContextOverride is set") + } + if _, ok := got["theme"]; !ok { + t.Error("expected theme to remain") + } +} + +func TestMergeSettings_EffortLevelStrippedWhenOverridden(t *testing.T) { + 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) + } + + hostSettings := map[string]any{ + "effortLevel": "high", + "theme": "dim", + } + data, _ := json.Marshal(hostSettings) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{ + EffortOverride: "low", + }) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + if _, ok := got["effortLevel"]; ok { + t.Error("effortLevel should be stripped when EffortOverride is set") + } + if _, ok := got["theme"]; !ok { + t.Error("expected theme to remain") + } +} + func TestMergeSettings_HostStatusLineDropped(t *testing.T) { fakeHome := t.TempDir() t.Setenv("HOME", fakeHome) diff --git a/internal/run/manager_agentinit.go b/internal/run/manager_agentinit.go index abe1bf37..0ff1f12a 100644 --- a/internal/run/manager_agentinit.go +++ b/internal/run/manager_agentinit.go @@ -150,7 +150,7 @@ 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, openCredStore func() (*credential.FileStore, error)) (*provider.ContainerConfig, error) { +func (m *Manager) setupCopilotStaging(ctx context.Context, copilotProvider provider.AgentProvider, opts Options, 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 { @@ -158,10 +158,23 @@ func (m *Manager) setupCopilotStaging(ctx context.Context, copilotProvider provi } } + // Copilot values pinned by moat.yaml or CLI flags (the copilot command's + // ConfigureAgent writes flag-resolved values back into the config) strip + // the matching keys from the staged settings.json. + var copilotModel, copilotContext, copilotEffort string + if opts.Config != nil { + copilotModel = opts.Config.Copilot.Model + copilotContext = opts.Config.Copilot.Context + copilotEffort = opts.Config.Copilot.ReasoningEffort + } + copilotConfig, prepErr := copilotProvider.PrepareContainer(ctx, provider.PrepareOpts{ - Credential: copilotCred, - ContainerHome: containerHome, - RuntimeContext: renderedContext, + Credential: copilotCred, + ContainerHome: containerHome, + RuntimeContext: renderedContext, + CopilotModel: copilotModel, + CopilotContext: copilotContext, + CopilotReasoningEffort: copilotEffort, }) if prepErr != nil { return nil, fmt.Errorf("preparing Copilot container config: %w", prepErr) diff --git a/internal/run/manager_agentinit_test.go b/internal/run/manager_agentinit_test.go index 7b017733..911f60b3 100644 --- a/internal/run/manager_agentinit_test.go +++ b/internal/run/manager_agentinit_test.go @@ -2,10 +2,13 @@ package run import ( "context" + "errors" "strings" "testing" "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/credential" + "github.com/majorcontext/moat/internal/provider" ) func TestSetupCodexStaging_UnknownGrant(t *testing.T) { @@ -31,6 +34,59 @@ func TestSetupCodexStaging_GrantNotDeclared(t *testing.T) { } } +// captureAgentProvider records the PrepareOpts passed to PrepareContainer. +// The embedded interface is nil; only PrepareContainer may be called. +type captureAgentProvider struct { + provider.AgentProvider + got provider.PrepareOpts +} + +func (c *captureAgentProvider) PrepareContainer(ctx context.Context, opts provider.PrepareOpts) (*provider.ContainerConfig, error) { + c.got = opts + return &provider.ContainerConfig{}, nil +} + +func TestSetupCopilotStaging_PassesCopilotOverrides(t *testing.T) { + m := &Manager{} + cfg := &config.Config{ + Copilot: config.CopilotConfig{ + Model: "claude-opus-4.6", + Context: "long_context", + ReasoningEffort: "high", + }, + } + fake := &captureAgentProvider{} + openStore := func() (*credential.FileStore, error) { return nil, errors.New("no store") } + + if _, err := m.setupCopilotStaging(context.Background(), fake, Options{Config: cfg}, "/home/moatuser", "", openStore); err != nil { + t.Fatalf("setupCopilotStaging: %v", err) + } + + if fake.got.CopilotModel != "claude-opus-4.6" { + t.Errorf("CopilotModel = %q, want claude-opus-4.6", fake.got.CopilotModel) + } + if fake.got.CopilotContext != "long_context" { + t.Errorf("CopilotContext = %q, want long_context", fake.got.CopilotContext) + } + if fake.got.CopilotReasoningEffort != "high" { + t.Errorf("CopilotReasoningEffort = %q, want high", fake.got.CopilotReasoningEffort) + } +} + +func TestSetupCopilotStaging_NilConfigNoOverrides(t *testing.T) { + m := &Manager{} + fake := &captureAgentProvider{} + openStore := func() (*credential.FileStore, error) { return nil, errors.New("no store") } + + if _, err := m.setupCopilotStaging(context.Background(), fake, Options{}, "/home/moatuser", "", openStore); err != nil { + t.Fatalf("setupCopilotStaging: %v", err) + } + + if fake.got.CopilotModel != "" || fake.got.CopilotContext != "" || fake.got.CopilotReasoningEffort != "" { + t.Errorf("expected empty copilot overrides with nil config, got %+v", fake.got) + } +} + func TestBuildLocalMCPConfig_NoSpecs(t *testing.T) { out, err := buildLocalMCPConfig("codex", nil, nil) if err != nil || out != nil { diff --git a/internal/run/manager_create.go b/internal/run/manager_create.go index cbff94e0..b85c6c20 100644 --- a/internal/run/manager_create.go +++ b/internal/run/manager_create.go @@ -1449,7 +1449,7 @@ region = %s return nil, fmt.Errorf("copilot provider not registered") } - cfg, stageErr := m.setupCopilotStaging(ctx, copilotProvider, containerHome, renderedContext, openCredStore) + cfg, stageErr := m.setupCopilotStaging(ctx, copilotProvider, opts, containerHome, renderedContext, openCredStore) if stageErr != nil { cleanupDaemonRun() cleanupSSH(sshServer) From 87897f2a49b27808e6cf233ecdbf7392ff976db1 Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Tue, 14 Jul 2026 09:51:36 +0000 Subject: [PATCH 3/3] fix(copilot): honor Copilot settings home Read host Copilot settings from COPILOT_HOME when configured and normalize legacy colorMode values to the current theme key before writing container settings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/content/guides/17-copilot.md | 4 +- internal/providers/copilot/agent.go | 3 +- internal/providers/copilot/settings.go | 26 ++- internal/providers/copilot/settings_test.go | 184 ++++++++++++++++++++ 5 files changed, 213 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f88bb1..67c498a0 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 -- **Copilot CLI settings passthrough** — `moat copilot` now carries over user preferences from the host's `~/.copilot/settings.json` (contextTier, effortLevel, footer, includeCoAuthoredBy, model, mouse, subagents, tabs, theme). 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)) +- **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)) diff --git a/docs/content/guides/17-copilot.md b/docs/content/guides/17-copilot.md index 5ae01c24..581386ed 100644 --- a/docs/content/guides/17-copilot.md +++ b/docs/content/guides/17-copilot.md @@ -75,11 +75,11 @@ copilot: ## User settings -Moat carries these settings from your host's `~/.copilot/settings.json` into the container: +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`. +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 diff --git a/internal/providers/copilot/agent.go b/internal/providers/copilot/agent.go index 3c7b0f62..46604db0 100644 --- a/internal/providers/copilot/agent.go +++ b/internal/providers/copilot/agent.go @@ -32,7 +32,8 @@ func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOp return nil, fmt.Errorf("writing copilot config: %w", err) } - // Merge host settings (~/.copilot/settings.json) with moat overrides. + // Merge host settings (COPILOT_HOME/settings.json or + // ~/.copilot/settings.json) with moat overrides. mergeOpts := MergeOpts{ ModelOverride: opts.CopilotModel, ContextOverride: opts.CopilotContext, diff --git a/internal/providers/copilot/settings.go b/internal/providers/copilot/settings.go index 55720a1e..c3d616b4 100644 --- a/internal/providers/copilot/settings.go +++ b/internal/providers/copilot/settings.go @@ -47,8 +47,13 @@ var moatOnlySettings = map[string]bool{ "statusLine": true, } -// loadHostSettings reads ~/.copilot/settings.json from the host. +// loadHostSettings reads Copilot's host user settings from COPILOT_HOME when +// set, or ~/.copilot/settings.json otherwise. func loadHostSettings() (map[string]json.RawMessage, error) { + if copilotHome := os.Getenv("COPILOT_HOME"); copilotHome != "" { + return loadSettingsFile(filepath.Join(copilotHome, "settings.json")) + } + homeDir, err := os.UserHomeDir() if err != nil { log.Debug("cannot determine home directory, skipping host Copilot settings", @@ -86,6 +91,21 @@ func loadSettingsFile(path string) (map[string]json.RawMessage, error) { return raw, nil } +// normalizeSettingsAliases maps legacy Copilot setting names to their current +// keys before source precedence is applied. +func normalizeSettingsAliases(raw map[string]json.RawMessage) map[string]json.RawMessage { + if raw == nil { + return nil + } + if _, ok := raw["theme"]; !ok { + if v, ok := raw["colorMode"]; ok { + raw["theme"] = v + } + } + delete(raw, "colorMode") + return raw +} + // MergeOpts provides override values that take precedence over settings.json. // These correspond to CLI flags or moat.yaml copilot.* fields that are passed // as --flags to the Copilot CLI (which naturally override settings.json). @@ -96,7 +116,7 @@ type MergeOpts struct { } // MergeSettings builds the container settings.json by: -// 1. Reading ~/.copilot/settings.json (host defaults) +// 1. Reading COPILOT_HOME/settings.json or ~/.copilot/settings.json (host defaults) // 2. Reading /copilot/settings.json (moat user overrides) // 3. Filtering to the allowlist // 4. Stripping settings overridden by moat.yaml/CLI flags @@ -111,11 +131,13 @@ func MergeSettings(opts MergeOpts) ([]byte, error) { if err != nil { return nil, err } + host = normalizeSettingsAliases(host) moat, err := loadMoatSettings() if err != nil { return nil, err } + moat = normalizeSettingsAliases(moat) if host == nil && moat == nil { return nil, nil diff --git a/internal/providers/copilot/settings_test.go b/internal/providers/copilot/settings_test.go index fd8967b5..602ec3db 100644 --- a/internal/providers/copilot/settings_test.go +++ b/internal/providers/copilot/settings_test.go @@ -49,6 +49,190 @@ func TestMergeSettings_HostSettingsCarriedOver(t *testing.T) { } } +func TestMergeSettings_UsesCopilotHomeWhenSet(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("MOAT_HOME", t.TempDir()) + + homeCopilotDir := filepath.Join(fakeHome, ".copilot") + if err := os.MkdirAll(homeCopilotDir, 0o755); err != nil { + t.Fatal(err) + } + homeData, _ := json.Marshal(map[string]any{"theme": "dim"}) + if err := os.WriteFile(filepath.Join(homeCopilotDir, "settings.json"), homeData, 0o600); err != nil { + t.Fatal(err) + } + + copilotHome := t.TempDir() + t.Setenv("COPILOT_HOME", copilotHome) + copilotHomeData, _ := json.Marshal(map[string]any{"theme": "high-contrast"}) + if err := os.WriteFile(filepath.Join(copilotHome, "settings.json"), copilotHomeData, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + var theme string + if err := json.Unmarshal(got["theme"], &theme); err != nil { + t.Fatalf("unmarshal theme: %v", err) + } + if theme != "high-contrast" { + t.Errorf("theme = %q, want %q", theme, "high-contrast") + } +} + +func TestMergeSettings_CopilotHomeDoesNotFallBackToDefaultHome(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + t.Setenv("MOAT_HOME", t.TempDir()) + + homeCopilotDir := filepath.Join(fakeHome, ".copilot") + if err := os.MkdirAll(homeCopilotDir, 0o755); err != nil { + t.Fatal(err) + } + homeData, _ := json.Marshal(map[string]any{"theme": "dim"}) + if err := os.WriteFile(filepath.Join(homeCopilotDir, "settings.json"), homeData, 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv("COPILOT_HOME", t.TempDir()) + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + if result != nil { + t.Errorf("expected nil when COPILOT_HOME has no settings file, got %s", result) + } +} + +func TestMergeSettings_LegacyColorModeNormalizedToTheme(t *testing.T) { + 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, _ := json.Marshal(map[string]any{"colorMode": "high-contrast"}) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + if _, ok := got["colorMode"]; ok { + t.Error("legacy colorMode should be normalized to theme") + } + var theme string + if err := json.Unmarshal(got["theme"], &theme); err != nil { + t.Fatalf("unmarshal theme: %v", err) + } + if theme != "high-contrast" { + t.Errorf("theme = %q, want %q", theme, "high-contrast") + } +} + +func TestMergeSettings_ThemePreferredOverLegacyColorMode(t *testing.T) { + 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, _ := json.Marshal(map[string]any{ + "theme": "github", + "colorMode": "dim", + }) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), data, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + var theme string + if err := json.Unmarshal(got["theme"], &theme); err != nil { + t.Fatalf("unmarshal theme: %v", err) + } + if theme != "github" { + t.Errorf("theme = %q, want %q", theme, "github") + } + if _, ok := got["colorMode"]; ok { + t.Error("legacy colorMode should not be written when theme is present") + } +} + +func TestMergeSettings_MoatLegacyColorModeOverridesHostTheme(t *testing.T) { + fakeHome := t.TempDir() + t.Setenv("HOME", fakeHome) + + moatHome := t.TempDir() + t.Setenv("MOAT_HOME", moatHome) + + copilotDir := filepath.Join(fakeHome, ".copilot") + if err := os.MkdirAll(copilotDir, 0o755); err != nil { + t.Fatal(err) + } + hostData, _ := json.Marshal(map[string]any{"theme": "github"}) + if err := os.WriteFile(filepath.Join(copilotDir, "settings.json"), hostData, 0o600); err != nil { + t.Fatal(err) + } + + moatCopilotDir := filepath.Join(moatHome, "copilot") + if err := os.MkdirAll(moatCopilotDir, 0o755); err != nil { + t.Fatal(err) + } + moatData, _ := json.Marshal(map[string]any{"colorMode": "dim"}) + if err := os.WriteFile(filepath.Join(moatCopilotDir, "settings.json"), moatData, 0o600); err != nil { + t.Fatal(err) + } + + result, err := MergeSettings(MergeOpts{}) + if err != nil { + t.Fatalf("MergeSettings: %v", err) + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(result, &got); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + var theme string + if err := json.Unmarshal(got["theme"], &theme); err != nil { + t.Fatalf("unmarshal theme: %v", err) + } + if theme != "dim" { + t.Errorf("theme = %q, want %q", theme, "dim") + } +} + func TestMergeSettings_DisallowedKeysFiltered(t *testing.T) { fakeHome := t.TempDir() t.Setenv("HOME", fakeHome)