From f470ea85e2dacf1bfa475150cfedd196e0d65dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Wed, 29 Apr 2026 23:42:24 +0100 Subject: [PATCH] fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs: 1. The Dockerfile editor and Image Tag input on the executor profile page stored their values in component-local useState inside DockerSections, not in the form state. Saving the profile never wrote them back, so "Use defaults" / typing in the editor / changing the tag never persisted — and the Build button was the only thing that consumed them. The same gap existed in the new-executor flow's buildProfileConfig. Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and useCreateProfileFormState (new), and emit them in buildSaveConfig / buildProfileConfig so they round-trip through profile.config. 2. Even when an image_tag is set on the profile, the container builder ignored it. imageName was always derived from the agent runtime's hardcoded `kandev/multi-agent`, so a user who built a custom image like `kandev/agent:latest` would still see Docker fail with "No such image: kandev/multi-agent:latest". Propagate `profile.config.image_tag` → metadata (`image_tag_override`) → ExecutorCreateRequest → ContainerConfig, and let it override `rt.Image` in buildContainerConfig when set. Also align the frontend default image tag (kandev/multi-agent:latest) with the backend's hardcoded runtime default so users who don't override end up with a working build. --- .../internal/agent/lifecycle/container.go | 36 +++++++++++-------- .../agent/lifecycle/executor_backend.go | 1 + .../agent/lifecycle/executor_docker.go | 17 ++++----- .../orchestrator/executor/executor_state.go | 3 ++ .../settings/executors/[profileId]/page.tsx | 34 +++++++++++++++++- .../settings/executors/new/[type]/page.tsx | 25 +++++++++++++ .../settings/profile-edit/docker-sections.tsx | 2 +- .../profile-edit/profile-runtime-sections.tsx | 22 ++++++++---- 8 files changed, 109 insertions(+), 31 deletions(-) diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index 404c3e870a..f369c1f692 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -21,20 +21,21 @@ import ( // ContainerConfig holds configuration for launching a Docker container type ContainerConfig struct { - AgentConfig agents.Agent - WorkspacePath string // If empty, workspace is not mounted (will clone inside container) - TaskID string - TaskDescription string - Model string - SessionID string - Credentials map[string]string - ProfileInfo *AgentProfileInfo - InstanceID string - MainRepoGitDir string // Path to main repo's .git directory (for worktrees) - McpServers []McpServerConfig - McpMode string - PrepareScript string // Script to run inside container before agent starts (e.g., clone repo) - BootstrapNonce string // one-time nonce for agentctl handshake (set internally) + AgentConfig agents.Agent + WorkspacePath string // If empty, workspace is not mounted (will clone inside container) + TaskID string + TaskDescription string + Model string + SessionID string + Credentials map[string]string + ProfileInfo *AgentProfileInfo + InstanceID string + MainRepoGitDir string // Path to main repo's .git directory (for worktrees) + McpServers []McpServerConfig + McpMode string + PrepareScript string // Script to run inside container before agent starts (e.g., clone repo) + ImageTagOverride string // If set, replaces the agent runtime's default image (e.g. profile.config.image_tag) + BootstrapNonce string // one-time nonce for agentctl handshake (set internally) } // ContainerManager handles Docker container lifecycle operations @@ -236,11 +237,16 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker ag := config.AgentConfig rt := ag.Runtime() - // Build image name with tag + // Build image name with tag. A profile-level image_tag override (e.g. a + // custom Dockerfile built via the executor profile UI) takes precedence + // over the agent's hardcoded runtime default. imageName := rt.Image if rt.Tag != "" { imageName = fmt.Sprintf("%s:%s", rt.Image, rt.Tag) } + if config.ImageTagOverride != "" { + imageName = config.ImageTagOverride + } // Build command using Agent's BuildCommand cmdOpts := agents.CommandOptions{ diff --git a/apps/backend/internal/agent/lifecycle/executor_backend.go b/apps/backend/internal/agent/lifecycle/executor_backend.go index 861871f340..32058a7882 100644 --- a/apps/backend/internal/agent/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/lifecycle/executor_backend.go @@ -72,6 +72,7 @@ const ( MetadataKeyRemoteAuthHome = "remote_auth_target_home" MetadataKeyGitUserName = "git_user_name" MetadataKeyGitUserEmail = "git_user_email" + MetadataKeyImageTagOverride = "image_tag_override" ) // persistentMetadataKeys lists metadata keys carried forward from a previous diff --git a/apps/backend/internal/agent/lifecycle/executor_docker.go b/apps/backend/internal/agent/lifecycle/executor_docker.go index df3c8cfb59..a961551f8c 100644 --- a/apps/backend/internal/agent/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/lifecycle/executor_docker.go @@ -146,14 +146,15 @@ func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreate // Convert ExecutorCreateRequest to ContainerConfig // Note: WorkspacePath is empty to skip mounting - we clone inside container containerCfg := ContainerConfig{ - AgentConfig: req.AgentConfig, - WorkspacePath: "", // Empty = no workspace mount, we'll clone instead - TaskID: req.TaskID, - SessionID: req.SessionID, - InstanceID: req.InstanceID, - Credentials: req.Env, // Env contains credentials from the caller - McpServers: req.McpServers, - PrepareScript: prepareScript, + AgentConfig: req.AgentConfig, + WorkspacePath: "", // Empty = no workspace mount, we'll clone instead + TaskID: req.TaskID, + SessionID: req.SessionID, + InstanceID: req.InstanceID, + Credentials: req.Env, // Env contains credentials from the caller + McpServers: req.McpServers, + PrepareScript: prepareScript, + ImageTagOverride: getMetadataString(req.Metadata, MetadataKeyImageTagOverride), } // Use ContainerManager to launch container (includes nonce handshake) diff --git a/apps/backend/internal/orchestrator/executor/executor_state.go b/apps/backend/internal/orchestrator/executor/executor_state.go index 2ca465f1ab..48040d6ff0 100644 --- a/apps/backend/internal/orchestrator/executor/executor_state.go +++ b/apps/backend/internal/orchestrator/executor/executor_state.go @@ -232,6 +232,9 @@ func (e *Executor) applyProfile(ctx context.Context, profileID string, cfg *exec if gitUserEmail := profile.Config["git_user_email"]; gitUserEmail != "" { metadata["git_user_email"] = gitUserEmail } + if imageTag := profile.Config["image_tag"]; imageTag != "" { + metadata["image_tag_override"] = imageTag + } } // resolveProfileEnvVars resolves profile env vars, dereferencing secret IDs to their values. diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index ad4fa57969..08cdec4f6e 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -258,6 +258,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { const [mcpPolicy, setMcpPolicy] = useState(profile.mcp_policy ?? ""); const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? ""); const [cleanupScript, setCleanupScript] = useState(profile.cleanup_script ?? ""); + const [dockerfile, setDockerfile] = useState(profile.config?.dockerfile ?? ""); + const [imageTag, setImageTag] = useState(profile.config?.image_tag ?? ""); const { envVarRows, addEnvVar, removeEnvVar, updateEnvVar } = useEnvVarRows(profile.env_vars); const [placeholders, setPlaceholders] = useState([]); const [spritesSecretId, setSpritesSecretId] = useState(() => @@ -295,6 +297,10 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { setPrepareScript, cleanupScript, setCleanupScript, + dockerfile, + setDockerfile, + imageTag, + setImageTag, envVarRows, addEnvVar, removeEnvVar, @@ -365,9 +371,27 @@ function buildSaveConfig( } else { delete config.git_user_email; } + applyDockerConfig(config, form); return config; } +function applyDockerConfig( + config: Record, + form: ReturnType, +): void { + if (!form.isDocker) return; + if (form.dockerfile.trim()) { + config.dockerfile = form.dockerfile; + } else { + delete config.dockerfile; + } + if (form.imageTag.trim()) { + config.image_tag = form.imageTag.trim(); + } else { + delete config.image_tag; + } +} + function ProfileEditSections({ executor, profile, @@ -389,7 +413,15 @@ function ProfileEditSections({ secrets={secrets} /> )} - {form.isDocker && } + {form.isDocker && ( + + )} {form.isRemote && ( ; @@ -177,12 +178,15 @@ type BuildProfileConfigInput = { localGitIdentity: GitIdentityState; gitUserName: string; gitUserEmail: string; + dockerfile: string; + imageTag: string; }; function buildProfileConfig(input: BuildProfileConfigInput): Record | undefined { const { isRemote, isSprites, + isDocker, networkPolicyRules, remoteCredentials, agentEnvVars, @@ -190,6 +194,8 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record = {}; if (isSprites && networkPolicyRules.length > 0) { @@ -216,9 +222,25 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record 0 ? config : undefined; } +function applyDockerCreateConfig( + config: Record, + isDocker: boolean, + dockerfile: string, + imageTag: string, +): void { + if (!isDocker) return; + if (dockerfile.trim()) { + config.dockerfile = dockerfile; + } + if (imageTag.trim()) { + config.image_tag = imageTag.trim(); + } +} + function useDefaultScripts(executorType: string, setPrepareScript: (v: string) => void) { useEffect(() => { fetchDefaultScripts(executorType) @@ -406,6 +428,7 @@ function useCreateProfileSave( config: buildProfileConfig({ isRemote: form.isRemote, isSprites: form.isSprites, + isDocker: form.isDocker, networkPolicyRules: form.networkPolicyRules, remoteCredentials: form.remoteCredentials, agentEnvVars: form.agentEnvVars, @@ -413,6 +436,8 @@ function useCreateProfileSave( localGitIdentity: form.localGitIdentity, gitUserName: form.gitUserName, gitUserEmail: form.gitUserEmail, + dockerfile: form.dockerfile, + imageTag: form.imageTag, }), prepare_script: form.prepareScript, cleanup_script: form.cleanupScript, diff --git a/apps/web/components/settings/profile-edit/docker-sections.tsx b/apps/web/components/settings/profile-edit/docker-sections.tsx index 0ea06be936..dd244743af 100644 --- a/apps/web/components/settings/profile-edit/docker-sections.tsx +++ b/apps/web/components/settings/profile-edit/docker-sections.tsx @@ -17,7 +17,7 @@ import { } from "@/lib/api/domains/settings-api"; import type { DockerContainer } from "@/lib/api/domains/settings-api"; -const DEFAULT_IMAGE_TAG = "kandev/agent:latest"; +const DEFAULT_IMAGE_TAG = "kandev/multi-agent:latest"; const DEFAULT_DOCKERFILE = "FROM ubuntu:24.04\n"; type BuildStatus = "idle" | "building" | "success" | "failed"; diff --git a/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx b/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx index aa04d48a06..d13573197d 100644 --- a/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx +++ b/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import type { ExecutorProfile } from "@/lib/types/http"; import type { SecretListItem } from "@/lib/types/http-secrets"; import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; @@ -16,17 +15,28 @@ import { import { NetworkPoliciesCard } from "@/components/settings/profile-edit/sprites-sections"; import { SpritesInstancesCard } from "@/components/settings/sprites-settings"; -export function DockerSections({ profile }: { profile: ExecutorProfile }) { - const [dockerfile, setDockerfile] = useState(profile.config?.dockerfile ?? ""); - const [imageTag, setImageTag] = useState(profile.config?.image_tag ?? ""); +type DockerSectionsProps = { + profile: ExecutorProfile; + dockerfile: string; + onDockerfileChange: (v: string) => void; + imageTag: string; + onImageTagChange: (v: string) => void; +}; +export function DockerSections({ + profile, + dockerfile, + onDockerfileChange, + imageTag, + onImageTagChange, +}: DockerSectionsProps) { return ( <>