diff --git a/cmd/create.go b/cmd/create.go index 22825d4..bb2194d 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -120,10 +120,9 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { providers: registered, noTTY: true, retrySleep: 5 * time.Second, - sandboxCmd: []string{"bash", "-c", - ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + - "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; true"}, + sandboxCmd: []string{"true"}, payloadDir: payloadDir, + env: agentCfg.BuildEnvMap(), onSuccess: func(n string) { fmt.Println() status.OKf("Sandbox created: %s — connect with: harness connect %s", n, n) diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 09e387e..eaa8540 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -42,7 +42,7 @@ func (m *mockGW) SandboxDelete(name string) error { m.deletedNames = append(m.deletedNames, name) return nil } -func (m *mockGW) CLIVersion() string { return "openshell v0.0.58" } +func (m *mockGW) CLIVersion() string { return "openshell v0.0.59" } func (m *mockGW) CLIPath() string { return "/usr/bin/openshell" } func (m *mockGW) InferenceModel() string { return "" } func (m *mockGW) InferenceSet(string, string) error { return nil } diff --git a/cmd/sandbox.go b/cmd/sandbox.go index de68b03..27c57be 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -22,6 +22,7 @@ type sandboxOpts struct { retrySleep time.Duration // pause between retry attempts sandboxCmd []string // command to run inside the sandbox payloadDir string // pre-rendered payload dir to upload + env map[string]string // env vars injected via --env on sandbox create onSuccess func(name string) // called after successful creation (optional) } @@ -64,6 +65,7 @@ func createSandbox(opts sandboxOpts) error { UploadSrc: uploadDir, UploadDst: "/sandbox/.config", Command: opts.sandboxCmd, + Env: opts.env, }) if err == nil { if opts.onSuccess != nil { diff --git a/cmd/up.go b/cmd/up.go index f5a4dfd..4131207 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -42,6 +42,9 @@ func NewUpCmd(harnessDir, cli string) *cobra.Command { agentPath := resolveAgentPath(harnessDir, agentName, agentFile) gw := gateway.New(cli) + if err := gw.CheckMinVersion("0.0.59"); err != nil { + status.Warn(fmt.Sprintf("OpenShell version: %v", err)) + } gwName := "local" if remote { @@ -187,13 +190,11 @@ func upLocal(opts upLocalOpts) error { // 5. Create sandbox status.Header("Sandbox") - envInit := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + - "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; " var sandboxCmd []string if noTTY { - sandboxCmd = []string{"bash", "-c", envInit + "true"} + sandboxCmd = []string{"true"} } else { - sandboxCmd = []string{"bash", "-c", envInit + "exec bash /sandbox/.config/openshell/run.sh"} + sandboxCmd = []string{"bash", "/sandbox/.config/openshell/run.sh"} } return createSandbox(sandboxOpts{ @@ -206,6 +207,7 @@ func upLocal(opts upLocalOpts) error { retrySleep: opts.retrySleep, sandboxCmd: sandboxCmd, payloadDir: payloadDir, + env: agentCfg.BuildEnvMap(), }) } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ad98572..09d3843 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -73,16 +73,21 @@ func Parse(data []byte) (*AgentConfig, error) { return &cfg, nil } -func (c *AgentConfig) BuildEnvSh() string { +func (c *AgentConfig) BuildEnvMap() map[string]string { env := make(map[string]string) for k, v := range c.Env { - env[k] = v + env[k] = os.ExpandEnv(v) } for _, p := range c.Providers { for k, v := range p.Config { - env[k] = v + env[k] = os.ExpandEnv(v) } } + return env +} + +func (c *AgentConfig) BuildEnvSh() string { + env := c.BuildEnvMap() if len(env) == 0 { return "" } @@ -93,7 +98,7 @@ func (c *AgentConfig) BuildEnvSh() string { sort.Strings(keys) var b strings.Builder for _, k := range keys { - fmt.Fprintf(&b, "export %s=%q\n", k, os.ExpandEnv(env[k])) + fmt.Fprintf(&b, "export %s=%q\n", k, env[k]) } return b.String() } @@ -102,10 +107,6 @@ func (c *AgentConfig) BuildRunSh() string { var b strings.Builder b.WriteString("#!/usr/bin/env bash\nset -euo pipefail\n\n") b.WriteString("PAYLOAD_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\n") - b.WriteString("# Source environment\n") - b.WriteString("if [[ -f \"$PAYLOAD_DIR/env.sh\" ]]; then\n") - b.WriteString(" . \"$PAYLOAD_DIR/env.sh\"\n") - b.WriteString("fi\n\n") b.WriteString("# Prepend payload bin to PATH\n") b.WriteString("export PATH=\"$PAYLOAD_DIR/bin:$PATH\"\n\n") b.WriteString("# Git auth\n") @@ -135,17 +136,6 @@ func RenderPayload(cfg *AgentConfig, baseDir, destDir string) error { return fmt.Errorf("creating bin dir: %w", err) } - if envContent := cfg.BuildEnvSh(); envContent != "" { - if err := os.WriteFile(filepath.Join(destDir, "env.sh"), []byte(envContent), 0o644); err != nil { - return fmt.Errorf("writing env.sh: %w", err) - } - // sandbox.env is sourced by the sandbox image's startup.sh on boot, - // making env vars available to sandbox exec sessions. - if err := os.WriteFile(filepath.Join(destDir, "sandbox.env"), []byte(envContent), 0o644); err != nil { - return fmt.Errorf("writing sandbox.env: %w", err) - } - } - runSh := cfg.BuildRunSh() if err := os.WriteFile(filepath.Join(destDir, "run.sh"), []byte(runSh), 0o755); err != nil { return fmt.Errorf("writing run.sh: %w", err) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 086f957..b22e006 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -230,6 +230,39 @@ env: } } +func TestBuildEnvMap(t *testing.T) { + cfg := &AgentConfig{ + Env: map[string]string{ + "TOP_VAR": "top-val", + "SHARED": "from-top", + }, + Providers: []ProviderRef{ + {Profile: "atlassian", Config: map[string]string{ + "JIRA_URL": "https://issues.redhat.com", + "SHARED": "from-provider", + }}, + }, + } + env := cfg.BuildEnvMap() + if env["TOP_VAR"] != "top-val" { + t.Errorf("TOP_VAR = %q, want top-val", env["TOP_VAR"]) + } + if env["JIRA_URL"] != "https://issues.redhat.com" { + t.Errorf("JIRA_URL = %q", env["JIRA_URL"]) + } + if env["SHARED"] != "from-provider" { + t.Errorf("SHARED = %q, want from-provider (provider should override top-level)", env["SHARED"]) + } +} + +func TestBuildEnvMap_Empty(t *testing.T) { + cfg := &AgentConfig{Providers: []ProviderRef{{Profile: "github"}}} + env := cfg.BuildEnvMap() + if len(env) != 0 { + t.Errorf("expected empty map, got %v", env) + } +} + func TestBuildEnvSh_Sorted(t *testing.T) { cfg := &AgentConfig{ Providers: []ProviderRef{ @@ -253,8 +286,8 @@ func TestBuildRunSh(t *testing.T) { if !strings.Contains(runSh, "#!/usr/bin/env bash") { t.Error("missing shebang") } - if !strings.Contains(runSh, ". \"$PAYLOAD_DIR/env.sh\"") { - t.Error("missing env.sh source") + if strings.Contains(runSh, "env.sh") { + t.Error("run.sh should not source env.sh — env vars are injected via --env") } if !strings.Contains(runSh, "gh auth setup-git") { t.Error("missing gh auth setup-git") @@ -296,9 +329,6 @@ func TestRenderPayload(t *testing.T) { t.Fatalf("RenderPayload: %v", err) } - if _, err := os.Stat(filepath.Join(destDir, "env.sh")); err != nil { - t.Error("missing env.sh") - } if _, err := os.Stat(filepath.Join(destDir, "run.sh")); err != nil { t.Error("missing run.sh") } @@ -308,10 +338,8 @@ func TestRenderPayload(t *testing.T) { if _, err := os.Stat(filepath.Join(destDir, "bin")); err != nil { t.Error("missing bin/ directory") } - - envData, _ := os.ReadFile(filepath.Join(destDir, "env.sh")) - if !strings.Contains(string(envData), "JIRA_URL") { - t.Errorf("env.sh missing JIRA_URL:\n%s", envData) + if _, err := os.Stat(filepath.Join(destDir, "env.sh")); !os.IsNotExist(err) { + t.Error("env.sh should not be created — env vars are injected via --env") } runData, _ := os.ReadFile(filepath.Join(destDir, "run.sh")) diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 4dcadfa..05b7c4d 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -1,10 +1,13 @@ package gateway import ( + "fmt" "io" "os" "os/exec" "regexp" + "sort" + "strconv" "strings" "syscall" @@ -38,6 +41,55 @@ func (c *CLI) CLIPath() string { return path } +func ParseCLIVersion(raw string) string { + raw = strings.TrimSpace(raw) + if i := strings.LastIndex(raw, "v"); i >= 0 { + return raw[i+1:] + } + return raw +} + +func parseVersionParts(v string) (major, minor, patch int, ok bool) { + parts := strings.SplitN(v, ".", 3) + if len(parts) != 3 { + return 0, 0, 0, false + } + var err error + major, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, 0, false + } + minor, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, 0, false + } + patch, err = strconv.Atoi(parts[2]) + if err != nil { + return 0, 0, 0, false + } + return major, minor, patch, true +} + +func (c *CLI) CheckMinVersion(minVersion string) error { + raw := c.CLIVersion() + if raw == "" { + return fmt.Errorf("could not determine openshell version") + } + installed := ParseCLIVersion(raw) + iMaj, iMin, iPatch, ok := parseVersionParts(installed) + if !ok { + return fmt.Errorf("could not parse openshell version %q", installed) + } + mMaj, mMin, mPatch, ok := parseVersionParts(minVersion) + if !ok { + return fmt.Errorf("invalid minimum version %q", minVersion) + } + if iMaj < mMaj || (iMaj == mMaj && iMin < mMin) || (iMaj == mMaj && iMin == mMin && iPatch < mPatch) { + return fmt.Errorf("openshell %s is below minimum %s (upgrade: openshell update)", installed, minVersion) + } + return nil +} + func (c *CLI) InferenceGet() error { return c.silent("inference", "get") } @@ -211,6 +263,16 @@ func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { if opts.UploadSrc != "" { args = append(args, "--upload", opts.UploadSrc+":"+opts.UploadDst, "--no-git-ignore") } + if len(opts.Env) > 0 { + keys := make([]string, 0, len(opts.Env)) + for k := range opts.Env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + args = append(args, "--env", k+"="+opts.Env[k]) + } + } if len(opts.Command) > 0 { args = append(args, "--") args = append(args, opts.Command...) diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 59ba091..999c5f8 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -396,6 +396,112 @@ func TestCLIPath_NotFound(t *testing.T) { } } +func TestSandboxCreate_WithEnv(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args") + bin := writeStub(t, `#!/bin/bash +echo "$@" > `+argsFile+` +`) + gw := New(bin) + gw.SandboxCreate(SandboxCreateOpts{ + Name: "env-test", + TTY: false, + Keep: true, + Env: map[string]string{ + "FOO": "bar", + "ANTHROPIC_API_KEY": "sk-proxy", + }, + Command: []string{"true"}, + }) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + for _, want := range []string{ + "--env ANTHROPIC_API_KEY=sk-proxy", + "--env FOO=bar", + } { + if !strings.Contains(args, want) { + t.Errorf("missing %q in: %s", want, args) + } + } + envIdx := strings.Index(args, "--env") + cmdIdx := strings.Index(args, "-- true") + if envIdx > cmdIdx { + t.Errorf("--env should appear before -- command separator: %s", args) + } +} + +func TestSandboxCreate_NoEnv(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args") + bin := writeStub(t, `#!/bin/bash +echo "$@" > `+argsFile+` +`) + gw := New(bin) + gw.SandboxCreate(SandboxCreateOpts{ + Name: "no-env", + TTY: false, + Keep: true, + }) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + if strings.Contains(args, "--env") { + t.Errorf("should not have --env when env map is nil: %s", args) + } +} + +func TestParseCLIVersion(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"openshell v0.0.59", "0.0.59"}, + {"openshell v0.0.58", "0.0.58"}, + {"v1.2.3", "1.2.3"}, + {"0.0.59", "0.0.59"}, + } + for _, tt := range tests { + got := ParseCLIVersion(tt.input) + if got != tt.want { + t.Errorf("ParseCLIVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestCheckMinVersion_Below(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +echo "openshell v0.0.57" +`) + gw := New(bin) + if err := gw.CheckMinVersion("0.0.59"); err == nil { + t.Error("expected error for version below minimum") + } +} + +func TestCheckMinVersion_Equal(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +echo "openshell v0.0.59" +`) + gw := New(bin) + if err := gw.CheckMinVersion("0.0.59"); err != nil { + t.Errorf("CheckMinVersion: %v", err) + } +} + +func TestCheckMinVersion_Above(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +echo "openshell v0.0.60" +`) + gw := New(bin) + if err := gw.CheckMinVersion("0.0.59"); err != nil { + t.Errorf("CheckMinVersion: %v", err) + } +} + +func TestCheckMinVersion_NoCLI(t *testing.T) { + gw := New("/nonexistent/openshell") + if err := gw.CheckMinVersion("0.0.59"); err == nil { + t.Error("expected error when CLI not found") + } +} + func TestProviderCreate_Args(t *testing.T) { dir := t.TempDir() argsFile := filepath.Join(dir, "args") diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index babfbce..c781308 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -90,4 +90,5 @@ type SandboxCreateOpts struct { UploadSrc string UploadDst string Command []string + Env map[string]string } diff --git a/openshell.toml b/openshell.toml index 0531f96..08441e7 100644 --- a/openshell.toml +++ b/openshell.toml @@ -19,4 +19,4 @@ providers = ["github", "vertex-local", "atlassian", "gws"] model = "claude-sonnet-4-6" [upstream] -chart-version = "0.0.58" +chart-version = "0.0.59"