Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .opencode-sandbox.yaml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 .

Expand Down Expand Up @@ -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 .

Expand Down Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion docs/implementation-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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@<target>`, 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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ sopencode image build
sopencode image build --opencode-version <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:
Expand Down
16 changes: 16 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 8 additions & 8 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions internal/cli/init_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ var wrapperCommands = map[string]bool{
"policy": true,
"image": true,
"config": true,
"upgrade": true,
"uninstall": true,
"help": true,
}
Expand Down Expand Up @@ -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":
Expand Down
25 changes: 17 additions & 8 deletions internal/cli/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading