fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime

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.
This commit is contained in:
José Almeida
2026-04-29 23:42:24 +01:00
parent 6cf0854e3b
commit f470ea85e2
8 changed files with 109 additions and 31 deletions
@@ -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{
@@ -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
@@ -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)
@@ -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.
@@ -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<ScriptPlaceholder[]>([]);
const [spritesSecretId, setSpritesSecretId] = useState<string | null>(() =>
@@ -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<string, string>,
form: ReturnType<typeof useProfileFormState>,
): 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 && <DockerSections profile={profile} />}
{form.isDocker && (
<DockerSections
profile={profile}
dockerfile={form.dockerfile}
onDockerfileChange={form.setDockerfile}
imageTag={form.imageTag}
onImageTagChange={form.setImageTag}
/>
)}
{form.isRemote && (
<SpritesSections
isRemote={form.isRemote}
@@ -170,6 +170,7 @@ function CreateFormActions({
type BuildProfileConfigInput = {
isRemote: boolean;
isSprites: boolean;
isDocker: boolean;
networkPolicyRules: NetworkPolicyRule[];
remoteCredentials: string[];
agentEnvVars: Record<string, string | null>;
@@ -177,12 +178,15 @@ type BuildProfileConfigInput = {
localGitIdentity: GitIdentityState;
gitUserName: string;
gitUserEmail: string;
dockerfile: string;
imageTag: string;
};
function buildProfileConfig(input: BuildProfileConfigInput): Record<string, string> | undefined {
const {
isRemote,
isSprites,
isDocker,
networkPolicyRules,
remoteCredentials,
agentEnvVars,
@@ -190,6 +194,8 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record<string, stri
localGitIdentity,
gitUserName,
gitUserEmail,
dockerfile,
imageTag,
} = input;
const config: Record<string, string> = {};
if (isSprites && networkPolicyRules.length > 0) {
@@ -216,9 +222,25 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record<string, stri
config.git_user_email = effectiveEmail;
}
}
applyDockerCreateConfig(config, isDocker, dockerfile, imageTag);
return Object.keys(config).length > 0 ? config : undefined;
}
function applyDockerCreateConfig(
config: Record<string, string>,
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,
@@ -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";
@@ -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 (
<>
<DockerfileBuildCard
dockerfile={dockerfile}
onDockerfileChange={setDockerfile}
onDockerfileChange={onDockerfileChange}
imageTag={imageTag}
onImageTagChange={setImageTag}
onImageTagChange={onImageTagChange}
/>
<DockerContainersCard profileId={profile.id} />
</>