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 desktop/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
121 changes: 106 additions & 15 deletions desktop/settings_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/BurntSushi/toml"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/config"
Expand Down Expand Up @@ -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"
}
Expand All @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions desktop/settings_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions internal/config/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading