From 50822f193ba5eef94f000297c221f75e956f9555 Mon Sep 17 00:00:00 2001 From: GTC2080 <140309575+GTC2080@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:17:35 +1000 Subject: [PATCH] fix(desktop): sync sandbox settings with project overrides --- desktop/go.mod | 2 +- desktop/settings_app.go | 121 ++++++++++++++++++++++++++++++----- desktop/settings_app_test.go | 75 ++++++++++++++++++++++ internal/config/render.go | 24 +++++++ 4 files changed, 206 insertions(+), 16 deletions(-) diff --git a/desktop/go.mod b/desktop/go.mod index e3b78da68..22fd9a7b5 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -14,6 +14,7 @@ require reasonix v0.0.0 require ( aead.dev/minisign v0.3.0 fyne.io/systray v1.12.2 + github.com/BurntSushi/toml v1.6.0 github.com/godbus/dbus/v5 v5.2.2 github.com/minio/selfupdate v0.6.0 github.com/wailsapp/wails/v2 v2.12.0 @@ -24,7 +25,6 @@ require ( require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect - github.com/BurntSushi/toml v1.6.0 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect diff --git a/desktop/settings_app.go b/desktop/settings_app.go index c721ce9dc..405a9bc48 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/BurntSushi/toml" "reasonix/internal/agent" "reasonix/internal/boot" "reasonix/internal/config" @@ -431,11 +432,9 @@ func (a *App) Settings() SettingsView { } } ctrl := a.activeCtrl() - bash := cfg.Sandbox.Bash - if bash == "" { - bash = "enforce" - } - shell := cfg.Tools.Shell.Prefer + sandboxCfg := a.sandboxConfigForView(cfg, cfgPath) + bash := sandboxCfg.BashMode() + shell := sandboxCfg.Tools.Shell.Prefer if shell == "" { shell = "auto" } @@ -454,8 +453,8 @@ func (a *App) Settings() SettingsView { Deny: nonNil(cfg.Permissions.Deny), }, Sandbox: SandboxView{ - Bash: bash, Network: cfg.Sandbox.Network, - WorkspaceRoot: cfg.Sandbox.WorkspaceRoot, AllowWrite: nonNil(cfg.Sandbox.AllowWrite), + Bash: bash, Network: sandboxCfg.Sandbox.Network, + WorkspaceRoot: sandboxCfg.Sandbox.WorkspaceRoot, AllowWrite: nonNil(sandboxCfg.Sandbox.AllowWrite), Shell: shell, }, Network: NetworkView{ @@ -698,6 +697,90 @@ func (a *App) loadDesktopUserConfigForView() (*config.Config, string, error) { return legacyCfg, userPath, nil } +func (a *App) loadSandboxConfigForEdit() (*config.Config, string, error) { + userCfg, userPath, err := a.loadDesktopUserConfigForEdit() + if err != nil { + return nil, "", err + } + projectPath := projectConfigPathForRoot(a.activeWorkspaceRoot()) + if projectPath == "" || sameConfigPath(projectPath, userPath) { + return userCfg, userPath, nil + } + hasProjectOverride, err := projectDefinesSandboxSettings(projectPath) + if err != nil { + return nil, "", err + } + if !hasProjectOverride { + return userCfg, userPath, nil + } + return config.LoadForEdit(projectPath), projectPath, nil +} + +func (a *App) sandboxConfigForView(userCfg *config.Config, userPath string) *config.Config { + if userCfg == nil { + return userCfg + } + projectPath := projectConfigPathForRoot(a.activeWorkspaceRoot()) + if projectPath == "" || sameConfigPath(projectPath, userPath) { + return userCfg + } + if _, err := os.Stat(projectPath); err != nil { + return userCfg + } + var projectCfg config.Config + meta, err := toml.DecodeFile(projectPath, &projectCfg) + if err != nil { + return userCfg + } + merged := *userCfg + if meta.IsDefined("sandbox", "bash") { + merged.Sandbox.Bash = projectCfg.Sandbox.Bash + } + if meta.IsDefined("sandbox", "network") { + merged.Sandbox.Network = projectCfg.Sandbox.Network + } + if meta.IsDefined("sandbox", "workspace_root") { + merged.Sandbox.WorkspaceRoot = projectCfg.Sandbox.WorkspaceRoot + } + if meta.IsDefined("sandbox", "allow_write") { + merged.Sandbox.AllowWrite = projectCfg.Sandbox.AllowWrite + } + if meta.IsDefined("tools", "shell", "prefer") { + merged.Tools.Shell.Prefer = projectCfg.Tools.Shell.Prefer + } + if meta.IsDefined("tools", "shell", "path") { + merged.Tools.Shell.Path = projectCfg.Tools.Shell.Path + } + return &merged +} + +func projectDefinesSandboxSettings(path string) (bool, error) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + var parsed config.Config + meta, err := toml.DecodeFile(path, &parsed) + if err != nil { + return false, fmt.Errorf("config %s: %w", path, err) + } + for _, keys := range [][]string{ + {"sandbox", "bash"}, + {"sandbox", "network"}, + {"sandbox", "workspace_root"}, + {"sandbox", "allow_write"}, + {"tools", "shell", "prefer"}, + {"tools", "shell", "path"}, + } { + if meta.IsDefined(keys...) { + return true, nil + } + } + return false, nil +} + func (a *App) migrateLegacyBotConfigToUser(userCfg *config.Config, userPath string) error { if userCfg == nil { return nil @@ -1597,14 +1680,22 @@ func (a *App) RemovePermissionRule(list, rule string) error { // SetSandbox updates the bash sandbox mode, network egress, and write roots. func (a *App) SetSandbox(bash string, network bool, workspaceRoot string, allowWrite []string, shell string) error { - return a.applyConfigChange(func(c *config.Config) error { - c.Sandbox.Bash = bash - c.Sandbox.Network = network - c.Sandbox.WorkspaceRoot = strings.TrimSpace(workspaceRoot) - c.Sandbox.AllowWrite = trimList(allowWrite) - c.Tools.Shell.Prefer = strings.TrimSpace(shell) - return nil - }) + if err := a.ensureActiveTabRebuildAllowed("settings"); err != nil { + return err + } + cfg, path, err := a.loadSandboxConfigForEdit() + if err != nil { + return err + } + cfg.Sandbox.Bash = bash + cfg.Sandbox.Network = network + cfg.Sandbox.WorkspaceRoot = strings.TrimSpace(workspaceRoot) + cfg.Sandbox.AllowWrite = trimList(allowWrite) + cfg.Tools.Shell.Prefer = strings.TrimSpace(shell) + if err := cfg.SaveTo(path); err != nil { + return err + } + return a.rebuild() } // SetNetwork updates ordinary outbound proxy settings. diff --git a/desktop/settings_app_test.go b/desktop/settings_app_test.go index e2c770dea..4f28903f1 100644 --- a/desktop/settings_app_test.go +++ b/desktop/settings_app_test.go @@ -465,6 +465,81 @@ func TestSaveProviderPreservesExplicitEmptyVisionModels(t *testing.T) { } } +func TestSettingsSandboxReflectsProjectOverride(t *testing.T) { + isolateDesktopUserDirs(t) + + userCfg := config.LoadForEdit(config.UserConfigPath()) + userCfg.Sandbox.Bash = "off" + userCfg.Sandbox.Network = true + if err := userCfg.SaveTo(config.UserConfigPath()); err != nil { + t.Fatalf("SaveTo user config: %v", err) + } + + project := robustTempDir(t) + projectPath := filepath.Join(project, "reasonix.toml") + if err := os.WriteFile(projectPath, []byte(` +[sandbox] +bash = "enforce" +network = true +`), 0o644); err != nil { + t.Fatalf("write project config: %v", err) + } + t.Chdir(project) + + got := NewApp().Settings() + if got.Sandbox.Bash != "enforce" { + t.Fatalf("Settings sandbox bash = %q, want project override enforce", got.Sandbox.Bash) + } +} + +func TestSetSandboxUpdatesProjectOverrideConfig(t *testing.T) { + isolateDesktopUserDirs(t) + + project := robustTempDir(t) + projectPath := filepath.Join(project, "reasonix.toml") + if err := os.WriteFile(projectPath, []byte(` +[sandbox] +bash = "enforce" +network = true +`), 0o644); err != nil { + t.Fatalf("write project config: %v", err) + } + t.Chdir(project) + + app := NewApp() + if err := app.SetSandbox("off", false, "custom-root", []string{" /tmp/a ", "", "/tmp/b"}, "powershell"); err != nil { + t.Fatalf("SetSandbox: %v", err) + } + + projectCfg := config.LoadForEdit(projectPath) + if got := projectCfg.BashMode(); got != "off" { + t.Fatalf("project sandbox bash mode = %q, want off", got) + } + if projectCfg.Sandbox.Network { + t.Fatal("project sandbox network = true, want false") + } + if projectCfg.Sandbox.WorkspaceRoot != "custom-root" { + t.Fatalf("project workspace root = %q, want custom-root", projectCfg.Sandbox.WorkspaceRoot) + } + if want := []string{"/tmp/a", "/tmp/b"}; !reflect.DeepEqual(projectCfg.Sandbox.AllowWrite, want) { + t.Fatalf("project allow_write = %v, want %v", projectCfg.Sandbox.AllowWrite, want) + } + if projectCfg.Tools.Shell.Prefer != "powershell" { + t.Fatalf("project shell prefer = %q, want powershell", projectCfg.Tools.Shell.Prefer) + } + if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) { + t.Fatalf("SetSandbox should update the project override without creating user config, stat err = %v", err) + } + + effective, err := config.LoadForRoot(project) + if err != nil { + t.Fatalf("LoadForRoot: %v", err) + } + if got := effective.BashMode(); got != "off" { + t.Fatalf("effective sandbox bash mode = %q, want off", got) + } +} + func TestOfficialMimoAPITemplateRemoved(t *testing.T) { if entries, keyEnv, err := officialProviderTemplate("mimo-api", "en"); err == nil { t.Fatalf("officialProviderTemplate(mimo-api) = entries=%v key=%q nil error, want unknown template", entries, keyEnv) diff --git a/internal/config/render.go b/internal/config/render.go index 58ac9e945..f1fe49899 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -333,6 +333,18 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { b.WriteString("[tools.background_jobs]\n") fmt.Fprintf(&b, "stalled_warning_seconds = %d # warn once per background job after this many quiet seconds; 0 disables\n\n", c.BackgroundJobStalledWarningSeconds()) + b.WriteString("[tools.shell]\n") + prefer := strings.TrimSpace(c.Tools.Shell.Prefer) + if prefer == "" { + prefer = "auto" + } + fmt.Fprintf(&b, "prefer = %q # auto|bash|powershell|pwsh\n", prefer) + if strings.TrimSpace(c.Tools.Shell.Path) != "" { + fmt.Fprintf(&b, "path = %q # optional absolute shell executable override\n\n", c.Tools.Shell.Path) + } else { + b.WriteString("# path = \"/usr/bin/bash\" # optional absolute shell executable override\n\n") + } + renderLSPConfig(&b, c.LSP) b.WriteString("[skills]\n") @@ -767,6 +779,18 @@ func RenderTOMLProjectDelta(c *Config) string { } } + // [tools.shell] + if !reflect.DeepEqual(c.Tools.Shell, d.Tools.Shell) { + b.WriteString("[tools.shell]\n") + if prefer := strings.TrimSpace(c.Tools.Shell.Prefer); prefer != "" { + fmt.Fprintf(&b, "prefer = %q\n", prefer) + } + if path := strings.TrimSpace(c.Tools.Shell.Path); path != "" { + fmt.Fprintf(&b, "path = %q\n", path) + } + b.WriteString("\n") + } + // [lsp] if !reflect.DeepEqual(c.LSP, d.LSP) { renderLSPConfig(&b, c.LSP)