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:
@@ -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} />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user