diff --git a/.gitignore b/.gitignore index 6d2ebbe..b4c1106 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Build output -airlock /airlock *.exe *.exe~ @@ -38,4 +37,8 @@ Thumbs.db /.gemini_security/ # Local task tracker -TASKS.md \ No newline at end of file +TASKS.md + +# Wizard-generated project config (not repo config) +airlock.toml + diff --git a/cmd/airlock/cli/cli.go b/cmd/airlock/cli/cli.go index db697ef..db9fcb6 100644 --- a/cmd/airlock/cli/cli.go +++ b/cmd/airlock/cli/cli.go @@ -11,19 +11,18 @@ import ( "sync/atomic" "github.com/muneebs/airlock/cmd/airlock/cli/tui" + "github.com/muneebs/airlock/cmd/airlock/cli/wizard" "github.com/muneebs/airlock/internal/api" + "github.com/muneebs/airlock/internal/bootstrap" "github.com/muneebs/airlock/internal/config" - "github.com/muneebs/airlock/internal/detect" - "github.com/muneebs/airlock/internal/mount" - "github.com/muneebs/airlock/internal/network" - "github.com/muneebs/airlock/internal/profile" - "github.com/muneebs/airlock/internal/sandbox" - "github.com/muneebs/airlock/internal/vm/lima" "github.com/spf13/cobra" ) var version = "0.1.0" +// Dependencies bundles the fully-wired interface values a command needs. +// It is built by internal/bootstrap and injected here — the cli package +// must not import concrete backends (see PRINCIPLES.md §5, DIP). type Dependencies struct { Manager api.SandboxManager Provider api.Provider @@ -32,76 +31,74 @@ type Dependencies struct { Mounts api.MountManager Network api.NetworkController Profiles api.ProfileRegistry + Detector api.RuntimeDetector ConfigDir string IsTTY bool } +// FromBootstrap adapts a bootstrap-assembled graph into cli.Dependencies, +// filling in TTY detection which is a presentation concern owned by cli. +func FromBootstrap(d *bootstrap.Dependencies) *Dependencies { + return &Dependencies{ + Manager: d.Manager, + Provider: d.Provider, + Provisioner: d.Provisioner, + Sheller: d.Sheller, + Mounts: d.Mounts, + Network: d.Network, + Profiles: d.Profiles, + Detector: d.Detector, + ConfigDir: d.ConfigDir, + IsTTY: isTerminal(os.Stdin) && isTerminal(os.Stdout), + } +} + func Execute() error { return ExecuteContext(context.Background()) } -// ExecuteContext runs the root command with the given context. The context -// is propagated to all subcommands so SIGINT/SIGTERM can cancel in-flight -// work and trigger rollback paths. +// ExecuteContext assembles default dependencies via bootstrap and runs the +// root command with the given context. Use ExecuteWithDeps for tests or +// alternative wirings. func ExecuteContext(ctx context.Context) error { - deps, err := assembleDependencies() + boot, err := bootstrap.Assemble() if err != nil { return fmt.Errorf("initialize: %w", err) } + return ExecuteWithDeps(ctx, FromBootstrap(boot)) +} + +// ExecuteWithDeps runs the root command against caller-supplied dependencies. +func ExecuteWithDeps(ctx context.Context, deps *Dependencies) error { return newRootCmd(os.Stdout, os.Stderr, deps).ExecuteContext(ctx) } -func assembleDependencies() (*Dependencies, error) { - home, err := os.UserHomeDir() +// loadAndValidateConfig reads airlock config from dir and validates its +// open-set fields (profile name, runtime type) against the deps registries. +// This keeps the concrete list of valid profiles/runtimes out of the +// config package — they come from the plugin registries (PRINCIPLES.md §5 OCP). +func loadAndValidateConfig(dir string, deps *Dependencies) (config.Config, error) { + cfg, err := config.Load(dir) if err != nil { - return nil, fmt.Errorf("get home dir: %w", err) - } - configDir := filepath.Join(home, ".airlock") - if err := os.MkdirAll(configDir, 0755); err != nil { - return nil, fmt.Errorf("create config dir: %w", err) + return config.Config{}, err } - limaProvider, err := lima.NewLimaProvider() - if err != nil { - return nil, fmt.Errorf("init lima provider: %w", err) + var profileNames []string + if deps != nil && deps.Profiles != nil { + profileNames = deps.Profiles.List() } - detector := detect.NewCompositeDetector() - profiles := profile.NewRegistry() - - mountStore, err := mount.NewJSONStore(filepath.Join(configDir, "mounts.json")) - if err != nil { - return nil, fmt.Errorf("init mount store: %w", err) + var runtimeTypes []string + if deps != nil && deps.Detector != nil { + for _, t := range deps.Detector.SupportedTypes() { + runtimeTypes = append(runtimeTypes, string(t)) + } } - storePath := filepath.Join(configDir, "sandboxes.json") - - networkCtrl := network.NewLimaController() - - mgr, err := sandbox.NewManager( - limaProvider, - limaProvider, - detector, - profiles, - mountStore, - networkCtrl, - storePath, - ) - if err != nil { - return nil, fmt.Errorf("init sandbox manager: %w", err) + if err := config.ValidateDynamic(cfg, profileNames, runtimeTypes); err != nil { + return config.Config{}, fmt.Errorf("invalid config: %w", err) } - - return &Dependencies{ - Manager: mgr, - Provider: limaProvider, - Provisioner: limaProvider, - Sheller: limaProvider, - Mounts: mountStore, - Network: networkCtrl, - Profiles: profiles, - ConfigDir: configDir, - IsTTY: isTerminal(os.Stdout), - }, nil + return cfg, nil } func isTerminal(f *os.File) bool { @@ -126,6 +123,7 @@ func newRootCmd(stdout, stderr io.Writer, deps *Dependencies) *cobra.Command { root.SetErr(stderr) root.AddCommand( + newInitCmd(deps), newSetupCmd(deps), newSandboxCmd(deps), newRunCmd(deps), @@ -138,7 +136,7 @@ func newRootCmd(stdout, stderr io.Writer, deps *Dependencies) *cobra.Command { newDestroyCmd(deps), newLockCmd(deps), newUnlockCmd(deps), - newConfigCmd(), + newConfigCmd(deps), newProfileCmd(deps), newVersionCmd(), ) @@ -171,7 +169,7 @@ before creating any sandboxes.`, nodeVersion = 22 } - cfg, err := config.Load(".") + cfg, err := loadAndValidateConfig(".", deps) if err != nil { return fmt.Errorf("load config: %w", err) } @@ -284,7 +282,7 @@ Security profiles: name = deriveSandboxName(source) } - cfg, err := config.Load(".") + cfg, err := loadAndValidateConfig(".", deps) if err != nil { return fmt.Errorf("load config: %w", err) } @@ -693,7 +691,7 @@ func newUnlockCmd(deps *Dependencies) *cobra.Command { } } -func newConfigCmd() *cobra.Command { +func newConfigCmd(deps *Dependencies) *cobra.Command { var showFormat string cmd := &cobra.Command{ @@ -703,7 +701,7 @@ func newConfigCmd() *cobra.Command { directory. If no config file exists, shows defaults. Supports --format=toml or --format=yaml output.`, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(".") + cfg, err := loadAndValidateConfig(".", deps) if err != nil { return fmt.Errorf("load config: %w", err) } @@ -762,6 +760,168 @@ func newProfileCmd(deps *Dependencies) *cobra.Command { } } +func newInitCmd(deps *Dependencies) *cobra.Command { + return &cobra.Command{ + Use: "init [path-or-url]", + Short: "Interactive wizard to create a new sandbox", + Long: `Launch an interactive wizard that guides you through creating a secure sandbox. + +The wizard will ask you simple questions about: +- What software you want to run +- How much you trust it +- What resources it needs +- Network access requirements + +It then creates the appropriate configuration and optionally the sandbox itself. + +Examples: + airlock init # Wizard for current directory + airlock init ./my-project # Wizard for specific directory + airlock init gh:user/repo # Wizard for GitHub repository`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + // Determine source + source := "." + if len(args) > 0 { + source = args[0] + } + + // Run the wizard + if !deps.IsTTY { + return fmt.Errorf("init requires an interactive terminal (TTY)") + } + + result, err := wizard.Run(source) + if err != nil { + return err + } + + // Detect runtime using injected detector (no concrete import) + var runtime string + if result.Source != "" && deps.Detector != nil { + detected, err := deps.Detector.Detect(result.Source) + if err == nil { + runtime = string(detected.Type) + } else { + fmt.Fprintf(cmd.ErrOrStderr(), "Note: could not auto-detect runtime: %v\n", err) + } + } + + // Save config if requested + wizardCfg := result.ToConfig(runtime) + + if result.SaveConfig { + // Determine target directory for config + var configDir string + if strings.HasPrefix(result.Source, "gh:") || strings.HasPrefix(result.Source, "https://") { + // For remote sources, use current directory + configDir = "." + } else { + configDir = result.Source + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + } + + if err := config.Save(configDir, wizardCfg); err != nil { + return fmt.Errorf("save configuration: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", tui.SuccessLine("Configuration saved to %s/airlock.toml", configDir)) + } + + // Create sandbox if requested + if result.CreateNow { + spec := result.ToSandboxSpec(runtime) + + var createStage atomic.Value + createStage.Store("starting") + + phases := []tui.Phase{ + { + Label: "Creating VM " + spec.Name + " (fresh Ubuntu boot takes 5–7 min)", + DoneLabel: "VM " + spec.Name + " created and booted", + Action: func() error { + _, err := deps.Manager.CreateWithOptions(ctx, spec, api.CreateOptions{ + Progress: func(stage string) { + createStage.Store(stage) + }, + SkipNetworkPolicy: true, + }) + return err + }, + Status: func() string { + s, _ := createStage.Load().(string) + return s + }, + }, + } + + // Add provisioning steps + for _, step := range deps.Provisioner.ProvisionSteps(spec.Name, wizardCfg.VM.NodeVersion) { + step := step + phases = append(phases, tui.Phase{ + Label: step.Label, + DoneLabel: step.Label, + Action: func() error { return step.Run(ctx) }, + }) + } + + // Apply network policy + phases = append(phases, tui.Phase{ + Label: "Applying network policy", + DoneLabel: "Network policy applied", + Action: func() error { + return deps.Manager.ApplyNetworkProfile(ctx, spec.Name) + }, + }) + + // Enforce user's network choice over profile default. + switch result.NetworkLevel { + case wizard.NetworkNone, wizard.NetworkDownloads: + phases = append(phases, tui.Phase{ + Label: "Locking network", + DoneLabel: "Network locked", + Action: func() error { + return deps.Network.Lock(ctx, spec.Name) + }, + }) + case wizard.NetworkOngoing: + phases = append(phases, tui.Phase{ + Label: "Unlocking network", + DoneLabel: "Network unlocked", + Action: func() error { + return deps.Network.Unlock(ctx, spec.Name) + }, + }) + } + + // Save snapshot + phases = append(phases, tui.Phase{ + Label: "Saving clean snapshot", + DoneLabel: "Snapshot saved", + Action: func() error { + return deps.Provisioner.SnapshotClean(ctx, spec.Name) + }, + }) + + if err := tui.RunPhases(phases); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", tui.SuccessLine("Sandbox %q is ready to use.", spec.Name)) + fmt.Fprintf(cmd.OutOrStdout(), "\nRun 'airlock shell %s' to enter the sandbox, or 'airlock run %s -- ' to run commands.\n", spec.Name, spec.Name) + } + + return nil + }, + } +} + func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", diff --git a/cmd/airlock/cli/wizard/mappings.go b/cmd/airlock/cli/wizard/mappings.go new file mode 100644 index 0000000..a1cca6e --- /dev/null +++ b/cmd/airlock/cli/wizard/mappings.go @@ -0,0 +1,240 @@ +// Package wizard provides an interactive TUI for creating airlock sandboxes. +// It translates user-friendly choices into technical configurations. +package wizard + +import ( + "github.com/muneebs/airlock/internal/api" + "github.com/muneebs/airlock/internal/config" +) + +// TrustLevel represents the user's trust in the software they're running. +type TrustLevel string + +const ( + TrustStrict TrustLevel = "strict" + TrustCautious TrustLevel = "cautious" + TrustDev TrustLevel = "dev" + TrustTrusted TrustLevel = "trusted" +) + +// ResourceLevel represents the VM resource requirements. +type ResourceLevel string + +const ( + ResourceLightweight ResourceLevel = "lightweight" + ResourceStandard ResourceLevel = "standard" + ResourceHeavy ResourceLevel = "heavy" +) + +// NetworkLevel represents the network access requirements. +type NetworkLevel string + +const ( + NetworkNone NetworkLevel = "none" + NetworkDownloads NetworkLevel = "downloads" + NetworkOngoing NetworkLevel = "ongoing" +) + +// TrustLevelInfo provides user-facing descriptions for trust levels. +type TrustLevelInfo struct { + Level TrustLevel + Label string + Description string + Warning string +} + +// TrustLevels returns all available trust levels with their UI info. +func TrustLevels() []TrustLevelInfo { + return []TrustLevelInfo{ + { + Level: TrustStrict, + Label: "🛡️ I don't trust it", + Description: "Random scripts/tools from the internet", + Warning: "", + }, + { + Level: TrustCautious, + Label: "⚠️ I'm not sure", + Description: "Projects from unknown sources", + Warning: "", + }, + { + Level: TrustDev, + Label: "🔧 I need to work on it", + Description: "My own or team's projects", + Warning: "Allows network and file access. The software can modify your project files.", + }, + { + Level: TrustTrusted, + Label: "✅ I trust it completely", + Description: "Software I authored or reviewed", + Warning: "Warning: This grants full system access. Only choose this for software you completely trust.", + }, + } +} + +// ResourceLevelInfo provides user-facing descriptions for resource levels. +type ResourceLevelInfo struct { + Level ResourceLevel + Label string + Description string + CPU int + Memory string +} + +// ResourceLevels returns all available resource levels. +func ResourceLevels() []ResourceLevelInfo { + return []ResourceLevelInfo{ + { + Level: ResourceLightweight, + Label: "🪶 Lightweight", + Description: "Scripts, small tools", + CPU: 1, + Memory: "2GiB", + }, + { + Level: ResourceStandard, + Label: "📦 Standard", + Description: "Most apps", + CPU: 2, + Memory: "4GiB", + }, + { + Level: ResourceHeavy, + Label: "🚀 Heavy", + Description: "Builds, databases, large projects", + CPU: 4, + Memory: "8GiB", + }, + } +} + +// NetworkLevelInfo provides user-facing descriptions for network levels. +type NetworkLevelInfo struct { + Level NetworkLevel + Label string + Description string + Warning string + LockAfter bool +} + +// NetworkLevels returns all available network levels. +func NetworkLevels() []NetworkLevelInfo { + return []NetworkLevelInfo{ + { + Level: NetworkNone, + Label: "🔒 None", + Description: "Completely offline", + Warning: "", + LockAfter: false, + }, + { + Level: NetworkDownloads, + Label: "⬇️ Downloads only", + Description: "Install dependencies, then lock", + Warning: "", + LockAfter: true, + }, + { + Level: NetworkOngoing, + Label: "🌐 Ongoing access", + Description: "Needs internet continuously", + Warning: "Warning: This allows the software to communicate with external servers continuously.", + LockAfter: false, + }, + } +} + +// MapTrustLevelToProfile returns the profile name for a trust level. +func MapTrustLevelToProfile(level TrustLevel) string { + switch level { + case TrustStrict: + return "strict" + case TrustCautious: + return "cautious" + case TrustDev: + return "dev" + case TrustTrusted: + return "trusted" + default: + return "cautious" + } +} + +// MapResourceLevel returns the CPU and memory for a resource level. +func MapResourceLevel(level ResourceLevel) (cpu int, memory string) { + switch level { + case ResourceLightweight: + return 1, "2GiB" + case ResourceStandard: + return 2, "4GiB" + case ResourceHeavy: + return 4, "8GiB" + default: + return 2, "4GiB" + } +} + +// IsInsecureChoice returns true if the choice requires a security warning. +func IsInsecureChoice(level TrustLevel) bool { + return level == TrustDev || level == TrustTrusted +} + +// IsInsecureNetwork returns true if the network choice requires a warning. +func IsInsecureNetwork(level NetworkLevel) bool { + return level == NetworkOngoing +} + +// WizardResult contains the final configuration from the wizard. +type WizardResult struct { + Source string + Name string + TrustLevel TrustLevel + ResourceLevel ResourceLevel + NetworkLevel NetworkLevel + StartAtLogin bool + SaveConfig bool + CreateNow bool +} + +// ToSandboxSpec converts wizard result to API sandbox spec. +func (r *WizardResult) ToSandboxSpec(runtime string) api.SandboxSpec { + cpu, memory := MapResourceLevel(r.ResourceLevel) + profile := MapTrustLevelToProfile(r.TrustLevel) + + defaults := config.Defaults() + lockAfter := r.NetworkLevel != NetworkOngoing + return api.SandboxSpec{ + Name: r.Name, + Source: r.Source, + Runtime: runtime, + Profile: profile, + CPU: &cpu, + Memory: memory, + Disk: defaults.VM.Disk, + Ports: defaults.Dev.Ports, + StartAtLogin: r.StartAtLogin, + LockNetworkAfterSetup: &lockAfter, + } +} + +// ToConfig converts wizard result to Config struct. Unspecified fields +// inherit canonical values from config.Defaults() so callers (e.g. the +// provisioner) see sensible defaults like NodeVersion and Disk. +func (r *WizardResult) ToConfig(runtime string) config.Config { + cpu, memory := MapResourceLevel(r.ResourceLevel) + profile := MapTrustLevelToProfile(r.TrustLevel) + + cfg := config.Defaults() + cfg.VM.CPU = cpu + cfg.VM.Memory = memory + cfg.Security.Profile = profile + cfg.Runtime.Type = runtime + cfg.StartAtLogin = r.StartAtLogin + return cfg +} + +// NeedsNetworkLock returns true if the network should be locked after setup. +func (r *WizardResult) NeedsNetworkLock() bool { + return r.NetworkLevel == NetworkDownloads +} diff --git a/cmd/airlock/cli/wizard/mappings_test.go b/cmd/airlock/cli/wizard/mappings_test.go new file mode 100644 index 0000000..acb6db7 --- /dev/null +++ b/cmd/airlock/cli/wizard/mappings_test.go @@ -0,0 +1,207 @@ +package wizard + +import ( + "testing" +) + +func TestMapTrustLevelToProfile(t *testing.T) { + tests := []struct { + name string + level TrustLevel + expected string + }{ + {"strict", TrustStrict, "strict"}, + {"cautious", TrustCautious, "cautious"}, + {"dev", TrustDev, "dev"}, + {"trusted", TrustTrusted, "trusted"}, + {"unknown", TrustLevel("unknown"), "cautious"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MapTrustLevelToProfile(tt.level) + if result != tt.expected { + t.Errorf("MapTrustLevelToProfile(%q) = %q, want %q", tt.level, result, tt.expected) + } + }) + } +} + +func TestMapResourceLevel(t *testing.T) { + tests := []struct { + name string + level ResourceLevel + expectedCPU int + expectedMem string + }{ + {"lightweight", ResourceLightweight, 1, "2GiB"}, + {"standard", ResourceStandard, 2, "4GiB"}, + {"heavy", ResourceHeavy, 4, "8GiB"}, + {"unknown", ResourceLevel("unknown"), 2, "4GiB"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cpu, mem := MapResourceLevel(tt.level) + if cpu != tt.expectedCPU { + t.Errorf("MapResourceLevel(%q) CPU = %d, want %d", tt.level, cpu, tt.expectedCPU) + } + if mem != tt.expectedMem { + t.Errorf("MapResourceLevel(%q) Memory = %q, want %q", tt.level, mem, tt.expectedMem) + } + }) + } +} + +func TestIsInsecureChoice(t *testing.T) { + tests := []struct { + name string + level TrustLevel + expected bool + }{ + {"strict is safe", TrustStrict, false}, + {"cautious is safe", TrustCautious, false}, + {"dev is insecure", TrustDev, true}, + {"trusted is insecure", TrustTrusted, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsInsecureChoice(tt.level) + if result != tt.expected { + t.Errorf("IsInsecureChoice(%q) = %v, want %v", tt.level, result, tt.expected) + } + }) + } +} + +func TestIsInsecureNetwork(t *testing.T) { + tests := []struct { + name string + level NetworkLevel + expected bool + }{ + {"none is safe", NetworkNone, false}, + {"downloads is safe", NetworkDownloads, false}, + {"ongoing is insecure", NetworkOngoing, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsInsecureNetwork(tt.level) + if result != tt.expected { + t.Errorf("IsInsecureNetwork(%q) = %v, want %v", tt.level, result, tt.expected) + } + }) + } +} + +func TestWizardResult_ToSandboxSpec(t *testing.T) { + result := WizardResult{ + Name: "my-sandbox", + Source: "./my-project", + TrustLevel: TrustCautious, + ResourceLevel: ResourceStandard, + NetworkLevel: NetworkDownloads, + StartAtLogin: false, + SaveConfig: true, + CreateNow: true, + } + + spec := result.ToSandboxSpec("node") + + if spec.Name != "my-sandbox" { + t.Errorf("Name = %q, want %q", spec.Name, "my-sandbox") + } + if spec.Source != "./my-project" { + t.Errorf("Source = %q, want %q", spec.Source, "./my-project") + } + if spec.Runtime != "node" { + t.Errorf("Runtime = %q, want %q", spec.Runtime, "node") + } + if spec.Profile != "cautious" { + t.Errorf("Profile = %q, want %q", spec.Profile, "cautious") + } + if spec.CPU == nil || *spec.CPU != 2 { + t.Errorf("CPU = %v, want 2", spec.CPU) + } + if spec.Memory != "4GiB" { + t.Errorf("Memory = %q, want %q", spec.Memory, "4GiB") + } +} + +func TestWizardResult_NeedsNetworkLock(t *testing.T) { + tests := []struct { + name string + level NetworkLevel + expected bool + }{ + {"none does not lock after", NetworkNone, false}, + {"downloads locks after", NetworkDownloads, true}, + {"ongoing does not lock after", NetworkOngoing, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := WizardResult{NetworkLevel: tt.level} + if got := result.NeedsNetworkLock(); got != tt.expected { + t.Errorf("NeedsNetworkLock() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestTrustLevels(t *testing.T) { + levels := TrustLevels() + if len(levels) != 4 { + t.Fatalf("TrustLevels() returned %d levels, want 4", len(levels)) + } + + // Check all expected levels exist + expected := []TrustLevel{TrustStrict, TrustCautious, TrustDev, TrustTrusted} + for i, exp := range expected { + if levels[i].Level != exp { + t.Errorf("TrustLevels()[%d].Level = %q, want %q", i, levels[i].Level, exp) + } + } + + // Check that dev and trusted have warnings + for _, level := range levels { + if level.Level == TrustDev || level.Level == TrustTrusted { + if level.Warning == "" { + t.Errorf("TrustLevels() %q should have a warning", level.Level) + } + } + } +} + +func TestResourceLevels(t *testing.T) { + levels := ResourceLevels() + if len(levels) != 3 { + t.Fatalf("ResourceLevels() returned %d levels, want 3", len(levels)) + } + + // Check all levels have valid CPU and memory + for _, level := range levels { + if level.CPU < 1 { + t.Errorf("ResourceLevels() %q has invalid CPU: %d", level.Level, level.CPU) + } + if level.Memory == "" { + t.Errorf("ResourceLevels() %q has empty Memory", level.Level) + } + } +} + +func TestNetworkLevels(t *testing.T) { + levels := NetworkLevels() + if len(levels) != 3 { + t.Fatalf("NetworkLevels() returned %d levels, want 3", len(levels)) + } + + // Check ongoing has warning + for _, level := range levels { + if level.Level == NetworkOngoing && level.Warning == "" { + t.Errorf("NetworkLevels() ongoing should have a warning") + } + } +} diff --git a/cmd/airlock/cli/wizard/questions.go b/cmd/airlock/cli/wizard/questions.go new file mode 100644 index 0000000..4a7a079 --- /dev/null +++ b/cmd/airlock/cli/wizard/questions.go @@ -0,0 +1,364 @@ +// Package wizard provides an interactive TUI for creating airlock sandboxes. +package wizard + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/muneebs/airlock/internal/config" +) + +// Styles for the wizard UI. +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("69")) + + warningStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Bold(true) +) + +// SourceInfo holds information about the code source. +type SourceInfo struct { + Path string + IsGitHub bool +} + +// DeriveSandboxName generates a name from source path or URL. +func DeriveSandboxName(source string) string { + // Handle GitHub URLs + if strings.HasPrefix(source, "gh:") { + parts := strings.SplitN(strings.TrimPrefix(source, "gh:"), "/", 2) + if len(parts) == 2 { + return sanitizeName(strings.TrimSuffix(parts[1], ".git")) + } + return sanitizeName(strings.TrimPrefix(source, "gh:")) + } + + if strings.HasPrefix(source, "https://github.com/") { + parts := strings.SplitN(strings.TrimPrefix(source, "https://github.com/"), "/", 3) + if len(parts) >= 2 { + return sanitizeName(strings.TrimSuffix(parts[1], ".git")) + } + } + + // Handle local paths + base := filepath.Base(source) + base = strings.TrimSuffix(base, filepath.Ext(base)) + if base == "" || base == "." || base == ".." { + return "sandbox" + } + return sanitizeName(base) +} + +func sanitizeName(name string) string { + var b strings.Builder + for i, r := range name { + if i == 0 && !isAlpha(r) && r != '_' { + b.WriteByte('_') + } + if isAlphaNum(r) || r == '_' || r == '-' || r == '.' { + b.WriteRune(r) + } + } + result := b.String() + if result == "" { + return "sandbox" + } + return result +} + +func isAlpha(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +func isAlphaNum(r rune) bool { + return isAlpha(r) || (r >= '0' && r <= '9') +} + +// Run starts the interactive wizard and returns the result. +func Run(source string) (*WizardResult, error) { + var result WizardResult + result.Source = source + + // Auto-detect name from source + suggestedName := DeriveSandboxName(source) + + // Step 1: Source confirmation (if not provided) + if source == "" { + var sourceInput string + sourceForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Where is the code you want to run?"). + Description("Enter a local path or GitHub URL (gh:user/repo)"). + Placeholder("./my-project or gh:user/repo"). + Value(&sourceInput). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("source is required") + } + return nil + }), + ), + ) + + if err := sourceForm.Run(); err != nil { + return nil, err + } + + result.Source = sourceInput + suggestedName = DeriveSandboxName(sourceInput) + } + + // Step 2: Sandbox name + result.Name = suggestedName + nameForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("What should we call this sandbox?"). + Description("This name will identify your sandbox in airlock"). + Value(&result.Name). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("name is required") + } + if !isValidSandboxName(s) { + return fmt.Errorf("name must contain only letters, numbers, hyphens, underscores, and dots") + } + return nil + }), + ), + ) + + if err := nameForm.Run(); err != nil { + return nil, err + } + + // Step 3: Trust level + trustOption := string(TrustCautious) // Default + trustOptions := []huh.Option[string]{} + for _, info := range TrustLevels() { + desc := fmt.Sprintf("%s - %s", info.Label, info.Description) + trustOptions = append(trustOptions, huh.NewOption(desc, string(info.Level))) + } + + trustForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("How much do you trust this software?"). + Description("Choose the option that best describes your situation"). + Options(trustOptions...). + Value(&trustOption), + ), + ) + + if err := trustForm.Run(); err != nil { + return nil, err + } + + result.TrustLevel = TrustLevel(trustOption) + + // Show warning for insecure choices + if IsInsecureChoice(result.TrustLevel) { + for _, info := range TrustLevels() { + if info.Level == result.TrustLevel && info.Warning != "" { + fmt.Println(warningStyle.Render("⚠️ " + info.Warning)) + fmt.Println() + break + } + } + } + + // Step 4: Resource level + resourceOption := string(ResourceStandard) // Default + resourceOptions := []huh.Option[string]{} + for _, info := range ResourceLevels() { + desc := fmt.Sprintf("%s - %s (%d CPU, %s RAM)", info.Label, info.Description, info.CPU, info.Memory) + resourceOptions = append(resourceOptions, huh.NewOption(desc, string(info.Level))) + } + + resourceForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("How demanding is this software?"). + Description("This determines how much of your computer's resources to allocate"). + Options(resourceOptions...). + Value(&resourceOption), + ), + ) + + if err := resourceForm.Run(); err != nil { + return nil, err + } + + result.ResourceLevel = ResourceLevel(resourceOption) + + // Step 5: Network level + networkOption := string(NetworkDownloads) // Default + networkOptions := []huh.Option[string]{} + for _, info := range NetworkLevels() { + desc := fmt.Sprintf("%s - %s", info.Label, info.Description) + networkOptions = append(networkOptions, huh.NewOption(desc, string(info.Level))) + } + + networkForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("What network access does it need?"). + Description("Choose how the sandbox can access the internet"). + Options(networkOptions...). + Value(&networkOption), + ), + ) + + if err := networkForm.Run(); err != nil { + return nil, err + } + + result.NetworkLevel = NetworkLevel(networkOption) + + // Show warning for insecure network choice + if IsInsecureNetwork(result.NetworkLevel) { + for _, info := range NetworkLevels() { + if info.Level == result.NetworkLevel && info.Warning != "" { + fmt.Println(warningStyle.Render("⚠️ " + info.Warning)) + fmt.Println() + break + } + } + } + + // Step 6: Persistence options + startAtLogin := false // Default + saveConfig := true // Default + + persistenceForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Save configuration file?"). + Description("Creates airlock.toml so you can easily restart this sandbox later"). + Value(&saveConfig), + + huh.NewConfirm(). + Title("Start at login?"). + Description("Automatically start this sandbox when you log in"). + Value(&startAtLogin), + ), + ) + + if err := persistenceForm.Run(); err != nil { + return nil, err + } + + result.StartAtLogin = startAtLogin + result.SaveConfig = saveConfig + + // Step 7: Confirmation + cpu, memory := MapResourceLevel(result.ResourceLevel) + profileName := MapTrustLevelToProfile(result.TrustLevel) + defaults := config.Defaults() + + fmt.Println() + fmt.Println(titleStyle.Render("Sandbox Summary")) + fmt.Println(strings.Repeat("─", 50)) + fmt.Printf(" Name: %s\n", result.Name) + fmt.Printf(" Source: %s\n", result.Source) + fmt.Printf(" Security: %s\n", profileName) + fmt.Printf(" Resources: %d CPU, %s RAM, %s disk\n", cpu, memory, defaults.VM.Disk) + fmt.Printf(" Network: %s\n", getNetworkDescription(result.NetworkLevel)) + fmt.Printf(" Auto-start: %s\n", boolToYesNo(result.StartAtLogin)) + fmt.Printf(" Config: %s\n", boolToYesNo(result.SaveConfig)+" (airlock.toml)") + fmt.Println(strings.Repeat("─", 50)) + fmt.Println() + + // Final action selection + var action string + actionOptions := []huh.Option[string]{ + huh.NewOption("✅ Create sandbox now", "create"), + huh.NewOption("💾 Save config only", "save"), + huh.NewOption("❌ Cancel", "cancel"), + } + + if !result.SaveConfig { + // Remove "Save config only" option if not saving + actionOptions = actionOptions[:1] + actionOptions = append(actionOptions, huh.NewOption("❌ Cancel", "cancel")) + } + + actionForm := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("What would you like to do?"). + Options(actionOptions...). + Value(&action), + ), + ) + + if err := actionForm.Run(); err != nil { + return nil, err + } + + switch action { + case "create": + result.CreateNow = true + case "save": + result.CreateNow = false + case "cancel": + return nil, fmt.Errorf("cancelled by user") + } + + return &result, nil +} + +func isValidSandboxName(name string) bool { + if name == "" { + return false + } + for i, r := range name { + if i == 0 { + if !isAlpha(r) && r != '_' { + return false + } + } else { + if !isAlphaNum(r) && r != '_' && r != '-' && r != '.' { + return false + } + } + } + return true +} + +func getNetworkDescription(level NetworkLevel) string { + switch level { + case NetworkNone: + return "Locked immediately" + case NetworkDownloads: + return "Lock after setup" + case NetworkOngoing: + return "Unlocked (ongoing access)" + default: + return "Lock after setup" + } +} + +func boolToYesNo(b bool) string { + if b { + return "Yes" + } + return "No" +} + +// IsTTY returns true if stdout is a terminal. +func IsTTY() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} diff --git a/cmd/airlock/cli/wizard/questions_test.go b/cmd/airlock/cli/wizard/questions_test.go new file mode 100644 index 0000000..acb765a --- /dev/null +++ b/cmd/airlock/cli/wizard/questions_test.go @@ -0,0 +1,260 @@ +package wizard + +import ( + "strings" + "testing" +) + +func TestDeriveSandboxName(t *testing.T) { + tests := []struct { + name string + source string + expected string + }{ + {"current dir", ".", "sandbox"}, + {"simple path", "./my-project", "my-project"}, + {"nested path", "./projects/my-app", "my-app"}, + {"gh short", "gh:user/repo", "repo"}, + {"gh with git", "gh:user/repo.git", "repo"}, + {"github https", "https://github.com/user/repo", "repo"}, + {"github https with git", "https://github.com/user/repo.git", "repo"}, + {"path with extension", "./my-project.tar.gz", "my-project.tar"}, // Double extension behavior + {"empty base", "./", "sandbox"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeriveSandboxName(tt.source) + if result != tt.expected { + t.Errorf("DeriveSandboxName(%q) = %q, want %q", tt.source, result, tt.expected) + } + }) + } +} + +func TestDeriveSandboxName_Sanitization(t *testing.T) { + tests := []struct { + name string + source string + expected string + }{ + {"with spaces", "./my project", "myproject"}, // Spaces are removed + {"starting with number", "./1project", "_1project"}, // Numbers at start get underscore prefix + {"with special chars", "./my@project#", "myproject"}, // Special chars removed + {"empty after sanitize", "./@#$%", "_"}, // All special chars become underscore + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeriveSandboxName(tt.source) + if result != tt.expected { + t.Errorf("DeriveSandboxName(%q) = %q, want %q", tt.source, result, tt.expected) + } + }) + } +} + +func TestIsValidSandboxName(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"valid simple", "my-project", true}, + {"valid with underscore", "my_project", true}, + {"valid with dot", "my.project", true}, + {"valid starts with underscore", "_myproject", true}, + {"empty", "", false}, + {"starts with number", "1project", false}, + {"contains space", "my project", false}, + {"contains slash", "my/project", false}, + {"contains special", "my@project", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidSandboxName(tt.input) + if result != tt.expected { + t.Errorf("isValidSandboxName(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestGetNetworkDescription(t *testing.T) { + tests := []struct { + name string + level NetworkLevel + expected string + }{ + {"none", NetworkNone, "Locked immediately"}, + {"downloads", NetworkDownloads, "Lock after setup"}, + {"ongoing", NetworkOngoing, "Unlocked (ongoing access)"}, + {"unknown", NetworkLevel("unknown"), "Lock after setup"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getNetworkDescription(tt.level) + if result != tt.expected { + t.Errorf("getNetworkDescription(%q) = %q, want %q", tt.level, result, tt.expected) + } + }) + } +} + +func TestBoolToYesNo(t *testing.T) { + if boolToYesNo(true) != "Yes" { + t.Errorf("boolToYesNo(true) = %q, want %q", boolToYesNo(true), "Yes") + } + if boolToYesNo(false) != "No" { + t.Errorf("boolToYesNo(false) = %q, want %q", boolToYesNo(false), "No") + } +} + +func TestSanitizeName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-project", "my-project"}, + {"my_project", "my_project"}, + {"my.project", "my.project"}, + {"my project", "myproject"}, // Space is removed (not replaced with underscore) + {"my@project", "myproject"}, // @ is removed + {"1project", "_1project"}, // Numbers at start get underscore prefix + {"@#$%", "_"}, // All special chars become underscore (but only one at start) + {"", "sandbox"}, // Empty becomes sandbox + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := sanitizeName(tt.input) + if result != tt.expected { + t.Errorf("sanitizeName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestIsAlpha(t *testing.T) { + tests := []struct { + r rune + expected bool + }{ + {'a', true}, + {'z', true}, + {'A', true}, + {'Z', true}, + {'0', false}, + {'9', false}, + {'-', false}, + {'_', false}, + {'@', false}, + } + + for _, tt := range tests { + t.Run(string(tt.r), func(t *testing.T) { + result := isAlpha(tt.r) + if result != tt.expected { + t.Errorf("isAlpha(%q) = %v, want %v", tt.r, result, tt.expected) + } + }) + } +} + +func TestIsAlphaNum(t *testing.T) { + tests := []struct { + r rune + expected bool + }{ + {'a', true}, + {'Z', true}, + {'0', true}, + {'9', true}, + {'-', false}, + {'_', false}, // Underscore is NOT alphanumeric in this implementation + {'@', false}, + } + + for _, tt := range tests { + t.Run(string(tt.r), func(t *testing.T) { + result := isAlphaNum(tt.r) + if result != tt.expected { + t.Errorf("isAlphaNum(%q) = %v, want %v", tt.r, result, tt.expected) + } + }) + } +} + +func TestTrustLevels_ContainExpected(t *testing.T) { + levels := TrustLevels() + + // Verify we have all expected levels + expectedLabels := map[string]bool{ + "strict": false, + "cautious": false, + "dev": false, + "trusted": false, + } + + for _, level := range levels { + for key := range expectedLabels { + if strings.Contains(string(level.Level), key) { + expectedLabels[key] = true + break + } + } + } + + for key, found := range expectedLabels { + if !found { + t.Errorf("TrustLevels() missing expected level: %s", key) + } + } +} + +func TestResourceLevels_ValidValues(t *testing.T) { + levels := ResourceLevels() + + // Verify all levels have valid resource values + for _, level := range levels { + if level.CPU < 1 { + t.Errorf("%s: CPU must be >= 1, got %d", level.Level, level.CPU) + } + + if !strings.HasSuffix(level.Memory, "GiB") { + t.Errorf("%s: Memory should end with GiB, got %s", level.Level, level.Memory) + } + } +} + +func TestNetworkLevels_ContainExpected(t *testing.T) { + levels := NetworkLevels() + + // Verify we have all expected levels + foundNone := false + foundDownloads := false + foundOngoing := false + + for _, level := range levels { + switch level.Level { + case NetworkNone: + foundNone = true + case NetworkDownloads: + foundDownloads = true + case NetworkOngoing: + foundOngoing = true + } + } + + if !foundNone { + t.Error("NetworkLevels() missing 'none'") + } + if !foundDownloads { + t.Error("NetworkLevels() missing 'downloads'") + } + if !foundOngoing { + t.Error("NetworkLevels() missing 'ongoing'") + } +} diff --git a/go.mod b/go.mod index 71202de..f987da2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/muneebs/airlock go 1.24.2 require ( + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/muesli/termenv v0.16.0 github.com/pelletier/go-toml/v2 v2.3.0 @@ -11,20 +12,33 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index bb2d204..0cb7bed 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,41 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -17,14 +43,28 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= @@ -41,9 +81,14 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index 1617048..165a9ed 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -93,4 +93,12 @@ type SandboxSpec struct { // Command runs immediately after sandbox creation. Command string `json:"command,omitempty" yaml:"command,omitempty"` + + // StartAtLogin requests the VM be started automatically at user login. + StartAtLogin bool `json:"start_at_login,omitempty" yaml:"start_at_login,omitempty"` + + // LockNetworkAfterSetup overrides the profile's LockAfterSetup decision + // when non-nil. Set by callers (e.g. the wizard) that let the user pick + // network behavior independently of the security profile. + LockNetworkAfterSetup *bool `json:"lock_network_after_setup,omitempty" yaml:"lock_network_after_setup,omitempty"` } diff --git a/internal/api/vm.go b/internal/api/vm.go index 6ee53d5..9167d2e 100644 --- a/internal/api/vm.go +++ b/internal/api/vm.go @@ -59,6 +59,9 @@ type VMSpec struct { // ProvisionCmds run once after first boot. ProvisionCmds []string `json:"provision_cmds" yaml:"provision_cmds"` + + // StartAtLogin starts the VM automatically when the user logs in. + StartAtLogin bool `json:"start_at_login,omitempty" yaml:"start_at_login,omitempty"` } // VMMount describes a host directory to mount inside the VM. diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..7e88233 --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,88 @@ +// Package bootstrap wires concrete implementations (Lima VM provider, iptables +// network controller, JSON mount store, sandbox manager) into the api +// interfaces the CLI consumes. Keeping this assembly in its own package is what +// lets the cli package stay free of concrete backend imports — satisfying the +// Dependency Inversion rule in PRINCIPLES.md §5. +package bootstrap + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/muneebs/airlock/internal/api" + "github.com/muneebs/airlock/internal/detect" + "github.com/muneebs/airlock/internal/mount" + "github.com/muneebs/airlock/internal/network" + "github.com/muneebs/airlock/internal/profile" + "github.com/muneebs/airlock/internal/sandbox" + "github.com/muneebs/airlock/internal/vm/lima" +) + +// Dependencies carries the fully-wired interface values the CLI needs. +// All fields are api-package interface types — the CLI never sees concrete +// backend types. +type Dependencies struct { + Manager api.SandboxManager + Provider api.Provider + Provisioner api.Provisioner + Sheller api.ShellProvider + Mounts api.MountManager + Network api.NetworkController + Profiles api.ProfileRegistry + Detector api.RuntimeDetector + ConfigDir string +} + +// Assemble constructs the default production dependency graph. +func Assemble() (*Dependencies, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("get home dir: %w", err) + } + configDir := filepath.Join(home, ".airlock") + if err := os.MkdirAll(configDir, 0755); err != nil { + return nil, fmt.Errorf("create config dir: %w", err) + } + + limaProvider, err := lima.NewLimaProvider() + if err != nil { + return nil, fmt.Errorf("init lima provider: %w", err) + } + + detector := detect.NewCompositeDetector() + profiles := profile.NewRegistry() + + mountStore, err := mount.NewJSONStore(filepath.Join(configDir, "mounts.json")) + if err != nil { + return nil, fmt.Errorf("init mount store: %w", err) + } + + networkCtrl := network.NewLimaController() + storePath := filepath.Join(configDir, "sandboxes.json") + + mgr, err := sandbox.NewManager( + limaProvider, + limaProvider, + detector, + profiles, + mountStore, + networkCtrl, + storePath, + ) + if err != nil { + return nil, fmt.Errorf("init sandbox manager: %w", err) + } + + return &Dependencies{ + Manager: mgr, + Provider: limaProvider, + Provisioner: limaProvider, + Sheller: limaProvider, + Mounts: mountStore, + Network: networkCtrl, + Profiles: profiles, + Detector: detector, + ConfigDir: configDir, + }, nil +} diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..6ffd362 --- /dev/null +++ b/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,65 @@ +package bootstrap + +import ( + "os/exec" + "testing" + + "github.com/muneebs/airlock/internal/api" +) + +// TestAssembleWiresAllInterfaces guards the DI contract: every exported +// field on Dependencies must be non-nil after Assemble so the cli layer +// never sees a nil interface (PRINCIPLES.md §5 DIP). +func TestAssembleWiresAllInterfaces(t *testing.T) { + if _, err := exec.LookPath("limactl"); err != nil { + t.Skip("limactl not installed; skipping bootstrap smoke test") + } + deps, err := Assemble() + if err != nil { + t.Fatalf("Assemble: %v", err) + } + + checks := []struct { + name string + val any + }{ + {"Manager", deps.Manager}, + {"Provider", deps.Provider}, + {"Provisioner", deps.Provisioner}, + {"Sheller", deps.Sheller}, + {"Mounts", deps.Mounts}, + {"Network", deps.Network}, + {"Profiles", deps.Profiles}, + {"Detector", deps.Detector}, + } + for _, c := range checks { + if c.val == nil { + t.Errorf("%s is nil", c.name) + } + } + if deps.ConfigDir == "" { + t.Error("ConfigDir is empty") + } +} + +// TestAssembleInterfaceTypes pins the interface return types so a future +// refactor cannot silently narrow the contract (LSP). +func TestAssembleInterfaceTypes(t *testing.T) { + if _, err := exec.LookPath("limactl"); err != nil { + t.Skip("limactl not installed; skipping bootstrap smoke test") + } + deps, err := Assemble() + if err != nil { + t.Fatalf("Assemble: %v", err) + } + var ( + _ api.SandboxManager = deps.Manager + _ api.Provider = deps.Provider + _ api.Provisioner = deps.Provisioner + _ api.ShellProvider = deps.Sheller + _ api.MountManager = deps.Mounts + _ api.NetworkController = deps.Network + _ api.ProfileRegistry = deps.Profiles + _ api.RuntimeDetector = deps.Detector + ) +} diff --git a/internal/config/config.go b/internal/config/config.go index 807da7e..168e777 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,12 +16,13 @@ import ( // Config represents an airlock project configuration. This is the single source // of truth — both TOML and YAML map to this struct with zero semantic difference. type Config struct { - VM VMConfig `json:"vm" yaml:"vm" toml:"vm"` - Dev DevConfig `json:"dev" yaml:"dev" toml:"dev"` - Runtime RuntimeConfig `json:"runtime" yaml:"runtime" toml:"runtime"` - Security SecurityConfig `json:"security" yaml:"security" toml:"security"` - Services ServicesConfig `json:"services" yaml:"services" toml:"services"` - Mounts []MountConfig `json:"mounts" yaml:"mounts" toml:"mounts"` + VM VMConfig `json:"vm" yaml:"vm" toml:"vm"` + Dev DevConfig `json:"dev" yaml:"dev" toml:"dev"` + Runtime RuntimeConfig `json:"runtime" yaml:"runtime" toml:"runtime"` + Security SecurityConfig `json:"security" yaml:"security" toml:"security"` + Services ServicesConfig `json:"services" yaml:"services" toml:"services"` + Mounts []MountConfig `json:"mounts" yaml:"mounts" toml:"mounts"` + StartAtLogin bool `json:"start_at_login,omitempty" yaml:"start_at_login,omitempty" toml:"start_at_login,omitempty"` } // VMConfig controls the virtual machine resource allocation. @@ -178,22 +179,13 @@ func mergeWithDefaults(cfg Config) Config { return cfg } +// validate enforces structural rules owned by the config package itself: +// mount paths must be relative and free of traversal, services.compose must +// be relative. Profile and runtime membership are NOT checked here — those +// are open sets owned by profile.Registry and detect.CompositeDetector, and +// are checked via ValidateDynamic once those registries are available. This +// keeps config free of hardcoded plugin lists (PRINCIPLES.md §4 DRY, §5 OCP). func validate(cfg Config) error { - validProfiles := map[string]bool{ - "strict": true, "cautious": true, "dev": true, "trusted": true, "": true, - } - if !validProfiles[cfg.Security.Profile] { - return fmt.Errorf("unknown security profile: %q (valid: strict, cautious, dev, trusted)", cfg.Security.Profile) - } - - validRuntimes := map[string]bool{ - "node": true, "go": true, "rust": true, "python": true, - "docker": true, "compose": true, "make": true, "dotnet": true, "": true, - } - if cfg.Runtime.Type != "" && !validRuntimes[cfg.Runtime.Type] { - return fmt.Errorf("unknown runtime type: %q", cfg.Runtime.Type) - } - if cfg.Services.Compose != "" { if strings.HasPrefix(cfg.Services.Compose, "/") { return fmt.Errorf("services.compose must be a relative path, got: %s", cfg.Services.Compose) @@ -217,6 +209,38 @@ func validate(cfg Config) error { return nil } +// ValidateDynamic checks cfg against the open sets of known profiles and +// runtime types. Callers pass in the currently-registered names (from +// api.ProfileRegistry.List() and api.RuntimeDetector.SupportedTypes()), so +// adding a new profile or runtime requires no edit to this package. +// +// An empty profile or runtime is always allowed (means "use default" or +// "auto-detect"). Nil slices skip the check for that dimension. +func ValidateDynamic(cfg Config, profileNames []string, runtimeTypes []string) error { + if cfg.Security.Profile != "" && profileNames != nil { + if !contains(profileNames, cfg.Security.Profile) { + return fmt.Errorf("unknown security profile: %q (valid: %s)", cfg.Security.Profile, strings.Join(profileNames, ", ")) + } + } + + if cfg.Runtime.Type != "" && runtimeTypes != nil { + if !contains(runtimeTypes, cfg.Runtime.Type) { + return fmt.Errorf("unknown runtime type: %q (valid: %s)", cfg.Runtime.Type, strings.Join(runtimeTypes, ", ")) + } + } + + return nil +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + // WriteTOML serializes a Config to TOML format. func WriteTOML(cfg Config) ([]byte, error) { return toml.Marshal(cfg) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 577f8af..4a25004 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -284,40 +284,46 @@ func TestConfigFileDetection(t *testing.T) { } } -func TestValidateInvalidProfile(t *testing.T) { - dir := t.TempDir() - tomlContent := ` -[security] -profile = "invalid" -` - err := os.WriteFile(filepath.Join(dir, "airlock.toml"), []byte(tomlContent), 0644) - if err != nil { - t.Fatal(err) - } +func TestValidateDynamicInvalidProfile(t *testing.T) { + cfg := Defaults() + cfg.Security.Profile = "invalid" - _, err = Load(dir) + err := ValidateDynamic(cfg, []string{"strict", "cautious"}, nil) if err == nil { t.Fatal("expected validation error for invalid profile") } } -func TestValidateInvalidRuntime(t *testing.T) { - dir := t.TempDir() - tomlContent := ` -[runtime] -type = "cobol" -` - err := os.WriteFile(filepath.Join(dir, "airlock.toml"), []byte(tomlContent), 0644) - if err != nil { - t.Fatal(err) - } +func TestValidateDynamicInvalidRuntime(t *testing.T) { + cfg := Defaults() + cfg.Runtime.Type = "cobol" - _, err = Load(dir) + err := ValidateDynamic(cfg, nil, []string{"node", "go"}) if err == nil { t.Fatal("expected validation error for invalid runtime") } } +func TestValidateDynamicAcceptsKnown(t *testing.T) { + cfg := Defaults() + cfg.Security.Profile = "cautious" + cfg.Runtime.Type = "go" + + if err := ValidateDynamic(cfg, []string{"strict", "cautious"}, []string{"node", "go"}); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateDynamicNilSkipsChecks(t *testing.T) { + cfg := Defaults() + cfg.Security.Profile = "anything" + cfg.Runtime.Type = "anything" + + if err := ValidateDynamic(cfg, nil, nil); err != nil { + t.Fatalf("nil registries should skip check, got %v", err) + } +} + func TestValidateAbsolutePathMount(t *testing.T) { dir := t.TempDir() tomlContent := ` diff --git a/internal/config/save.go b/internal/config/save.go new file mode 100644 index 0000000..12bf746 --- /dev/null +++ b/internal/config/save.go @@ -0,0 +1,174 @@ +// Package config handles loading and saving of airlock project configuration. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Save writes the configuration to an airlock.toml file in the specified directory. +// The saved file includes helpful comments explaining each section. +func Save(dir string, cfg Config) error { + path := filepath.Join(dir, "airlock.toml") + + content, err := FormatWithComments(cfg) + if err != nil { + return fmt.Errorf("format config: %w", err) + } + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("write config file: %w", err) + } + + return nil +} + +// FormatWithComments formats the configuration as TOML with human-friendly comments. +// It uses WriteTOML for the actual serialization (DRY principle) and prepends comments. +func FormatWithComments(cfg Config) (string, error) { + // Get the base TOML content using the existing serialization + tomlBytes, err := WriteTOML(cfg) + if err != nil { + return "", fmt.Errorf("serialize config: %w", err) + } + + // Parse TOML and inject comments between sections + tomlStr := string(tomlBytes) + + var b strings.Builder + + // Header with generation info + b.WriteString("# Airlock Configuration\n") + b.WriteString(fmt.Sprintf("# Generated on %s\n", time.Now().Format("2006-01-02"))) + b.WriteString("#\n") + b.WriteString("# Documentation: https://github.com/muneebs/airlock\n") + b.WriteString("#\n\n") + + // Process TOML content section by section, adding comments. + // Split on TOML section headers (^\[.*\]$) rather than blank lines, + // which is fragile with multi-line values, arrays of tables, and inline tables. + sections := splitTOMLSections(tomlStr) + nonEmpty := sections[:0] + for _, s := range sections { + if strings.TrimSpace(s) != "" { + nonEmpty = append(nonEmpty, s) + } + } + for i, section := range nonEmpty { + // Add section comment based on section header + sectionName := extractSectionName(section) + comment := sectionComment(sectionName, cfg) + if comment != "" { + b.WriteString(comment) + b.WriteString("\n") + } + + b.WriteString(strings.TrimRight(section, "\n")) + + // Add separator between sections (but not after last) + if i < len(nonEmpty)-1 { + b.WriteString("\n\n") + } + } + + return b.String(), nil +} + +// splitTOMLSections splits TOML content at section headers (lines matching +// `[...]` or `[[...]]`). The header line is retained with its section. +// Content preceding the first header is returned as the first element. +func splitTOMLSections(s string) []string { + var sections []string + var cur strings.Builder + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + isHeader := strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") + if isHeader && cur.Len() > 0 { + sections = append(sections, cur.String()) + cur.Reset() + } + cur.WriteString(line) + cur.WriteString("\n") + } + if cur.Len() > 0 { + sections = append(sections, cur.String()) + } + return sections +} + +// extractSectionName gets the section name from TOML content (e.g., "[vm]" -> "vm") +func extractSectionName(content string) string { + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + return strings.Trim(trimmed, "[]") + } + } + return "" +} + +// sectionComment returns the appropriate comment for a given section +func sectionComment(section string, cfg Config) string { + switch section { + case "security": + return securitySectionComment(cfg.Security.Profile) + case "vm": + return vmSectionComment(cfg.VM) + case "dev": + return devSectionComment() + case "runtime": + return runtimeSectionComment(cfg.Runtime.Type) + case "services": + return servicesSectionComment() + case "mounts": + return mountsSectionComment() + default: + return "" + } +} + +func securitySectionComment(profile string) string { + var desc string + switch profile { + case "strict": + desc = "No host mounts, network locked. For untrusted software." + case "cautious": + desc = "Read-only mounts, network locked. Default for unknown software." + case "dev": + desc = "Read-write mounts, open network. For trusted development." + case "trusted": + desc = "Full access. Only for software you completely trust." + default: + desc = "Read-only mounts, network locked. Default for unknown software." + } + + return fmt.Sprintf("# Security Profile: %s\n# %s", profile, desc) +} + +func vmSectionComment(vm VMConfig) string { + var b strings.Builder + b.WriteString("# VM Resources\n") + b.WriteString("# Configure the virtual machine hardware.\n") + b.WriteString(fmt.Sprintf("# Current: %d CPU, %s RAM, %s disk", vm.CPU, vm.Memory, vm.Disk)) + return b.String() +} + +func devSectionComment() string { + return "# Development Settings\n# Configure ports and default commands." +} + +func runtimeSectionComment(runtimeType string) string { + return fmt.Sprintf("# Runtime Configuration\n# Auto-detected: %s", runtimeType) +} + +func servicesSectionComment() string { + return "# Services\n# Define background services to start automatically." +} + +func mountsSectionComment() string { + return "# Additional Mounts\n# Extra directories to mount into the sandbox.\n# Paths are relative to this config file." +} diff --git a/internal/config/save_test.go b/internal/config/save_test.go new file mode 100644 index 0000000..683491b --- /dev/null +++ b/internal/config/save_test.go @@ -0,0 +1,124 @@ +// Package config handles loading and saving of airlock project configuration. +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSave(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Defaults() + cfg.Security.Profile = "cautious" + cfg.VM.CPU = 2 + cfg.VM.Memory = "4GiB" + + if err := Save(tmpDir, cfg); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Check file exists + path := filepath.Join(tmpDir, "airlock.toml") + if _, err := os.Stat(path); err != nil { + t.Errorf("Save() did not create file: %v", err) + } + + // Check file is valid TOML by loading it + loaded, err := Load(tmpDir) + if err != nil { + t.Errorf("Save() created invalid TOML: %v", err) + } + + if loaded.Security.Profile != cfg.Security.Profile { + t.Errorf("Loaded profile = %q, want %q", loaded.Security.Profile, cfg.Security.Profile) + } +} + +func TestFormatWithComments(t *testing.T) { + cfg := Defaults() + cfg.Security.Profile = "strict" + cfg.Runtime.Type = "node" + cfg.StartAtLogin = true + + content, err := FormatWithComments(cfg) + if err != nil { + t.Fatalf("FormatWithComments() error = %v", err) + } + + // Check for expected comments + expectedComments := []string{ + "# Airlock Configuration", + "# Generated on", + "# Documentation:", + "# Security Profile:", + "strict", + "# VM Resources", + "# Development Settings", + } + + for _, expected := range expectedComments { + if !strings.Contains(content, expected) { + t.Errorf("FormatWithComments() missing expected content: %q", expected) + } + } + + // Check for TOML structure + if !strings.Contains(content, "[security]") { + t.Error("FormatWithComments() missing [security] section") + } + if !strings.Contains(content, "[vm]") { + t.Error("FormatWithComments() missing [vm] section") + } +} + +func TestSecuritySectionComment(t *testing.T) { + tests := []struct { + profile string + expected string + }{ + {"strict", "No host mounts, network locked"}, + {"cautious", "Read-only mounts, network locked"}, + {"dev", "Read-write mounts, open network"}, + {"trusted", "Full access"}, + {"unknown", "Read-only mounts, network locked"}, + } + + for _, tt := range tests { + result := securitySectionComment(tt.profile) + if !strings.Contains(result, tt.expected) { + t.Errorf("securitySectionComment(%q) missing %q", tt.profile, tt.expected) + } + } +} + +func TestFormatWithComments_StartAtLogin(t *testing.T) { + cfg := Defaults() + cfg.StartAtLogin = true + + content, err := FormatWithComments(cfg) + if err != nil { + t.Fatalf("FormatWithComments() error = %v", err) + } + + // StartAtLogin=true is non-zero, so omitempty still emits the key. + if !strings.Contains(content, "start_at_login = true") { + t.Error("FormatWithComments() missing start_at_login = true when StartAtLogin is true") + } +} + +func TestFormatWithComments_Runtime(t *testing.T) { + cfg := Defaults() + cfg.Runtime.Type = "python" + + content, err := FormatWithComments(cfg) + if err != nil { + t.Fatalf("FormatWithComments() error = %v", err) + } + + if !strings.Contains(content, "python") { + t.Error("FormatWithComments() missing runtime type in comments") + } +} diff --git a/internal/sandbox/create.go b/internal/sandbox/create.go index 91ca30f..9505acd 100644 --- a/internal/sandbox/create.go +++ b/internal/sandbox/create.go @@ -108,7 +108,7 @@ func (m *Manager) CreateWithOptions(ctx context.Context, spec api.SandboxSpec, o if !opts.SkipNetworkPolicy { report("applying network policy") - if err := m.applyNetworkPolicy(ctx, spec.Name, prof); err != nil { + if err := m.applyNetworkPolicy(ctx, spec.Name, prof, spec.LockNetworkAfterSetup); err != nil { m.mu.Lock() info.State = api.StateErrored _ = m.put(info) @@ -194,22 +194,27 @@ func (m *Manager) ApplyNetworkProfile(ctx context.Context, name string) error { if perr != nil { return fmt.Errorf("resolve profile %q: %w", info.Profile, perr) } - return m.applyNetworkPolicy(ctx, name, prof) + return m.applyNetworkPolicy(ctx, name, prof, nil) } -func (m *Manager) applyNetworkPolicy(ctx context.Context, name string, prof api.Profile) error { +func (m *Manager) applyNetworkPolicy(ctx context.Context, name string, prof api.Profile, lockOverride *bool) error { + lockAfter := prof.Network.LockAfterSetup + if lockOverride != nil { + lockAfter = *lockOverride + } + policy := api.NetworkPolicy{ AllowDNS: prof.Network.AllowDNS, AllowOutbound: prof.Network.AllowOutbound, AllowEstablished: prof.Network.AllowEstablished, - LockAfterSetup: prof.Network.LockAfterSetup, + LockAfterSetup: lockAfter, } if err := m.network.ApplyPolicy(ctx, name, policy); err != nil { return err } - if prof.Network.LockAfterSetup { + if lockAfter { if err := m.network.Lock(ctx, name); err != nil { return err } diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index f034ff9..6468250 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -144,18 +144,19 @@ func resolveResources(spec api.SandboxSpec, prof api.Profile, cfgDefaults api.Sa } vmSpec := api.VMSpec{ - Name: spec.Name, - OS: "Linux", - Arch: "default", - CPU: cpu, - Memory: memory, - Disk: disk, - Ports: spec.Ports, + Name: spec.Name, + OS: "Linux", + Arch: "default", + CPU: cpu, + Memory: memory, + Disk: disk, + Ports: spec.Ports, + StartAtLogin: spec.StartAtLogin, } - if spec.Source != "" { + if hostPath := resolveMountHostPath(spec.Source); hostPath != "" { vmSpec.Mounts = append(vmSpec.Mounts, api.VMMount{ - HostPath: spec.Source, + HostPath: hostPath, GuestPath: "/home/airlock/projects/" + spec.Name, Writable: prof.Mount.Writable, Inotify: true, @@ -165,6 +166,31 @@ func resolveResources(spec api.SandboxSpec, prof api.Profile, cfgDefaults api.Sa return vmSpec } +// resolveMountHostPath turns a SandboxSpec.Source into an absolute host +// path suitable for a Lima mount. Lima rejects relative paths — see +// `mounts[0].location must be an absolute path`. Remote sources +// (gh:..., https://...) return "" because they are cloned into the VM, +// not mounted from the host. +func resolveMountHostPath(source string) string { + if source == "" || isRemoteSource(source) { + return "" + } + abs, err := filepath.Abs(source) + if err != nil { + return "" + } + return abs +} + +func isRemoteSource(source string) bool { + for _, prefix := range []string{"gh:", "https://", "http://", "git@"} { + if len(source) >= len(prefix) && source[:len(prefix)] == prefix { + return true + } + } + return false +} + // ErrNotFound is returned when a sandbox name does not exist. type ErrNotFound struct { Name string diff --git a/internal/vm/lima/config.go b/internal/vm/lima/config.go index 3614460..4720a2c 100644 --- a/internal/vm/lima/config.go +++ b/internal/vm/lima/config.go @@ -54,6 +54,7 @@ type LimaConfig struct { Mounts []LimaMount `yaml:"mounts"` PortForwards []LimaPortForward `yaml:"portForwards,omitempty"` Provision []LimaProvision `yaml:"provision,omitempty"` + StartAtLogin bool `yaml:"startAtLogin,omitempty"` } type LimaImage struct { @@ -86,13 +87,14 @@ func GenerateConfig(spec api.VMSpec) (string, error) { } cfg := LimaConfig{ - VMType: "vz", - OS: "Linux", - Arch: "default", - CPUs: spec.CPU, - Memory: spec.Memory, - Disk: spec.Disk, - MountType: "virtiofs", + VMType: "vz", + OS: "Linux", + Arch: "default", + CPUs: spec.CPU, + Memory: spec.Memory, + Disk: spec.Disk, + MountType: "virtiofs", + StartAtLogin: spec.StartAtLogin, Images: []LimaImage{ {Location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img", Arch: "aarch64"}, {Location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img", Arch: "x86_64"}, diff --git a/internal/vm/lima/provider.go b/internal/vm/lima/provider.go index 843f4cf..352d4a9 100644 --- a/internal/vm/lima/provider.go +++ b/internal/vm/lima/provider.go @@ -96,7 +96,11 @@ func (p *LimaProvider) Start(ctx context.Context, name string) error { if err := validateName(name); err != nil { return fmt.Errorf("invalid vm name: %w", err) } - _, err := p.runCmdDetached(ctx, "start", name) + // --tty=false is required when the caller may not own a TTY (e.g. the + // `airlock init` wizard handed its TTY to huh, or CI). Without it, + // limactl renders progress via an interactive terminal writer that + // corrupts or blocks on a non-tty stdout. + _, err := p.runCmdDetached(ctx, "start", "--tty=false", name) if err != nil { return fmt.Errorf("start VM %s: %w", name, err) } @@ -108,7 +112,7 @@ func (p *LimaProvider) Stop(ctx context.Context, name string) error { if err := validateName(name); err != nil { return fmt.Errorf("invalid vm name: %w", err) } - _, err := p.runCmd(ctx, "stop", name) + _, err := p.runCmd(ctx, "stop", "--tty=false", name) if err != nil { return fmt.Errorf("stop VM %s: %w", name, err) } @@ -120,7 +124,7 @@ func (p *LimaProvider) Delete(ctx context.Context, name string) error { if err := validateName(name); err != nil { return fmt.Errorf("invalid vm name: %w", err) } - _, err := p.runCmd(ctx, "delete", name) + _, err := p.runCmd(ctx, "delete", "--tty=false", name) if err != nil { return fmt.Errorf("delete VM %s: %w", name, err) } @@ -344,7 +348,12 @@ func (p *LimaProvider) Shell(ctx context.Context, name string) error { if err := validateName(name); err != nil { return fmt.Errorf("invalid vm name: %w", err) } - cmd := exec.CommandContext(ctx, p.limactlPath, "shell", name) + // limactl shell logs in as the host user by default, which cannot access + // /home/airlock/projects/. Switch to the airlock user via sudo -iu + // and cd into the project mount before handing off to an interactive bash. + workdir := "/home/airlock/projects/" + name + inner := fmt.Sprintf("cd %s && exec bash", shellEscape(workdir)) + cmd := exec.CommandContext(ctx, p.limactlPath, "shell", "--workdir", "/", name, "--", "sudo", "-iu", "airlock", "bash", "-c", inner) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/internal/vm/lima/snapshot.go b/internal/vm/lima/snapshot.go index 30d2177..62e1557 100644 --- a/internal/vm/lima/snapshot.go +++ b/internal/vm/lima/snapshot.go @@ -166,7 +166,13 @@ func (p *LimaProvider) ProvisionSteps(name string, nodeVersion int) []api.Provis {fmt.Sprintf("Installing Node.js %d", nodeVersion), "node.js", []string{"sudo", "bash", "-c", fmt.Sprintf("curl -fsSL https://deb.nodesource.com/setup_%d.x | bash - && apt-get install -y nodejs", nodeVersion)}}, {"Installing pnpm", "pnpm", []string{"sudo", "npm", "install", "-g", "pnpm"}}, {"Creating airlock user", "create airlock user", []string{"sudo", "bash", "-c", "id airlock &>/dev/null || useradd -m -s /bin/bash airlock"}}, - {"Preparing airlock home", "setup airlock dirs", []string{"sudo", "bash", "-c", "mkdir -p /home/airlock/.npm-global /home/airlock/projects && chown -R airlock:airlock /home/airlock"}}, + // chown must be -xdev so it does not descend into virtiofs mounts + // (e.g. /home/airlock/projects/) where chown returns EPERM. + // chown must skip virtiofs mount points under /home/airlock/projects/* + // (EPERM across fs boundary). -xdev alone still visits the mountpoint + // entry itself, so prune /home/airlock/projects explicitly and chown + // only that directory (not its contents). + {"Preparing airlock home", "setup airlock dirs", []string{"sudo", "bash", "-c", "mkdir -p /home/airlock/.npm-global /home/airlock/projects && find /home/airlock -xdev -path /home/airlock/projects -prune -o -print0 | xargs -0 chown airlock:airlock && chown airlock:airlock /home/airlock/projects"}}, {"Configuring npm prefix", "npm prefix", []string{"sudo", "-u", "airlock", "bash", "--login", "-c", "npm config set prefix /home/airlock/.npm-global"}}, } diff --git a/test/integration/harness_test.go b/test/integration/harness_test.go index 0b4a760..e1f6697 100644 --- a/test/integration/harness_test.go +++ b/test/integration/harness_test.go @@ -73,15 +73,18 @@ func newHarness(t *testing.T) *harness { printf '{"name":"%s","status":"Stopped"}\n' "$name" > "$SDIR/$name.json" ;; start) - name="$2" + shift + for arg in "$@"; do case "$arg" in --*) ;; *) name="$arg" ;; esac; done printf '{"name":"%s","status":"Running"}\n' "$name" > "$SDIR/$name.json" ;; stop) - name="$2" + shift + for arg in "$@"; do case "$arg" in --*) ;; *) name="$arg" ;; esac; done printf '{"name":"%s","status":"Stopped"}\n' "$name" > "$SDIR/$name.json" ;; delete) - name="$2" + shift + for arg in "$@"; do case "$arg" in --*) ;; *) name="$arg" ;; esac; done rm -f "$SDIR/$name.json" ;; shell)