diff --git a/.github/workflows/core-release.yml b/.github/workflows/core-release.yml index 22bfaf3..438b430 100644 --- a/.github/workflows/core-release.yml +++ b/.github/workflows/core-release.yml @@ -31,6 +31,9 @@ jobs: # Plugins (data-driven YAML plugin definitions) cp -r core/plugins "$STAGING/plugins" + # Presets (runtime.yaml files for @builtin/* images) + cp -r core/presets "$STAGING/presets" + # Gateway source (compiled during Docker build) cp -r core/gateway "$STAGING/gateway" diff --git a/internal/generate/v1/dockerfile.go b/internal/generate/v1/dockerfile.go index b78b197..af6074e 100644 --- a/internal/generate/v1/dockerfile.go +++ b/internal/generate/v1/dockerfile.go @@ -11,31 +11,35 @@ import ( "github.com/donbader/agent-sandbox/internal/plugin" ) -// Presets maps @builtin/* to base image + install commands. -var Presets = map[string]struct { - BaseImage string - Installs []string -}{ +// legacyPresets is a fallback for core versions that don't ship presets/ in the tarball. +// Remove once all supported core versions include presets (>= core-v0.8.0). +var legacyPresets = map[string]*Preset{ "@builtin/codex": { + Name: "codex", BaseImage: "node:24-slim", - Installs: []string{ + Install: []string{ "apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates iptables iputils-ping gosu && rm -rf /var/lib/apt/lists/*", - "--mount=type=cache,target=/root/.npm npm install -g @openai/codex@0.136.0", + "--mount=type=cache,target=/root/.npm npm install -g @openai/codex@0.136.0 @zed-industries/codex-acp@0.15.0", }, + CMD: []string{"sleep", "infinity"}, }, "@builtin/claude-code": { + Name: "claude-code", BaseImage: "node:24-slim", - Installs: []string{ + Install: []string{ "apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates iptables iputils-ping gosu && rm -rf /var/lib/apt/lists/*", - "--mount=type=cache,target=/root/.npm npm install -g @anthropic-ai/claude-code", + "--mount=type=cache,target=/root/.npm npm install -g @anthropic-ai/claude-code@2.1.161 @agentclientprotocol/claude-agent-acp@0.40.0", }, + CMD: []string{"sleep", "infinity"}, }, "@builtin/pi": { + Name: "pi", BaseImage: "node:24-slim", - Installs: []string{ + Install: []string{ "apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates iptables iputils-ping gosu && rm -rf /var/lib/apt/lists/*", "--mount=type=cache,target=/root/.npm npm install -g @earendil-works/pi-coding-agent@0.75.5 pi-acp@0.0.27", }, + CMD: []string{"sleep", "infinity"}, }, } @@ -56,8 +60,8 @@ type dockerfileData struct { // BuildDockerfile generates a Dockerfile string using the embedded templates. // This is a convenience wrapper around RenderDockerfile for callers that don't manage their own Loader. -func BuildDockerfile(cfg *config.Config, contribs *plugin.Contributions, entrypointPath string) (string, error) { - return RenderDockerfile(templates.NewEmbeddedLoader(), cfg, contribs, entrypointPath) +func BuildDockerfile(cfg *config.Config, contribs *plugin.Contributions, entrypointPath string, presets map[string]*Preset) (string, error) { + return RenderDockerfile(templates.NewEmbeddedLoader(), cfg, contribs, entrypointPath, presets) } // EntrypointScript returns the entrypoint script using the embedded templates. @@ -90,19 +94,32 @@ func RenderEntrypointScript(loader *templates.Loader, preEntrypoint []string) (s } // RenderDockerfile executes the Dockerfile template from config and plugin contributions. -func RenderDockerfile(loader *templates.Loader, cfg *config.Config, contribs *plugin.Contributions, entrypointPath string) (string, error) { +func RenderDockerfile(loader *templates.Loader, cfg *config.Config, contribs *plugin.Contributions, entrypointPath string, presets map[string]*Preset) (string, error) { tmpl, err := loader.Load("Dockerfile.tmpl") if err != nil { return "", fmt.Errorf("load Dockerfile template: %w", err) } - // Resolve preset + // Resolve preset from loaded core presets baseImage := cfg.Runtime.Image var presetInstalls []string - _, isPreset := Presets[cfg.Runtime.Image] - if preset, ok := Presets[cfg.Runtime.Image]; ok { - baseImage = preset.BaseImage - presetInstalls = preset.Installs + var isPreset bool + if presets != nil { + if preset, ok := presets[cfg.Runtime.Image]; ok { + isPreset = true + baseImage = preset.BaseImage + presetInstalls = preset.Install + } + } + // Fallback: if preset not resolved but image looks like a builtin, use legacy defaults. + // This handles older core versions that don't ship presets/ in the tarball. + // Remove once all supported core versions include presets (>= core-v0.8.0). + if !isPreset && strings.HasPrefix(cfg.Runtime.Image, "@builtin/") { + if p, ok := legacyPresets[cfg.Runtime.Image]; ok { + isPreset = true + baseImage = p.BaseImage + presetInstalls = p.Install + } } // Collect extra builds (user + plugin) @@ -121,8 +138,17 @@ func RenderDockerfile(loader *templates.Loader, cfg *config.Config, contribs *pl } cmd = string(ep) } else if isPreset { - // Presets default to sleep infinity so containers stay alive for interactive use. - cmd = `["sleep","infinity"]` + // Use preset's cmd if defined, otherwise default to sleep infinity. + if presets != nil { + if p, ok := presets[cfg.Runtime.Image]; ok && len(p.CMD) > 0 { + ep, _ := json.Marshal(p.CMD) + cmd = string(ep) + } else { + cmd = `["sleep","infinity"]` + } + } else { + cmd = `["sleep","infinity"]` + } } var buf bytes.Buffer diff --git a/internal/generate/v1/dockerfile_test.go b/internal/generate/v1/dockerfile_test.go index 559bbe6..99ce074 100644 --- a/internal/generate/v1/dockerfile_test.go +++ b/internal/generate/v1/dockerfile_test.go @@ -9,6 +9,28 @@ import ( "github.com/stretchr/testify/require" ) +// testPresets provides preset data for tests without needing a core directory. +var testPresets = map[string]*Preset{ + "@builtin/codex": { + Name: "codex", + BaseImage: "node:24-slim", + Install: []string{ + "apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates iptables iputils-ping gosu && rm -rf /var/lib/apt/lists/*", + "--mount=type=cache,target=/root/.npm npm install -g @openai/codex@0.136.0 @zed-industries/codex-acp@0.15.0", + }, + CMD: []string{"sleep", "infinity"}, + }, + "@builtin/pi": { + Name: "pi", + BaseImage: "node:24-slim", + Install: []string{ + "apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates iptables iputils-ping gosu && rm -rf /var/lib/apt/lists/*", + "--mount=type=cache,target=/root/.npm npm install -g @earendil-works/pi-coding-agent@0.75.5 pi-acp@0.0.27", + }, + CMD: []string{"sleep", "infinity"}, + }, +} + func TestBuildDockerfile(t *testing.T) { cfg := &config.Config{ Runtime: config.RuntimeConfig{ @@ -24,7 +46,7 @@ func TestBuildDockerfile(t *testing.T) { }, } - output, err := BuildDockerfile(cfg, contribs, ".build/entrypoint.sh") + output, err := BuildDockerfile(cfg, contribs, ".build/entrypoint.sh", nil) require.NoError(t, err) assert.Contains(t, output, "FROM node:24-slim") @@ -43,7 +65,7 @@ func TestBuildDockerfile_BuiltinPreset(t *testing.T) { }, } - output, err := BuildDockerfile(cfg, nil, ".build/entrypoint.sh") + output, err := BuildDockerfile(cfg, nil, ".build/entrypoint.sh", testPresets) require.NoError(t, err) assert.Contains(t, output, "FROM node:24-slim") @@ -59,7 +81,7 @@ func TestBuildDockerfile_CustomImage(t *testing.T) { }, } - output, err := BuildDockerfile(cfg, nil, ".build/entrypoint.sh") + output, err := BuildDockerfile(cfg, nil, ".build/entrypoint.sh", nil) require.NoError(t, err) assert.Contains(t, output, "FROM python:3.12-slim") @@ -67,6 +89,21 @@ func TestBuildDockerfile_CustomImage(t *testing.T) { assert.NotContains(t, output, "npm install") } +func TestBuildDockerfile_PresetDefaultCMD(t *testing.T) { + cfg := &config.Config{ + Runtime: config.RuntimeConfig{ + Image: "@builtin/pi", + }, + } + + output, err := BuildDockerfile(cfg, nil, ".build/entrypoint.sh", testPresets) + require.NoError(t, err) + + assert.Contains(t, output, "FROM node:24-slim") + assert.Contains(t, output, "pi-coding-agent") + assert.Contains(t, output, `CMD ["sleep","infinity"]`) +} + func TestEntrypointScript_NoPreEntrypoint(t *testing.T) { script := EntrypointScript(nil) assert.Contains(t, script, `exec gosu "$AGENT_USER" "$@"`) diff --git a/internal/generate/v1/generator.go b/internal/generate/v1/generator.go index 824855e..b95f214 100644 --- a/internal/generate/v1/generator.go +++ b/internal/generate/v1/generator.go @@ -19,6 +19,7 @@ type Generator struct { gatewayFS fs.FS coreDir string templates *templates.Loader + presets map[string]*Preset } // AgentResult holds the per-agent generation output. @@ -49,11 +50,20 @@ func NewGeneratorWithCore(projectDir, coreDir string) *Generator { pluginsDir := filepath.Join(coreDir, "plugins") bundled = os.DirFS(pluginsDir) } + var presets map[string]*Preset + if coreDir != "" { + var err error + presets, err = LoadPresets(coreDir) + if err != nil { + presets = nil // fall through — will error at generate time if preset is used + } + } return &Generator{ projectDir: projectDir, bundledFS: bundled, coreDir: coreDir, templates: templates.FindLoader(coreDir), + presets: presets, } } @@ -69,6 +79,13 @@ func (g *Generator) SetBundledPluginsFS(pluginsFS fs.FS) { } } +// SetPresets sets the runtime presets map (used when no core directory is available, e.g. tests). +func (g *Generator) SetPresets(presets map[string]*Preset) { + if g.presets == nil { + g.presets = presets + } +} + // Run executes the full generation pipeline for a single-agent project. func (g *Generator) Run() error { cfg, err := config.Load(g.projectDir) @@ -254,7 +271,7 @@ func (g *Generator) generateAgent(cfg *config.Config, agentDir, buildDir string) } entrypointPath := filepath.Join(relBuildDir, "entrypoint.sh") - dockerfile, err := RenderDockerfile(g.templates, cfg, merged, entrypointPath) + dockerfile, err := RenderDockerfile(g.templates, cfg, merged, entrypointPath, g.presets) if err != nil { return nil, fmt.Errorf("build dockerfile: %w", err) } diff --git a/internal/generate/v1/generator_test.go b/internal/generate/v1/generator_test.go index 3d29f7e..be91fea 100644 --- a/internal/generate/v1/generator_test.go +++ b/internal/generate/v1/generator_test.go @@ -52,6 +52,7 @@ installations: require.NoError(t, os.WriteFile(filepath.Join(projectDir, "agent.yaml"), []byte(agentYAML), 0644)) g := NewGenerator(projectDir, nil) + g.SetPresets(testPresets) require.NoError(t, g.Run()) // Verify outputs @@ -322,6 +323,7 @@ gateway: require.NoError(t, os.WriteFile(filepath.Join(projectDir, "agent.yaml"), []byte(agentYAML), 0644)) g := NewGenerator(projectDir, nil) + g.SetPresets(testPresets) require.NoError(t, g.Run()) buildDir := filepath.Join(projectDir, ".build") @@ -392,6 +394,7 @@ gateway: require.NoError(t, os.WriteFile(filepath.Join(projectDir, "fleet.yaml"), []byte(fleetYAML), 0644)) g := NewGenerator(projectDir, nil) + g.SetPresets(testPresets) agents := []config.FleetAgent{ {Config: mustParseConfig(t, filepath.Join(projectDir, "coder", "agent.yaml")), Dir: filepath.Join(projectDir, "coder")}, diff --git a/internal/generate/v1/preset.go b/internal/generate/v1/preset.go new file mode 100644 index 0000000..37b1c7e --- /dev/null +++ b/internal/generate/v1/preset.go @@ -0,0 +1,109 @@ +package v1 + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Preset holds the parsed content of a runtime.yaml file. +type Preset struct { + Name string `yaml:"name"` + BaseImage string `yaml:"base_image"` + Install []string `yaml:"install"` + CMD []string `yaml:"cmd"` + AcpCMD []string `yaml:"acp_cmd"` +} + +// LoadPresets reads all presets from a core directory's presets/ folder. +// Returns a map keyed by "@builtin/". +// Returns an empty map (no error) if the presets directory doesn't exist +// (backward compat with older core versions that don't ship presets). +func LoadPresets(coreDir string) (map[string]*Preset, error) { + presetsDir := filepath.Join(coreDir, "presets") + entries, err := os.ReadDir(presetsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read presets dir: %w", err) + } + + presets := make(map[string]*Preset) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + runtimePath := filepath.Join(presetsDir, entry.Name(), "runtime.yaml") + data, err := os.ReadFile(runtimePath) + if err != nil { + continue // skip directories without runtime.yaml + } + var p Preset + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("parse %s: %w", runtimePath, err) + } + key := "@builtin/" + p.Name + if p.Name == "" { + key = "@builtin/" + entry.Name() + } + + // Prepend standard system packages (iptables, gosu, etc.) to install commands. + // runtime.yaml only declares agent-specific installs. + p.Install = prependSystemPackages(p.Install) + + presets[key] = &p + } + + return presets, nil +} + +// prependSystemPackages ensures the first install command includes the base system +// packages needed for the sandbox entrypoint (iptables for DNAT, gosu for user switching). +func prependSystemPackages(installs []string) []string { + if len(installs) == 0 { + return []string{ + "apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates iptables iputils-ping gosu && rm -rf /var/lib/apt/lists/*", + } + } + // The first install command in runtime.yaml handles apt — augment it with sandbox deps. + first := installs[0] + // If it already has iptables, don't modify. + if contains(first, "iptables") { + return installs + } + // Inject sandbox deps into the existing apt-get install line. + augmented := augmentAptInstall(first) + result := make([]string, len(installs)) + result[0] = augmented + copy(result[1:], installs[1:]) + return result +} + +func augmentAptInstall(cmd string) string { + // Insert " iptables iputils-ping gosu" before "&& rm -rf" + const marker = "&& rm -rf" + idx := len(cmd) + for i := range cmd { + if i+len(marker) <= len(cmd) && cmd[i:i+len(marker)] == marker { + idx = i + break + } + } + return cmd[:idx] + "iptables iputils-ping gosu " + cmd[idx:] +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/release/fetcher.go b/internal/release/fetcher.go index 5c81d9c..bdfea17 100644 --- a/internal/release/fetcher.go +++ b/internal/release/fetcher.go @@ -213,18 +213,38 @@ func download(version, destDir string) error { asset := AssetPrefix + version + ".tar.gz" url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", GitHubRepo, tag, asset) - resp, err := http.Get(url) //nolint:gosec - if err != nil { - return fmt.Errorf("download %s: %w", url, err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("core version %s not found (no release asset at %s)", version, url) + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 2 * time.Second) + } + var err error + resp, err = http.Get(url) //nolint:gosec + if err != nil { + lastErr = fmt.Errorf("download %s: %w", url, err) + continue + } + if resp.StatusCode == http.StatusNotFound { + _ = resp.Body.Close() + return fmt.Errorf("core version %s not found (no release asset at %s)", version, url) + } + if resp.StatusCode >= 500 { + _ = resp.Body.Close() + lastErr = fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode) + continue + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode) + } + lastErr = nil + break } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode) + if lastErr != nil { + return lastErr } + defer func() { _ = resp.Body.Close() }() if err := os.MkdirAll(destDir, 0755); err != nil { return fmt.Errorf("create cache dir: %w", err)