From 64f6cdf5602a75e83aeb521af8db3607b63cf6e7 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 12 Mar 2026 14:31:26 +0300 Subject: [PATCH] refactor: rename Permissions.SkipRequests to Overrides().SkipPermissionRequests --- internal/app/app.go | 2 +- internal/backend/backend.go | 9 +- internal/cmd/root.go | 5 +- internal/cmd/server.go | 2 +- internal/config/config.go | 3 +- internal/config/load.go | 9 ++ internal/config/scope.go | 18 ++++ internal/config/store.go | 38 +++++++-- internal/config/store_test.go | 152 ++++++++++++++++++++++++++++++++++ 9 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 internal/config/store_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 20ff19dedcaef4d0cbcddf268b0f2e1ac7b3b3d4..32754c6251fdd5c97d848c86d3cfbd489c9d94d2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -81,7 +81,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er messages := message.NewService(q) files := history.NewService(q, conn) cfg := store.Config() - skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests + skipPermissionsRequests := store.Overrides().SkipPermissionRequests var allowedTools []string if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil { allowedTools = cfg.Permissions.AllowedTools diff --git a/internal/backend/backend.go b/internal/backend/backend.go index d4d9dfdc8be730b351fffd88593573ceab174bbe..f14e5b7229939f5b9af11047cec7bb68a5e59cab 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -95,10 +95,7 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err) } - if cfg.Config().Permissions == nil { - cfg.Config().Permissions = &config.Permissions{} - } - cfg.Config().Permissions.SkipRequests = args.YOLO + cfg.Overrides().SkipPermissionRequests = args.YOLO if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil { return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err) @@ -140,7 +137,7 @@ func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Works Path: args.Path, DataDir: cfg.Config().Options.DataDirectory, Debug: cfg.Config().Options.Debug, - YOLO: cfg.Config().Permissions.SkipRequests, + YOLO: cfg.Overrides().SkipPermissionRequests, Config: cfg.Config(), Env: args.Env, } @@ -199,7 +196,7 @@ func workspaceToProto(ws *Workspace) proto.Workspace { return proto.Workspace{ ID: ws.ID, Path: ws.Path, - YOLO: cfg.Permissions != nil && cfg.Permissions.SkipRequests, + YOLO: ws.Cfg.Overrides().SkipPermissionRequests, DataDir: cfg.Options.DataDirectory, Debug: cfg.Options.Debug, Config: cfg, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 7cb101c4e035558615288e9e446d4ddecd4ab788..b5eea1cceef1ca0c4e97187093671900cde2d8dc 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -240,11 +240,8 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } + store.Overrides().SkipPermissionRequests = yolo cfg := store.Config() - if cfg.Permissions == nil { - cfg.Permissions = &config.Permissions{} - } - cfg.Permissions.SkipRequests = yolo if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil { return nil, err diff --git a/internal/cmd/server.go b/internal/cmd/server.go index ce20d20b282f5ee364b886699c32d07594ca03fd..9311d2f3895299c6f30d70ebc9e2b2a02528b05e 100644 --- a/internal/cmd/server.go +++ b/internal/cmd/server.go @@ -34,7 +34,7 @@ var serverCmd = &cobra.Command{ return fmt.Errorf("failed to get debug flag: %v", err) } - cfg, err := config.Load("", dataDir, debug) + cfg, err := config.Load(config.GlobalWorkspaceDir(), dataDir, debug) if err != nil { return fmt.Errorf("failed to load configuration: %v", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 8e9b3f0fb7349f4b911c9a6c41fc3e3890f3f19e..12e09c14e82ce044b14d30e5404715ada622783a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -212,8 +212,7 @@ func (c Completions) Limits() (depth, items int) { } type Permissions struct { - AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"` // Tools that don't require permission prompts - SkipRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode) + AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"` } type TrailerStyle string diff --git a/internal/config/load.go b/internal/config/load.go index 4c42fc232fbeaaa6f4d40ba597ee7299f17111df..967204c5ac15913fbe563733edbace59f49f1993 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -792,6 +792,15 @@ func GlobalConfigData() string { return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName)) } +// GlobalWorkspaceDir returns the path to the global server workspace +// directory. This directory acts as a meta-workspace for the server +// process, giving it a real workingDir so that config loading, scoped +// writes, and provider resolution behave identically to project +// workspaces. +func GlobalWorkspaceDir() string { + return filepath.Dir(GlobalConfigData()) +} + func assignIfNil[T any](ptr **T, val T) { if *ptr == nil { *ptr = &val diff --git a/internal/config/scope.go b/internal/config/scope.go index 971ce32c3ed662dd0d0627c4f1c858372f3b4514..89f7cb181f2750995450a3384132252ccf9603cf 100644 --- a/internal/config/scope.go +++ b/internal/config/scope.go @@ -1,5 +1,7 @@ package config +import "fmt" + // Scope determines which config file is targeted for read/write operations. type Scope int @@ -9,3 +11,19 @@ const ( // ScopeWorkspace targets the workspace config (.crush/crush.json). ScopeWorkspace ) + +// String returns a human-readable label for the scope. +func (s Scope) String() string { + switch s { + case ScopeGlobal: + return "global" + case ScopeWorkspace: + return "workspace" + default: + return fmt.Sprintf("Scope(%d)", int(s)) + } +} + +// ErrNoWorkspaceConfig is returned when a workspace-scoped write is +// attempted on a ConfigStore that has no workspace config path. +var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured") diff --git a/internal/config/store.go b/internal/config/store.go index 4dfe6130bc23007ec3df12e5a88cb53bc3ad5a2d..f15d75c00f2e9792a251b34ad847bb87f1e40764 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -18,6 +18,13 @@ import ( "github.com/tidwall/sjson" ) +// RuntimeOverrides holds per-session settings that are never persisted to +// disk. They are applied on top of the loaded Config and survive only for +// the lifetime of the process (or workspace). +type RuntimeOverrides struct { + SkipPermissionRequests bool +} + // ConfigStore is the single entry point for all config access. It owns the // pure-data Config, runtime state (working directory, resolver, known // providers), and persistence to both global and workspace config files. @@ -28,6 +35,7 @@ type ConfigStore struct { globalDataPath string // ~/.local/share/crush/crush.json workspacePath string // .crush/crush.json knownProviders []catwalk.Provider + overrides RuntimeOverrides } // Config returns the pure-data config struct (read-only after load). @@ -63,20 +71,32 @@ func (s *ConfigStore) SetupAgents() { s.config.SetupAgents() } +// Overrides returns the runtime overrides for this store. +func (s *ConfigStore) Overrides() *RuntimeOverrides { + return &s.overrides +} + // configPath returns the file path for the given scope. -func (s *ConfigStore) configPath(scope Scope) string { +func (s *ConfigStore) configPath(scope Scope) (string, error) { switch scope { case ScopeWorkspace: - return s.workspacePath + if s.workspacePath == "" { + return "", ErrNoWorkspaceConfig + } + return s.workspacePath, nil default: - return s.globalDataPath + return s.globalDataPath, nil } } // HasConfigField checks whether a key exists in the config file for the given // scope. func (s *ConfigStore) HasConfigField(scope Scope, key string) bool { - data, err := os.ReadFile(s.configPath(scope)) + path, err := s.configPath(scope) + if err != nil { + return false + } + data, err := os.ReadFile(path) if err != nil { return false } @@ -85,7 +105,10 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool { // SetConfigField sets a key/value pair in the config file for the given scope. func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error { - path := s.configPath(scope) + path, err := s.configPath(scope) + if err != nil { + return fmt.Errorf("%s: %w", key, err) + } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -110,7 +133,10 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error { // RemoveConfigField removes a key from the config file for the given scope. func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error { - path := s.configPath(scope) + path, err := s.configPath(scope) + if err != nil { + return fmt.Errorf("%s: %w", key, err) + } data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("failed to read config file: %w", err) diff --git a/internal/config/store_test.go b/internal/config/store_test.go new file mode 100644 index 0000000000000000000000000000000000000000..91107943b4be67023ebcd9b2473fae53d5148b73 --- /dev/null +++ b/internal/config/store_test.go @@ -0,0 +1,152 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) { + t.Parallel() + + store := &ConfigStore{ + globalDataPath: "/some/global/crush.json", + } + + path, err := store.configPath(ScopeGlobal) + require.NoError(t, err) + require.Equal(t, "/some/global/crush.json", path) +} + +func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) { + t.Parallel() + + store := &ConfigStore{ + workspacePath: "/some/workspace/.crush/crush.json", + } + + path, err := store.configPath(ScopeWorkspace) + require.NoError(t, err) + require.Equal(t, "/some/workspace/.crush/crush.json", path) +} + +func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) { + t.Parallel() + + store := &ConfigStore{ + globalDataPath: "/some/global/crush.json", + workspacePath: "", + } + + _, err := store.configPath(ScopeWorkspace) + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoWorkspaceConfig)) +} + +func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) { + t.Parallel() + + store := &ConfigStore{ + config: &Config{}, + globalDataPath: filepath.Join(t.TempDir(), "global.json"), + workspacePath: "", + } + + err := store.SetConfigField(ScopeWorkspace, "foo", "bar") + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoWorkspaceConfig)) +} + +func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + globalPath := filepath.Join(dir, "crush.json") + store := &ConfigStore{ + config: &Config{}, + globalDataPath: globalPath, + } + + err := store.SetConfigField(ScopeGlobal, "foo", "bar") + require.NoError(t, err) + + data, err := os.ReadFile(globalPath) + require.NoError(t, err) + require.Contains(t, string(data), `"foo"`) +} + +func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) { + t.Parallel() + + store := &ConfigStore{ + config: &Config{}, + globalDataPath: filepath.Join(t.TempDir(), "global.json"), + workspacePath: "", + } + + err := store.RemoveConfigField(ScopeWorkspace, "foo") + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoWorkspaceConfig)) +} + +func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) { + t.Parallel() + + store := &ConfigStore{ + config: &Config{}, + globalDataPath: filepath.Join(t.TempDir(), "global.json"), + workspacePath: "", + } + + has := store.HasConfigField(ScopeWorkspace, "foo") + require.False(t, has) +} + +func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) { + t.Parallel() + + store1 := &ConfigStore{config: &Config{}} + store2 := &ConfigStore{config: &Config{}} + + require.False(t, store1.Overrides().SkipPermissionRequests) + require.False(t, store2.Overrides().SkipPermissionRequests) + + store1.Overrides().SkipPermissionRequests = true + + require.True(t, store1.Overrides().SkipPermissionRequests) + require.False(t, store2.Overrides().SkipPermissionRequests) +} + +func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) { + t.Parallel() + + store := &ConfigStore{config: &Config{}} + overrides := store.Overrides() + + require.False(t, overrides.SkipPermissionRequests) + + overrides.SkipPermissionRequests = true + require.True(t, store.Overrides().SkipPermissionRequests) +} + +func TestGlobalWorkspaceDir(t *testing.T) { + dir := t.TempDir() + t.Setenv("CRUSH_GLOBAL_DATA", dir) + + wsDir := GlobalWorkspaceDir() + globalData := GlobalConfigData() + + require.Equal(t, filepath.Dir(globalData), wsDir) + require.Equal(t, dir, wsDir) +} + +func TestScope_String(t *testing.T) { + t.Parallel() + + require.Equal(t, "global", ScopeGlobal.String()) + require.Equal(t, "workspace", ScopeWorkspace.String()) + require.Contains(t, Scope(99).String(), "Scope(99)") +}