diff --git a/README.md b/README.md index 5e35490..ea81112 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,16 @@ bbox claude-code --cpus 4 --memory 4096 # Use a different workspace bbox claude-code --workspace /path/to/project -# Enable interactive per-file review (snapshot isolation is always active) +# Enable interactive per-file review (snapshot mode only) bbox claude-code --review # Exclude files from snapshot bbox claude-code --exclude "*.log" --exclude "tmp/" +# Skip snapshot isolation entirely: the agent writes directly to your workspace +# (no review, no undo). --yes is required on the first run. +bbox claude-code --workspace-mode=direct --yes + # Lock down egress to LLM provider only bbox claude-code --egress-profile locked @@ -151,6 +155,22 @@ bbox claude-code -- --help bbox list ``` +### Workspace modes + +By default, bbox runs the agent against a copy-on-write snapshot of your workspace +and flushes changes back when the agent exits. No write lands on your real files +without going through the diff engine. Add `--review` to approve each file +interactively. + +For quick, trusted edits where you're driving the agent turn-by-turn and snapshot +overhead isn't worth it, pass `--workspace-mode=direct`. The VM mounts your +workspace read-write and writes land immediately. In direct mode, `--review` and +`--exclude` are rejected (they only apply to snapshots), and git credential +sanitization is skipped. Per-workspace `.broodbox.yaml` cannot enable direct mode; +only the operator can, globally or on the CLI, and `--yes` is required on first +use. Use direct mode when you'd trust the agent with an unsandboxed shell anyway. +Otherwise stay on snapshot mode (the default). + ## Configuration Brood Box uses a three-level config system: CLI flags > per-workspace > global. CLI flags always win. @@ -165,6 +185,9 @@ defaults: memory: 4096 egress_profile: "permissive" +workspace: + mode: "snapshot" # snapshot (default) or direct + review: enabled: true exclude_patterns: @@ -208,6 +231,11 @@ review: Note that `review.enabled` is **ignored** in per-workspace config for security. An untrusted repo cannot disable review on your behalf. +`workspace.mode: direct` from per-workspace config is also ignored. An untrusted +repo cannot turn off snapshot isolation. Setting `workspace.mode: snapshot` +in `.broodbox.yaml` is allowed (tighten-only: a repo can force snapshot even if +the global config enables direct). + Similarly, `egress_profile` in per-workspace config cannot widen the global profile. ### Exclude patterns diff --git a/cmd/bbox/main.go b/cmd/bbox/main.go index 2d746e5..8a3f04a 100644 --- a/cmd/bbox/main.go +++ b/cmd/bbox/main.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "maps" "os" @@ -78,6 +79,8 @@ func rootCmd() *cobra.Command { image string debug bool review bool + workspaceMode string + yesAckDirect bool excludes []string logFile string egressProfile string @@ -108,10 +111,15 @@ func rootCmd() *cobra.Command { Long: `bbox boots a microVM, mounts your workspace, forwards secrets, and drops into an interactive terminal session with a coding agent. -Workspace snapshot isolation is always active: a COW snapshot is created -before the VM starts, and changes are flushed back after the agent finishes. -Use --review to interactively approve or reject each changed file; without it, -all changes are auto-accepted. +Workspace isolation modes: + snapshot (default): A COW snapshot of the workspace is created before + the VM starts, and changes are flushed back after the agent finishes. + Use --review to interactively approve or reject each changed file; + without it, all changes are auto-accepted. + direct: The workspace is mounted directly into the VM with no snapshot. + The agent writes through to your filesystem immediately: no review, + no undo, and git credential sanitization is skipped. + Enable with --workspace-mode=direct (requires --yes on first use). Supported agents: claude-code, codex, opencode @@ -121,6 +129,7 @@ Example: bbox opencode --workspace /path/to/project bbox claude-code --review bbox claude-code --review --exclude "*.log" --exclude "tmp/" + bbox claude-code --workspace-mode=direct --yes bbox claude-code --egress-profile locked bbox claude-code --allow-host "custom-api.example.com:443" bbox claude-code --no-mcp @@ -145,6 +154,8 @@ Example: image: image, debug: debug, review: review, + workspaceMode: workspaceMode, + yesAckDirect: yesAckDirect, excludes: excludes, logFile: logFile, egressProfile: egressProfile, @@ -181,8 +192,10 @@ Example: cmd.Flags().StringVar(&cfgPath, "config", "", "Config file path (default: ~/.config/broodbox/config.yaml)") cmd.Flags().StringVar(&image, "image", "", "Override OCI image reference") cmd.Flags().BoolVar(&debug, "debug", false, "Enable debug-level logging to file (default: info level)") - cmd.Flags().BoolVar(&review, "review", false, "Enable interactive per-file review of workspace changes (snapshot isolation is always active)") - cmd.Flags().StringSliceVar(&excludes, "exclude", nil, "Additional exclude patterns for workspace snapshot (repeatable)") + cmd.Flags().BoolVar(&review, "review", false, "Enable interactive per-file review of workspace changes (snapshot mode only)") + cmd.Flags().StringVar(&workspaceMode, "workspace-mode", "", "Workspace isolation mode: snapshot (default), direct. direct mounts the workspace read-write with no snapshot, no review, no undo") + cmd.Flags().BoolVar(&yesAckDirect, "yes", false, "Acknowledge dangerous options without prompting (required on first --workspace-mode=direct run)") + cmd.Flags().StringSliceVar(&excludes, "exclude", nil, "Additional exclude patterns for workspace snapshot (repeatable, snapshot mode only)") cmd.Flags().StringVar(&logFile, "log-file", "", "Override log file path (default: ~/.config/broodbox/vms//broodbox.log)") cmd.Flags().StringVar(&egressProfile, "egress-profile", "", "Egress restriction level: permissive, standard, locked (default: agent's built-in default)") cmd.Flags().StringSliceVar(&allowHosts, "allow-host", nil, "Additional allowed egress DNS hostname[:port] — no IP addresses (repeatable)") @@ -339,6 +352,8 @@ type runFlags struct { image string debug bool review bool + workspaceMode string + yesAckDirect bool excludes []string logFile string egressProfile string @@ -393,6 +408,22 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error { "pull policy %q requires a cache to serve hits", domainconfig.PullNever) } + // Validate --workspace-mode and its interaction with other flags. + if flags.workspaceMode != "" && !domainconfig.IsValidWorkspaceMode(flags.workspaceMode) { + return fmt.Errorf("invalid --workspace-mode %q: valid values are %v", + flags.workspaceMode, domainconfig.ValidWorkspaceModes()) + } + if flags.workspaceMode == domainconfig.WorkspaceModeDirect { + if flags.review { + return errors.New("--review has no effect in direct mode: there is no snapshot to review against. " + + "Remove --review or drop --workspace-mode=direct") + } + if len(flags.excludes) > 0 { + return errors.New("--exclude applies to snapshot matching and is ignored in direct mode. " + + "Remove --exclude or drop --workspace-mode=direct") + } + } + // Resolve workspace early so we can derive a deterministic VM name. earlyWs := flags.workspace if earlyWs == "" { @@ -538,32 +569,70 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error { } } + // Resolve effective workspace mode. CLI wins over global config; unset + // means snapshot. Workspace-local .broodbox.yaml was already merged with + // tighten-only semantics so it can only force snapshot, never direct. + effectiveMode := flags.workspaceMode + if effectiveMode == "" { + if cfg != nil { + effectiveMode = cfg.Workspace.ResolvedWorkspaceMode() + } else { + effectiveMode = domainconfig.WorkspaceModeSnapshot + } + } + directMode := effectiveMode == domainconfig.WorkspaceModeDirect + // Determine interactive review mode. Default is disabled unless --review - // is set or config explicitly enables it. Snapshot isolation is always on. + // is set or config explicitly enables it. Only meaningful in snapshot mode. interactiveReview := flags.review if !interactiveReview && cfg != nil && cfg.Review.Enabled != nil && *cfg.Review.Enabled { interactiveReview = true } + if directMode { + // --review/review.enabled have no effect without a snapshot; the CLI + // combination was already rejected above, so here we're quietly + // ignoring a global config default. Warn once so the user notices. + if interactiveReview { + _, _ = fmt.Fprintln(os.Stderr, + "Warning: review.enabled is ignored in direct mode (no snapshot to review against).") + } + interactiveReview = false + + // First-run acknowledgement: require an explicit --yes once, then + // persist a sentinel so daily use doesn't nag. + if err := ensureDirectModeAck(flags.yesAckDirect, logger); err != nil { + return err + } + + // Startup banner: remind the user every run that isolation is off. + _, _ = fmt.Fprintf(os.Stderr, + "! Direct mode: agent writes directly to %s. No snapshot, no review, no undo.\n", + ws, + ) + } - // Merge exclude patterns from config and CLI. + // Merge exclude patterns from config and CLI. Only used in snapshot mode. var excludePatterns []string if cfg != nil { excludePatterns = append(excludePatterns, cfg.Review.ExcludePatterns...) } excludePatterns = append(excludePatterns, flags.excludes...) - // Build exclude matchers — always needed since snapshot isolation is always active. - excludeCfg, err := exclude.LoadExcludeConfig(ws, excludePatterns, logger) - if err != nil { - return fmt.Errorf("loading exclude config: %w", err) - } - snapshotMatcher := exclude.NewMatcherFromConfig(excludeCfg) + // Build exclude matchers (snapshot mode only; direct mode has no diff). + var snapshotMatcher, diffMatcher snapshot.Matcher + if !directMode { + excludeCfg, excludeErr := exclude.LoadExcludeConfig(ws, excludePatterns, logger) + if excludeErr != nil { + return fmt.Errorf("loading exclude config: %w", excludeErr) + } + snapshotMatcher = exclude.NewMatcherFromConfig(excludeCfg) - gitignorePatterns, err := exclude.LoadGitignorePatterns(ws, logger) - if err != nil { - logger.Warn("failed to load .gitignore patterns", "error", err) + gitignorePatterns, gitignoreErr := exclude.LoadGitignorePatterns(ws, logger) + if gitignoreErr != nil { + logger.Warn("failed to load .gitignore patterns", "error", gitignoreErr) + } + diffMatcher = exclude.NewDiffMatcher(excludeCfg, gitignorePatterns) } - diffMatcher := exclude.NewDiffMatcher(excludeCfg, gitignorePatterns) // Validate and convert config-file egress hosts. configEgressHosts, egressErr := domainconfig.ToEgressHosts(cfg.Network.AllowHosts) @@ -785,22 +854,29 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error { // Wire git identity provider (unconditional — used for both review and no-review modes). deps.GitIdentityProvider = infragit.NewHostIdentityProvider("") - // Wire snapshot isolation dependencies (always active). - deps.WorkspaceCloner = infraws.NewFSWorkspaceCloner( - infraws.NewPlatformCloner(), snapDir, logger, - ) - if interactiveReview { - deps.Reviewer = review.NewInteractiveReviewer(os.Stdin, os.Stdout) - } else { - deps.Reviewer = review.NewAutoAcceptReviewer(logger, os.Stderr) - } - deps.Flusher = review.NewFSFlusher() - deps.Differ = diff.NewFSDiffer() + // Wire snapshot isolation dependencies only when snapshot mode is active. + // In direct mode we leave WorkspaceCloner / Reviewer / Flusher / Differ + // nil; SandboxRunner already guards every call site against nil deps, + // and the snapshot post-processors (git config sanitizer, worktree + // reconstruction) are deliberately skipped; they operate on the + // snapshot directory that does not exist in direct mode. + if !directMode { + deps.WorkspaceCloner = infraws.NewFSWorkspaceCloner( + infraws.NewPlatformCloner(), snapDir, logger, + ) + if interactiveReview { + deps.Reviewer = review.NewInteractiveReviewer(os.Stdin, os.Stdout) + } else { + deps.Reviewer = review.NewAutoAcceptReviewer(logger, os.Stderr) + } + deps.Flusher = review.NewFSFlusher() + deps.Differ = diff.NewFSDiffer() - // Wire snapshot post-processors (worktree reconstruction, then git config sanitizer). - deps.SnapshotPostProcessors = []workspace.SnapshotPostProcessor{ - infragit.NewWorktreeProcessor(logger), - infragit.NewConfigSanitizer(logger), + // Wire snapshot post-processors (worktree reconstruction, then git config sanitizer). + deps.SnapshotPostProcessors = []workspace.SnapshotPostProcessor{ + infragit.NewWorktreeProcessor(logger), + infragit.NewConfigSanitizer(logger), + } } // Validate and parse egress flags. @@ -875,7 +951,7 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error { EnvForwardExtra: flags.envForward, PullPolicy: flags.pull, Snapshot: sandbox.SnapshotOpts{ - Enabled: true, + Enabled: !directMode, SnapshotMatcher: snapshotMatcher, DiffMatcher: diffMatcher, }, @@ -1041,6 +1117,23 @@ func warnLocalConfigOverrides(w io.Writer, localCfg, globalCfg *domainconfig.Con warnings = append(warnings, "review.enabled (interactive review) is ignored for security — use --review or global config") } + // Workspace.Mode: tighten-only. Local can only force snapshot, + // never widen to direct. Surface both intents for visibility. + if localCfg.Workspace.Mode != "" { + mode := sanitizeValue(localCfg.Workspace.Mode) + switch localCfg.Workspace.Mode { + case domainconfig.WorkspaceModeDirect: + warnings = append(warnings, + fmt.Sprintf("workspace.mode %q is ignored: direct mode cannot be enabled from workspace config, only globally or via --workspace-mode", mode)) + case domainconfig.WorkspaceModeSnapshot: + warnings = append(warnings, + fmt.Sprintf("forces workspace.mode: %s (overrides global if direct)", mode)) + default: + warnings = append(warnings, + fmt.Sprintf("workspace.mode %q is not recognized (treated as snapshot)", mode)) + } + } + // Auth.SaveCredentials — always ignored for security, warn if set. if localCfg.Auth.SaveCredentials != nil { warnings = append(warnings, "auth.save_credentials is ignored in workspace config — use --no-save-credentials flag or global config") @@ -1228,6 +1321,55 @@ func sanitizeAll(ss []string) []string { return out } +// ensureDirectModeAck enforces the first-run acknowledgement for +// --workspace-mode=direct. The first invocation must pass --yes; once the +// acknowledgement is persisted under $XDG_STATE_HOME/broodbox, subsequent +// direct-mode runs proceed without the flag. +// +// Prompting interactively is deliberately avoided: the banner prints right +// before the VM boots and stealing stdin for a y/n prompt at that moment +// would be worse UX than requiring the explicit flag once. +func ensureDirectModeAck(yes bool, logger *slog.Logger) error { + stateBase := xdg.StateHome + if stateBase == "" { + // No state home (rare: misconfigured XDG). Require --yes every run + // rather than failing closed (we can't persist ack anywhere). + if !yes { + return errors.New( + "--workspace-mode=direct requires --yes to confirm (XDG_STATE_HOME is unset so acknowledgement cannot be persisted)") + } + return nil + } + + ackDir := filepath.Join(stateBase, "broodbox") + ackPath := filepath.Join(ackDir, "direct-mode-ack") + + if _, err := os.Stat(ackPath); err == nil { + // Already acknowledged on a previous run. + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + logger.Warn("failed to check direct-mode ack sentinel, requiring --yes", "error", err) + if !yes { + return errors.New("--workspace-mode=direct requires --yes to confirm") + } + } + + if !yes { + return errors.New( + "--workspace-mode=direct disables snapshot isolation, review, and git config sanitization. " + + "Pass --yes to confirm on this first run; subsequent runs will not require it") + } + + if err := os.MkdirAll(ackDir, 0o700); err != nil { + logger.Warn("failed to create ack directory, continuing without persisting", "error", err) + return nil + } + if err := os.WriteFile(ackPath, []byte("direct-mode acknowledged\n"), 0o600); err != nil { + logger.Warn("failed to write direct-mode ack sentinel, continuing without persisting", "error", err) + } + return nil +} + // credentialSeederForAgent returns a Seeder for the given agent, // or nil if no seeder is available. func credentialSeederForAgent(name string, logger *slog.Logger) credential.Seeder { diff --git a/internal/infra/config/writer.go b/internal/infra/config/writer.go index af9044c..00cd572 100644 --- a/internal/infra/config/writer.go +++ b/internal/infra/config/writer.go @@ -36,9 +36,19 @@ var defaultConfigTemplate = `# Brood Box configuration # # "locked" allows only the agent's LLM provider. # egress_profile: "" -# Workspace snapshot isolation and review behavior. -# Snapshot isolation is always active. Use review.enabled or --review -# to interactively approve or reject each changed file. +# Workspace isolation mode. +# "snapshot" (default) exposes the workspace via a copy-on-write snapshot; +# the agent never touches your real files until the flush step. +# "direct" mounts the workspace read-write inside the VM with no snapshot, +# no review, no undo, and git credential sanitization is skipped. +# Equivalent to --workspace-mode=direct on the CLI. Per-workspace +# .broodbox.yaml CANNOT set "direct" (only the operator can). +# workspace: +# mode: "snapshot" + +# Workspace snapshot review behavior. Only applies in snapshot mode. +# Use review.enabled or --review to interactively approve or reject +# each changed file. # review: # # Enable interactive per-file review of workspace changes. # # NOTE: This setting is ignored in per-workspace .broodbox.yaml diff --git a/pkg/domain/config/config.go b/pkg/domain/config/config.go index d961da0..92ef5ab 100644 --- a/pkg/domain/config/config.go +++ b/pkg/domain/config/config.go @@ -22,6 +22,10 @@ type Config struct { // Defaults specifies default resource limits. Defaults DefaultsConfig `yaml:"defaults"` + // Workspace configures how the workspace is exposed to the VM + // (snapshot isolation vs direct pass-through). + Workspace WorkspaceConfig `yaml:"workspace"` + // Review configures workspace snapshot isolation. Review ReviewConfig `yaml:"review"` @@ -62,6 +66,10 @@ type Config struct { // has its own Validate). Add more here as new classes of footgun // emerge. func (c *Config) Validate() error { + if !IsValidWorkspaceMode(c.Workspace.Mode) { + return fmt.Errorf("workspace.mode %q: valid values are %v", + c.Workspace.Mode, ValidWorkspaceModes()) + } for name, override := range c.Agents { if err := agent.ValidateEnvForwardPatterns(override.EnvForward); err != nil { return fmt.Errorf("agents.%s.%w", name, err) @@ -466,6 +474,83 @@ type ReviewConfig struct { ExcludePatterns []string `yaml:"exclude_patterns,omitempty"` } +// WorkspaceConfig configures how the host workspace is exposed to the VM. +type WorkspaceConfig struct { + // Mode selects the workspace isolation strategy. + // "" / "snapshot" (default): COW snapshot, diff + flush on exit. + // "direct": mount the workspace directly with no snapshot, no review, + // and no undo. Must be set globally or on the CLI; workspace-local + // .broodbox.yaml cannot widen to "direct". + Mode string `yaml:"mode,omitempty"` +} + +const ( + // WorkspaceModeSnapshot (default) exposes the workspace via a COW + // snapshot. Changes are diffed and flushed back after the agent exits. + WorkspaceModeSnapshot = "snapshot" + + // WorkspaceModeDirect mounts the workspace directly into the VM with + // no isolation. Writes land on the host filesystem immediately. + WorkspaceModeDirect = "direct" +) + +// workspaceModeStrictness orders modes from strictest to most permissive. +// "snapshot" is strictest because it preserves the ability to review/reject. +var workspaceModeStrictness = []string{ + WorkspaceModeSnapshot, + WorkspaceModeDirect, +} + +// ValidWorkspaceModes returns the list of valid workspace mode names. +func ValidWorkspaceModes() []string { + return []string{WorkspaceModeSnapshot, WorkspaceModeDirect} +} + +// IsValidWorkspaceMode reports whether the given mode name is recognized. +// The empty string is treated as valid (implicit default). +func IsValidWorkspaceMode(mode string) bool { + switch mode { + case "", WorkspaceModeSnapshot, WorkspaceModeDirect: + return true + default: + return false + } +} + +// ResolvedWorkspaceMode returns the effective mode, mapping "" to the default. +func (w WorkspaceConfig) ResolvedWorkspaceMode() string { + if w.Mode == "" { + return WorkspaceModeSnapshot + } + return w.Mode +} + +// StricterWorkspaceMode returns the stricter of two mode names. Empty strings +// and unrecognized values are normalized to "snapshot" (the safe side) before +// comparison. A typo in either input can therefore only tighten, never widen, +// the effective mode. +func StricterWorkspaceMode(a, b string) string { + a = normalizeWorkspaceMode(a) + b = normalizeWorkspaceMode(b) + for _, m := range workspaceModeStrictness { + if m == a || m == b { + return m + } + } + return WorkspaceModeSnapshot +} + +// normalizeWorkspaceMode maps empty and unrecognized values to the safe default +// so unknown inputs behave as "snapshot" during merge. +func normalizeWorkspaceMode(m string) string { + switch m { + case WorkspaceModeSnapshot, WorkspaceModeDirect: + return m + default: + return WorkspaceModeSnapshot + } +} + const ( // MaxCPUs is the upper bound for vCPU allocation. MaxCPUs uint32 = 128 @@ -615,6 +700,17 @@ func MergeConfigs(global, local *Config) *Config { )) } + // Workspace.Mode: tighten-only. Local can only request a stricter + // mode (snapshot), never widen to direct. Unknown modes fall back + // to the snapshot default rather than silently disabling isolation. + // When both sides leave the mode unset, preserve the empty default so + // round-tripping a no-op local config does not inject an explicit value. + if global.Workspace.Mode == "" && local.Workspace.Mode == "" { + result.Workspace.Mode = "" + } else { + result.Workspace.Mode = StricterWorkspaceMode(global.Workspace.Mode, local.Workspace.Mode) + } + // Review.Enabled: local value is IGNORED (global preserved). // Review.ExcludePatterns: additive. if len(global.Review.ExcludePatterns) > 0 || len(local.Review.ExcludePatterns) > 0 { diff --git a/pkg/domain/config/config_test.go b/pkg/domain/config/config_test.go index 7bcecc1..d16803c 100644 --- a/pkg/domain/config/config_test.go +++ b/pkg/domain/config/config_test.go @@ -222,6 +222,58 @@ func TestMergeConfigs(t *testing.T) { Defaults: DefaultsConfig{EgressProfile: "permissive"}, }, }, + { + name: "workspace mode: local direct cannot widen global snapshot", + global: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeSnapshot}, + }, + local: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeDirect}, + }, + want: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeSnapshot}, + }, + }, + { + name: "workspace mode: local direct cannot widen empty global (default snapshot)", + global: &Config{}, + local: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeDirect}, + }, + want: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeSnapshot}, + }, + }, + { + name: "workspace mode: local snapshot tightens global direct", + global: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeDirect}, + }, + local: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeSnapshot}, + }, + want: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeSnapshot}, + }, + }, + { + name: "workspace mode: both empty stays empty", + global: &Config{}, + local: &Config{}, + want: &Config{}, + }, + { + name: "workspace mode: unrecognized local falls back to snapshot default", + global: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeDirect}, + }, + local: &Config{ + Workspace: WorkspaceConfig{Mode: "bogus"}, + }, + want: &Config{ + Workspace: WorkspaceConfig{Mode: WorkspaceModeSnapshot}, + }, + }, { name: "network allow_hosts are additive", global: &Config{ @@ -251,6 +303,60 @@ func TestMergeConfigs(t *testing.T) { } } +func TestStricterWorkspaceMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + a, b string + want string + }{ + {"both empty defaults to snapshot", "", "", WorkspaceModeSnapshot}, + {"snapshot vs direct → snapshot", WorkspaceModeSnapshot, WorkspaceModeDirect, WorkspaceModeSnapshot}, + {"direct vs snapshot → snapshot", WorkspaceModeDirect, WorkspaceModeSnapshot, WorkspaceModeSnapshot}, + {"direct vs direct → direct", WorkspaceModeDirect, WorkspaceModeDirect, WorkspaceModeDirect}, + {"empty vs direct → snapshot", "", WorkspaceModeDirect, WorkspaceModeSnapshot}, + {"unknown falls back to snapshot", "bogus", "also-bogus", WorkspaceModeSnapshot}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, StricterWorkspaceMode(tt.a, tt.b)) + }) + } +} + +func TestIsValidWorkspaceMode(t *testing.T) { + t.Parallel() + + assert.True(t, IsValidWorkspaceMode("")) + assert.True(t, IsValidWorkspaceMode(WorkspaceModeSnapshot)) + assert.True(t, IsValidWorkspaceMode(WorkspaceModeDirect)) + assert.False(t, IsValidWorkspaceMode("bogus")) + assert.False(t, IsValidWorkspaceMode("SNAPSHOT")) +} + +func TestConfigValidate_WorkspaceMode(t *testing.T) { + t.Parallel() + + require.NoError(t, (&Config{}).Validate()) + require.NoError(t, (&Config{Workspace: WorkspaceConfig{Mode: WorkspaceModeSnapshot}}).Validate()) + require.NoError(t, (&Config{Workspace: WorkspaceConfig{Mode: WorkspaceModeDirect}}).Validate()) + + err := (&Config{Workspace: WorkspaceConfig{Mode: "bogus"}}).Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "workspace.mode") +} + +func TestWorkspaceConfig_ResolvedWorkspaceMode(t *testing.T) { + t.Parallel() + + assert.Equal(t, WorkspaceModeSnapshot, WorkspaceConfig{}.ResolvedWorkspaceMode()) + assert.Equal(t, WorkspaceModeSnapshot, WorkspaceConfig{Mode: WorkspaceModeSnapshot}.ResolvedWorkspaceMode()) + assert.Equal(t, WorkspaceModeDirect, WorkspaceConfig{Mode: WorkspaceModeDirect}.ResolvedWorkspaceMode()) +} + func TestMergeConfigs_DoesNotMutateGlobal(t *testing.T) { t.Parallel()