From aa4c1082dcd8ff72081c31247d3acf0e2773e79d Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Tue, 10 Feb 2026 08:40:33 +0800 Subject: [PATCH] feat: wire up extra_packages config to Docker image build The extra_packages field was defined in the config and documented in the README but never actually used. Pass it as a Docker build arg so users can install additional apt packages into the agent image. Co-Authored-By: Claude Opus 4.6 --- assets/Dockerfile | 6 ++++ internal/daemon/daemon.go | 2 +- internal/daemon/daemon_test.go | 2 +- internal/docker/docker.go | 11 ++++++-- internal/docker/docker_test.go | 50 +++++++++++++++++++++++++++++----- 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/assets/Dockerfile b/assets/Dockerfile index 06d2bbf..6d40945 100644 --- a/assets/Dockerfile +++ b/assets/Dockerfile @@ -19,6 +19,12 @@ RUN apt-get update && apt-get install -y \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* +ARG EXTRA_PACKAGES="" +RUN if [ -n "$EXTRA_PACKAGES" ]; then \ + apt-get update && apt-get install -y $EXTRA_PACKAGES \ + && rm -rf /var/lib/apt/lists/*; \ + fi + RUN npm install -g @anthropic-ai/claude-code RUN userdel -r ubuntu 2>/dev/null; useradd -m -s /bin/bash -u 1000 agent diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 302eb1c..9793df5 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -311,7 +311,7 @@ func Run(projectDir string, cfg *config.Config, apiKey, oauthToken string, docke // Build image. slog.Info("building docker image") - if err := d.docker.BuildImage(projectDir); err != nil { + if err := d.docker.BuildImage(projectDir, cfg.Docker.ExtraPackages); err != nil { return fmt.Errorf("daemon: failed to build image: %w", err) } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 3d03253..8d67f39 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -32,7 +32,7 @@ type mockDockerClient struct { logsErr error } -func (m *mockDockerClient) BuildImage(projectDir string) error { +func (m *mockDockerClient) BuildImage(projectDir string, extraPackages []string) error { return m.buildErr } diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 246a099..546dad6 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -62,7 +62,7 @@ type AgentInfo struct { // DockerClient is the interface for Docker operations so the daemon and CLI // can be tested without a real Docker daemon. type DockerClient interface { - BuildImage(projectDir string) error + BuildImage(projectDir string, extraPackages []string) error StartAgent(ctx context.Context, opts AgentOpts) (string, error) StopAgent(ctx context.Context, agentID int) error StopAllAgents(ctx context.Context) error @@ -113,7 +113,7 @@ func newClientWithAPI(projectName string, api dockerAPI) *Client { // BuildImage writes the embedded Dockerfile and entrypoint into .metamorph/docker/, // creates a tar build context, and builds the image. -func (c *Client) BuildImage(projectDir string) error { +func (c *Client) BuildImage(projectDir string, extraPackages []string) error { buildDir := filepath.Join(projectDir, constants.DockerDir) if err := os.MkdirAll(buildDir, 0755); err != nil { return fmt.Errorf("docker: failed to create build dir: %w", err) @@ -137,6 +137,12 @@ func (c *Client) BuildImage(projectDir string) error { return fmt.Errorf("docker: failed to create build context: %w", err) } + buildArgs := make(map[string]*string) + if len(extraPackages) > 0 { + pkgs := strings.Join(extraPackages, " ") + buildArgs["EXTRA_PACKAGES"] = &pkgs + } + ctx, cancel := context.WithTimeout(context.Background(), buildTimeout) defer cancel() @@ -144,6 +150,7 @@ func (c *Client) BuildImage(projectDir string) error { Tags: []string{defaultImageTag}, Dockerfile: "Dockerfile", Remove: true, + BuildArgs: buildArgs, }) if err != nil { return fmt.Errorf("docker: failed to build image: %w", err) diff --git a/internal/docker/docker_test.go b/internal/docker/docker_test.go index 78c49a0..350167b 100644 --- a/internal/docker/docker_test.go +++ b/internal/docker/docker_test.go @@ -51,10 +51,11 @@ type mockDocker struct { logsErr error // Track calls for assertions. - created []mockCreateCall - started []string - stopped []string - removed []string + buildOptions types.ImageBuildOptions + created []mockCreateCall + started []string + stopped []string + removed []string } type mockCreateCall struct { @@ -68,6 +69,7 @@ func (m *mockDocker) Ping(ctx context.Context) (types.Ping, error) { } func (m *mockDocker) ImageBuild(ctx context.Context, buildContext io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) { + m.buildOptions = options if m.buildErr != nil { return types.ImageBuildResponse{}, m.buildErr } @@ -121,7 +123,7 @@ func TestBuildImage(t *testing.T) { mock := &mockDocker{buildBody: `{"stream":"Successfully built abc123"}`} c := newClientWithAPI("test-project", mock) - if err := c.BuildImage(projectDir); err != nil { + if err := c.BuildImage(projectDir, nil); err != nil { t.Fatalf("BuildImage: %v", err) } @@ -145,7 +147,7 @@ func TestBuildImage(t *testing.T) { mock := &mockDocker{buildErr: fmt.Errorf("build failed")} c := newClientWithAPI("test-project", mock) - err := c.BuildImage(projectDir) + err := c.BuildImage(projectDir, nil) if err == nil { t.Fatal("expected error") } @@ -153,6 +155,40 @@ func TestBuildImage(t *testing.T) { t.Errorf("unexpected error: %v", err) } }) + + t.Run("passes extra packages as build arg", func(t *testing.T) { + projectDir := t.TempDir() + + mock := &mockDocker{buildBody: `{"stream":"Successfully built abc123"}`} + c := newClientWithAPI("test-project", mock) + + if err := c.BuildImage(projectDir, []string{"vim", "htop"}); err != nil { + t.Fatalf("BuildImage: %v", err) + } + + got, ok := mock.buildOptions.BuildArgs["EXTRA_PACKAGES"] + if !ok || got == nil { + t.Fatal("expected EXTRA_PACKAGES build arg to be set") + } + if *got != "vim htop" { + t.Errorf("EXTRA_PACKAGES = %q, want %q", *got, "vim htop") + } + }) + + t.Run("omits build arg when no extra packages", func(t *testing.T) { + projectDir := t.TempDir() + + mock := &mockDocker{buildBody: `{"stream":"Successfully built abc123"}`} + c := newClientWithAPI("test-project", mock) + + if err := c.BuildImage(projectDir, nil); err != nil { + t.Fatalf("BuildImage: %v", err) + } + + if _, ok := mock.buildOptions.BuildArgs["EXTRA_PACKAGES"]; ok { + t.Error("EXTRA_PACKAGES build arg should not be set when no extra packages") + } + }) } func TestStartAgent(t *testing.T) { @@ -716,7 +752,7 @@ func TestDockerClientInterface(t *testing.T) { // mockDockerClient is a full mock of the DockerClient interface for consumers. type mockDockerClient struct{} -func (m *mockDockerClient) BuildImage(projectDir string) error { return nil } +func (m *mockDockerClient) BuildImage(projectDir string, extraPackages []string) error { return nil } func (m *mockDockerClient) StartAgent(ctx context.Context, opts AgentOpts) (string, error) { return "mock-id", nil }