From d63e39ca095fe42697b29eda14d74e7940d370c7 Mon Sep 17 00:00:00 2001 From: Stephen <939775+thecodeassassin@users.noreply.github.com> Date: Wed, 13 May 2026 00:23:43 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Short:=20IPC=20fix=20+=20Qwen=20+=20env=20p?= =?UTF-8?q?rofiles=20+=20OpenCode=20install=20overhaul=20+=20"profiles?= =?UTF-8?q?=E2=86=92roles"=20rename=20+=20build=20cache=20hardening=20+=20?= =?UTF-8?q?raw-command=20escape=20in=20entrypoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 76 +++++- cmd/config_cmd.go | 40 ++- cmd/env.go | 308 ++++++++++++++++++++++ cmd/rebuild.go | 5 - cmd/run.go | 40 +++ internal/agents/codex/docker.go | 6 +- internal/agents/jstools/install.go | 29 ++ internal/agents/jstools/install_test.go | 25 ++ internal/agents/opencode/docker.go | 90 +++---- internal/agents/opencode/opencode.go | 13 + internal/agents/opencode/opencode_test.go | 14 +- internal/agents/opencode/version.go | 40 +-- internal/agents/qwen/docker.go | 4 +- internal/env/env.go | 224 ++++++++++++++++ internal/env/env_test.go | 166 ++++++++++++ internal/image/core.go | 3 + internal/image/project.go | 8 +- internal/image/tools.go | 3 + internal/ipc/server.go | 8 + internal/profile/dockerfile.go | 8 +- internal/profile/manager.go | 2 +- internal/profile/profile.go | 28 +- internal/run/run.go | 44 ++-- internal/run/run_test.go | 37 +++ internal/wizard/roles.go | 31 +-- internal/wizard/roles_test.go | 8 +- internal/wizard/tui.go | 94 ++++++- internal/wizard/wizard.go | 4 +- static/build/docker-entrypoint | 40 +++ 29 files changed, 1233 insertions(+), 165 deletions(-) create mode 100644 cmd/env.go create mode 100644 internal/agents/jstools/install.go create mode 100644 internal/agents/jstools/install_test.go create mode 100644 internal/env/env.go create mode 100644 internal/env/env_test.go diff --git a/README.md b/README.md index 293c726..6619b68 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ **Multi-Agent Container Sandbox** by [Cloud Exit](https://cloud-exit.com) -Run AI coding assistants (Claude, Codex, OpenCode) in isolated containers with defense-in-depth security. +Run AI coding assistants (Claude, Codex, OpenCode, Qwen) in isolated containers with defense-in-depth security. ## Getting Started @@ -40,6 +40,7 @@ exitbox run claude # Or run other agents exitbox run codex exitbox run opencode +exitbox run qwen ``` ExitBox automatically: @@ -56,7 +57,7 @@ ExitBox automatically: - **Encrypted Vault** — AES-256 + Argon2id encrypted secret storage with per-access approval popups; agents can read and write secrets from inside the container - **Sandbox-Aware Agents** — automatic instruction injection tells agents about container restrictions, vault usage, and security rules - **Named Resumable Sessions** — save and resume agent conversations by name across container restarts -- **Multi-Agent Support** — run Claude Code, OpenAI Codex, or OpenCode in the same isolated environment +- **Multi-Agent Support** — run Claude Code, OpenAI Codex, OpenCode, or Qwen Code in the same isolated environment - **Workspace Isolation** — named contexts (personal, work, client) with separate credentials, tools, and vault per workspace - **Codex Account Switching** — save and switch between multiple Codex logins inside a workspace with `exitbox codex accounts` - **IDE Integration** — Unix socket relay connects host editors (VS Code, Cursor, Windsurf) to agents inside the container for go-to-definition, diagnostics, and code actions @@ -64,7 +65,7 @@ ExitBox automatically: - **GitHub CLI Authentication** — pre-flight vault import for `GITHUB_TOKEN` with automatic in-container export so `gh` and HTTPS git work transparently - **RTK Token Optimizer (Experimental)** — optional [rtk](https://github.com/rtk-ai/rtk) integration reduces CLI output token consumption by 60-90% - **External Tools** — configure third-party tools (GitHub CLI, etc.) via the setup wizard; packages are auto-installed at image build time -- **Supply-Chain Hardened Installs** — Claude Code installed via direct binary download with SHA-256 checksum verification +- **Supply-Chain Hardened Installs** — Claude Code and OpenCode installed via direct download with SHA-256 checksum verification - **Alpine Base Image** — minimal ~5 MB base with 3-layer image hierarchy and incremental rebuilds - **Setup Wizard** — interactive TUI that configures roles, languages, tools, agents, firewall, and vault in one pass - **Cross-Platform** — native binaries for Linux, macOS, and Windows @@ -269,8 +270,9 @@ Skills are stored at `~/.config/exitbox/profiles/global//skills//SKILL.md` | | Codex | `~/.agents/skills//SKILL.md` | | OpenCode | `~/.config/opencode/skills//SKILL.md` | +| Qwen | `~/.config/qwen/skills//SKILL.md` | -All three agents use the same [Agent Skills](https://agentskills.io) SKILL.md format with YAML frontmatter, so a single skill works across agents. +All agents use the same [Agent Skills](https://agentskills.io) SKILL.md format with YAML frontmatter, so a single skill works across agents. ### Named Resumable Sessions @@ -303,6 +305,7 @@ The first access in a session prompts for the vault password. Subsequent reads o | `claude` | Anthropic's Claude Code CLI | None (installed in container) | | `codex` | OpenAI's Codex CLI | None (downloaded)| | `opencode` | OpenCode AI assistant | None (binary download) | +| `qwen` | Qwen Code AI assistant | None (npm install) | All agents are installed inside the container. Existing host config (`~/.claude`, etc.) is imported once into managed storage on first run. Use `exitbox config import ` (or `exitbox config import all`) to re-seed from host config. Use `--workspace` to target a specific workspace. Use `--config`/`-c` to import a specific config file. Use `exitbox config edit ` to open the agent's primary config file in your editor: @@ -312,8 +315,45 @@ exitbox config import opencode -c opencode.json # Import OpenCode config fil exitbox config import codex -c config.toml -w work # Import into specific workspace exitbox config edit claude # Edit Claude settings.json in $EDITOR exitbox config edit codex -w work # Edit Codex config.toml for 'work' workspace +exitbox config edit claude --profile openrouter # Edit profile-scoped settings.json (seeded from default) ``` +### Environment Variable Profiles + +Named bundles of environment variables stored per-workspace in the KV store. Apply a profile at run time to inject variables via container `-e` flags. Useful for switching between providers (e.g. OpenRouter, direct Anthropic) without editing config. + +```bash +exitbox env create openrouter # Opens $EDITOR; paste KEY=VALUE lines, save +exitbox env edit openrouter # Edit existing profile +exitbox env list # List profiles in active workspace +exitbox env show openrouter # Show keys (values redacted) +exitbox env show openrouter --unsafe # Show raw values +exitbox env delete openrouter +exitbox env default openrouter # Auto-load this profile on every run +exitbox env default # Show current default +exitbox env default --clear # Remove the default +``` + +When a default profile is set, it is loaded automatically on every `exitbox run` without needing `--profile`. CLI `--profile` overrides the default. + +Example profile for routing Claude Code via OpenRouter: + +``` +OPENROUTER_API_KEY=sk-or-v1-... +ANTHROPIC_BASE_URL=https://openrouter.ai/api +ANTHROPIC_AUTH_TOKEN=$OPENROUTER_API_KEY +ANTHROPIC_API_KEY= +``` + +Apply at run time: + +```bash +exitbox run claude --profile openrouter # Loads env vars + uses profile-scoped agent config +exitbox run claude -- --profile openrouter # Same (after --) +``` + +Config files can be scoped per-profile — `exitbox config edit claude --profile openrouter` creates a copy of the default config under the profile, which takes effect only when `--profile openrouter` is used at run time. CLI `-e KEY=VAL` flags override profile values. + ## Installation ### Prerequisites @@ -441,6 +481,7 @@ exitbox setup # Run the interactive setup wizard (recommended first exitbox run claude [args] # Run Claude Code exitbox run codex [args] # Run Codex exitbox run opencode [args] # Run OpenCode +exitbox run qwen [args] # Run Qwen Code ``` ### Management @@ -466,6 +507,13 @@ exitbox config edit # Open agent config file in $EDITOR exitbox skills install # Install a skill from URL/path exitbox skills list # List installed skills exitbox skills remove # Remove an installed skill +exitbox env create # Create a new env profile +exitbox env edit # Edit env profile in $EDITOR +exitbox env list # List env profiles +exitbox env show # Show env profile (values redacted) +exitbox env delete # Delete env profile +exitbox env default [profile] # Get or set the default env profile +exitbox env default --clear # Remove the default env profile ``` ### Config Generation @@ -563,7 +611,7 @@ Agents are automatically informed about vault commands and these rules via sandb #### How Workspaces Work - **Isolated credentials**: Each workspace has its own agent config directory at `~/.config/exitbox/profiles/global///`. API keys, auth tokens, and conversation history are not shared between workspaces. -- **Development stacks**: Each workspace can have its own set of development profiles (languages/tools). The setup wizard or `exitbox workspaces add` lets you pick the stack for each workspace. +- **Development stacks**: Each workspace can have its own set of dev roles (languages/tools). The setup wizard or `exitbox workspaces add` lets you pick the stack for each workspace. - **Per-project auto-detection**: Workspaces can be scoped to a directory. When you run an agent from that directory, ExitBox automatically uses the matching workspace. - **Default workspace**: Set via `exitbox setup` or `exitbox workspaces default`. Used when no directory-scoped workspace matches. - **Credential import**: When creating a workspace, you can import credentials from the host or copy them from an existing workspace. You can also import later with `exitbox config import --workspace `. @@ -675,15 +723,16 @@ exitbox run --full-git-support claude # Mount host .gitconfig and SSH agent exitbox run --ollama claude # Use host Ollama for local models exitbox run --memory 16g --cpus 8 claude # Custom resource limits exitbox run --version 1.0.123 claude # Pin specific agent version +exitbox run --profile openrouter claude # Apply env profile (vars + profile-scoped config) ``` -All flags have long forms: `-f`/`--no-firewall`, `-r`/`--read-only`, `-v`/`--verbose`, `-n`/`--no-env`, `--resume [SESSION|TOKEN]`, `--no-resume`, `--name`, `-i`/`--include-dir`, `-t`/`--tools`, `-a`/`--allow-urls`, `-u`/`--update`, `-w`/`--workspace`, `--full-git-support`, `--ollama`, `--memory`, `--cpus`, `--version`. +All flags have long forms: `-f`/`--no-firewall`, `-r`/`--read-only`, `-v`/`--verbose`, `-n`/`--no-env`, `--resume [SESSION|TOKEN]`, `--no-resume`, `--name`, `-i`/`--include-dir`, `-t`/`--tools`, `-a`/`--allow-urls`, `-u`/`--update`, `-w`/`--workspace`, `--profile`, `--full-git-support`, `--ollama`, `--memory`, `--cpus`, `--version`. -## Available Profiles +## Available Roles -Profiles are pre-configured development environments. The setup wizard suggests profiles based on your developer role, or you can add them manually. +Roles (formerly "profiles") are pre-configured development environments (toolchains, language runtimes, CLI stacks). The setup wizard suggests roles based on the developer role preset you pick (Frontend, Backend, AI Developer, etc.), or you can add them manually. -| Profile | Description | +| Role | Description | |:--------------|:-----------------------------------------| | `base` | Base development tools | | `build-tools` | Build toolchain helpers | @@ -841,6 +890,8 @@ All managed paths follow the pattern `~/.config/exitbox/profiles/global/", Short: "Open agent config file in $EDITOR", Long: `Opens the agent's primary config file in your $EDITOR (or vi). -Creates the file if it doesn't exist. +Creates the file if it doesn't exist. With --profile, edits the +profile-scoped config (seeded from the default config on first edit). Examples: - exitbox config edit claude Edit Claude settings.json - exitbox config edit codex Edit Codex config.toml - exitbox config edit opencode -w work Edit OpenCode config in 'work' workspace`, + exitbox config edit claude Edit Claude settings.json (default) + exitbox config edit codex Edit Codex config.toml + exitbox config edit opencode -w work Edit OpenCode config in 'work' workspace + exitbox config edit claude --profile openrouter Edit Claude settings.json scoped to 'openrouter' profile`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] @@ -169,16 +172,36 @@ Examples: cfg := config.LoadOrDefault() workspaceName := resolveConfigWorkspace(cfg, workspace) - wsDir := profile.WorkspaceAgentDir(workspaceName, name) + + if envProfile != "" { + if err := validateProfileName(envProfile); err != nil { + ui.Errorf("%v", err) + } + } + + wsDir := envProfileConfigDir(workspaceName, name, envProfile) p := a.ConfigFilePath(wsDir) - // Create parent dirs + empty file if it doesn't exist. + // Seed from default config on first edit of a profile-scoped file. if _, err := os.Stat(p); os.IsNotExist(err) { if mkErr := os.MkdirAll(filepath.Dir(p), 0755); mkErr != nil { ui.Errorf("Failed to create directory: %v", mkErr) } - if wErr := os.WriteFile(p, []byte{}, 0644); wErr != nil { - ui.Errorf("Failed to create config file: %v", wErr) + if envProfile != "" { + defaultPath := a.ConfigFilePath(profile.WorkspaceAgentDir(workspaceName, name)) + if data, readErr := os.ReadFile(defaultPath); readErr == nil { + if wErr := os.WriteFile(p, data, 0644); wErr != nil { + ui.Errorf("Failed to seed profile config: %v", wErr) + } + } else { + if wErr := os.WriteFile(p, []byte{}, 0644); wErr != nil { + ui.Errorf("Failed to create config file: %v", wErr) + } + } + } else { + if wErr := os.WriteFile(p, []byte{}, 0644); wErr != nil { + ui.Errorf("Failed to create config file: %v", wErr) + } } } @@ -197,6 +220,7 @@ Examples: } cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace name (default: active workspace)") + cmd.Flags().StringVar(&envProfile, "profile", "", "Scope to a named env profile (creates profile-specific config)") return cmd } diff --git a/cmd/env.go b/cmd/env.go new file mode 100644 index 0000000..f20c81d --- /dev/null +++ b/cmd/env.go @@ -0,0 +1,308 @@ +// ExitBox - Multi-Agent Container Sandbox +// Copyright (C) 2026 Cloud Exit B.V. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/cloud-exit/exitbox/internal/config" + "github.com/cloud-exit/exitbox/internal/env" + "github.com/cloud-exit/exitbox/internal/ui" + "github.com/spf13/cobra" +) + +func newEnvCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Short: "Manage environment variable profiles for a workspace", + Long: `Environment variable profiles store named bundles of KEY=VALUE +pairs in the workspace KV store. Apply a profile at run time with +'exitbox run --profile ' to inject the variables via +container '-e' flags. + +Example use case: route Claude Code through OpenRouter by saving +ANTHROPIC_BASE_URL, OPENROUTER_API_KEY, etc. in a profile.`, + } + + cmd.AddCommand(newEnvCreateCmd()) + cmd.AddCommand(newEnvEditCmd()) + cmd.AddCommand(newEnvListCmd()) + cmd.AddCommand(newEnvShowCmd()) + cmd.AddCommand(newEnvDeleteCmd()) + cmd.AddCommand(newEnvDefaultCmd()) + return cmd +} + +func newEnvCreateCmd() *cobra.Command { + var workspace string + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new env profile and open it in $EDITOR", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + ws := resolveEnvWorkspace(workspace) + if env.Exists(ws, name) { + ui.Errorf("Env profile '%s' already exists in workspace '%s'", name, ws) + } + editEnvProfile(ws, name, true) + }, + } + cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace name (default: active)") + return cmd +} + +func newEnvEditCmd() *cobra.Command { + var workspace string + cmd := &cobra.Command{ + Use: "edit ", + Short: "Edit an env profile in $EDITOR", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + ws := resolveEnvWorkspace(workspace) + editEnvProfile(ws, name, false) + }, + } + cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace name (default: active)") + return cmd +} + +func newEnvListCmd() *cobra.Command { + var workspace string + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List env profiles in a workspace", + Run: func(cmd *cobra.Command, args []string) { + ws := resolveEnvWorkspace(workspace) + names, err := env.List(ws) + if err != nil { + ui.Errorf("Failed to list env profiles: %v", err) + } + if len(names) == 0 { + ui.Infof("No env profiles in workspace '%s'", ws) + return + } + for _, n := range names { + fmt.Println(n) + } + }, + } + cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace name (default: active)") + return cmd +} + +func newEnvShowCmd() *cobra.Command { + var workspace string + var unsafe bool + cmd := &cobra.Command{ + Use: "show ", + Short: "Print env profile contents (values redacted unless --unsafe)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + ws := resolveEnvWorkspace(workspace) + p, err := env.Load(ws, name) + if err != nil { + ui.Errorf("%v", err) + } + if !unsafe { + redacted := make(map[string]string, len(p.Vars)) + for k, v := range p.Vars { + if v == "" { + redacted[k] = "" + } else { + redacted[k] = "" + } + } + fmt.Print(env.FormatEnvFile(redacted)) + return + } + fmt.Print(env.FormatEnvFile(p.Vars)) + }, + } + cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace name (default: active)") + cmd.Flags().BoolVar(&unsafe, "unsafe", false, "Show raw values (default: redacted)") + return cmd +} + +func newEnvDeleteCmd() *cobra.Command { + var workspace string + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete an env profile", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + ws := resolveEnvWorkspace(workspace) + if !env.Exists(ws, name) { + ui.Errorf("Env profile '%s' not found in workspace '%s'", name, ws) + } + if err := env.Delete(ws, name); err != nil { + ui.Errorf("Failed to delete env profile: %v", err) + } + ui.Successf("Deleted env profile '%s' from workspace '%s'", name, ws) + }, + } + cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace name (default: active)") + return cmd +} + +func newEnvDefaultCmd() *cobra.Command { + var workspace string + var clear bool + cmd := &cobra.Command{ + Use: "default [profile]", + Short: "Get or set the default env profile for a workspace", + Long: `With no arguments, prints the current default profile. +With a profile name, sets it as the default (auto-loaded on every run). +Use --clear to remove the default.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ws := resolveEnvWorkspace(workspace) + + if clear { + if err := env.ClearDefault(ws); err != nil { + ui.Errorf("Failed to clear default: %v", err) + } + ui.Successf("Cleared default env profile for workspace '%s'", ws) + return + } + + if len(args) == 0 { + name, err := env.GetDefault(ws) + if err != nil { + ui.Errorf("Failed to get default: %v", err) + } + if name == "" { + ui.Infof("No default env profile set for workspace '%s'", ws) + return + } + fmt.Println(name) + return + } + + name := args[0] + if err := env.SetDefault(ws, name); err != nil { + ui.Errorf("%v", err) + } + ui.Successf("Set default env profile to '%s' for workspace '%s'", name, ws) + }, + } + cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace name (default: active)") + cmd.Flags().BoolVar(&clear, "clear", false, "Remove the default profile setting") + return cmd +} + +// editEnvProfile opens a tempfile with the profile contents in $EDITOR, +// then parses and saves it back to KV. If isNew is true, writes a header. +func editEnvProfile(ws, name string, isNew bool) { + var initial string + if isNew { + initial = fmt.Sprintf("# Env profile '%s' for workspace '%s'\n"+ + "# One KEY=VALUE per line. Empty values (KEY=) are passed as-is.\n"+ + "# Lines starting with '#' are ignored.\n\n", name, ws) + } else { + p, err := env.Load(ws, name) + if err != nil { + ui.Errorf("%v", err) + } + initial = env.FormatEnvFile(p.Vars) + } + + tmp, err := os.CreateTemp("", fmt.Sprintf("exitbox-env-%s-*.env", name)) + if err != nil { + ui.Errorf("Failed to create tempfile: %v", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := tmp.WriteString(initial); err != nil { + _ = tmp.Close() + ui.Errorf("Failed to write tempfile: %v", err) + } + if err := tmp.Close(); err != nil { + ui.Errorf("Failed to close tempfile: %v", err) + } + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + c := exec.Command(editor, tmpPath) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Run(); err != nil { + ui.Errorf("Editor exited with error: %v", err) + } + + content, err := os.ReadFile(tmpPath) + if err != nil { + ui.Errorf("Failed to read tempfile: %v", err) + } + vars, parseErr := env.ParseEnvFile(string(content)) + if parseErr != nil { + ui.Errorf("Parse error: %v", parseErr) + } + if len(vars) == 0 { + ui.Warn("No variables defined; profile not saved.") + return + } + if err := env.Save(ws, &env.Profile{Name: name, Vars: vars}); err != nil { + ui.Errorf("Failed to save env profile: %v", err) + } + ui.Successf("Saved env profile '%s' to workspace '%s' (%d vars)", name, ws, len(vars)) +} + +// resolveEnvWorkspace resolves the workspace using the same logic as config commands. +func resolveEnvWorkspace(override string) string { + cfg := config.LoadOrDefault() + return resolveConfigWorkspace(cfg, override) +} + +// envProfileConfigDir returns the workspace agent config dir scoped to a profile. +// If profile is empty, returns the default agent dir. +func envProfileConfigDir(workspace, agent, profile string) string { + base := config.WorkspaceAgentDir(workspace, agent) + if profile == "" { + return base + } + return filepath.Join(base, "profiles", profile) +} + +// validateProfileName returns an error if the name is unsafe for use in +// filesystem paths or KV keys. +func validateProfileName(name string) error { + if name == "" { + return fmt.Errorf("profile name cannot be empty") + } + if strings.ContainsAny(name, "/\\:") || name == "." || name == ".." { + return fmt.Errorf("invalid profile name: %s", name) + } + return nil +} + +func init() { + rootCmd.AddCommand(newEnvCmd()) +} diff --git a/cmd/rebuild.go b/cmd/rebuild.go index f7f5576..da83ab5 100644 --- a/cmd/rebuild.go +++ b/cmd/rebuild.go @@ -99,11 +99,6 @@ Examples: if err := image.BuildCore(ctx, rt, agentName, true, version); err != nil { ui.Errorf("Failed to rebuild %s core image: %v", a.DisplayName(), err) } - - ui.Infof("Rebuilding %s container image...", a.DisplayName()) - if err := image.BuildCore(ctx, rt, agentName, true, version); err != nil { - ui.Errorf("Failed to rebuild %s core image: %v", a.DisplayName(), err) - } if err := image.BuildProject(ctx, rt, agentName, projectDir, rebuildWorkspace, true); err != nil { ui.Errorf("Failed to rebuild %s project image: %v", a.DisplayName(), err) } diff --git a/cmd/run.go b/cmd/run.go index 956f77f..a1d9718 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -30,6 +30,7 @@ import ( "github.com/cloud-exit/exitbox/internal/agents" "github.com/cloud-exit/exitbox/internal/config" "github.com/cloud-exit/exitbox/internal/container" + "github.com/cloud-exit/exitbox/internal/env" "github.com/cloud-exit/exitbox/internal/image" "github.com/cloud-exit/exitbox/internal/profile" "github.com/cloud-exit/exitbox/internal/project" @@ -72,6 +73,7 @@ Flags (passed after the agent name): --version VERSION Pin specific agent version (e.g., 1.0.123) -v, --verbose Enable verbose output -w, --workspace NAME Use a specific workspace for this session + --profile NAME Apply an env profile (loads vars + profile-scoped config) -e, --env KEY=VALUE Pass environment variables -t, --tools PKG Add Alpine packages to the image -i, --include-dir DIR Mount host dir inside /workspace @@ -174,6 +176,37 @@ func runAgent(agentName string, passthrough []string) { } } + // Load env profile vars and prepend to EnvVars so CLI -e flags override + // profile values. If no --profile given, check for a workspace default. + if flags.EnvProfile == "" { + active, _ := profile.ResolveActiveWorkspace(cfg, projectDir, flags.Workspace) + if active != nil { + if defName, err := env.GetDefault(active.Workspace.Name); err == nil && defName != "" { + flags.EnvProfile = defName + } + } + } + if flags.EnvProfile != "" { + if err := validateProfileName(flags.EnvProfile); err != nil { + ui.Errorf("%v", err) + } + active, _ := profile.ResolveActiveWorkspace(cfg, projectDir, flags.Workspace) + if active == nil { + ui.Errorf("Cannot resolve workspace for env profile '%s'", flags.EnvProfile) + } + envProfile, err := env.Load(active.Workspace.Name, flags.EnvProfile) + if err != nil { + ui.Errorf("%v", err) + } + var profileVars []string + for k, v := range envProfile.Vars { + profileVars = append(profileVars, k+"="+v) + } + flags.EnvVars = append(profileVars, flags.EnvVars...) + ui.Infof("Loaded env profile '%s' (%d vars) from workspace '%s'", + flags.EnvProfile, len(envProfile.Vars), active.Workspace.Name) + } + // Session resolution logic: // // --name "X" → SessionName="X", Resume=true (implied). Container @@ -239,6 +272,7 @@ func runAgent(agentName string, passthrough []string) { ProjectDir: projectDir, WorkspaceHash: workspaceHash, WorkspaceOverride: flags.Workspace, + EnvProfile: flags.EnvProfile, NoFirewall: flags.NoFirewall, ReadOnly: flags.ReadOnly, NoEnv: flags.NoEnv, @@ -346,6 +380,7 @@ type parsedFlags struct { AgentVersion string ForceUpdate bool Workspace string + EnvProfile string Ollama bool Memory string CPUs string @@ -405,6 +440,11 @@ func parseRunFlags(passthrough []string, defaults config.DefaultFlags) parsedFla i++ f.Workspace = passthrough[i] } + case "--profile": + if i+1 < len(passthrough) { + i++ + f.EnvProfile = passthrough[i] + } case "-e", "--env": if i+1 < len(passthrough) { i++ diff --git a/internal/agents/codex/docker.go b/internal/agents/codex/docker.go index 00d6ad4..9103ed0 100644 --- a/internal/agents/codex/docker.go +++ b/internal/agents/codex/docker.go @@ -62,7 +62,11 @@ func (c *Codex) GetFullDockerfile(version string) (string, error) { func (c *Codex) PrepareBuild(in agent.PrepareBuildInput) error { version := in.Version if version == "" { - version = "latest" + var err error + version, err = c.GetLatestVersion() + if err != nil { + return fmt.Errorf("failed to get latest Codex version: %w", err) + } } binaryName := c.BinaryName() if binaryName == "" { diff --git a/internal/agents/jstools/install.go b/internal/agents/jstools/install.go new file mode 100644 index 0000000..533ac3e --- /dev/null +++ b/internal/agents/jstools/install.go @@ -0,0 +1,29 @@ +package jstools + +import "strings" + +// InstallDependencies generates a Dockerfile RUN step for apk-installed +// packages and globally installed npm packages. +func InstallDependencies(apkPackages, npmPackages []string) string { + var parts []string + + if len(apkPackages) > 0 { + parts = append(parts, "apk add --no-cache "+strings.Join(apkPackages, " ")) + } + if len(npmPackages) > 0 { + parts = append(parts, "npm install -g "+strings.Join(npmPackages, " ")) + } + if len(parts) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("RUN ") + for i, part := range parts { + if i > 0 { + b.WriteString(" && \\\n ") + } + b.WriteString(part) + } + return b.String() +} diff --git a/internal/agents/jstools/install_test.go b/internal/agents/jstools/install_test.go new file mode 100644 index 0000000..789c73b --- /dev/null +++ b/internal/agents/jstools/install_test.go @@ -0,0 +1,25 @@ +package jstools + +import "testing" + +func TestInstallDependencies_APKAndNPM(t *testing.T) { + got := InstallDependencies([]string{"nodejs", "npm"}, []string{"bun", "foo@1.2.3"}) + want := "RUN apk add --no-cache nodejs npm && \\\n npm install -g bun foo@1.2.3" + if got != want { + t.Fatalf("InstallDependencies() = %q, want %q", got, want) + } +} + +func TestInstallDependencies_OnlyAPK(t *testing.T) { + got := InstallDependencies([]string{"nodejs", "npm"}, nil) + want := "RUN apk add --no-cache nodejs npm" + if got != want { + t.Fatalf("InstallDependencies() = %q, want %q", got, want) + } +} + +func TestInstallDependencies_Empty(t *testing.T) { + if got := InstallDependencies(nil, nil); got != "" { + t.Fatalf("InstallDependencies() = %q, want empty string", got) + } +} diff --git a/internal/agents/opencode/docker.go b/internal/agents/opencode/docker.go index 7e3234d..e14cfcb 100644 --- a/internal/agents/opencode/docker.go +++ b/internal/agents/opencode/docker.go @@ -1,45 +1,53 @@ -// ExitBox - Multi-Agent Container Sandbox -// Copyright (C) 2026 Cloud Exit B.V. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - package opencode import ( "fmt" "os" - "path/filepath" "github.com/cloud-exit/exitbox/internal/agent" ) -// opencodeReleaseRepo is the GitHub org/repo for OpenCode release downloads (v-prefixed tags). -const opencodeReleaseRepo = "anomalyco/opencode" - func (o *OpenCode) GetDockerfileInstall(buildCtx string) (string, error) { - return fmt.Sprintf(`# Install OpenCode binary with SHA-256 verification + if o.NpmPackageName() == "" { + return "", fmt.Errorf("unsupported architecture for OpenCode") + } + return `# Install OpenCode via direct GitHub release download with SHA-256 verification. +# No "curl | bash" — fetches tarball, verifies digest from GitHub API, extracts. ARG OPENCODE_VERSION -ARG OPENCODE_CHECKSUM -COPY %s /tmp/opencode.tar.gz -RUN echo "${OPENCODE_CHECKSUM} /tmp/opencode.tar.gz" | sha256sum -c - && \ - tar -xzf /tmp/opencode.tar.gz -C /usr/local/bin && \ - chmod +x /usr/local/bin/opencode && \ - rm -f /tmp/opencode.tar.gz`, o.BinaryName()), nil +RUN set -e && \ + case "$(uname -m)" in \ + x86_64|amd64) OC_ARCH="x64" ;; \ + aarch64|arm64) OC_ARCH="arm64" ;; \ + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; \ + esac && \ + ASSET="opencode-linux-${OC_ARCH}.tar.gz" && \ + echo "Installing OpenCode v${OPENCODE_VERSION} (${ASSET})..." && \ + META=$(curl -fsSL "https://api.github.com/repos/anomalyco/opencode/releases/tags/v${OPENCODE_VERSION}") && \ + DIGEST=$(printf '%s' "$META" | jq -r --arg n "$ASSET" '.assets[] | select(.name == $n) | .digest') && \ + EXPECTED="${DIGEST#sha256:}" && \ + if ! echo "$EXPECTED" | grep -qE '^[a-f0-9]{64}$'; then \ + echo "ERROR: No valid sha256 digest found for ${ASSET}" >&2; exit 1; \ + fi && \ + curl -fsSL -o /tmp/opencode.tar.gz \ + "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${ASSET}" && \ + ACTUAL=$(sha256sum /tmp/opencode.tar.gz | cut -d' ' -f1) && \ + if [ "$ACTUAL" != "$EXPECTED" ]; then \ + echo "ERROR: Checksum mismatch" >&2; \ + echo " Expected: $EXPECTED" >&2; \ + echo " Actual: $ACTUAL" >&2; \ + rm -f /tmp/opencode.tar.gz; exit 1; \ + fi && \ + echo "Checksum verified: $ACTUAL" && \ + mkdir -p /tmp/opencode-extract && \ + tar -xzf /tmp/opencode.tar.gz -C /tmp/opencode-extract && \ + OC_BIN=$(find /tmp/opencode-extract -type f -name opencode | head -n1) && \ + test -n "$OC_BIN" && \ + install -m 755 "$OC_BIN" /usr/local/bin/opencode && \ + rm -rf /tmp/opencode.tar.gz /tmp/opencode-extract && \ + /usr/local/bin/opencode --version`, nil } // GetFullDockerfile returns the complete Dockerfile for OpenCode. -// Builds on exitbox-base with pre-downloaded musl binary (same pattern as Claude/Codex). func (o *OpenCode) GetFullDockerfile(version string) (string, error) { install, err := o.GetDockerfileInstall("") if err != nil { @@ -56,28 +64,16 @@ func (o *OpenCode) GetFullDockerfile(version string) (string, error) { func (o *OpenCode) PrepareBuild(in agent.PrepareBuildInput) error { version := in.Version if version == "" { - version = "latest" - } - binaryName := o.BinaryName() - if binaryName == "" { - return fmt.Errorf("unsupported architecture for OpenCode") - } - if in.Download == nil || in.FileSHA256 == nil { - return fmt.Errorf("PrepareBuildInput.Download and FileSHA256 are required for OpenCode") - } - url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", opencodeReleaseRepo, version, binaryName) - if in.Logf != nil { - in.Logf("Downloading OpenCode %s...", version) - } - dlPath := filepath.Join(in.BuildDir, binaryName) - if err := in.Download(in.Ctx, url, dlPath); err != nil { - return fmt.Errorf("failed to download OpenCode: %w", err) + var err error + version, err = o.GetLatestVersion() + if err != nil { + return fmt.Errorf("failed to get latest OpenCode version: %w", err) + } } - checksum := in.FileSHA256(dlPath) if in.Logf != nil { - in.Logf("OpenCode SHA-256: %s", checksum) + in.Logf("Building OpenCode image with version %s (bun install at build time)", version) } - df := fmt.Sprintf("FROM exitbox-base\n\nARG OPENCODE_VERSION=%s\nARG OPENCODE_CHECKSUM=%s\n", version, checksum) + df := fmt.Sprintf("FROM exitbox-base\n\nARG OPENCODE_VERSION=%s\n", version) install, err := o.GetDockerfileInstall(in.BuildDir) if err != nil { return fmt.Errorf("failed to get OpenCode install instructions: %w", err) diff --git a/internal/agents/opencode/opencode.go b/internal/agents/opencode/opencode.go index 10e8130..48a8900 100644 --- a/internal/agents/opencode/opencode.go +++ b/internal/agents/opencode/opencode.go @@ -55,6 +55,19 @@ func (o *OpenCode) BinaryName() string { } } +// NpmPackageName returns the platform-specific npm package that contains the +// actual OpenCode binary for this architecture. +func (o *OpenCode) NpmPackageName() string { + switch runtime.GOARCH { + case "amd64": + return "opencode-linux-x64" + case "arm64": + return "opencode-linux-arm64" + default: + return "" + } +} + func (o *OpenCode) HostConfigPaths() []string { home := os.Getenv("HOME") return []string{ diff --git a/internal/agents/opencode/opencode_test.go b/internal/agents/opencode/opencode_test.go index f1a0008..7002df8 100644 --- a/internal/agents/opencode/opencode_test.go +++ b/internal/agents/opencode/opencode_test.go @@ -67,8 +67,20 @@ func TestOpenCodeAgent(t *testing.T) { if err != nil { t.Fatalf("GetDockerfileInstall() error: %v", err) } + if !strings.Contains(df, "api.github.com/repos/anomalyco/opencode") { + t.Error("GetDockerfileInstall() should fetch release metadata from GitHub API") + } if !strings.Contains(df, "sha256sum") { - t.Error("GetDockerfileInstall() should contain sha256sum verification") + t.Error("GetDockerfileInstall() should verify SHA-256 checksum") + } + if !strings.Contains(df, "Checksum mismatch") { + t.Error("GetDockerfileInstall() should fail on checksum mismatch") + } + if !strings.Contains(df, "/usr/local/bin/opencode") { + t.Error("GetDockerfileInstall() should install opencode binary to /usr/local/bin") + } + if !strings.Contains(df, "/usr/local/bin/opencode --version") { + t.Error("GetDockerfileInstall() should verify opencode binary works") } // GetFullDockerfile diff --git a/internal/agents/opencode/version.go b/internal/agents/opencode/version.go index a29a839..1b20f8e 100644 --- a/internal/agents/opencode/version.go +++ b/internal/agents/opencode/version.go @@ -1,19 +1,3 @@ -// ExitBox - Multi-Agent Container Sandbox -// Copyright (C) 2026 Cloud Exit B.V. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - package opencode import ( @@ -25,26 +9,30 @@ import ( "github.com/cloud-exit/exitbox/internal/container" ) -const opencodeGitHubRepo = "openai/opencode" - func (o *OpenCode) GetLatestVersion() (string, error) { - out, err := exec.Command("curl", "-s", - fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", opencodeGitHubRepo)).Output() + pkg := o.NpmPackageName() + if pkg == "" { + return "", fmt.Errorf("unsupported architecture for OpenCode") + } + out, err := exec.Command("curl", "-fsSL", + fmt.Sprintf("https://registry.npmjs.org/%s/latest", pkg)).Output() if err != nil { return "", fmt.Errorf("failed to fetch OpenCode latest version: %w", err) } var release struct { - TagName string `json:"tag_name"` + Version string `json:"version"` + Error string `json:"error"` } if err := json.Unmarshal(out, &release); err != nil { return "", err } - // Strip leading 'v' if present - v := strings.TrimPrefix(release.TagName, "v") - if v == "" { - return "", fmt.Errorf("empty tag_name") + if release.Error != "" && release.Version == "" { + return "", fmt.Errorf("npm registry error: %s", release.Error) + } + if release.Version == "" { + return "", fmt.Errorf("empty version") } - return v, nil + return release.Version, nil } func (o *OpenCode) GetInstalledVersion(rt container.Runtime, img string) (string, error) { diff --git a/internal/agents/qwen/docker.go b/internal/agents/qwen/docker.go index dae5581..11d967d 100644 --- a/internal/agents/qwen/docker.go +++ b/internal/agents/qwen/docker.go @@ -21,6 +21,7 @@ import ( "os" "github.com/cloud-exit/exitbox/internal/agent" + "github.com/cloud-exit/exitbox/internal/agents/jstools" ) const qwenNPMPackage = "@qwen-code/qwen-code" @@ -28,8 +29,7 @@ const qwenNPMPackage = "@qwen-code/qwen-code" func (q *Qwen) GetDockerfileInstall(buildCtx string) (string, error) { return `# Install Node.js and Qwen Code via npm (requires Node 20+) ARG QWEN_VERSION -RUN apk add --no-cache nodejs npm && \ - npm install -g ` + qwenNPMPackage + `@${QWEN_VERSION} && \ +` + jstools.InstallDependencies([]string{"nodejs", "npm"}, []string{qwenNPMPackage + "@${QWEN_VERSION}"}) + ` && \ qwen --version LABEL exitbox.agent.version="${QWEN_VERSION}"`, nil } diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..c923661 --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,224 @@ +// ExitBox - Multi-Agent Container Sandbox +// Copyright (C) 2026 Cloud Exit B.V. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package env stores named environment variable profiles in the workspace KV. +// Each profile is a set of KEY=VALUE pairs serialized as JSON under key +// "env.". +package env + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/cloud-exit/exitbox/internal/config" + "github.com/cloud-exit/exitbox/internal/kvstore" +) + +const keyPrefix = "env." +const defaultKey = "env.__default__" + +// Profile holds a named set of environment variables. +type Profile struct { + Name string + Vars map[string]string +} + +// Save persists the profile to the workspace KV. +func Save(workspace string, p *Profile) error { + store, err := openStore(workspace) + if err != nil { + return err + } + defer func() { _ = store.Close() }() + + data, err := json.Marshal(p.Vars) + if err != nil { + return fmt.Errorf("marshal vars: %w", err) + } + return store.Set([]byte(keyPrefix+p.Name), data) +} + +// Load returns the profile or an error if not found. +func Load(workspace, name string) (*Profile, error) { + store, err := openStore(workspace) + if err != nil { + return nil, err + } + defer func() { _ = store.Close() }() + + val, err := store.Get([]byte(keyPrefix + name)) + if err != nil { + if errors.Is(err, kvstore.ErrNotFound) { + return nil, fmt.Errorf("env profile '%s' not found in workspace '%s'", name, workspace) + } + return nil, err + } + var vars map[string]string + if err := json.Unmarshal(val, &vars); err != nil { + return nil, fmt.Errorf("invalid profile data: %w", err) + } + return &Profile{Name: name, Vars: vars}, nil +} + +// List returns all env profile names in the workspace, sorted. +func List(workspace string) ([]string, error) { + store, err := openStore(workspace) + if err != nil { + return nil, err + } + defer func() { _ = store.Close() }() + + var names []string + err = store.Iterate([]byte(keyPrefix), func(key, _ []byte) error { + names = append(names, strings.TrimPrefix(string(key), keyPrefix)) + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(names) + return names, nil +} + +// Delete removes a profile. +func Delete(workspace, name string) error { + store, err := openStore(workspace) + if err != nil { + return err + } + defer func() { _ = store.Close() }() + return store.Delete([]byte(keyPrefix + name)) +} + +// Exists reports whether a profile with the given name exists. +func Exists(workspace, name string) bool { + store, err := openStore(workspace) + if err != nil { + return false + } + defer func() { _ = store.Close() }() + _, err = store.Get([]byte(keyPrefix + name)) + return err == nil +} + +// FormatEnvFile renders vars as KEY=VALUE lines, sorted by key. +// Empty values render as `KEY=` (preserves explicit-empty semantics). +func FormatEnvFile(vars map[string]string) string { + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(vars[k]) + b.WriteByte('\n') + } + return b.String() +} + +// ParseEnvFile parses KEY=VALUE lines into a map. Lines starting with `#` +// or empty lines are ignored. Values are taken verbatim (no shell expansion). +// Returns an error on malformed lines. +func ParseEnvFile(content string) (map[string]string, error) { + vars := make(map[string]string) + for i, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + // Strip optional `export ` prefix. + trimmed = strings.TrimPrefix(trimmed, "export ") + idx := strings.Index(trimmed, "=") + if idx <= 0 { + return nil, fmt.Errorf("line %d: expected KEY=VALUE, got %q", i+1, line) + } + key := strings.TrimSpace(trimmed[:idx]) + val := trimmed[idx+1:] + // Strip matching outer quotes. + val = stripQuotes(val) + vars[key] = val + } + return vars, nil +} + +func stripQuotes(s string) string { + if len(s) >= 2 { + first, last := s[0], s[len(s)-1] + if (first == '"' && last == '"') || (first == '\'' && last == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +// SetDefault marks a profile as the workspace default. +func SetDefault(workspace, name string) error { + store, err := openStore(workspace) + if err != nil { + return err + } + defer func() { _ = store.Close() }() + + if _, err := store.Get([]byte(keyPrefix + name)); err != nil { + if errors.Is(err, kvstore.ErrNotFound) { + return fmt.Errorf("env profile '%s' not found in workspace '%s'", name, workspace) + } + return err + } + return store.Set([]byte(defaultKey), []byte(name)) +} + +// GetDefault returns the default profile name, or "" if none set. +func GetDefault(workspace string) (string, error) { + store, err := openStore(workspace) + if err != nil { + return "", err + } + defer func() { _ = store.Close() }() + + val, err := store.Get([]byte(defaultKey)) + if err != nil { + if errors.Is(err, kvstore.ErrNotFound) { + return "", nil + } + return "", err + } + return string(val), nil +} + +// ClearDefault removes the default profile setting. +func ClearDefault(workspace string) error { + store, err := openStore(workspace) + if err != nil { + return err + } + defer func() { _ = store.Close() }() + return store.Delete([]byte(defaultKey)) +} + +func openStore(workspace string) (*kvstore.Store, error) { + if workspace == "" { + return nil, fmt.Errorf("workspace required") + } + return kvstore.Open(kvstore.Options{Dir: config.KVDir(workspace)}) +} diff --git a/internal/env/env_test.go b/internal/env/env_test.go new file mode 100644 index 0000000..d2a9b48 --- /dev/null +++ b/internal/env/env_test.go @@ -0,0 +1,166 @@ +// ExitBox - Multi-Agent Container Sandbox +// Copyright (C) 2026 Cloud Exit B.V. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package env + +import ( + "reflect" + "testing" +) + +func TestDefaultProfile(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + ws := "test-ws" + + // No default set initially. + name, err := GetDefault(ws) + if err != nil { + t.Fatalf("GetDefault: %v", err) + } + if name != "" { + t.Fatalf("expected empty default, got %q", name) + } + + // SetDefault fails for non-existent profile. + if err := SetDefault(ws, "nope"); err == nil { + t.Fatal("expected error for non-existent profile") + } + + // Save a profile, then set it as default. + if err := Save(ws, &Profile{Name: "prod", Vars: map[string]string{"A": "1"}}); err != nil { + t.Fatalf("Save: %v", err) + } + if err := SetDefault(ws, "prod"); err != nil { + t.Fatalf("SetDefault: %v", err) + } + + name, err = GetDefault(ws) + if err != nil { + t.Fatalf("GetDefault: %v", err) + } + if name != "prod" { + t.Fatalf("expected 'prod', got %q", name) + } + + // ClearDefault removes it. + if err := ClearDefault(ws); err != nil { + t.Fatalf("ClearDefault: %v", err) + } + name, err = GetDefault(ws) + if err != nil { + t.Fatalf("GetDefault after clear: %v", err) + } + if name != "" { + t.Fatalf("expected empty after clear, got %q", name) + } +} + +func TestParseEnvFile(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + wantErr bool + }{ + { + name: "simple", + input: "FOO=bar\nBAZ=qux\n", + want: map[string]string{"FOO": "bar", "BAZ": "qux"}, + }, + { + name: "empty value (explicit unset)", + input: "ANTHROPIC_API_KEY=\n", + want: map[string]string{"ANTHROPIC_API_KEY": ""}, + }, + { + name: "comments and blank lines", + input: "# comment\n\nFOO=bar\n# another\n", + want: map[string]string{"FOO": "bar"}, + }, + { + name: "export prefix", + input: "export OPENROUTER_API_KEY=secret\n", + want: map[string]string{"OPENROUTER_API_KEY": "secret"}, + }, + { + name: "double-quoted value", + input: `URL="https://api.example.com"`, + want: map[string]string{"URL": "https://api.example.com"}, + }, + { + name: "single-quoted value", + input: `KEY='val with spaces'`, + want: map[string]string{"KEY": "val with spaces"}, + }, + { + name: "value with equals", + input: "TOKEN=abc=def=ghi\n", + want: map[string]string{"TOKEN": "abc=def=ghi"}, + }, + { + name: "missing equals", + input: "INVALID_LINE\n", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseEnvFile(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestFormatEnvFile(t *testing.T) { + vars := map[string]string{ + "BAZ": "qux", + "FOO": "bar", + "ANTHROPIC_API_KEY": "", + } + got := FormatEnvFile(vars) + want := "ANTHROPIC_API_KEY=\nBAZ=qux\nFOO=bar\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestRoundTrip(t *testing.T) { + in := map[string]string{ + "OPENROUTER_API_KEY": "sk-or-v1-abc", + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", + "ANTHROPIC_AUTH_TOKEN": "$OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY": "", + } + formatted := FormatEnvFile(in) + parsed, err := ParseEnvFile(formatted) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !reflect.DeepEqual(in, parsed) { + t.Errorf("round-trip mismatch:\nin=%v\nout=%v", in, parsed) + } +} diff --git a/internal/image/core.go b/internal/image/core.go index 3791110..c1db704 100644 --- a/internal/image/core.go +++ b/internal/image/core.go @@ -141,6 +141,9 @@ func BuildCore(ctx context.Context, rt container.Runtime, agentName string, forc } args := buildArgs(cmd) + if force { + args = append(args, "--no-cache") + } args = append(args, "-t", imageName, "-f", dockerfilePath, diff --git a/internal/image/project.go b/internal/image/project.go index e9402b3..9fdf3cb 100644 --- a/internal/image/project.go +++ b/internal/image/project.go @@ -60,7 +60,7 @@ func BuildProject(ctx context.Context, rt container.Runtime, agentName, projectD cmd := container.Cmd(rt) // Ensure tools image exists (tools → core → base cascade) - if err := BuildTools(ctx, rt, agentName, false); err != nil { + if err := BuildTools(ctx, rt, agentName, force); err != nil { return err } @@ -102,10 +102,10 @@ func BuildProject(ctx context.Context, rt container.Runtime, agentName, projectD // but be explicit in case that changes) df.WriteString("USER root\n\n") - // Validate all development profiles up front. + // Validate all development roles up front. for _, p := range developmentProfiles { - if !profile.Exists(p) { - return fmt.Errorf("unknown development profile '%s'. Run 'exitbox setup' to configure your development stack", p) + if !profile.RoleExists(p) { + return fmt.Errorf("unknown development role '%s'. Run 'exitbox setup' to configure your development stack", p) } } diff --git a/internal/image/tools.go b/internal/image/tools.go index 207b565..3613ec6 100644 --- a/internal/image/tools.go +++ b/internal/image/tools.go @@ -121,6 +121,9 @@ func BuildTools(ctx context.Context, rt container.Runtime, agentName string, for } args := buildArgs(cmd) + if force { + args = append(args, "--no-cache") + } args = append(args, "-t", imageName, "-f", dockerfilePath, diff --git a/internal/ipc/server.go b/internal/ipc/server.go index c28c410..f9d5aaa 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -82,6 +82,14 @@ func (s *Server) Handle(msgType string, h HandlerFunc) { s.handlers[msgType] = h } +// HasHandler reports whether a handler is registered for the given msgType. +func (s *Server) HasHandler(msgType string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.handlers[msgType] + return ok +} + // Start begins accepting connections in a background goroutine. func (s *Server) Start() { s.wg.Add(1) diff --git a/internal/profile/dockerfile.go b/internal/profile/dockerfile.go index 58e90d9..1c69e11 100644 --- a/internal/profile/dockerfile.go +++ b/internal/profile/dockerfile.go @@ -18,15 +18,15 @@ package profile import "strings" -// Packages returns the space-separated Alpine packages for a profile, -// or empty string if the profile has none or only custom install steps. +// Packages returns the space-separated Alpine packages for a role, +// or empty string if the role has none or only custom install steps. func Packages(name string) string { - // The node/javascript profiles have apk packages too. + // The node/javascript roles have apk packages too. switch name { case "node", "javascript": return "nodejs npm" } - p := Get(name) + p := GetRole(name) if p == nil { return "" } diff --git a/internal/profile/manager.go b/internal/profile/manager.go index abbd657..7667bfb 100644 --- a/internal/profile/manager.go +++ b/internal/profile/manager.go @@ -117,7 +117,7 @@ func AddWorkspace(w config.Workspace, cfg *config.Config) error { return fmt.Errorf("workspace name cannot be empty") } for _, dev := range w.Development { - if !Exists(dev) { + if !RoleExists(dev) { return &InvalidWorkspaceError{Name: dev} } } diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 8718073..69d337b 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -16,16 +16,16 @@ package profile -// Profile defines a development profile with its packages and description. -type Profile struct { +// Role defines a development role (formerly "profile") with its packages and description. +type Role struct { Name string Description string Packages string // Space-separated Alpine packages } -// All returns all available profiles. -func All() []Profile { - return []Profile{ +// AllRoles returns all available development roles. +func AllRoles() []Role { + return []Role{ {"core", "Compatibility alias for base profile", "gcc g++ make git pkgconf openssl-dev libffi-dev zlib-dev tmux"}, {"base", "Base development tools (git, vim, curl)", "gcc g++ make git pkgconf openssl-dev libffi-dev zlib-dev tmux"}, {"build-tools", "Build toolchain helpers (cmake, autoconf, libtool)", "cmake samurai autoconf automake libtool"}, @@ -53,21 +53,21 @@ func All() []Profile { } } -// Exists returns true if the profile name is valid. -func Exists(name string) bool { - for _, p := range All() { - if p.Name == name { +// RoleExists returns true if the role name is valid. +func RoleExists(name string) bool { + for _, r := range AllRoles() { + if r.Name == name { return true } } return false } -// Get returns a profile by name, or nil if not found. -func Get(name string) *Profile { - for _, p := range All() { - if p.Name == name { - return &p +// GetRole returns a role by name, or nil if not found. +func GetRole(name string) *Role { + for _, r := range AllRoles() { + if r.Name == name { + return &r } } return nil diff --git a/internal/run/run.go b/internal/run/run.go index 2348639..3177b2f 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -53,6 +53,7 @@ type Options struct { ProjectDir string WorkspaceHash string WorkspaceOverride string + EnvProfile string NoFirewall bool ReadOnly bool NoEnv bool @@ -178,21 +179,15 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) { } } - // IPC server for runtime domain allow requests. - var ipcServer *ipc.Server - if !opts.NoFirewall { - var ipcErr error - ipcServer, ipcErr = ipc.NewServer() - if ipcErr != nil { - ui.Warnf("Failed to start IPC server: %v", ipcErr) - } else { - ipcServer.Handle("allow_domain", ipc.NewAllowDomainHandler(ipc.AllowDomainHandlerConfig{ - Runtime: rt, - ContainerName: containerName, - })) - ipcServer.Start() - defer ipcServer.Stop() - } + // IPC server: always start (vault/kv depend on it). + // Domain-allow handler only required when firewall is enabled. + ipcServer, ipcErr := newIPCServer(!opts.NoFirewall, rt, containerName) + if ipcErr != nil { + ui.Warnf("Failed to start IPC server: %v", ipcErr) + ipcServer = nil + } else { + ipcServer.Start() + defer ipcServer.Stop() } // IDE relay: bridge the host IDE's WebSocket to a Unix socket in the @@ -400,6 +395,9 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) { if opts.ResumeToken != "" { args = append(args, "-e", "EXITBOX_RESUME_TOKEN="+opts.ResumeToken) } + if opts.EnvProfile != "" { + args = append(args, "-e", "EXITBOX_ENV_PROFILE="+opts.EnvProfile) + } if opts.Keybindings != "" { args = append(args, "-e", "EXITBOX_KEYBINDINGS="+opts.Keybindings) } @@ -566,3 +564,19 @@ func portAvailable(host string, port int) bool { _ = ln.Close() return true } + +// newIPCServer creates and configures an IPC server. +// When firewallEnabled is true, it also registers the allow_domain handler. +func newIPCServer(firewallEnabled bool, rt container.Runtime, containerName string) (*ipc.Server, error) { + s, err := ipc.NewServer() + if err != nil { + return nil, err + } + if firewallEnabled { + s.Handle("allow_domain", ipc.NewAllowDomainHandler(ipc.AllowDomainHandlerConfig{ + Runtime: rt, + ContainerName: containerName, + })) + } + return s, nil +} diff --git a/internal/run/run_test.go b/internal/run/run_test.go index d795c84..2791da3 100644 --- a/internal/run/run_test.go +++ b/internal/run/run_test.go @@ -124,3 +124,40 @@ func TestIsReservedEnvVar_NotReserved(t *testing.T) { } } } + +// TestNewIPCServer_AlwaysCreated ensures the IPC server is created +// regardless of firewall mode (so vault/kv still work with --no-firewall). +func TestNewIPCServer_AlwaysCreated(t *testing.T) { + // Firewall enabled + _, err := newIPCServer(true, nil, "test") + if err != nil { + t.Fatalf("newIPCServer(firewall=true) failed: %v", err) + } + + // Firewall disabled — must still succeed + _, err = newIPCServer(false, nil, "test") + if err != nil { + t.Fatalf("newIPCServer(firewall=false) failed: %v", err) + } +} + +// TestNewIPCServer_AllowDomainHandler_OnlyWithFirewall ensures the +// allow_domain handler is only registered when firewall is enabled. +func TestNewIPCServer_AllowDomainHandler_OnlyWithFirewall(t *testing.T) { + withFirewall, err := newIPCServer(true, nil, "test") + if err != nil { + t.Fatalf("newIPCServer(firewall=true) failed: %v", err) + } + + withoutFirewall, err := newIPCServer(false, nil, "test") + if err != nil { + t.Fatalf("newIPCServer(firewall=false) failed: %v", err) + } + + if !withFirewall.HasHandler("allow_domain") { + t.Error("allow_domain handler missing with firewall enabled") + } + if withoutFirewall.HasHandler("allow_domain") { + t.Error("allow_domain handler present with firewall disabled; should be absent") + } +} diff --git a/internal/wizard/roles.go b/internal/wizard/roles.go index a8f0a97..1c23549 100644 --- a/internal/wizard/roles.go +++ b/internal/wizard/roles.go @@ -24,11 +24,12 @@ import ( "github.com/cloud-exit/exitbox/internal/agents" ) -// Role represents a developer role with preset defaults. +// Role represents a developer role preset (Frontend, Backend, etc.) that +// composes one or more dev roles (python, go, etc.) plus default tools. type Role struct { Name string Description string - Profiles []string // Default profiles to activate + Roles []string // Default dev roles (python, go, ml, ...) to activate Languages []string // Pre-checked language names in language step ToolCategories []string // Pre-checked tool category names in tools step } @@ -64,70 +65,70 @@ var Roles = []Role{ { Name: "Frontend", Description: "Web frontend development", - Profiles: []string{"node", "web", "build-tools"}, + Roles: []string{"node", "web", "build-tools"}, Languages: []string{"Node/JS"}, ToolCategories: []string{"Build Tools", "Web"}, }, { Name: "Backend", Description: "Server-side development", - Profiles: []string{"python", "database", "build-tools"}, + Roles: []string{"python", "database", "build-tools"}, Languages: []string{"Python", "Go"}, ToolCategories: []string{"Build Tools", "Database"}, }, { Name: "Fullstack", Description: "Full-stack web development", - Profiles: []string{"node", "python", "database", "web", "dotnet", "build-tools"}, + Roles: []string{"node", "python", "database", "web", "dotnet", "build-tools"}, Languages: []string{"Node/JS", "Python", ".NET"}, ToolCategories: []string{"Build Tools", "Database", "Web"}, }, { Name: "DevOps", Description: "Infrastructure and operations", - Profiles: []string{"devops", "node", "networking", "shell", "build-tools"}, + Roles: []string{"devops", "node", "networking", "shell", "build-tools"}, Languages: []string{"Go", "Python", "Node/JS"}, ToolCategories: []string{"Build Tools", "Networking", "DevOps", "Shell Utils"}, }, { Name: "Kubernetes", Description: "Kubernetes development and operations", - Profiles: []string{"kubernetes", "devops", "networking", "shell", "build-tools"}, + Roles: []string{"kubernetes", "devops", "networking", "shell", "build-tools"}, Languages: []string{"Go", "Python"}, ToolCategories: []string{"Build Tools", "Networking", "Kubernetes", "DevOps", "Shell Utils"}, }, { Name: "Data Science", Description: "Data analysis and machine learning", - Profiles: []string{"python", "datascience", "database"}, + Roles: []string{"python", "datascience", "database"}, Languages: []string{"Python"}, ToolCategories: []string{"Database"}, }, { Name: "Mobile", Description: "Mobile application development", - Profiles: []string{"flutter", "node"}, + Roles: []string{"flutter", "node"}, Languages: []string{"Flutter/Dart", "Node/JS"}, ToolCategories: []string{"Build Tools"}, }, { Name: "Embedded", Description: "Embedded systems and IoT", - Profiles: []string{"c", "embedded", "build-tools"}, + Roles: []string{"c", "embedded", "build-tools"}, Languages: []string{"C/C++", "Rust"}, ToolCategories: []string{"Build Tools"}, }, { Name: "Security", Description: "Security research and tooling", - Profiles: []string{"security", "networking", "shell"}, + Roles: []string{"security", "networking", "shell"}, Languages: []string{"Python", "Go"}, ToolCategories: []string{"Networking", "Security", "Shell Utils"}, }, { Name: "AI Developer", Description: "AI/ML development (huggingface-cli, model tooling)", - Profiles: []string{"python", "ml", "build-tools"}, + Roles: []string{"python", "ml", "build-tools"}, Languages: []string{"Python"}, ToolCategories: []string{"Build Tools"}, }, @@ -221,8 +222,8 @@ func GetRole(name string) *Role { return nil } -// ComputeProfiles computes the profile list from roles + language selections. -func ComputeProfiles(roleNames []string, languages []string) []string { +// ComputeRoles computes the dev role list from role presets + language selections. +func ComputeRoles(roleNames []string, languages []string) []string { seen := make(map[string]bool) var result []string @@ -235,7 +236,7 @@ func ComputeProfiles(roleNames []string, languages []string) []string { for _, roleName := range roleNames { if role := GetRole(roleName); role != nil { - for _, p := range role.Profiles { + for _, p := range role.Roles { add(p) } } diff --git a/internal/wizard/roles_test.go b/internal/wizard/roles_test.go index e4f2dec..26fc7ac 100644 --- a/internal/wizard/roles_test.go +++ b/internal/wizard/roles_test.go @@ -107,7 +107,7 @@ func TestAIDeveloperRole(t *testing.T) { // Verify python comes before ml so the venv exists when ml's pip install runs. pythonIdx, mlIdx := -1, -1 - for i, p := range role.Profiles { + for i, p := range role.Roles { if p == "python" { pythonIdx = i } @@ -126,8 +126,8 @@ func TestAIDeveloperRole(t *testing.T) { } } -func TestComputeProfiles_AIDeveloper(t *testing.T) { - profiles := ComputeProfiles([]string{"AI Developer"}, nil) +func TestComputeRoles_AIDeveloper(t *testing.T) { + profiles := ComputeRoles([]string{"AI Developer"}, nil) wantContains := []string{"python", "ml", "build-tools"} for _, want := range wantContains { found := false @@ -138,7 +138,7 @@ func TestComputeProfiles_AIDeveloper(t *testing.T) { } } if !found { - t.Errorf("ComputeProfiles(AI Developer) missing %q, got %v", want, profiles) + t.Errorf("ComputeRoles(AI Developer) missing %q, got %v", want, profiles) } } } diff --git a/internal/wizard/tui.go b/internal/wizard/tui.go index ac46139..fc31173 100644 --- a/internal/wizard/tui.go +++ b/internal/wizard/tui.go @@ -24,8 +24,19 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/cloud-exit/exitbox/internal/apk" "github.com/cloud-exit/exitbox/internal/config" + "github.com/cloud-exit/exitbox/internal/env" ) +// listEnvProfileNames returns the env profile names for the given workspace, +// or nil on error (treated as "no profiles"). +func listEnvProfileNames(workspace string) []string { + names, err := env.List(workspace) + if err != nil { + return nil + } + return names +} + // Step identifies the current wizard step. type Step int @@ -44,6 +55,7 @@ const ( stepDomains stepCopyCredentials stepVault + stepEnvProfiles stepReview stepDone ) @@ -494,6 +506,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateCopyCredentials(msg) case stepVault: return m.updateVault(msg) + case stepEnvProfiles: + return m.updateEnvProfiles(msg) case stepDomains: return m.updateDomains(msg) case stepReview: @@ -532,6 +546,8 @@ func (m Model) View() string { content = m.viewCopyCredentials() case stepVault: content = m.viewVault() + case stepEnvProfiles: + content = m.viewEnvProfiles() case stepDomains: content = m.viewDomains() case stepReview: @@ -708,8 +724,8 @@ func (m Model) updateWorkspaceSelect(msg tea.Msg) (tea.Model, tea.Cmd) { // Re-check roles: only check roles whose profiles all exist // in this workspace's development stack. for _, role := range Roles { - match := len(role.Profiles) > 0 - for _, p := range role.Profiles { + match := len(role.Roles) > 0 + for _, p := range role.Roles { if !devSet[p] { match = false break @@ -2145,6 +2161,7 @@ var topMenuOptions = []struct { }{ {"Workspace management", "Configure roles, languages, tools, packages, and workspace settings"}, {"General settings", "Configure keybindings and global preferences"}, + {"Env profiles", "View env profiles for a workspace (create/edit via 'exitbox env' CLI)"}, } func (m Model) updateTopMenu(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -2160,17 +2177,21 @@ func (m Model) updateTopMenu(msg tea.Msg) (tea.Model, tea.Cmd) { } case "enter": m.topMenuChoice = m.topMenuCursor - if m.topMenuChoice == 0 { + switch m.topMenuChoice { + case 0: // Workspace management flow if len(m.workspaces) > 1 { m.step = stepWorkspaceSelect } else { m.step = stepRole } - } else { + case 1: // General settings flow — single screen: keybindings m.step = stepKeybindings m.kbCursor = 0 + case 2: + // Env profiles view + m.step = stepEnvProfiles } m.cursor = 0 case "esc", "q": @@ -2205,6 +2226,67 @@ func (m Model) viewTopMenu() string { return b.String() } +// --- Env Profiles Step (informational) --- + +func (m Model) updateEnvProfiles(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "esc", "q", "enter": + m.step = stepTopMenu + } + } + return m, nil +} + +func (m Model) viewEnvProfiles() string { + var b strings.Builder + b.WriteString(titleStyle.Render("Environment Variable Profiles")) + b.WriteString("\n\n") + + ws := m.activeWorkspaceName() + if ws == "" { + b.WriteString(dimStyle.Render("No active workspace — create one via 'Workspace management' first.\n")) + } else { + b.WriteString(fmt.Sprintf("Workspace: %s\n\n", selectedStyle.Render(ws))) + names := listEnvProfileNames(ws) + if len(names) == 0 { + b.WriteString(dimStyle.Render(" (no profiles defined)\n")) + } else { + for _, n := range names { + b.WriteString(fmt.Sprintf(" %s %s\n", successStyle.Render("•"), n)) + } + } + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render("Manage via CLI:\n")) + b.WriteString(" exitbox env create # new profile, opens $EDITOR\n") + b.WriteString(" exitbox env edit # edit existing\n") + b.WriteString(" exitbox env list # list profiles\n") + b.WriteString(" exitbox env show # show (redacted)\n") + b.WriteString(" exitbox env delete # remove\n\n") + b.WriteString(dimStyle.Render("Apply at run time:\n")) + b.WriteString(" exitbox run --profile \n\n") + b.WriteString(dimStyle.Render("Profile-scoped agent config:\n")) + b.WriteString(" exitbox config edit --profile \n\n") + b.WriteString(helpStyle.Render("Esc/Enter to return")) + return b.String() +} + +// activeWorkspaceName returns the active workspace name or empty string. +func (m Model) activeWorkspaceName() string { + if m.state.WorkspaceName != "" { + return m.state.WorkspaceName + } + if m.defaultWorkspace != "" { + return m.defaultWorkspace + } + if len(m.workspaces) > 0 { + return m.workspaces[0].Name + } + return "" +} + // --- Keybindings Step --- var allKeybindingActions = []struct { @@ -2579,7 +2661,7 @@ func (m Model) viewReview() string { if m.state.OriginalDevelopment != nil { profiles = applyLanguageDelta(m.state.OriginalDevelopment, m.state.Languages) } else { - profiles = ComputeProfiles(m.state.Roles, m.state.Languages) + profiles = ComputeRoles(m.state.Roles, m.state.Languages) } if len(profiles) > 0 { b.WriteString(fmt.Sprintf("\n Development stack: %s\n", selectedStyle.Render(strings.Join(profiles, ", ")))) @@ -2685,7 +2767,7 @@ func (m Model) viewWorkspaceOnlyReview() string { b.WriteString(fmt.Sprintf(" Languages: %s\n", dimStyle.Render("none"))) } - dev := ComputeProfiles(m.state.Roles, m.state.Languages) + dev := ComputeRoles(m.state.Roles, m.state.Languages) if len(dev) > 0 { b.WriteString(fmt.Sprintf(" Development: %s\n", selectedStyle.Render(strings.Join(dev, ", ")))) } else { diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index 246c525..1ae518f 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -114,7 +114,7 @@ func RunWorkspaceCreation(existingCfg *config.Config, workspaceName string) (*Wo return &WorkspaceCreationResult{ Workspace: &config.Workspace{ Name: name, - Development: ComputeProfiles(wm.Result().Roles, wm.Result().Languages), + Development: ComputeRoles(wm.Result().Roles, wm.Result().Languages), Packages: wm.Result().CustomPackages, Vault: config.VaultConfig{Enabled: wm.Result().VaultEnabled, ReadOnly: wm.Result().VaultReadOnly}, }, @@ -140,7 +140,7 @@ func applyResult(state State, existingCfg *config.Config) error { // stack and apply language selection changes on top. development = applyLanguageDelta(state.OriginalDevelopment, state.Languages) } else { - development = ComputeProfiles(state.Roles, state.Languages) + development = ComputeRoles(state.Roles, state.Languages) } cfg.Roles = state.Roles diff --git a/static/build/docker-entrypoint b/static/build/docker-entrypoint index 648ed66..f024b70 100755 --- a/static/build/docker-entrypoint +++ b/static/build/docker-entrypoint @@ -301,6 +301,22 @@ apply_active_workspace_links() { workspace_root="${GLOBAL_WORKSPACE_ROOT}/${name}/${AGENT}" + # If an env profile is selected, scope agent config to its subdirectory. + # Seeded on first edit via 'exitbox config edit --profile '. + if [[ -n "${EXITBOX_ENV_PROFILE:-}" ]]; then + local profile_root="${workspace_root}/profiles/${EXITBOX_ENV_PROFILE}" + if [[ ! -d "$profile_root" ]]; then + mkdir -p "$profile_root" + # Seed from default agent config so first run has working defaults. + if [[ -d "$workspace_root" ]]; then + cp -a "${workspace_root}/." "${profile_root}/" 2>/dev/null || true + # Don't recursively nest profiles within profiles. + rm -rf "${profile_root}/profiles" 2>/dev/null || true + fi + fi + workspace_root="$profile_root" + fi + # Credential isolation: only the workspace that was mounted at container # start has real data. If the user switched to a different workspace via # Ctrl+P, its directory is empty (not bind-mounted from host). @@ -851,6 +867,9 @@ build_resume_args() { run_agent_once() { if [[ $# -gt 0 ]]; then + if should_exec_raw "$1"; then + exec "$@" + fi case "$1" in claude|codex|opencode) exec "$@" @@ -909,6 +928,9 @@ run_agent_loop() { set +e if [[ $# -gt 0 ]]; then + if should_exec_raw "$1"; then + "$@" + else case "$1" in claude|codex|opencode) if [[ "$user_has_resume" == "false" && ${#RESUME_ARGS[@]} -gt 0 ]]; then @@ -925,6 +947,7 @@ run_agent_loop() { fi ;; esac + fi else if [[ ${#RESUME_ARGS[@]} -gt 0 ]]; then "$AGENT" "${RESUME_ARGS[@]}" @@ -939,6 +962,23 @@ run_agent_loop() { exit "$code" } +should_exec_raw() { + local cmd="${1:-}" + [[ -z "$cmd" ]] && return 1 + + if [[ "${EXITBOX_RAW_COMMAND:-}" == "true" ]]; then + return 0 + fi + + case "$cmd" in + sh|bash|ash|zsh|fish|env|printenv|pwd|ls|cat|find|which|whereis|stat|test) + return 0 + ;; + esac + + return 1 +} + setup_git_credential_helper() { # If gh is installed, configure git to use gh as the HTTPS credential helper. # Skip when ~/.gitconfig is read-only (e.g. mounted from host via FullGitSupport) From 0601aff0d3732cb34fc481e5c3f5575514375457 Mon Sep 17 00:00:00 2001 From: Stephen <939775+thecodeassassin@users.noreply.github.com> Date: Wed, 13 May 2026 15:26:15 +0000 Subject: [PATCH 2/2] fix: address PR #21 review feedback - config_cmd.go: return on invalid profile name; note profile-scoped config copy - env.go: validateProfileName in all subcommands; clear default on delete; multi-word EDITOR via sh -c; sanitize temp name - run.go: surface ResolveActiveWorkspace/GetDefault errors; early returns - jstools: add "trusted input only" comment - opencode: ensure curl/jq; GITHUB_TOKEN auth; safer binary selection; fix log message - opencode/version.go: document npm/GitHub version alignment invariant - entrypoint: refactor should_exec_raw/run_agent_loop/tmux logic; add tests for passthrough and auth flows --- cmd/config_cmd.go | 5 + cmd/env.go | 48 ++++++++- cmd/run.go | 18 +++- internal/agents/jstools/install.go | 1 + internal/agents/opencode/docker.go | 19 +++- internal/agents/opencode/version.go | 4 + static/build/docker-entrypoint | 133 +++++++++++++++++-------- static/build/docker-entrypoint_test.sh | 78 +++++++++++++++ 8 files changed, 252 insertions(+), 54 deletions(-) diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 89f3f75..ae5e43e 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -176,9 +176,14 @@ Examples: if envProfile != "" { if err := validateProfileName(envProfile); err != nil { ui.Errorf("%v", err) + return } } + // Profile-scoped config is seeded from the default config on first + // edit. If the default contains API keys or tokens, they are copied + // into the profile directory. Rotate secrets if you split profiles + // between providers. wsDir := envProfileConfigDir(workspaceName, name, envProfile) p := a.ConfigFilePath(wsDir) diff --git a/cmd/env.go b/cmd/env.go index f20c81d..f3424a1 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -59,9 +59,14 @@ func newEnvCreateCmd() *cobra.Command { Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] + if err := validateProfileName(name); err != nil { + ui.Errorf("%v", err) + return + } ws := resolveEnvWorkspace(workspace) if env.Exists(ws, name) { ui.Errorf("Env profile '%s' already exists in workspace '%s'", name, ws) + return } editEnvProfile(ws, name, true) }, @@ -78,6 +83,10 @@ func newEnvEditCmd() *cobra.Command { Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] + if err := validateProfileName(name); err != nil { + ui.Errorf("%v", err) + return + } ws := resolveEnvWorkspace(workspace) editEnvProfile(ws, name, false) }, @@ -120,6 +129,10 @@ func newEnvShowCmd() *cobra.Command { Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] + if err := validateProfileName(name); err != nil { + ui.Errorf("%v", err) + return + } ws := resolveEnvWorkspace(workspace) p, err := env.Load(ws, name) if err != nil { @@ -154,12 +167,22 @@ func newEnvDeleteCmd() *cobra.Command { Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] + if err := validateProfileName(name); err != nil { + ui.Errorf("%v", err) + return + } ws := resolveEnvWorkspace(workspace) if !env.Exists(ws, name) { ui.Errorf("Env profile '%s' not found in workspace '%s'", name, ws) + return } if err := env.Delete(ws, name); err != nil { ui.Errorf("Failed to delete env profile: %v", err) + return + } + // If deleted profile is current default, clear default. + if cur, err := env.GetDefault(ws); err == nil && cur == name { + _ = env.ClearDefault(ws) } ui.Successf("Deleted env profile '%s' from workspace '%s'", name, ws) }, @@ -203,8 +226,13 @@ Use --clear to remove the default.`, } name := args[0] + if err := validateProfileName(name); err != nil { + ui.Errorf("%v", err) + return + } if err := env.SetDefault(ws, name); err != nil { ui.Errorf("%v", err) + return } ui.Successf("Set default env profile to '%s' for workspace '%s'", name, ws) }, @@ -216,6 +244,7 @@ Use --clear to remove the default.`, // editEnvProfile opens a tempfile with the profile contents in $EDITOR, // then parses and saves it back to KV. If isNew is true, writes a header. +// Uses sh -c so multi-word EDITOR (e.g. "code --wait") works. func editEnvProfile(ws, name string, isNew bool) { var initial string if isNew { @@ -226,13 +255,23 @@ func editEnvProfile(ws, name string, isNew bool) { p, err := env.Load(ws, name) if err != nil { ui.Errorf("%v", err) + return } initial = env.FormatEnvFile(p.Vars) } - tmp, err := os.CreateTemp("", fmt.Sprintf("exitbox-env-%s-*.env", name)) + // Sanitize name into a safe temp pattern. + safeName := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '_' + }, name) + + tmp, err := os.CreateTemp("", fmt.Sprintf("exitbox-env-%s-*.env", safeName)) if err != nil { ui.Errorf("Failed to create tempfile: %v", err) + return } tmpPath := tmp.Name() defer func() { _ = os.Remove(tmpPath) }() @@ -240,30 +279,35 @@ func editEnvProfile(ws, name string, isNew bool) { if _, err := tmp.WriteString(initial); err != nil { _ = tmp.Close() ui.Errorf("Failed to write tempfile: %v", err) + return } if err := tmp.Close(); err != nil { ui.Errorf("Failed to close tempfile: %v", err) + return } editor := os.Getenv("EDITOR") if editor == "" { editor = "vi" } - c := exec.Command(editor, tmpPath) + c := exec.Command("sh", "-c", fmt.Sprintf("%q \"$1\"", editor), "sh", tmpPath) c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr if err := c.Run(); err != nil { ui.Errorf("Editor exited with error: %v", err) + return } content, err := os.ReadFile(tmpPath) if err != nil { ui.Errorf("Failed to read tempfile: %v", err) + return } vars, parseErr := env.ParseEnvFile(string(content)) if parseErr != nil { ui.Errorf("Parse error: %v", parseErr) + return } if len(vars) == 0 { ui.Warn("No variables defined; profile not saved.") diff --git a/cmd/run.go b/cmd/run.go index a1d9718..6e69f78 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -179,9 +179,14 @@ func runAgent(agentName string, passthrough []string) { // Load env profile vars and prepend to EnvVars so CLI -e flags override // profile values. If no --profile given, check for a workspace default. if flags.EnvProfile == "" { - active, _ := profile.ResolveActiveWorkspace(cfg, projectDir, flags.Workspace) + active, err := profile.ResolveActiveWorkspace(cfg, projectDir, flags.Workspace) + if err != nil { + ui.Warnf("Could not resolve workspace for default env profile: %v", err) + } if active != nil { - if defName, err := env.GetDefault(active.Workspace.Name); err == nil && defName != "" { + if defName, err := env.GetDefault(active.Workspace.Name); err != nil { + ui.Warnf("Could not read default env profile: %v", err) + } else if defName != "" { flags.EnvProfile = defName } } @@ -189,14 +194,17 @@ func runAgent(agentName string, passthrough []string) { if flags.EnvProfile != "" { if err := validateProfileName(flags.EnvProfile); err != nil { ui.Errorf("%v", err) + return } - active, _ := profile.ResolveActiveWorkspace(cfg, projectDir, flags.Workspace) - if active == nil { - ui.Errorf("Cannot resolve workspace for env profile '%s'", flags.EnvProfile) + active, err := profile.ResolveActiveWorkspace(cfg, projectDir, flags.Workspace) + if err != nil { + ui.Errorf("Cannot resolve workspace for env profile '%s': %v", flags.EnvProfile, err) + return } envProfile, err := env.Load(active.Workspace.Name, flags.EnvProfile) if err != nil { ui.Errorf("%v", err) + return } var profileVars []string for k, v := range envProfile.Vars { diff --git a/internal/agents/jstools/install.go b/internal/agents/jstools/install.go index 533ac3e..359c840 100644 --- a/internal/agents/jstools/install.go +++ b/internal/agents/jstools/install.go @@ -4,6 +4,7 @@ import "strings" // InstallDependencies generates a Dockerfile RUN step for apk-installed // packages and globally installed npm packages. +// Callers must pass trusted package specifiers only (shell-injection-prone). func InstallDependencies(apkPackages, npmPackages []string) string { var parts []string diff --git a/internal/agents/opencode/docker.go b/internal/agents/opencode/docker.go index e14cfcb..8041672 100644 --- a/internal/agents/opencode/docker.go +++ b/internal/agents/opencode/docker.go @@ -13,8 +13,11 @@ func (o *OpenCode) GetDockerfileInstall(buildCtx string) (string, error) { } return `# Install OpenCode via direct GitHub release download with SHA-256 verification. # No "curl | bash" — fetches tarball, verifies digest from GitHub API, extracts. +# Note: base image must include curl, jq, sha256sum. +# For CI rate limits, pass GITHUB_TOKEN as build-arg or env to reduce 403s. ARG OPENCODE_VERSION RUN set -e && \ + apk add --no-cache curl jq && \ case "$(uname -m)" in \ x86_64|amd64) OC_ARCH="x64" ;; \ aarch64|arm64) OC_ARCH="arm64" ;; \ @@ -22,7 +25,9 @@ RUN set -e && \ esac && \ ASSET="opencode-linux-${OC_ARCH}.tar.gz" && \ echo "Installing OpenCode v${OPENCODE_VERSION} (${ASSET})..." && \ - META=$(curl -fsSL "https://api.github.com/repos/anomalyco/opencode/releases/tags/v${OPENCODE_VERSION}") && \ + META=$(curl -fsSL \ + -H "Authorization: Bearer ${GITHUB_TOKEN:-}" \ + "https://api.github.com/repos/anomalyco/opencode/releases/tags/v${OPENCODE_VERSION}") && \ DIGEST=$(printf '%s' "$META" | jq -r --arg n "$ASSET" '.assets[] | select(.name == $n) | .digest') && \ EXPECTED="${DIGEST#sha256:}" && \ if ! echo "$EXPECTED" | grep -qE '^[a-f0-9]{64}$'; then \ @@ -40,8 +45,14 @@ RUN set -e && \ echo "Checksum verified: $ACTUAL" && \ mkdir -p /tmp/opencode-extract && \ tar -xzf /tmp/opencode.tar.gz -C /tmp/opencode-extract && \ - OC_BIN=$(find /tmp/opencode-extract -type f -name opencode | head -n1) && \ - test -n "$OC_BIN" && \ + OC_BIN="/tmp/opencode-extract/opencode" && \ + if [ ! -f "$OC_BIN" ]; then \ + OC_BIN=$(find /tmp/opencode-extract -maxdepth 2 -type f -name opencode) && \ + OC_COUNT=$(echo "$OC_BIN" | wc -l) && \ + if [ "$OC_COUNT" -ne 1 ]; then \ + echo "ERROR: Expected exactly one opencode binary, found $OC_COUNT" >&2; exit 1; \ + fi; \ + fi && \ install -m 755 "$OC_BIN" /usr/local/bin/opencode && \ rm -rf /tmp/opencode.tar.gz /tmp/opencode-extract && \ /usr/local/bin/opencode --version`, nil @@ -71,7 +82,7 @@ func (o *OpenCode) PrepareBuild(in agent.PrepareBuildInput) error { } } if in.Logf != nil { - in.Logf("Building OpenCode image with version %s (bun install at build time)", version) + in.Logf("Building OpenCode image with version %s (GitHub release tarball, SHA-256 verified)", version) } df := fmt.Sprintf("FROM exitbox-base\n\nARG OPENCODE_VERSION=%s\n", version) install, err := o.GetDockerfileInstall(in.BuildDir) diff --git a/internal/agents/opencode/version.go b/internal/agents/opencode/version.go index 1b20f8e..91ce049 100644 --- a/internal/agents/opencode/version.go +++ b/internal/agents/opencode/version.go @@ -9,6 +9,10 @@ import ( "github.com/cloud-exit/exitbox/internal/container" ) +// GetLatestVersion queries npm for the latest version. +// The Dockerfile installs from GitHub releases using v${VERSION}. +// Invariant: npm latest must match a tagged GitHub release vX. +// If they diverge (publish lag, pipeline changes), builds will fail. func (o *OpenCode) GetLatestVersion() (string, error) { pkg := o.NpmPackageName() if pkg == "" { diff --git a/static/build/docker-entrypoint b/static/build/docker-entrypoint index f024b70..82ead05 100755 --- a/static/build/docker-entrypoint +++ b/static/build/docker-entrypoint @@ -388,14 +388,22 @@ codex_session_storage_name() { echo "$name" } +has_auth_action() { + local arg lower + for arg in "$@"; do + lower="${arg,,}" + case "$lower" in + *login*|*logout*|*auth*) + return 0 + ;; + esac + done + return 1 +} + codex_uses_direct_home() { [[ "$AGENT" != "codex" ]] && return 1 - case "${1:-}" in - login|logout|auth) - return 0 - ;; - esac - return 1 + has_auth_action "$@" } configure_codex_session_storage() { @@ -867,21 +875,19 @@ build_resume_args() { run_agent_once() { if [[ $# -gt 0 ]]; then - if should_exec_raw "$1"; then - exec "$@" + if should_exec_raw "$@"; then + exec_raw_command "$@" fi - case "$1" in - claude|codex|opencode) - exec "$@" - ;; - *) - if [[ -n "$AGENT" ]]; then - exec "$AGENT" "$@" - else - exec "$@" - fi - ;; - esac + if [[ -n "$AGENT" ]]; then + if [[ "${1:-}" == "$AGENT" ]]; then + shift + fi + if [[ $# -gt 0 ]]; then + exec "$AGENT" "$@" + fi + exec "$AGENT" + fi + exec "$@" else if [[ -n "$AGENT" ]]; then exec "$AGENT" @@ -891,6 +897,34 @@ run_agent_once() { fi } +run_raw_command() { + if [[ -n "$AGENT" ]]; then + if [[ "${1:-}" == "$AGENT" ]]; then + shift + fi + if [[ $# -gt 0 ]]; then + "$AGENT" "$@" + else + "$AGENT" + fi + return + fi + "$@" +} + +exec_raw_command() { + if [[ -n "$AGENT" ]]; then + if [[ "${1:-}" == "$AGENT" ]]; then + shift + fi + if [[ $# -gt 0 ]]; then + exec "$AGENT" "$@" + fi + exec "$AGENT" + fi + exec "$@" +} + run_agent_loop() { apply_active_workspace_links configure_codex_session_storage "$@" @@ -928,25 +962,27 @@ run_agent_loop() { set +e if [[ $# -gt 0 ]]; then - if should_exec_raw "$1"; then - "$@" + if should_exec_raw "$@"; then + run_raw_command "$@" else - case "$1" in - claude|codex|opencode) + local cmd_args=("$@") + if [[ "${cmd_args[0]}" == "$AGENT" ]]; then + cmd_args=("${cmd_args[@]:1}") + fi + + if [[ ${#cmd_args[@]} -gt 0 ]]; then if [[ "$user_has_resume" == "false" && ${#RESUME_ARGS[@]} -gt 0 ]]; then - "$@" "${RESUME_ARGS[@]}" + "$AGENT" "${cmd_args[@]}" "${RESUME_ARGS[@]}" else - "$@" + "$AGENT" "${cmd_args[@]}" fi - ;; - *) + else if [[ "$user_has_resume" == "false" && ${#RESUME_ARGS[@]} -gt 0 ]]; then - "$AGENT" "$@" "${RESUME_ARGS[@]}" + "$AGENT" "${RESUME_ARGS[@]}" else - "$AGENT" "$@" + "$AGENT" fi - ;; - esac + fi fi else if [[ ${#RESUME_ARGS[@]} -gt 0 ]]; then @@ -963,20 +999,30 @@ run_agent_loop() { } should_exec_raw() { - local cmd="${1:-}" - [[ -z "$cmd" ]] && return 1 - if [[ "${EXITBOX_RAW_COMMAND:-}" == "true" ]]; then return 0 fi - case "$cmd" in - sh|bash|ash|zsh|fish|env|printenv|pwd|ls|cat|find|which|whereis|stat|test) - return 0 - ;; - esac + has_auth_action "$@" +} - return 1 +should_start_tmux() { + [[ -n "$AGENT" ]] || return 1 + [[ -z "${TMUX:-}" ]] || return 1 + [[ -t 0 && -t 1 ]] || return 1 + command -v tmux >/dev/null 2>&1 || return 1 + + if [[ $# -gt 0 ]] && should_exec_raw "$@"; then + return 1 + fi + return 0 +} + +build_agent_loop_command() { + local loop_args=("/usr/local/bin/docker-entrypoint" "__agent-loop" "$@") + local cmd + printf -v cmd '%q ' "${loop_args[@]}" + echo "${cmd% }" } setup_git_credential_helper() { @@ -1617,14 +1663,15 @@ if [[ "${1:-}" == "__agent-loop" ]]; then exit $? fi -if [[ $# -eq 0 && -n "$AGENT" && -z "${TMUX:-}" && -t 0 && -t 1 ]] && command -v tmux >/dev/null 2>&1; then +if should_start_tmux "$@"; then # Fall back to a widely-supported TERM if the host terminal's terminfo # is not available inside the container (e.g. xterm-kitty, alacritty). if ! infocmp "$TERM" >/dev/null 2>&1; then export TERM="xterm-256color" fi TMUX_CONF="$(write_tmux_conf)" - tmux -f "$TMUX_CONF" new-session -A -s "exitbox-${AGENT}" "/usr/local/bin/docker-entrypoint __agent-loop" + TMUX_LOOP_CMD="$(build_agent_loop_command "$@")" + tmux -f "$TMUX_CONF" new-session -A -s "exitbox-${AGENT}" "$TMUX_LOOP_CMD" TMUX_EXIT=$? # Show resume status after tmux exits (visible on the host terminal). diff --git a/static/build/docker-entrypoint_test.sh b/static/build/docker-entrypoint_test.sh index fd9c1c7..32dd7b2 100644 --- a/static/build/docker-entrypoint_test.sh +++ b/static/build/docker-entrypoint_test.sh @@ -92,8 +92,12 @@ SESSION_DIR_FOR_NAME_FUNC="$(extract_func session_dir_for_name)" ENSURE_NAMED_SESSION_DIR_FUNC="$(extract_func ensure_named_session_dir)" LEGACY_RESUME_FILE_FUNC="$(extract_func legacy_resume_file)" CODEX_SESSION_STORAGE_NAME_FUNC="$(extract_func codex_session_storage_name)" +HAS_AUTH_ACTION_FUNC="$(extract_func has_auth_action)" CODEX_USES_DIRECT_HOME_FUNC="$(extract_func codex_uses_direct_home)" CONFIGURE_CODEX_SESSION_STORAGE_FUNC="$(extract_func configure_codex_session_storage)" +SHOULD_EXEC_RAW_FUNC="$(extract_func should_exec_raw)" +RUN_RAW_COMMAND_FUNC="$(extract_func run_raw_command)" +BUILD_AGENT_LOOP_COMMAND_FUNC="$(extract_func build_agent_loop_command)" SESSION_HELPER_FUNCS="${DEFAULT_SESSION_NAME_FUNC} ${CURRENT_SESSION_NAME_FUNC} @@ -107,6 +111,7 @@ ${SESSION_KEY_FOR_NAME_FUNC} ${SESSION_DIR_FOR_NAME_FUNC} ${ENSURE_NAMED_SESSION_DIR_FUNC} ${LEGACY_RESUME_FILE_FUNC} +${HAS_AUTH_ACTION_FUNC} ${CODEX_USES_DIRECT_HOME_FUNC}" CODEX_SESSION_HELPERS="${LINK_PATH_FUNC} @@ -646,6 +651,75 @@ test_build_resume_args_skips_codex_login() { assert_eq "build_resume_args (codex login has no resume args)" "" "$result" } +# ============================================================================ +# Test: should_exec_raw only triggers for auth/login/logout flows +# ============================================================================ +test_should_exec_raw_only_for_auth_flows() { + local result + result="$( + eval "$HAS_AUTH_ACTION_FUNC" + eval "$SHOULD_EXEC_RAW_FUNC" + if should_exec_raw --name box --dangerously-skip-permissions; then + echo "raw" + else + echo "wrapped" + fi + )" 2>/dev/null + + assert_eq "should_exec_raw (normal flags stay wrapped)" "wrapped" "$result" +} + +# ============================================================================ +# Test: should_exec_raw detects auth keywords across args (case-insensitive) +# ============================================================================ +test_should_exec_raw_detects_auth_keywords() { + local result + result="$( + eval "$HAS_AUTH_ACTION_FUNC" + eval "$SHOULD_EXEC_RAW_FUNC" + if should_exec_raw codex LoGiN; then + echo "raw" + else + echo "wrapped" + fi + )" 2>/dev/null + + assert_eq "should_exec_raw (login keyword across args)" "raw" "$result" +} + +# ============================================================================ +# Test: run_raw_command still prefixes AGENT for auth/login flows +# ============================================================================ +test_run_raw_command_prefixes_agent() { + local result + result="$( + AGENT="codex" + codex() { + echo "codex:$*" + } + eval "$RUN_RAW_COMMAND_FUNC" + run_raw_command login + )" 2>/dev/null + + assert_eq "run_raw_command (login executes via agent binary)" "codex:login" "$result" +} + +# ============================================================================ +# Test: build_agent_loop_command preserves passthrough args +# ============================================================================ +test_build_agent_loop_command_passthrough() { + local result + result="$( + eval "$BUILD_AGENT_LOOP_COMMAND_FUNC" + build_agent_loop_command --dangerously-skip-permissions --name box + )" 2>/dev/null + + assert_contains "build_agent_loop_command includes agent loop marker" "$result" "__agent-loop" + assert_contains "build_agent_loop_command includes dangerous flag" "$result" "--dangerously-skip-permissions" + assert_contains "build_agent_loop_command includes name flag" "$result" "--name" + assert_contains "build_agent_loop_command includes name value" "$result" "box" +} + # ============================================================================ # Test: write_tmux_conf includes scrolling settings # ============================================================================ @@ -764,6 +838,10 @@ test_configure_codex_session_storage_isolates_named_sessions test_configure_codex_session_storage_uses_active_session test_configure_codex_session_storage_bypasses_login_flow test_build_resume_args_skips_codex_login +test_should_exec_raw_only_for_auth_flows +test_should_exec_raw_detects_auth_keywords +test_run_raw_command_prefixes_agent +test_build_agent_loop_command_passthrough test_write_tmux_conf_scroll_settings test_parse_keybindings_default test_parse_keybindings_custom