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
3 changes: 3 additions & 0 deletions .github/workflows/core-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
66 changes: 46 additions & 20 deletions internal/generate/v1/dockerfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
}

Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
43 changes: 40 additions & 3 deletions internal/generate/v1/dockerfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -59,14 +81,29 @@ 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")
assert.Contains(t, output, `CMD ["python","main.py"]`)
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" "$@"`)
Expand Down
19 changes: 18 additions & 1 deletion internal/generate/v1/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/generate/v1/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")},
Expand Down
109 changes: 109 additions & 0 deletions internal/generate/v1/preset.go
Original file line number Diff line number Diff line change
@@ -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/<name>".
// 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
}
Loading
Loading