diff --git a/.opencode-sandbox.yaml b/.opencode-sandbox.yaml new file mode 100644 index 0000000..154553d --- /dev/null +++ b/.opencode-sandbox.yaml @@ -0,0 +1,57 @@ +version: 1 +image: + name: ghcr.io/rabbitcybersec/opencode-sandbox:latest + +project: + target: /workspace + readonly: false + +opencode: + mountHostConfig: true + mountHostData: true + generatedConfig: true + autoupdate: false + +# Project skills are automatically discovered from .opencode-sandbox/skills/. +# Global skills from ~/.config/opencode-sandbox/skills/ are always included. +# skills: +# include: +# - "*" +# exclude: [] + +network: + inheritGlobal: true + mode: practical + ebpf: + initImage: ghcr.io/rabbitcybersec/opencode-sandbox-init:latest + blocklist: [] + allowlist: [] + # Allow the container to reach services on the macOS host (e.g., MCP servers). + # Requires: sudo container system dns create host.container.internal --localhost 203.0.113.113 + localhostAccess: + enabled: false + ip: 203.0.113.113 + domain: host.container.internal + +audit: + commands: + enabled: true + backend: ebpf + failClosed: false + logArgs: full + maxArgs: 64 + maxArgBytes: 16384 + includeExecutables: [] + excludeExecutables: [] + includeCwd: [] + excludeCwd: [] + mirrorProjectEvents: false + eventLog: ~/.local/state/opencode-sandbox/runs + +resources: + cpus: 4 + memory: 4g + +container: + namePrefix: opencode-sandbox + remove: true diff --git a/README.md b/README.md index 53b1988..1db3b9d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ sopencode init # Fetch the published default image sopencode image pull +# Upgrade OpenCode in the configured runtime image +sopencode upgrade + # Run OpenCode in the sandbox sopencode run . @@ -49,6 +52,9 @@ go build -o ./opencode-sandbox ./cmd/opencode-sandbox # Or build the image locally from source ./opencode-sandbox image build +# Upgrade OpenCode in the configured runtime image +./opencode-sandbox upgrade + # Run OpenCode in the sandbox ./opencode-sandbox run . @@ -177,6 +183,17 @@ opencode-sandbox image pull opencode-sandbox image pull --strict-init ``` +OpenCode itself can be upgraded without a source checkout: + +```bash +opencode-sandbox upgrade +opencode-sandbox upgrade v1.15.13 +``` + +The wrapper pulls the configured runtime image and rebuilds a local derived +image with the requested OpenCode npm package. It does not mutate an ephemeral +running container. + Local source builds remain supported: ```bash diff --git a/docs/implementation-spec.md b/docs/implementation-spec.md index 9369125..4199df9 100644 --- a/docs/implementation-spec.md +++ b/docs/implementation-spec.md @@ -187,7 +187,7 @@ opencode-sandbox . -- run "summarize this repo" Argument parsing rules: - Wrapper subcommands are reserved words: `doctor`, `init`, `run`, `skills`, - `policy`, `image`, `config`, and `help`. + `policy`, `image`, `config`, `upgrade`, and `help`. - Root-level `--help` and `-h` belong to OpenCode, not the wrapper. Wrapper help is available through `opencode-sandbox help` and subcommand help such as `opencode-sandbox help init`. @@ -531,6 +531,27 @@ Behavior: - Avoids secrets at build time. - Emits a clear next command on success. +### 7.9 `upgrade` + +Purpose: upgrade OpenCode in the configured runtime image without mutating an +ephemeral running container. + +Command: + +```text +opencode-sandbox upgrade [target] +``` + +Behavior: + +- Loads the effective image configuration for the current project. +- Pulls the configured runtime image before rebuilding. +- Defaults the OpenCode npm package target to `latest`. +- Builds a temporary derived image as root with `opencode-ai@`, restores + the unprivileged `opencode` user, and tags the result as the configured image. +- Rejects OpenCode's `--method` flag because managed image upgrades always use + npm during image build. + ## 8. Config Schema Implement config as a versioned YAML document. Use strict decoding: @@ -1377,6 +1398,7 @@ available. - `opencode-sandbox init --global` creates global config under `~/.config/opencode-sandbox`. - `opencode-sandbox image build` builds image. +- `opencode-sandbox upgrade [target]` upgrades OpenCode in the configured image. - `opencode-sandbox run .` starts OpenCode TUI. - `cd project && opencode-sandbox --help` runs OpenCode help in the sandbox. - Existing OpenCode auth works without copying the host home directory. @@ -1646,6 +1668,7 @@ v1 is complete when: - `init --global` creates global config under `~/.config/opencode-sandbox/config.yaml`. - `image build` builds the OpenCode image. +- `upgrade [target]` upgrades OpenCode in the configured image. - `run .` starts OpenCode in `/workspace`. - `opencode-sandbox --help` from a project directory runs OpenCode help in the sandbox. diff --git a/docs/quickstart.md b/docs/quickstart.md index e978773..1157d4a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -187,6 +187,18 @@ sopencode image build sopencode image build --opencode-version ``` +Upgrade OpenCode in the configured runtime image without a source checkout: + +```bash +sopencode upgrade +sopencode upgrade v1.15.13 +``` + +With no target, `upgrade` installs the latest OpenCode npm package. The wrapper +first pulls the configured runtime image, then rebuilds a local derived image +with the requested OpenCode version. This keeps normal runs read-only and +unprivileged. + ## 7. Run OpenCode in a project From a project folder: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 043781f..8838eda 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -131,6 +131,22 @@ Encrypted DNS (DoH/DNS-over-TLS) can bypass the local DNS resolver. Project config is discovered by walking up from the project path. Global config lives at `~/.config/opencode-sandbox/config.yaml`. +### OpenCode upgrade fails inside the container + +Do not forward OpenCode's npm self-update into a running sandbox. The runtime +image installs OpenCode globally as root, while normal runs use an unprivileged +user and an ephemeral read-only container filesystem. + +Use the wrapper-managed upgrade command instead: + +```bash +opencode-sandbox upgrade +opencode-sandbox upgrade v1.15.13 +``` + +This pulls the configured runtime image and builds a local derived image with +the requested OpenCode package version. + ### Image build fails For normal installs, prefer pulling the published image: diff --git a/internal/cli/help.go b/internal/cli/help.go index 4abdd77..ea3c372 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -13,6 +13,7 @@ func runHelp(args []string) error { fmt.Println(" policy Test network policy") fmt.Println(" image Pull published images or build from source") fmt.Println(" config Inspect configuration") + fmt.Println(" upgrade Upgrade OpenCode in the configured runtime image") fmt.Println(" uninstall Remove global artifacts and container resources") fmt.Println(" help Show this help") fmt.Println() diff --git a/internal/cli/init.go b/internal/cli/init.go index 9a4bc48..bb8df2b 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -35,17 +35,16 @@ func runInit(args []string) error { } func initGlobal(force bool) error { - configDir, err := os.UserConfigDir() + path, err := config.GlobalConfigPath() if err != nil { return fmt.Errorf("getting config dir: %w", err) } - path := configDir + "/opencode-sandbox/config.yaml" if _, err := os.Stat(path); err == nil && !force { return fmt.Errorf("global config already exists at %s; use --force to overwrite", path) } - if err := os.MkdirAll(configDir+"/opencode-sandbox", 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("creating config directory: %w", err) } @@ -178,11 +177,12 @@ opencode: generatedConfig: true autoupdate: false -skills: - importedDir: .opencode-sandbox/skills - include: - - "*" - exclude: [] +# Project skills are automatically discovered from .opencode-sandbox/skills/. +# Global skills from ~/.config/opencode-sandbox/skills/ are always included. +# skills: +# include: +# - "*" +# exclude: [] network: inheritGlobal: true diff --git a/internal/cli/init_config_test.go b/internal/cli/init_config_test.go index 4aed200..532309a 100644 --- a/internal/cli/init_config_test.go +++ b/internal/cli/init_config_test.go @@ -29,6 +29,12 @@ func TestInitProjectCreatesConfig(t *testing.T) { if !strings.Contains(string(data), "audit:") || !strings.Contains(string(data), "commands:") { t.Error("expected command audit in generated project config") } + if strings.Contains(string(data), "\nskills:\n") { + t.Error("project config should not actively override global skills settings") + } + if strings.Contains(string(data), "\n importedDir: .opencode-sandbox/skills") { + t.Error("project config should not set importedDir to the project skills directory") + } } func TestInitProjectRefusesOverwrite(t *testing.T) { @@ -54,12 +60,39 @@ func TestInitProjectForceOverwrite(t *testing.T) { } func TestInitGlobalCreatesConfig(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) if err := initGlobal(false); err != nil { t.Fatalf("initGlobal failed: %v", err) } } +func TestInitGlobalUsesLoadedGlobalConfigPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + path := filepath.Join(home, ".config", "opencode-sandbox", "config.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("existing"), 0644); err != nil { + t.Fatal(err) + } + + if err := initGlobal(true); err != nil { + t.Fatalf("initGlobal --force failed: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "version: 1") { + t.Fatalf("expected global config at loaded path to be updated, got:\n%s", data) + } +} + func TestDetectExistingConfigs(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0644); err != nil { @@ -72,7 +105,9 @@ func TestDetectExistingConfigs(t *testing.T) { } func TestConfigPathGlobal(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) if err := runConfigPath([]string{"--global"}); err != nil { t.Fatalf("config path --global failed: %v", err) } diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 269f600..fa5a845 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -29,7 +29,9 @@ func TestInitProjectExists(t *testing.T) { } func TestInitGlobal(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) if err := initGlobal(false); err != nil { t.Fatalf("initGlobal failed: %v", err) } diff --git a/internal/cli/root.go b/internal/cli/root.go index dadd6b5..430c666 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -8,6 +8,7 @@ var wrapperCommands = map[string]bool{ "policy": true, "image": true, "config": true, + "upgrade": true, "uninstall": true, "help": true, } @@ -40,6 +41,8 @@ func Execute(args []string) error { return runImage(args[1:]) case "config": return runConfig(args[1:]) + case "upgrade": + return runUpgrade(args[1:]) case "uninstall": return runUninstall(args[1:]) case "help": diff --git a/internal/cli/skills.go b/internal/cli/skills.go index 0da936c..00f49e9 100644 --- a/internal/cli/skills.go +++ b/internal/cli/skills.go @@ -78,6 +78,15 @@ func runSkillsImport(args []string) error { return fmt.Errorf("getting config dir: %w", err) } destDir = filepath.Join(configDir, "opencode-sandbox", "skills") + // On macOS, os.UserConfigDir() returns ~/Library/Preferences, + // but skills should be installed at ~/.config/opencode-sandbox/skills. + home, err := os.UserHomeDir() + if err == nil { + xdgDir := filepath.Join(home, ".config", "opencode-sandbox", "skills") + if _, err := os.Stat(xdgDir); err == nil || xdgDir != destDir { + destDir = xdgDir + } + } case "project": if project == "" { wd, err := os.Getwd() @@ -182,20 +191,20 @@ func collectSkillsList(project string) ([]skillListItem, error) { // On macOS, os.UserConfigDir() returns ~/Library/Preferences, // but skills may be installed at ~/.config/opencode-sandbox/skills. // Check both locations. - home, _ := os.UserHomeDir() - xdgConfigDir := filepath.Join(home, ".config") - roots := []struct { scope string path string }{ {"global-imported", filepath.Join(configDir, "opencode-sandbox", "skills")}, } - if xdgConfigDir != configDir { - roots = append(roots, struct { - scope string - path string - }{"global-xdg", filepath.Join(xdgConfigDir, "opencode-sandbox", "skills")}) + if home, err := os.UserHomeDir(); err == nil { + xdgConfigDir := filepath.Join(home, ".config") + if xdgConfigDir != configDir { + roots = append(roots, struct { + scope string + path string + }{"global-xdg", filepath.Join(xdgConfigDir, "opencode-sandbox", "skills")}) + } } roots = append(roots, []struct { scope string diff --git a/internal/cli/skills_test.go b/internal/cli/skills_test.go index 2908a3a..604c629 100644 --- a/internal/cli/skills_test.go +++ b/internal/cli/skills_test.go @@ -46,3 +46,85 @@ func TestCollectSkillsList(t *testing.T) { t.Fatalf("expected project native skill, got %v", got) } } + +func TestCollectSkillsListIncludesHomeDotConfigGlobal(t *testing.T) { + home := t.TempDir() + project := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + global := filepath.Join(home, ".config", "opencode-sandbox", "skills", "global-xdg") + if err := os.MkdirAll(global, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(global, "SKILL.md"), []byte("---\nname: global-xdg\ndescription: test\n---\n"), 0644); err != nil { + t.Fatal(err) + } + + list, err := collectSkillsList(project) + if err != nil { + t.Fatalf("collectSkillsList failed: %v", err) + } + for _, item := range list { + if item.Name == "global-xdg" && item.Scope == "global-xdg" { + return + } + } + t.Fatalf("expected ~/.config global skill in list, got %#v", list) +} + +func TestCollectSkillsListIncludesSymlinkedHomeDotConfigGlobal(t *testing.T) { + home := t.TempDir() + project := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + target := filepath.Join(t.TempDir(), "real-skill") + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("---\nname: linked-global\ndescription: test\n---\n"), 0644); err != nil { + t.Fatal(err) + } + globalRoot := filepath.Join(home, ".config", "opencode-sandbox", "skills") + if err := os.MkdirAll(globalRoot, 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(globalRoot, "linked-global")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + list, err := collectSkillsList(project) + if err != nil { + t.Fatalf("collectSkillsList failed: %v", err) + } + for _, item := range list { + if item.Name == "linked-global" && item.Scope == "global-xdg" { + return + } + } + t.Fatalf("expected symlinked ~/.config global skill in list, got %#v", list) +} + +func TestRunSkillsImportGlobalUsesHomeDotConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + source := filepath.Join(t.TempDir(), "skill") + if err := os.MkdirAll(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("---\nname: imported-global\n---\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := runSkillsImport([]string{source}); err != nil { + t.Fatalf("runSkillsImport failed: %v", err) + } + + want := filepath.Join(home, ".config", "opencode-sandbox", "skills", "imported-global", "SKILL.md") + if _, err := os.Stat(want); err != nil { + t.Fatalf("expected global skill at %s: %v", want, err) + } +} diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go new file mode 100644 index 0000000..2050f5a --- /dev/null +++ b/internal/cli/upgrade.go @@ -0,0 +1,92 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +const upgradeContainerfile = `ARG BASE_IMAGE +FROM ${BASE_IMAGE} +USER root +ARG OPENCODE_VERSION=latest +RUN npm install -g "opencode-ai@${OPENCODE_VERSION}" && npm cache clean --force +USER opencode +` + +var runUpgradeCommand = func(cmd *exec.Cmd) error { + return cmd.Run() +} + +func runUpgrade(args []string) error { + target, err := parseUpgradeTarget(args) + if err != nil { + return err + } + + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + cfg, err := loadRunConfig(wd, RunPlan{}) + if err != nil { + return fmt.Errorf("loading runtime image config: %w", err) + } + image := cfg.Image.Name + + fmt.Printf("Pulling base runtime image %s...\n", image) + if err := runUpgradeContainerCommand("image", "pull", image); err != nil { + return fmt.Errorf("pulling runtime image %q: %w", image, err) + } + + contextDir, err := os.MkdirTemp("", "opencode-sandbox-upgrade-*") + if err != nil { + return fmt.Errorf("creating temporary upgrade context: %w", err) + } + defer os.RemoveAll(contextDir) + + containerfile := filepath.Join(contextDir, "Containerfile") + if err := os.WriteFile(containerfile, []byte(upgradeContainerfile), 0644); err != nil { + return fmt.Errorf("writing temporary upgrade Containerfile: %w", err) + } + + fmt.Printf("Building %s with OpenCode %s...\n", image, target) + if err := runUpgradeContainerCommand( + "build", + "--file", containerfile, + "--tag", image, + "--build-arg", "BASE_IMAGE="+image, + "--build-arg", "OPENCODE_VERSION="+target, + contextDir, + ); err != nil { + return fmt.Errorf("building upgraded runtime image %q: %w", image, err) + } + + fmt.Printf("\nOpenCode %s is ready in %s.\n", target, image) + return nil +} + +func parseUpgradeTarget(args []string) (string, error) { + if len(args) == 0 { + return "latest", nil + } + if args[0] == "--method" || args[0] == "-m" { + return "", fmt.Errorf("upgrade option %s is not supported: opencode-sandbox always upgrades with npm during image build", args[0]) + } + if len(args) != 1 { + return "", fmt.Errorf("usage: opencode-sandbox upgrade [target]") + } + if args[0] == "" || args[0][0] == '-' { + return "", fmt.Errorf("unknown upgrade option: %s", args[0]) + } + return args[0], nil +} + +func runUpgradeContainerCommand(args ...string) error { + cmd := exec.Command("container", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return runUpgradeCommand(cmd) +} diff --git a/internal/cli/upgrade_test.go b/internal/cli/upgrade_test.go new file mode 100644 index 0000000..5cc5711 --- /dev/null +++ b/internal/cli/upgrade_test.go @@ -0,0 +1,219 @@ +package cli + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestExecuteUpgradeDispatchesWrapperCommand(t *testing.T) { + withUpgradeWorkingDir(t, "") + calls := captureUpgradeCommands(t, nil) + + if err := Execute([]string{"upgrade", "v1.15.13"}); err != nil { + t.Fatalf("Execute failed: %v", err) + } + + assertUpgradeCommands(t, calls, "ghcr.io/rabbitcybersec/opencode-sandbox:latest", "v1.15.13") +} + +func TestRunUpgradeDefaultsToLatest(t *testing.T) { + withUpgradeWorkingDir(t, "") + calls := captureUpgradeCommands(t, nil) + + if err := runUpgrade(nil); err != nil { + t.Fatalf("runUpgrade failed: %v", err) + } + + assertUpgradeCommands(t, calls, "ghcr.io/rabbitcybersec/opencode-sandbox:latest", "latest") +} + +func TestRunUpgradeUsesConfiguredImageAndRestoresUnprivilegedUser(t *testing.T) { + withUpgradeWorkingDir(t, "version: 1\nimage:\n name: example.test/opencode-sandbox:custom\n") + var containerfile string + calls := captureUpgradeCommands(t, func(cmd *exec.Cmd) { + if len(cmd.Args) > 1 && cmd.Args[1] == "build" { + path := argValue(t, cmd.Args, "--file") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading generated Containerfile: %v", err) + } + containerfile = string(data) + } + }) + + if err := runUpgrade([]string{"v1.15.13"}); err != nil { + t.Fatalf("runUpgrade failed: %v", err) + } + + assertUpgradeCommands(t, calls, "example.test/opencode-sandbox:custom", "v1.15.13") + for _, want := range []string{ + "ARG BASE_IMAGE", + "FROM ${BASE_IMAGE}", + "USER root", + "ARG OPENCODE_VERSION=latest", + `RUN npm install -g "opencode-ai@${OPENCODE_VERSION}" && npm cache clean --force`, + "USER opencode", + } { + if !strings.Contains(containerfile, want) { + t.Fatalf("generated Containerfile missing %q:\n%s", want, containerfile) + } + } +} + +func TestRunUpgradeRejectsMethodFlag(t *testing.T) { + withUpgradeWorkingDir(t, "") + + err := runUpgrade([]string{"--method", "npm"}) + if err == nil || !strings.Contains(err.Error(), "--method") { + t.Fatalf("expected --method rejection, got %v", err) + } +} + +func TestRunUpgradeRejectsExtraArguments(t *testing.T) { + withUpgradeWorkingDir(t, "") + + err := runUpgrade([]string{"v1.15.13", "unexpected"}) + if err == nil || !strings.Contains(err.Error(), "usage:") { + t.Fatalf("expected usage error, got %v", err) + } +} + +func TestRunUpgradeStopsWhenPullFails(t *testing.T) { + withUpgradeWorkingDir(t, "") + var calls [][]string + oldRun := runUpgradeCommand + runUpgradeCommand = func(cmd *exec.Cmd) error { + calls = append(calls, append([]string(nil), cmd.Args...)) + return errors.New("pull failed") + } + t.Cleanup(func() { runUpgradeCommand = oldRun }) + + err := runUpgrade(nil) + if err == nil || !strings.Contains(err.Error(), "pulling runtime image") { + t.Fatalf("expected pull error, got %v", err) + } + if len(calls) != 1 { + t.Fatalf("expected build to be skipped, got calls %v", calls) + } +} + +func TestRunUpgradeReportsBuildFailure(t *testing.T) { + withUpgradeWorkingDir(t, "") + var calls [][]string + oldRun := runUpgradeCommand + runUpgradeCommand = func(cmd *exec.Cmd) error { + calls = append(calls, append([]string(nil), cmd.Args...)) + if len(cmd.Args) > 1 && cmd.Args[1] == "build" { + return errors.New("build failed") + } + return nil + } + t.Cleanup(func() { runUpgradeCommand = oldRun }) + + err := runUpgrade(nil) + if err == nil || !strings.Contains(err.Error(), "building upgraded runtime image") { + t.Fatalf("expected build error, got %v", err) + } + if len(calls) != 2 { + t.Fatalf("expected pull and build calls, got %v", calls) + } +} + +func withUpgradeWorkingDir(t *testing.T, configYAML string) { + t.Helper() + home := t.TempDir() + project := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + if configYAML != "" { + if err := os.WriteFile(filepath.Join(project, ".opencode-sandbox.yaml"), []byte(configYAML), 0644); err != nil { + t.Fatalf("writing project config: %v", err) + } + } + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("getting working directory: %v", err) + } + if err := os.Chdir(project); err != nil { + t.Fatalf("changing working directory: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(oldWD); err != nil { + t.Errorf("restoring working directory: %v", err) + } + }) +} + +func captureUpgradeCommands(t *testing.T, inspect func(*exec.Cmd)) *[][]string { + t.Helper() + var calls [][]string + oldRun := runUpgradeCommand + runUpgradeCommand = func(cmd *exec.Cmd) error { + calls = append(calls, append([]string(nil), cmd.Args...)) + if inspect != nil { + inspect(cmd) + } + return nil + } + t.Cleanup(func() { runUpgradeCommand = oldRun }) + return &calls +} + +func assertUpgradeCommands(t *testing.T, calls *[][]string, image, target string) { + t.Helper() + if len(*calls) != 2 { + t.Fatalf("expected pull and build calls, got %v", *calls) + } + wantPull := []string{"container", "image", "pull", image} + assertStringSlice(t, (*calls)[0], wantPull) + + build := (*calls)[1] + if len(build) == 0 || build[0] != "container" || build[1] != "build" { + t.Fatalf("unexpected build command: %v", build) + } + if got := argValue(t, build, "--tag"); got != image { + t.Fatalf("build tag = %q, want %q", got, image) + } + if !containsPair(build, "--build-arg", "BASE_IMAGE="+image) { + t.Fatalf("build command missing base image arg: %v", build) + } + if !containsPair(build, "--build-arg", "OPENCODE_VERSION="+target) { + t.Fatalf("build command missing OpenCode version arg: %v", build) + } +} + +func argValue(t *testing.T, args []string, flag string) string { + t.Helper() + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + return args[i+1] + } + } + t.Fatalf("missing %s in %v", flag, args) + return "" +} + +func containsPair(args []string, first, second string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == first && args[i+1] == second { + return true + } + } + return false +} + +func assertStringSlice(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} diff --git a/internal/config/load.go b/internal/config/load.go index 57d21d9..74555ed 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -49,7 +49,17 @@ func FindProjectConfig(startDir string) (string, error) { } // GlobalConfigPath returns the path to the global config file. +// On macOS, os.UserConfigDir() returns ~/Library/Preferences, but configs +// are typically stored at ~/.config. We check ~/.config first, then fall +// back to os.UserConfigDir(). func GlobalConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err == nil { + xdgPath := filepath.Join(home, ".config", "opencode-sandbox", "config.yaml") + if _, err := os.Stat(xdgPath); err == nil { + return xdgPath, nil + } + } configDir, err := os.UserConfigDir() if err != nil { return "", err diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 6916a36..24ee139 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -35,6 +35,28 @@ func TestGlobalConfigPath(t *testing.T) { } } +func TestGlobalConfigPathPrefersHomeDotConfigWhenPresent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + want := filepath.Join(home, ".config", "opencode-sandbox", "config.yaml") + if err := os.MkdirAll(filepath.Dir(want), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(want, []byte("version: 1\n"), 0644); err != nil { + t.Fatal(err) + } + + got, err := GlobalConfigPath() + if err != nil { + t.Fatalf("GlobalConfigPath failed: %v", err) + } + if got != want { + t.Fatalf("GlobalConfigPath() = %q, want %q", got, want) + } +} + func TestLoadValid(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") diff --git a/internal/containercmd/builder_test.go b/internal/containercmd/builder_test.go index f769aa8..2d5a421 100644 --- a/internal/containercmd/builder_test.go +++ b/internal/containercmd/builder_test.go @@ -307,6 +307,24 @@ func TestBuildArgvEventLogDir(t *testing.T) { } } +func TestBuildArgvMountsMergedSkillsReadOnly(t *testing.T) { + cfg := config.Defaults() + plan := Plan{ + ProjectPath: "/tmp/project", + StagingDir: "/tmp/staging", + MergedSkillsDir: "/tmp/merged-skills", + Image: "test:latest", + Effective: cfg, + } + + argv := BuildArgv(plan) + joined := strings.Join(argv, " ") + want := "type=bind,source=/tmp/merged-skills,target=/sandbox/home/.config/opencode/skills,readonly" + if !strings.Contains(joined, want) { + t.Fatalf("expected merged skills readonly mount %q, got:\n%s", want, joined) + } +} + func TestBuildArgvDurableOpenCodeStateMounts(t *testing.T) { cfg := config.Defaults() plan := Plan{ diff --git a/internal/runtime/skills.go b/internal/runtime/skills.go index 8b300f4..bd1d7a8 100644 --- a/internal/runtime/skills.go +++ b/internal/runtime/skills.go @@ -18,13 +18,13 @@ func MergeSkills(stagingDir, projectPath string, cfg config.EffectiveSkills) (st } // Copy global imported skills first. - configDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("getting user config dir: %w", err) - } - globalSkills := filepath.Join(configDir, "opencode-sandbox", "skills") - if err := copySkillsDir(globalSkills, mergedDir, cfg.Include, cfg.Exclude); err != nil { - return "", fmt.Errorf("copying global skills: %w", err) + // On macOS, os.UserConfigDir() returns ~/Library/Preferences, + // but skills are typically installed at ~/.config/opencode-sandbox/skills. + globalSkillsDirs := globalSkillsDirs() + for _, globalSkills := range globalSkillsDirs { + if err := copySkillsDir(globalSkills, mergedDir, cfg.Include, cfg.Exclude); err != nil { + return "", fmt.Errorf("copying global skills from %s: %w", globalSkills, err) + } } if cfg.ImportedDir != "" { @@ -32,7 +32,15 @@ func MergeSkills(stagingDir, projectPath string, cfg config.EffectiveSkills) (st if err != nil { return "", err } - if importedDir != globalSkills { + // Avoid duplicating global dirs. + isGlobal := false + for _, d := range globalSkillsDirs { + if importedDir == d { + isGlobal = true + break + } + } + if !isGlobal { if err := copySkillsDir(importedDir, mergedDir, cfg.Include, cfg.Exclude); err != nil { return "", fmt.Errorf("copying configured skills: %w", err) } @@ -59,13 +67,13 @@ func copySkillsDir(src, dst string, include, exclude []string) error { } for _, e := range entries { - if !e.IsDir() { + srcSkill, ok, err := skillSourceFromEntry(src, e) + if err != nil || !ok { continue } if !skillAllowed(e.Name(), include, exclude) { continue } - srcSkill := filepath.Join(src, e.Name()) dstSkill := filepath.Join(dst, e.Name()) if err := copyDir(srcSkill, dstSkill); err != nil { return fmt.Errorf("copying skill %s: %w", e.Name(), err) @@ -74,6 +82,32 @@ func copySkillsDir(src, dst string, include, exclude []string) error { return nil } +func skillSourceFromEntry(root string, e os.DirEntry) (string, bool, error) { + path := filepath.Join(root, e.Name()) + if e.IsDir() { + return path, true, nil + } + info, err := e.Info() + if err != nil { + return "", false, err + } + if info.Mode()&os.ModeSymlink == 0 { + return "", false, nil + } + target, err := filepath.EvalSymlinks(path) + if err != nil { + return "", false, err + } + targetInfo, err := os.Stat(target) + if err != nil { + return "", false, err + } + if !targetInfo.IsDir() { + return "", false, nil + } + return target, true, nil +} + func resolveSkillDir(path, projectPath string) (string, error) { if strings.HasPrefix(path, "~/") { home, err := os.UserHomeDir() @@ -88,6 +122,25 @@ func resolveSkillDir(path, projectPath string) (string, error) { return filepath.Clean(path), nil } +// globalSkillsDirs returns potential global skills directories. +// On macOS, os.UserConfigDir() returns ~/Library/Preferences, +// but skills may be installed at ~/.config/opencode-sandbox/skills. +func globalSkillsDirs() []string { + var dirs []string + configDir, err := os.UserConfigDir() + if err == nil { + dirs = append(dirs, filepath.Join(configDir, "opencode-sandbox", "skills")) + } + home, err := os.UserHomeDir() + if err == nil { + xdgDir := filepath.Join(home, ".config", "opencode-sandbox", "skills") + if len(dirs) == 0 || xdgDir != dirs[0] { + dirs = append(dirs, xdgDir) + } + } + return dirs +} + func skillAllowed(name string, include, exclude []string) bool { if len(include) > 0 { matched := false diff --git a/internal/runtime/skills_test.go b/internal/runtime/skills_test.go index fe90575..fa33811 100644 --- a/internal/runtime/skills_test.go +++ b/internal/runtime/skills_test.go @@ -8,6 +8,13 @@ import ( "github.com/RabbITCybErSeC/opencode-sandbox/internal/config" ) +func isolateGlobalSkills(t *testing.T) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) +} + func TestMergeSkills(t *testing.T) { staging := t.TempDir() project := t.TempDir() @@ -95,6 +102,8 @@ func TestMergeSkillsProjectWins(t *testing.T) { } func TestMergeSkillsNoSkills(t *testing.T) { + isolateGlobalSkills(t) + staging := t.TempDir() project := t.TempDir() @@ -109,6 +118,8 @@ func TestMergeSkillsNoSkills(t *testing.T) { } func TestMergeSkillsHonorsFiltersAndImportedDir(t *testing.T) { + isolateGlobalSkills(t) + staging := t.TempDir() project := t.TempDir() imported := filepath.Join(project, "custom-skills") @@ -141,3 +152,68 @@ func TestMergeSkillsHonorsFiltersAndImportedDir(t *testing.T) { t.Error("expected excluded skill to be skipped") } } + +func TestMergeSkillsIncludesHomeDotConfigGlobalWithProjectImportedDir(t *testing.T) { + staging := t.TempDir() + project := t.TempDir() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + globalSkills := filepath.Join(home, ".config", "opencode-sandbox", "skills") + globalSkill := filepath.Join(globalSkills, "global-xdg") + if err := os.MkdirAll(globalSkill, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(globalSkill, "SKILL.md"), []byte("global"), 0644); err != nil { + t.Fatal(err) + } + + cfg := config.Defaults().Skills + cfg.ImportedDir = ".opencode-sandbox/skills" + + mergedDir, err := MergeSkills(staging, project, cfg) + if err != nil { + t.Fatalf("MergeSkills failed: %v", err) + } + if _, err := os.Stat(filepath.Join(mergedDir, "global-xdg", "SKILL.md")); err != nil { + t.Error("expected ~/.config global skill to be merged") + } +} + +func TestMergeSkillsFollowsSymlinkedGlobalSkillDir(t *testing.T) { + staging := t.TempDir() + project := t.TempDir() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + target := filepath.Join(t.TempDir(), "real-skill") + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("linked"), 0644); err != nil { + t.Fatal(err) + } + globalSkills := filepath.Join(home, ".config", "opencode-sandbox", "skills") + if err := os.MkdirAll(globalSkills, 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(globalSkills, "linked-skill")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + mergedDir, err := MergeSkills(staging, project, config.Defaults().Skills) + if err != nil { + t.Fatalf("MergeSkills failed: %v", err) + } + data, err := os.ReadFile(filepath.Join(mergedDir, "linked-skill", "SKILL.md")) + if err != nil { + t.Fatalf("expected symlinked global skill to be merged: %v", err) + } + if string(data) != "linked" { + t.Fatalf("unexpected copied skill contents: %q", data) + } +} diff --git a/internal/skills/discover.go b/internal/skills/discover.go index 9b8bdc9..08a0ac1 100644 --- a/internal/skills/discover.go +++ b/internal/skills/discover.go @@ -21,10 +21,10 @@ func Discover(root string) ([]SkillInfo, error) { } var skills []SkillInfo for _, e := range entries { - if !e.IsDir() { + skillDir, ok, err := skillDirFromEntry(root, e) + if err != nil || !ok { continue } - skillDir := filepath.Join(root, e.Name()) skill, err := validateSkillDir(skillDir) if err != nil { continue // skip invalid @@ -34,6 +34,28 @@ func Discover(root string) ([]SkillInfo, error) { return skills, nil } +func skillDirFromEntry(root string, e os.DirEntry) (string, bool, error) { + path := filepath.Join(root, e.Name()) + if e.IsDir() { + return path, true, nil + } + info, err := e.Info() + if err != nil { + return "", false, err + } + if info.Mode()&os.ModeSymlink == 0 { + return "", false, nil + } + targetInfo, err := os.Stat(path) + if err != nil { + return "", false, err + } + if !targetInfo.IsDir() { + return "", false, nil + } + return path, true, nil +} + func validateSkillDir(dir string) (SkillInfo, error) { skillMd := filepath.Join(dir, "SKILL.md") if _, err := os.Stat(skillMd); err != nil { diff --git a/internal/skills/discover_test.go b/internal/skills/discover_test.go index 5b6a259..5b18fca 100644 --- a/internal/skills/discover_test.go +++ b/internal/skills/discover_test.go @@ -25,3 +25,28 @@ func TestDiscover(t *testing.T) { t.Errorf("expected 2 skills, got %d", len(found)) } } + +func TestDiscoverFollowsSymlinkedSkillDirs(t *testing.T) { + root := t.TempDir() + target := filepath.Join(t.TempDir(), "real-skill") + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("---\nname: linked-skill\n---\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "linked-skill")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + found, err := Discover(root) + if err != nil { + t.Fatalf("Discover failed: %v", err) + } + if len(found) != 1 { + t.Fatalf("expected 1 skill, got %d", len(found)) + } + if found[0].Name != "linked-skill" { + t.Fatalf("expected linked skill, got %#v", found[0]) + } +} diff --git a/internal/skills/import.go b/internal/skills/import.go index 9ff2c6d..0b1f495 100644 --- a/internal/skills/import.go +++ b/internal/skills/import.go @@ -39,10 +39,10 @@ func Import(opts ImportOptions) ([]ManifestEntry, error) { return nil, fmt.Errorf("reading source dir: %w", err) } for _, e := range entries { - if !e.IsDir() { + skillDir, ok, err := skillDirFromEntry(opts.Source, e) + if err != nil || !ok { continue } - skillDir := filepath.Join(opts.Source, e.Name()) if hasSkillMd(skillDir) { sources = append(sources, skillDir) } @@ -105,17 +105,21 @@ func importSingle(src string, opts ImportOptions) (ManifestEntry, error) { } dest := filepath.Join(opts.DestDir, name) + copySource, err := resolveSkillSource(src) + if err != nil { + return ManifestEntry{}, err + } if !opts.DryRun { if err := os.MkdirAll(dest, 0755); err != nil { return ManifestEntry{}, fmt.Errorf("creating dest dir: %w", err) } - if err := copySkillDir(src, dest); err != nil { + if err := copySkillDir(copySource, dest); err != nil { return ManifestEntry{}, fmt.Errorf("copying skill: %w", err) } } - hash, err := hashSkillDir(src) + hash, err := hashSkillDir(copySource) if err != nil { return ManifestEntry{}, fmt.Errorf("hashing skill: %w", err) } @@ -141,6 +145,28 @@ func hasSkillMd(dir string) bool { return err == nil } +func resolveSkillSource(dir string) (string, error) { + info, err := os.Lstat(dir) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink == 0 { + return dir, nil + } + target, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", err + } + targetInfo, err := os.Stat(target) + if err != nil { + return "", err + } + if !targetInfo.IsDir() { + return "", fmt.Errorf("skill source symlink is not a directory: %s", dir) + } + return target, nil +} + func matchesFilters(name string, include, exclude []string) bool { // If include is specified, name must match at least one pattern. if len(include) > 0 { diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 6720f12..8b905fa 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -55,7 +55,6 @@ func TestImportSingle(t *testing.T) { if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("---\nname: imported\n---\n"), 0644); err != nil { t.Fatal(err) } - dst := t.TempDir() opts := ImportOptions{ Source: src, @@ -82,6 +81,33 @@ func TestImportSingle(t *testing.T) { } } +func TestImportSymlinkedSkillFromParent(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(t.TempDir(), "real-skill") + dst := t.TempDir() + + if err := os.Mkdir(target, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("# linked import"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(parent, "linked-import")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + results, err := Import(ImportOptions{Source: parent, DestDir: dst, Scope: "global"}) + if err != nil { + t.Fatalf("Import failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 imported skill, got %d", len(results)) + } + if _, err := os.Stat(filepath.Join(dst, "linked-import", "SKILL.md")); err != nil { + t.Fatalf("expected copied linked skill: %v", err) + } +} + func TestImportFilters(t *testing.T) { src := t.TempDir() for _, name := range []string{"keep", "skip"} {