From a91160ed999bdd1899e6c50b3012624c123ee857 Mon Sep 17 00:00:00 2001 From: Peter Jausovec Date: Fri, 24 Apr 2026 15:27:41 -0700 Subject: [PATCH 1/3] add UI for configuring skills from git repos Signed-off-by: Peter Jausovec --- .../config/crd/bases/kagent.dev_agents.yaml | 6 +- .../crd/bases/kagent.dev_sandboxagents.yaml | 6 +- go/api/v1alpha2/agent_types.go | 4 +- .../translator/agent/adk_api_translator.go | 14 +- .../translator/agent/skills-init.sh.tmpl | 5 + .../translator/agent/skills_unit_test.go | 30 ++ .../outputs/agent_with_git_skills.json | 2 +- .../testdata/outputs/agent_with_skills.json | 2 +- .../templates/kagent.dev_agents.yaml | 6 +- .../templates/kagent.dev_sandboxagents.yaml | 6 +- ui/src/app/actions/agents.ts | 56 ++- ui/src/app/agents/new/page.tsx | 262 ++++++++++-- ui/src/components/AgentsProvider.tsx | 5 +- .../sidebars/AgentDetailsSidebar.tsx | 74 +++- ui/src/lib/__tests__/agentSkillsForm.test.ts | 386 ++++++++++++++++++ ui/src/lib/agentSkillsForm.ts | 248 +++++++++++ ui/src/types/index.ts | 10 + 17 files changed, 1048 insertions(+), 74 deletions(-) create mode 100644 ui/src/lib/__tests__/agentSkillsForm.test.ts create mode 100644 ui/src/lib/agentSkillsForm.ts diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index 8180fda2d..c92d4091e 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -10216,8 +10216,10 @@ spec: skills from. properties: name: - description: Name for the skill directory under /skills. - Defaults to the repo name. + description: |- + Name for the skill directory under /skills. If omitted, defaults to the last + segment of Path when Path is set; otherwise defaults to the repo name (last + URL path segment, without .git). type: string path: description: Subdirectory within the repo to use as the diff --git a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml index 9118e971b..cfaace48b 100644 --- a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml +++ b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml @@ -7866,8 +7866,10 @@ spec: skills from. properties: name: - description: Name for the skill directory under /skills. - Defaults to the repo name. + description: |- + Name for the skill directory under /skills. If omitted, defaults to the last + segment of Path when Path is set; otherwise defaults to the repo name (last + URL path segment, without .git). type: string path: description: Subdirectory within the repo to use as the diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index f19b0c3f4..83df4c175 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -141,7 +141,9 @@ type GitRepo struct { // +optional Path string `json:"path,omitempty"` - // Name for the skill directory under /skills. Defaults to the repo name. + // Name for the skill directory under /skills. If omitted, defaults to the last + // segment of Path when Path is set; otherwise defaults to the repo name (last + // URL path segment, without .git). // +optional Name string `json:"name,omitempty"` } diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 19e09add0..d7399cdee 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -1080,12 +1080,16 @@ func isCommitSHA(ref string) bool { } // gitSkillName returns the directory name for a git skill ref. -// If Name is set, it is used; otherwise the last path segment of the repo URL -// (with any .git suffix stripped) is used. -// Query parameters and fragments are stripped before extracting the base name. +// If Name is set, it is used. Otherwise, if Path (in-repo directory) is set, the +// last path segment of Path is used. If Path is empty, the last path segment of +// the repo URL (with any .git suffix stripped) is used. +// Query parameters and fragments are stripped before extracting the base name from the URL. func gitSkillName(ref v1alpha2.GitRepo) string { - if ref.Name != "" { - return ref.Name + if n := strings.TrimSpace(ref.Name); n != "" { + return n + } + if p := strings.Trim(strings.TrimSpace(ref.Path), "/"); p != "" { + return path.Base(p) } // Parse the URL to strip query params and fragments u := ref.URL diff --git a/go/core/internal/controller/translator/agent/skills-init.sh.tmpl b/go/core/internal/controller/translator/agent/skills-init.sh.tmpl index 4f0f3370e..6bc5c9f7a 100644 --- a/go/core/internal/controller/translator/agent/skills-init.sh.tmpl +++ b/go/core/internal/controller/translator/agent/skills-init.sh.tmpl @@ -82,3 +82,8 @@ mkdir -p "$_dest" tar xf '/tmp/oci-skill.tar' -C "$_dest" rm -f '/tmp/oci-skill.tar' {{- end }} +# The init process (git, krane, tar) and the main kagent run as different +# UIDs. Clones and archives are commonly created as root with mode 0700, while the agent +# runs as a non-root user and mounts /skills read-only—so it cannot chown. Relax perms +# so the agent uid can list and read SKILL.md under /skills. +chmod -R a+rX /skills diff --git a/go/core/internal/controller/translator/agent/skills_unit_test.go b/go/core/internal/controller/translator/agent/skills_unit_test.go index a45b97a59..d205ab957 100644 --- a/go/core/internal/controller/translator/agent/skills_unit_test.go +++ b/go/core/internal/controller/translator/agent/skills_unit_test.go @@ -75,6 +75,36 @@ func Test_gitSkillName(t *testing.T) { ref: v1alpha2.GitRepo{URL: "git@github.com:org/repo.git"}, want: "repo", }, + { + name: "path last segment when name empty (monorepo)", + ref: v1alpha2.GitRepo{ + URL: "https://github.com/reponame/myskills.git", + Path: "someskills/skill1", + }, + want: "skill1", + }, + { + name: "path with leading and trailing slash", + ref: v1alpha2.GitRepo{ + URL: "https://github.com/reponame/myskills.git", + Path: "/someskills/skill1/", + }, + want: "skill1", + }, + { + name: "explicit name still wins over path", + ref: v1alpha2.GitRepo{ + URL: "https://github.com/reponame/myskills.git", + Path: "someskills/skill1", + Name: "custom", + }, + want: "custom", + }, + { + name: "no path uses repo name", + ref: v1alpha2.GitRepo{URL: "https://github.com/reponame/myskills"}, + want: "myskills", + }, } for _, tt := range tests { diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json index 98cc97bea..71c7ed0a1 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json @@ -241,7 +241,7 @@ "command": [ "/bin/sh", "-c", - "set -e\n_auth_mount=\"$(cat \u003c\u003c'ENDVAL'\n/git-auth\nENDVAL\n)\"\nif [ -f \"${_auth_mount}/ssh-privatekey\" ]; then\n mkdir -p ~/.ssh\n chmod 700 ~/.ssh\n cp \"${_auth_mount}/ssh-privatekey\" ~/.ssh/id_rsa\n chmod 600 ~/.ssh/id_rsa\n touch ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\nelif [ -f \"${_auth_mount}/token\" ]; then\n git config --global credential.helper \"!f() { echo username=x-access-token; echo password=\\$(cat ${_auth_mount}/token); }; f\"\nfi\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/my-skills\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nv2.0.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/k8s-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_subpath=\"$(cat \u003c\u003c'ENDVAL'\nskills/k8s\nENDVAL\n)\"\n_tmp=\"$(mktemp -d)\"\ncp -rL \"${_dest}/${_subpath}/.\" \"$_tmp/\"\nrm -rf \"$_dest\"\nmv \"$_tmp\" \"$_dest\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/another-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nabc123def456abc123def456abc123def456abc1\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/another-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (commit ${_ref}) into ${_dest}\"\ngit clone -- \"$_url\" \"$_dest\"\ncd \"$_dest\" \u0026\u0026 git checkout \"$_ref\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/private-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nmain\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/private-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/oci-skill:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/oci-skill\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n" + "set -e\n_auth_mount=\"$(cat \u003c\u003c'ENDVAL'\n/git-auth\nENDVAL\n)\"\nif [ -f \"${_auth_mount}/ssh-privatekey\" ]; then\n mkdir -p ~/.ssh\n chmod 700 ~/.ssh\n cp \"${_auth_mount}/ssh-privatekey\" ~/.ssh/id_rsa\n chmod 600 ~/.ssh/id_rsa\n touch ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\nelif [ -f \"${_auth_mount}/token\" ]; then\n git config --global credential.helper \"!f() { echo username=x-access-token; echo password=\\$(cat ${_auth_mount}/token); }; f\"\nfi\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/my-skills\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nv2.0.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/k8s-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_subpath=\"$(cat \u003c\u003c'ENDVAL'\nskills/k8s\nENDVAL\n)\"\n_tmp=\"$(mktemp -d)\"\ncp -rL \"${_dest}/${_subpath}/.\" \"$_tmp/\"\nrm -rf \"$_dest\"\nmv \"$_tmp\" \"$_dest\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/another-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nabc123def456abc123def456abc123def456abc1\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/another-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (commit ${_ref}) into ${_dest}\"\ngit clone -- \"$_url\" \"$_dest\"\ncd \"$_dest\" \u0026\u0026 git checkout \"$_ref\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/private-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nmain\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/private-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/oci-skill:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/oci-skill\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# Git clone and archives may leave root-owned mode 0700 trees; the main container often\n# runs as a different non-root UID and must traverse/read /skills (read-only mount).\nchmod -R a+rX /skills\n" ], "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", "name": "skills-init", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json index e65fa19d5..42aabbf2c 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json @@ -241,7 +241,7 @@ "command": [ "/bin/sh", "-c", - "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nfoo:latest\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/foo\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n" + "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nfoo:latest\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/foo\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# Git clone and archives may leave root-owned mode 0700 trees; the main container often\n# runs as a different non-root UID and must traverse/read /skills (read-only mount).\nchmod -R a+rX /skills\n" ], "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", "name": "skills-init", diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index 8180fda2d..c92d4091e 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -10216,8 +10216,10 @@ spec: skills from. properties: name: - description: Name for the skill directory under /skills. - Defaults to the repo name. + description: |- + Name for the skill directory under /skills. If omitted, defaults to the last + segment of Path when Path is set; otherwise defaults to the repo name (last + URL path segment, without .git). type: string path: description: Subdirectory within the repo to use as the diff --git a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml index 9118e971b..cfaace48b 100644 --- a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml @@ -7866,8 +7866,10 @@ spec: skills from. properties: name: - description: Name for the skill directory under /skills. - Defaults to the repo name. + description: |- + Name for the skill directory under /skills. If omitted, defaults to the last + segment of Path when Path is set; otherwise defaults to the repo name (last + URL path segment, without .git). type: string path: description: Subdirectory within the repo to use as the diff --git a/ui/src/app/actions/agents.ts b/ui/src/app/actions/agents.ts index 013903e12..6f223479a 100644 --- a/ui/src/app/actions/agents.ts +++ b/ui/src/app/actions/agents.ts @@ -1,12 +1,22 @@ "use server"; -import { AgentSpec, BaseResponse, DeclarativeAgentSpec, PromptSource, SandboxAgent } from "@/types"; -import { Agent, AgentResponse, Tool } from "@/types"; +import { + Agent, + AgentResponse, + AgentSpec, + BaseResponse, + DeclarativeAgentSpec, + PromptSource, + SandboxAgent, + SkillForAgent, + Tool, +} from "@/types"; import { revalidatePath } from "next/cache"; import { fetchApi, createErrorResponse } from "./utils"; import { AgentFormData } from "@/components/AgentsProvider"; import { isMcpTool } from "@/lib/toolUtils"; import { k8sRefUtils } from "@/lib/k8sUtils"; +import { formRowsToGitRepos, type GitSkillFormRow } from "@/lib/agentSkillsForm"; function attachPromptTemplateToDeclarative(decl: DeclarativeAgentSpec, agentFormData: AgentFormData) { if (!agentFormData.promptSources?.some((s) => s.name.trim())) { @@ -31,6 +41,34 @@ function attachPromptTemplateToDeclarative(decl: DeclarativeAgentSpec, agentForm } } +function buildSkillsForAgentSpec(agentFormData: AgentFormData): SkillForAgent | undefined { + const refs = (agentFormData.skillRefs || []).map((r) => r.trim()).filter(Boolean); + const rows: GitSkillFormRow[] = (agentFormData.skillGitRepos || []).map((g) => ({ + url: g.url ?? "", + ref: g.ref ?? "", + path: g.path ?? "", + name: g.name ?? "", + })); + const gitRefs = formRowsToGitRepos(rows); + + if (refs.length === 0 && gitRefs.length === 0) { + return undefined; + } + + const skills: SkillForAgent = {}; + if (refs.length > 0) { + skills.refs = refs; + } + if (gitRefs.length > 0) { + skills.gitRefs = gitRefs; + const secretName = agentFormData.skillsGitAuthSecretName?.trim(); + if (secretName) { + skills.gitAuthSecretRef = { name: secretName }; + } + } + return skills; +} + /** * Converts AgentFormData to Agent format * @param agentFormData The form data to convert @@ -138,10 +176,9 @@ function fromAgentFormDataToAgent(agentFormData: AgentFormData): Agent { tools: convertTools(agentFormData.tools || []), }; - if (agentFormData.skillRefs && agentFormData.skillRefs.length > 0) { - base.spec!.skills = { - refs: agentFormData.skillRefs, - }; + const skills = buildSkillsForAgentSpec(agentFormData); + if (skills) { + base.spec!.skills = skills; } if (agentFormData.memory?.modelConfig) { @@ -337,10 +374,9 @@ function fromAgentFormDataToSandboxAgent(agentFormData: AgentFormData): SandboxA description: agentFormData.description, }; - if (agentFormData.skillRefs && agentFormData.skillRefs.length > 0) { - spec.skills = { - refs: agentFormData.skillRefs, - }; + const skills = buildSkillsForAgentSpec(agentFormData); + if (skills) { + spec.skills = skills; } return { diff --git a/ui/src/app/agents/new/page.tsx b/ui/src/app/agents/new/page.tsx index 552c57c7f..93e81dfc7 100644 --- a/ui/src/app/agents/new/page.tsx +++ b/ui/src/app/agents/new/page.tsx @@ -1,10 +1,10 @@ "use client"; -import React, { useState, useEffect, Suspense, useCallback } from "react"; +import React, { useState, useEffect, Suspense, useCallback, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Brain, Loader2, Settings2, PlusCircle, Trash2, Layers } from "lucide-react"; +import { Brain, GitBranch, Loader2, Settings2, PlusCircle, Trash2, Layers } from "lucide-react"; import { formAgentTypeFromApi, formUsesByoSections, formUsesDeclarativeSections } from "@/lib/agentFormLayout"; import { ModelConfig, AgentType, ContextConfig } from "@/types"; import { SystemPromptSection } from "@/components/create/SystemPromptSection"; @@ -22,6 +22,19 @@ import { AgentFormData } from "@/components/AgentsProvider"; import { Tool, EnvVar } from "@/types"; import { toast } from "sonner"; import { NamespaceCombobox } from "@/components/NamespaceCombobox"; +import { + MAX_SKILLS_PER_SOURCE, + applyGitSkillUrlPathChange, + formRowsToGitRepos, + gitRepoToFormRow, + gitSkillRowUrlIssues, + isDuplicateGitSkillFormRow, + isDuplicateOciSkillRef, + isValidSkillContainerImage, + newEmptyGitSkillRow, + validateDeclarativeAgentSkills, + type GitSkillFormRow, +} from "@/lib/agentSkillsForm"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -78,6 +91,8 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo memoryTtlDays: string; selectedTools: Tool[]; skillRefs: string[]; + skillGitRepos: GitSkillFormRow[]; + skillsGitAuthSecretName: string; byoImage: string; byoCmd: string; byoArgs: string; @@ -105,6 +120,8 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo memoryTtlDays: "", selectedTools: [], skillRefs: [""], + skillGitRepos: [newEmptyGitSkillRow()], + skillsGitAuthSecretName: "", byoImage: "", byoCmd: "", byoArgs: "", @@ -124,6 +141,11 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo const useDeclarativeAgentFields = formUsesDeclarativeSections(state.agentType, state.byoImage); const showByoFields = formUsesByoSections(state.agentType, state.byoImage); + const resolvedGitSkillRepos = useMemo( + () => formRowsToGitRepos(state.skillGitRepos || []), + [state.skillGitRepos], + ); + const ensureConfigMapSource = useCallback((cmName: string) => { const t = cmName.trim(); if (!t) { @@ -196,6 +218,11 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo selectedTools: (decl?.tools && agentResponse.tools) ? agentResponse.tools : [], selectedModel: agentResponse.modelConfigRef ? { ref: agentResponse.modelConfigRef, spec: { model: agentResponse.model || "", provider: "" } } : null, skillRefs: (agent.spec?.skills?.refs && agent.spec.skills.refs.length > 0) ? agent.spec.skills.refs : [""], + skillGitRepos: + agent.spec?.skills?.gitRefs && agent.spec.skills.gitRefs.length > 0 + ? agent.spec.skills.gitRefs.map(gitRepoToFormRow) + : [newEmptyGitSkillRow()], + skillsGitAuthSecretName: agent.spec?.skills?.gitAuthSecretRef?.name || "", stream: decl?.stream ?? false, selectedMemoryModel: memoryModelConfig ? { ref: memoryModelConfig, spec: { model: memorySpec?.modelConfig || "", provider: "" } } : null, memoryTtlDays: memorySpec?.ttlDays ? String(memorySpec.ttlDays) : "", @@ -248,13 +275,6 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo void fetchAgentData(); }, [isEditMode, agentName, agentNamespace, getAgent]); - const isValidContainerImage = (image: string): boolean => { - if (!image.trim()) return false; - // Basic regex for container image format: [registry/]repository[:tag|@digest] - const imageRegex = /^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?\/)?[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*(?::[A-Za-z0-9][A-Za-z0-9._-]*)?(?:@sha256:[a-f0-9]{64})?$/i; - return imageRegex.test(image.trim()); - }; - const validateForm = () => { const memoryEnabled = !!(state.selectedMemoryModel?.ref || state.memoryTtlDays); const formData = { @@ -279,28 +299,15 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo const newErrors = validateAgentData(formData); - if (useDeclarativeAgentFields && state.skillRefs && state.skillRefs.length > 0) { - // Filter out empty/whitespace entries first - if all are empty, treat as "no skills" - const nonEmptyRefs = state.skillRefs.filter(ref => ref.trim()); - - // Only validate if there are actual skill references - if (nonEmptyRefs.length > 0) { - // Check for invalid image formats - const invalidRefs = nonEmptyRefs.filter(ref => !isValidContainerImage(ref)); - if (invalidRefs.length > 0) { - newErrors.skills = `Invalid container image format: ${invalidRefs[0]}`; - } else { - // Check for duplicates (case-insensitive, trimmed) - const trimmedRefs = nonEmptyRefs.map(ref => ref.trim().toLowerCase()); - const duplicates = trimmedRefs.filter((ref, index) => trimmedRefs.indexOf(ref) !== index); - if (duplicates.length > 0) { - // Find the first duplicate in the original array for error message - const dupIndex = trimmedRefs.findIndex((ref, idx) => trimmedRefs.indexOf(ref) !== idx); - newErrors.skills = `Duplicate skill detected: ${nonEmptyRefs[dupIndex]}`; - } - } + if (useDeclarativeAgentFields) { + const skillsError = validateDeclarativeAgentSkills({ + skillRefs: state.skillRefs || [], + skillGitRepos: state.skillGitRepos || [], + skillsGitAuthSecretName: state.skillsGitAuthSecretName || "", + }); + if (skillsError) { + newErrors.skills = skillsError; } - // If all refs are empty/whitespace, that's fine - no skills will be included } setState(prev => ({ ...prev, errors: newErrors })); @@ -378,7 +385,12 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo modelName: state.selectedModel?.ref || "", stream: state.stream, tools: state.selectedTools, - skillRefs: useDeclarativeAgentFields ? (state.skillRefs || []).filter(ref => ref.trim()) : undefined, + skillRefs: useDeclarativeAgentFields ? (state.skillRefs || []).filter((ref) => ref.trim()) : undefined, + skillGitRepos: useDeclarativeAgentFields ? formRowsToGitRepos(state.skillGitRepos || []) : undefined, + skillsGitAuthSecretName: + useDeclarativeAgentFields && (state.skillsGitAuthSecretName || "").trim() + ? (state.skillsGitAuthSecretName || "").trim() + : undefined, memory: useDeclarativeAgentFields && memoryEnabled ? { modelConfig: state.selectedMemoryModel?.ref || "", @@ -830,14 +842,14 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo
- +

- Add skills container images. Each skill will be pulled and mounted for your agent to use. + Pull skills from container images (for example GHCR). Each image is mounted under /skills.

{(state.skillRefs || []).map((ref, idx) => { - const isDuplicate = ref.trim() && state.skillRefs.filter(r => r.trim() === ref.trim()).length > 1; - const isInvalid = ref.trim() && !isValidContainerImage(ref); + const isDuplicate = isDuplicateOciSkillRef(ref, state.skillRefs); + const isInvalid = ref.trim() && !isValidSkillContainerImage(ref); const hasError = isDuplicate || isInvalid; return ( @@ -866,11 +878,17 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo variant="outline" size="icon" onClick={() => { - if ((state.skillRefs || []).length < 20) { + if ((state.skillRefs || []).length < MAX_SKILLS_PER_SOURCE) { setState(prev => ({ ...prev, skillRefs: [...prev.skillRefs, ""] })); } }} + disabled={ + (state.skillRefs || []).length >= MAX_SKILLS_PER_SOURCE || + state.isSubmitting || + state.isLoading + } title="Add skill" + type="button" > @@ -880,6 +898,154 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo onClick={() => setState(prev => ({ ...prev, skillRefs: prev.skillRefs.filter((_, i) => i !== idx) }))} disabled={(state.skillRefs || []).length <= 1} title="Remove skill" + type="button" + > + + +
+
+ ); + })} +
+ + +
+ +

+ Clone skills from a Git remote (HTTPS or SSH). The folder name under{" "} + /skills defaults to the last segment of the path in the repo, or the repo name if + there is no path. You can change it. Optional ref (branch/tag/SHA) and a Secret in the agent namespace for private repos + (token or SSH key). +

+
+ {(state.skillGitRepos || []).map((row, idx) => { + const { hasExtraWithoutUrl, urlInvalid } = gitSkillRowUrlIssues(row); + const dupGit = isDuplicateGitSkillFormRow(row, resolvedGitSkillRepos); + + return ( +
+
+
+ + { + const copy = [...state.skillGitRepos]; + copy[idx] = applyGitSkillUrlPathChange(copy[idx], { url: e.target.value }); + setState((prev) => ({ + ...prev, + skillGitRepos: copy, + errors: { ...prev.errors, skills: undefined }, + })); + }} + disabled={state.isSubmitting || state.isLoading} + className={hasExtraWithoutUrl || urlInvalid || dupGit ? "border-red-500" : ""} + /> + {hasExtraWithoutUrl && ( +

Set a URL when using ref, path, or name

+ )} + {urlInvalid && ( +

Use https://, http://, git@, or ssh://

+ )} + {dupGit && ( +

Duplicate URL + ref + path

+ )} +
+
+
+ + { + const copy = [...state.skillGitRepos]; + copy[idx] = { ...copy[idx], ref: e.target.value }; + setState((prev) => ({ + ...prev, + skillGitRepos: copy, + errors: { ...prev.errors, skills: undefined }, + })); + }} + disabled={state.isSubmitting || state.isLoading} + /> +
+
+ + { + const copy = [...state.skillGitRepos]; + copy[idx] = applyGitSkillUrlPathChange(copy[idx], { path: e.target.value }); + setState((prev) => ({ + ...prev, + skillGitRepos: copy, + errors: { ...prev.errors, skills: undefined }, + })); + }} + disabled={state.isSubmitting || state.isLoading} + /> +
+
+ + { + const copy = [...state.skillGitRepos]; + copy[idx] = { ...copy[idx], name: e.target.value }; + setState((prev) => ({ + ...prev, + skillGitRepos: copy, + errors: { ...prev.errors, skills: undefined }, + })); + }} + disabled={state.isSubmitting || state.isLoading} + /> +
+
+
+
+ + @@ -888,8 +1054,30 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo ); })}
+
+ + + setState((prev) => ({ + ...prev, + skillsGitAuthSecretName: e.target.value, + errors: { ...prev.errors, skills: undefined }, + })) + } + disabled={state.isSubmitting || state.isLoading} + /> +

+ For HTTPS, use a token key; for SSH, use ssh-privatekey. +

+
{state.errors.skills && ( -

❌ {state.errors.skills}

+

❌ {state.errors.skills}

)}
diff --git a/ui/src/components/AgentsProvider.tsx b/ui/src/components/AgentsProvider.tsx index e26d62a57..616ca6de1 100644 --- a/ui/src/components/AgentsProvider.tsx +++ b/ui/src/components/AgentsProvider.tsx @@ -13,6 +13,7 @@ import type { AgentType, EnvVar, ContextConfig, + GitRepo, } from "@/types"; import { getModelConfigs } from "@/app/actions/modelConfigs"; import { formUsesByoSections, formUsesDeclarativeSections } from "@/lib/agentFormLayout"; @@ -44,8 +45,10 @@ export interface AgentFormData { modelName?: string; tools: Tool[]; stream?: boolean; - // Skills + // Skills (OCI container refs and/or Git repositories; at least one list may be set) skillRefs?: string[]; + skillGitRepos?: GitRepo[]; + skillsGitAuthSecretName?: string; // Memory memory?: { modelConfig?: string; diff --git a/ui/src/components/sidebars/AgentDetailsSidebar.tsx b/ui/src/components/sidebars/AgentDetailsSidebar.tsx index 50768d9da..0d877ed29 100644 --- a/ui/src/components/sidebars/AgentDetailsSidebar.tsx +++ b/ui/src/components/sidebars/AgentDetailsSidebar.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { ChevronRight, Edit, ShieldAlert } from "lucide-react"; -import type { AgentResponse, Tool, ToolsResponse } from "@/types"; +import { ChevronRight, Edit, GitBranch, ShieldAlert } from "lucide-react"; +import type { AgentResponse, GitRepo, Tool, ToolsResponse } from "@/types"; import { SidebarHeader, Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from "@/components/ui/sidebar"; import { ScrollArea } from "@/components/ui/scroll-area"; import { LoadingState } from "@/components/LoadingState"; @@ -265,19 +265,22 @@ export function AgentDetailsSidebar({ selectedAgentName, currentAgent, allTools )} - {isDeclarativeLikeAgent && selectedTeam?.agent.spec?.skills?.refs && selectedTeam.agent.spec.skills.refs.length > 0 && ( + {isDeclarativeLikeAgent && (() => { + const oci = selectedTeam?.agent.spec?.skills?.refs ?? []; + const git = selectedTeam?.agent.spec?.skills?.gitRefs ?? []; + if (oci.length + git.length === 0) return null; + return (
Skills - {selectedTeam.agent.spec.skills.refs.length} + {oci.length + git.length}
- {selectedTeam.agent.spec.skills.refs.map((skillRef, index) => { + {oci.map((skillRef, index) => { // Parse OCI image reference: [registry/]repository[:tag][@digest] - // Groups: (1) registry, (2) repository, (3) tag, (4) digest const refMatch = skillRef.match( /^(?:((?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9-]+(?::\d+)?|localhost(?::\d+)?|[a-zA-Z0-9-]+:\d+)\/)?([^:@]+)(?::([^@]+))?(?:@(.+))?$/ ); @@ -286,14 +289,12 @@ export function AgentDetailsSidebar({ selectedAgentName, currentAgent, allTools const tag = refMatch?.[3] ?? null; const digest = refMatch?.[4] ?? null; - // Only show a version badge when the ref was successfully parsed. - // Truncate digests to keep the badge compact. const versionBadge = refMatch ? tag ?? (digest ? (digest.length > 16 ? digest.substring(0, 16) + "\u2026" : digest) : "latest") : null; const displayName = repoName ?? skillRef; return ( - + @@ -321,10 +322,63 @@ export function AgentDetailsSidebar({ selectedAgentName, currentAgent, allTools ); })} + {git.map((g: GitRepo, index) => { + const refLabel = g.ref?.trim() || "main"; + const fromUrl = g.url + ?.split("/") + .filter(Boolean) + .pop() + ?.replace(/\.git$/i, ""); + const displayName = (g.name && g.name.trim()) || fromUrl || "Git"; + const linkHref = (g.url || "").trim(); + const rowInner = ( +
+ + + {displayName} + + + {refLabel} + +
+ ); + return ( + + + + {linkHref ? ( + + + {rowInner} + + + ) : ( + + {rowInner} + + )} + + +

Name: {displayName}

+ {g.path && ( +

Path: {g.path}

+ )} +
+
+
+ ); + })}
- )} + ); + })()} diff --git a/ui/src/lib/__tests__/agentSkillsForm.test.ts b/ui/src/lib/__tests__/agentSkillsForm.test.ts new file mode 100644 index 000000000..1d5972f46 --- /dev/null +++ b/ui/src/lib/__tests__/agentSkillsForm.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from "@jest/globals"; +import type { GitRepo } from "@/types"; +import { + MAX_SKILLS_PER_SOURCE, + applyGitSkillUrlPathChange, + defaultGitSkillFolderName, + formRowToGitRepo, + formRowsToGitRepos, + gitRepoToFormRow, + gitSkillDedupeKeyFromFormRow, + gitSkillDedupeKeyFromRepo, + gitSkillSourceDedupeKey, + gitSkillRowUrlIssues, + isDuplicateGitSkillFormRow, + isDuplicateOciSkillRef, + isPlausibleGitRemoteUrl, + isValidSkillContainerImage, + newEmptyGitSkillRow, + validateDeclarativeAgentSkills, + type GitSkillFormRow, +} from "../agentSkillsForm"; + +describe("agentSkillsForm", () => { + describe("newEmptyGitSkillRow", () => { + it("returns an empty row", () => { + expect(newEmptyGitSkillRow()).toEqual({ + url: "", + ref: "", + path: "", + name: "", + }); + }); + }); + + describe("gitRepoToFormRow", () => { + it("maps API fields to form strings", () => { + expect( + gitRepoToFormRow({ + url: "https://github.com/a/b.git", + ref: "v1", + path: "pkg/skill", + name: "myskill", + }), + ).toEqual({ + url: "https://github.com/a/b.git", + ref: "v1", + path: "pkg/skill", + name: "myskill", + }); + }); + + it("defaults name from URL when name omitted in API", () => { + expect(gitRepoToFormRow({ url: "https://x/y" })).toEqual({ + url: "https://x/y", + ref: "", + path: "", + name: "y", + }); + }); + + it("defaults name from path when name omitted in API", () => { + expect( + gitRepoToFormRow({ + url: "https://github.com/peterj/myskills", + path: "someskills/skill1", + }), + ).toEqual({ + url: "https://github.com/peterj/myskills", + ref: "", + path: "someskills/skill1", + name: "skill1", + }); + }); + }); + + describe("defaultGitSkillFolderName", () => { + it("uses last path segment from in-repo path", () => { + expect(defaultGitSkillFolderName("https://github.com/peterj/myskills", "/someskills/skill1/")).toBe("skill1"); + }); + + it("uses repo name when path is empty", () => { + expect(defaultGitSkillFolderName("https://github.com/peterj/myskills", "")).toBe("myskills"); + }); + + it("handles scp-style GitHub URL", () => { + expect(defaultGitSkillFolderName("git@github.com:peterj/myskills.git", "")).toBe("myskills"); + }); + }); + + describe("applyGitSkillUrlPathChange", () => { + it("autofills name from URL when name was empty", () => { + const row: GitSkillFormRow = { url: "", ref: "", path: "", name: "" }; + expect(applyGitSkillUrlPathChange(row, { url: "https://github.com/peterj/myskills" })).toMatchObject({ + name: "myskills", + }); + }); + + it("replaces name when it matched previous default and path is added", () => { + const row: GitSkillFormRow = { + url: "https://github.com/peterj/myskills", + ref: "main", + path: "", + name: "myskills", + }; + const next = applyGitSkillUrlPathChange(row, { path: "a/skill1" }); + expect(next.name).toBe("skill1"); + }); + + it("keeps a custom name when it does not match previous default", () => { + const row: GitSkillFormRow = { + url: "https://github.com/peterj/myskills", + ref: "main", + path: "a/b", + name: "custom", + }; + const next = applyGitSkillUrlPathChange(row, { path: "x/y" }); + expect(next.name).toBe("custom"); + }); + }); + + describe("formRowToGitRepos", () => { + it("matches one row of formRowsToGitRepos for non-empty URL", () => { + const row: GitSkillFormRow = { + url: "https://a/b", + ref: "r1", + path: "p/q", + name: "", + }; + const one = formRowToGitRepo(row); + const batch = formRowsToGitRepos([row]); + expect(one).toEqual(batch[0]); + }); + + it("returns null for blank URL", () => { + expect(formRowToGitRepo({ url: " ", ref: "x", path: "", name: "" })).toBeNull(); + }); + }); + + describe("formRowsToGitRepos", () => { + it("drops rows with blank URL", () => { + const rows: GitSkillFormRow[] = [ + { url: "", ref: "main", path: "", name: "" }, + { url: " https://github.com/o/r.git ", ref: "", path: "", name: "" }, + ]; + expect(formRowsToGitRepos(rows)).toEqual([{ url: "https://github.com/o/r.git", name: "r" }]); + }); + + it("includes optional ref, path, name when non-empty", () => { + const rows: GitSkillFormRow[] = [ + { + url: "git@github.com:o/r.git", + ref: " develop ", + path: " skills/x ", + name: " myname ", + }, + ]; + expect(formRowsToGitRepos(rows)).toEqual([ + { + url: "git@github.com:o/r.git", + ref: "develop", + path: "skills/x", + name: "myname", + }, + ]); + }); + }); + + describe("isPlausibleGitRemoteUrl", () => { + it.each([ + ["https://github.com/a/b.git", true], + ["http://internal/git", true], + ["git@github.com:a/b.git", true], + ["ssh://git@host/repo", true], + ["git://host/repo", true], + ["", false], + ["github.com/a/b", false], + ["ftp://host/r.git", false], + ])("%s → %s", (url, expected) => { + expect(isPlausibleGitRemoteUrl(url)).toBe(expected); + }); + }); + + describe("isValidSkillContainerImage", () => { + it("rejects empty or whitespace", () => { + expect(isValidSkillContainerImage("")).toBe(false); + expect(isValidSkillContainerImage(" ")).toBe(false); + }); + + it("accepts typical registry/repo:tag refs", () => { + expect(isValidSkillContainerImage("ghcr.io/org/skill:v1")).toBe(true); + expect(isValidSkillContainerImage("docker.io/library/alpine:latest")).toBe(true); + }); + }); + + describe("gitSkillDedupeKeyFromRepo / gitSkillDedupeKeyFromFormRow", () => { + it("normalizes case and trims", () => { + const g: GitRepo = { url: " HTTPS://X/Y ", ref: " Main ", path: " P " }; + expect(gitSkillDedupeKeyFromRepo(g)).toBe("https://x/y|main|p"); + }); + + it("matches between repo and form row for same logical repo", () => { + const row: GitSkillFormRow = { + url: "https://a/b", + ref: "r", + path: "p", + name: "n", + }; + const repo = formRowsToGitRepos([row])[0]; + expect(gitSkillDedupeKeyFromFormRow(row)).toBe(gitSkillDedupeKeyFromRepo(repo)); + }); + }); + + describe("gitSkillRowUrlIssues", () => { + it("flags ref/path/name without URL", () => { + expect(gitSkillRowUrlIssues({ url: "", ref: "main", path: "", name: "" })).toEqual({ + hasExtraWithoutUrl: true, + urlInvalid: false, + }); + }); + + it("flags invalid URL scheme", () => { + expect(gitSkillRowUrlIssues({ url: "noscheme/repo", ref: "", path: "", name: "" })).toEqual({ + hasExtraWithoutUrl: false, + urlInvalid: true, + }); + }); + + it("clean empty row has no issues", () => { + expect(gitSkillRowUrlIssues(newEmptyGitSkillRow())).toEqual({ + hasExtraWithoutUrl: false, + urlInvalid: false, + }); + }); + }); + + describe("isDuplicateGitSkillFormRow", () => { + const resolved: GitRepo[] = [ + { url: "https://a/b", ref: "main", path: "p1" }, + { url: "https://a/b", ref: "main", path: "p1" }, + ]; + + it("returns false when URL is empty", () => { + expect( + isDuplicateGitSkillFormRow({ url: "", ref: "", path: "", name: "" }, resolved), + ).toBe(false); + }); + + it("returns true when row matches duplicate in resolved list", () => { + const row: GitSkillFormRow = { + url: "https://a/b", + ref: "main", + path: "p1", + name: "", + }; + expect(isDuplicateGitSkillFormRow(row, resolved)).toBe(true); + }); + }); + + describe("isDuplicateOciSkillRef", () => { + it("returns false for blank ref", () => { + expect(isDuplicateOciSkillRef("", ["a", "b"])).toBe(false); + }); + + it("is case-insensitive", () => { + const all = ["ghcr.io/x:v1", "GHCR.IO/x:v1"]; + expect(isDuplicateOciSkillRef("ghcr.io/x:v1", all)).toBe(true); + }); + }); + + describe("validateDeclarativeAgentSkills", () => { + it("returns undefined when skills are empty", () => { + expect( + validateDeclarativeAgentSkills({ + skillRefs: ["", ""], + skillGitRepos: [newEmptyGitSkillRow()], + skillsGitAuthSecretName: "", + }), + ).toBeUndefined(); + }); + + it("errors on invalid OCI ref", () => { + const msg = validateDeclarativeAgentSkills({ + skillRefs: ["not-a-valid-image!!!"], + skillGitRepos: [newEmptyGitSkillRow()], + skillsGitAuthSecretName: "", + }); + expect(msg).toMatch(/Invalid container image format/); + }); + + it("errors on duplicate OCI refs", () => { + const msg = validateDeclarativeAgentSkills({ + skillRefs: ["ghcr.io/o/s:v1", "ghcr.io/o/s:v1"], + skillGitRepos: [newEmptyGitSkillRow()], + skillsGitAuthSecretName: "", + }); + expect(msg).toMatch(/Duplicate skill image/); + }); + + it("errors when OCI count exceeds max", () => { + const refs = Array.from({ length: MAX_SKILLS_PER_SOURCE + 1 }, (_, i) => `ghcr.io/o/s${i}:v1`); + const msg = validateDeclarativeAgentSkills({ + skillRefs: refs, + skillGitRepos: [newEmptyGitSkillRow()], + skillsGitAuthSecretName: "", + }); + expect(msg).toMatch(/At most/); + }); + + it("errors on partial Git row (ref without URL)", () => { + const msg = validateDeclarativeAgentSkills({ + skillRefs: [], + skillGitRepos: [{ url: "", ref: "main", path: "", name: "" }], + skillsGitAuthSecretName: "", + }); + expect(msg).toMatch(/need a repository URL/); + }); + + it("errors on invalid Git URL", () => { + const msg = validateDeclarativeAgentSkills({ + skillRefs: [], + skillGitRepos: [{ url: "bad-scheme/x", ref: "", path: "", name: "" }], + skillsGitAuthSecretName: "", + }); + expect(msg).toMatch(/Invalid Git URL/); + }); + + it("errors on duplicate Git repos", () => { + const row: GitSkillFormRow = { + url: "https://github.com/a/b.git", + ref: "main", + path: "", + name: "", + }; + const msg = validateDeclarativeAgentSkills({ + skillRefs: [], + skillGitRepos: [row, { ...row }], + skillsGitAuthSecretName: "", + }); + expect(msg).toMatch(/Duplicate Git skill/); + }); + + it("errors when secret set but no Git repos", () => { + const msg = validateDeclarativeAgentSkills({ + skillRefs: ["ghcr.io/o/s:v1"], + skillGitRepos: [newEmptyGitSkillRow()], + skillsGitAuthSecretName: "my-secret", + }); + expect(msg).toMatch(/Add at least one Git repository/); + }); + + it("errors on invalid secret name", () => { + const msg = validateDeclarativeAgentSkills({ + skillRefs: [], + skillGitRepos: [ + { url: "https://github.com/a/b.git", ref: "", path: "", name: "" }, + ], + skillsGitAuthSecretName: "Bad_Name", + }); + expect(msg).toMatch(/valid Kubernetes resource name/); + }); + + it("allows valid secret with Git repo", () => { + expect( + validateDeclarativeAgentSkills({ + skillRefs: [], + skillGitRepos: [ + { url: "https://github.com/a/b.git", ref: "", path: "", name: "" }, + ], + skillsGitAuthSecretName: "git-auth", + }), + ).toBeUndefined(); + }); + + it("allows OCI and Git together when both valid", () => { + expect( + validateDeclarativeAgentSkills({ + skillRefs: ["ghcr.io/org/skill:v1"], + skillGitRepos: [ + { url: "https://github.com/o/r.git", ref: "main", path: "skills/x", name: "" }, + ], + skillsGitAuthSecretName: "", + }), + ).toBeUndefined(); + }); + }); +}); diff --git a/ui/src/lib/agentSkillsForm.ts b/ui/src/lib/agentSkillsForm.ts new file mode 100644 index 000000000..f9875083d --- /dev/null +++ b/ui/src/lib/agentSkillsForm.ts @@ -0,0 +1,248 @@ +import type { GitRepo } from "@/types"; +import { isResourceNameValid } from "@/lib/utils"; + +/** Matches CRD max items for `skills.refs` and `skills.gitRefs`. */ +export const MAX_SKILLS_PER_SOURCE = 20; + +/** Form row for `spec.skills.gitRefs` (GitRepo). */ +export type GitSkillFormRow = { + url: string; + ref: string; + path: string; + name: string; +}; + +export function newEmptyGitSkillRow(): GitSkillFormRow { + return { url: "", ref: "", path: "", name: "" }; +} + +/** Last non-empty segment of a slash-separated path (no leading/trailing slashes required). */ +function lastPathSegment(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts.length > 0 ? (parts[parts.length - 1] ?? "") : ""; +} + +/** + * Default folder name under /skills: last path segment in `pathInRepo` if set, else + * the repo name from the clone URL. Matches the controller’s gitSkillName when + * `name` is omitted in the API. + */ +export function defaultGitSkillFolderName(url: string, pathInRepo: string): string { + const p = pathInRepo.trim().replace(/^\/+/, "").replace(/\/+$/g, ""); + if (p) { + return lastPathSegment(p); + } + const u = url.trim(); + if (!u) { + return ""; + } + if (/^(?:https?|git|git\+ssh|ssh):\/\//i.test(u)) { + try { + const parsed = new URL(u); + const seg = parsed.pathname.replace(/\/+$/, "").replace(/\.git$/i, ""); + if (seg) { + return lastPathSegment(seg); + } + } catch { + /* fall through */ + } + } + const scp = /^git@[^:]+:(.+)$/.exec(u); + if (scp) { + const tail = scp[1].replace(/\.git$/i, ""); + if (tail) { + return lastPathSegment(tail); + } + } + const noGit = u.replace(/\.git$/i, ""); + const i = noGit.lastIndexOf("/"); + if (i >= 0) { + return noGit.slice(i + 1); + } + return noGit; +} + +/** + * When URL or path in repo changes, update the suggested "name" only if the user + * has not set a custom value (empty or still equal to the previous default). + */ +export function applyGitSkillUrlPathChange( + row: GitSkillFormRow, + change: { url?: string; path?: string }, +): GitSkillFormRow { + const nextUrl = change.url !== undefined ? change.url : row.url; + const nextPath = change.path !== undefined ? change.path : row.path; + const oldDerived = defaultGitSkillFolderName(row.url, row.path); + const newDerived = defaultGitSkillFolderName(nextUrl, nextPath); + const t = row.name.trim(); + const name = t === "" || t === oldDerived ? newDerived : row.name; + return { ...row, url: nextUrl, path: nextPath, name }; +} + +export function gitRepoToFormRow(g: GitRepo): GitSkillFormRow { + const url = g.url || ""; + const path = g.path || ""; + const d = defaultGitSkillFolderName(url, path); + return { + url, + ref: g.ref ?? "", + path, + name: (g.name && g.name.trim()) || d, + }; +} + +/** + * One form row → API `GitRepo` (or `null` if URL is blank — empty rows are dropped). + * Applies the same `name` defaulting as the server (via `defaultGitSkillFolderName`). + */ +export function formRowToGitRepo(row: GitSkillFormRow): GitRepo | null { + const url = row.url.trim(); + if (!url) { + return null; + } + const o: GitRepo = { url }; + const r = row.ref.trim(); + if (r) o.ref = r; + const p = row.path.trim(); + if (p) o.path = p; + const n = row.name.trim() || defaultGitSkillFolderName(url, p); + if (n) o.name = n; + return o; +} + +/** Non-empty GitRepo entries from form rows (empty URL rows are dropped). */ +export function formRowsToGitRepos(rows: GitSkillFormRow[]): GitRepo[] { + return rows + .map((row) => formRowToGitRepo(row)) + .filter((g): g is GitRepo => g !== null); +} + +const GIT_REMOTE_RE = /^(https?:\/\/|git@|git:\/\/|ssh:\/\/)/i; + +export function isPlausibleGitRemoteUrl(url: string): boolean { + return GIT_REMOTE_RE.test(url.trim()); +} + +/** Basic check for OCI skill image reference format. */ +export function isValidSkillContainerImage(image: string): boolean { + if (!image.trim()) return false; + const imageRegex = + /^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?\/)?[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*(?::[A-Za-z0-9][A-Za-z0-9._-]*)?(?:@sha256:[a-f0-9]{64})?$/i; + return imageRegex.test(image.trim()); +} + +/** + * Stable key for “same Git skill source” (URL + ref + in-repo path). Use for + * de-duplication and for comparing a form row to a resolved `GitRepo`. + */ +export function gitSkillSourceDedupeKey(url: string, ref: string, pathInRepo: string): string { + return `${url.trim().toLowerCase()}|${ref.trim().toLowerCase()}|${pathInRepo.trim().toLowerCase()}`; +} + +export function gitSkillDedupeKeyFromRepo(g: GitRepo): string { + return gitSkillSourceDedupeKey(g.url, g.ref || "", g.path || ""); +} + +export function gitSkillDedupeKeyFromFormRow(row: GitSkillFormRow): string { + return gitSkillSourceDedupeKey(row.url, row.ref, row.path); +} + +export function gitSkillRowUrlIssues(row: GitSkillFormRow): { + hasExtraWithoutUrl: boolean; + urlInvalid: boolean; +} { + const urlTrim = row.url.trim(); + const hasExtraWithoutUrl = + !urlTrim && !!(row.ref.trim() || row.path.trim() || row.name.trim()); + const urlInvalid = urlTrim.length > 0 && !isPlausibleGitRemoteUrl(urlTrim); + return { hasExtraWithoutUrl, urlInvalid }; +} + +function hasDuplicateStrings(keys: string[]): boolean { + return keys.some((k, i) => keys.indexOf(k) !== i); +} + +/** True when this row’s URL+ref+path appears more than once among resolved Git repos. */ +export function isDuplicateGitSkillFormRow( + row: GitSkillFormRow, + resolvedGitRepos: GitRepo[], +): boolean { + if (!row.url.trim()) { + return false; + } + const rowKey = gitSkillDedupeKeyFromFormRow(row); + const count = resolvedGitRepos.filter( + (g) => gitSkillDedupeKeyFromRepo(g) === rowKey, + ).length; + return count > 1; +} + +export function isDuplicateOciSkillRef(ref: string, allRefs: string[]): boolean { + const t = ref.trim(); + if (!t) { + return false; + } + return allRefs.filter((r) => r.trim().toLowerCase() === t.toLowerCase()).length > 1; +} + +export type DeclarativeAgentSkillsFormInput = { + skillRefs: string[]; + skillGitRepos: GitSkillFormRow[]; + skillsGitAuthSecretName: string; +}; + +/** + * Validates OCI refs, Git repos, and optional git auth secret for the declarative agent form. + * Returns the first error message, or `undefined` if valid. + */ +export function validateDeclarativeAgentSkills( + input: DeclarativeAgentSkillsFormInput, +): string | undefined { + const nonEmptyRefs = (input.skillRefs || []).filter((ref) => ref.trim()); + const gitRepos = formRowsToGitRepos(input.skillGitRepos || []); + + if (nonEmptyRefs.length > 0) { + if (nonEmptyRefs.length > MAX_SKILLS_PER_SOURCE) { + return `At most ${MAX_SKILLS_PER_SOURCE} container image skills are allowed`; + } + const invalidRefs = nonEmptyRefs.filter((ref) => !isValidSkillContainerImage(ref)); + if (invalidRefs.length > 0) { + return `Invalid container image format: ${invalidRefs[0]}`; + } + const trimmedLower = nonEmptyRefs.map((ref) => ref.trim().toLowerCase()); + if (hasDuplicateStrings(trimmedLower)) { + const dupIdx = trimmedLower.findIndex( + (ref, idx) => trimmedLower.indexOf(ref) !== idx, + ); + return `Duplicate skill image: ${nonEmptyRefs[dupIdx]}`; + } + } + + const partialGit = (input.skillGitRepos || []).some( + (row) => + !row.url.trim() && !!(row.ref.trim() || row.path.trim() || row.name.trim()), + ); + if (partialGit) { + return "Git skill rows that set ref, path, or name need a repository URL"; + } + if (gitRepos.length > MAX_SKILLS_PER_SOURCE) { + return `At most ${MAX_SKILLS_PER_SOURCE} Git skill sources are allowed`; + } + const badUrl = gitRepos.find((g) => !isPlausibleGitRemoteUrl(g.url)); + if (badUrl) { + return `Invalid Git URL (use https://, http://, git@, or ssh://): ${badUrl.url}`; + } + if (hasDuplicateStrings(gitRepos.map(gitSkillDedupeKeyFromRepo))) { + return "Duplicate Git skill (same URL, ref, and path)"; + } + + const sec = input.skillsGitAuthSecretName?.trim(); + if (sec && gitRepos.length === 0) { + return "Add at least one Git repository to use a credentials secret, or clear the secret name"; + } + if (sec && !isResourceNameValid(sec)) { + return "Git auth secret name must be a valid Kubernetes resource name"; + } + + return undefined; +} diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 87a268dda..ed6676d5d 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -249,9 +249,19 @@ export interface McpServerTool extends TypedLocalReference { export type AgentType = "Declarative" | "BYO" | "Sandbox"; +/** Single Git repository source for skills. */ +export interface GitRepo { + url: string; + ref?: string; + path?: string; + name?: string; +} + export interface SkillForAgent { insecureSkipVerify?: boolean; refs?: string[]; + gitAuthSecretRef?: { name: string }; + gitRefs?: GitRepo[]; } /** Kubernetes SandboxAgent CRD (kagent.dev/v1alpha2). Spec matches Agent.spec (AgentSpec). */ From 32a5f831b260de95df9b54a374dbb737855645e2 Mon Sep 17 00:00:00 2001 From: Peter Jausovec Date: Sat, 25 Apr 2026 15:17:15 -0700 Subject: [PATCH 2/3] update golden Signed-off-by: Peter Jausovec --- .../agent/testdata/outputs/agent_with_git_skills.json | 2 +- .../translator/agent/testdata/outputs/agent_with_skills.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json index 71c7ed0a1..1c51c857d 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json @@ -241,7 +241,7 @@ "command": [ "/bin/sh", "-c", - "set -e\n_auth_mount=\"$(cat \u003c\u003c'ENDVAL'\n/git-auth\nENDVAL\n)\"\nif [ -f \"${_auth_mount}/ssh-privatekey\" ]; then\n mkdir -p ~/.ssh\n chmod 700 ~/.ssh\n cp \"${_auth_mount}/ssh-privatekey\" ~/.ssh/id_rsa\n chmod 600 ~/.ssh/id_rsa\n touch ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\nelif [ -f \"${_auth_mount}/token\" ]; then\n git config --global credential.helper \"!f() { echo username=x-access-token; echo password=\\$(cat ${_auth_mount}/token); }; f\"\nfi\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/my-skills\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nv2.0.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/k8s-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_subpath=\"$(cat \u003c\u003c'ENDVAL'\nskills/k8s\nENDVAL\n)\"\n_tmp=\"$(mktemp -d)\"\ncp -rL \"${_dest}/${_subpath}/.\" \"$_tmp/\"\nrm -rf \"$_dest\"\nmv \"$_tmp\" \"$_dest\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/another-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nabc123def456abc123def456abc123def456abc1\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/another-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (commit ${_ref}) into ${_dest}\"\ngit clone -- \"$_url\" \"$_dest\"\ncd \"$_dest\" \u0026\u0026 git checkout \"$_ref\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/private-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nmain\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/private-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/oci-skill:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/oci-skill\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# Git clone and archives may leave root-owned mode 0700 trees; the main container often\n# runs as a different non-root UID and must traverse/read /skills (read-only mount).\nchmod -R a+rX /skills\n" + "set -e\n_auth_mount=\"$(cat \u003c\u003c'ENDVAL'\n/git-auth\nENDVAL\n)\"\nif [ -f \"${_auth_mount}/ssh-privatekey\" ]; then\n mkdir -p ~/.ssh\n chmod 700 ~/.ssh\n cp \"${_auth_mount}/ssh-privatekey\" ~/.ssh/id_rsa\n chmod 600 ~/.ssh/id_rsa\n touch ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\nelif [ -f \"${_auth_mount}/token\" ]; then\n git config --global credential.helper \"!f() { echo username=x-access-token; echo password=\\$(cat ${_auth_mount}/token); }; f\"\nfi\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/my-skills\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nv2.0.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/k8s-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_subpath=\"$(cat \u003c\u003c'ENDVAL'\nskills/k8s\nENDVAL\n)\"\n_tmp=\"$(mktemp -d)\"\ncp -rL \"${_dest}/${_subpath}/.\" \"$_tmp/\"\nrm -rf \"$_dest\"\nmv \"$_tmp\" \"$_dest\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/another-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nabc123def456abc123def456abc123def456abc1\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/another-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (commit ${_ref}) into ${_dest}\"\ngit clone -- \"$_url\" \"$_dest\"\ncd \"$_dest\" \u0026\u0026 git checkout \"$_ref\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/private-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nmain\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/private-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/oci-skill:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/oci-skill\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# The init process (git, krane, tar) and the main kagent run as different\n# UIDs. Clones and archives are commonly created as root with mode 0700, while the agent\n# runs as a non-root user and mounts /skills read-only—so it cannot chown. Relax perms\n# so the agent uid can list and read SKILL.md under /skills.\nchmod -R a+rX /skills\n" ], "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", "name": "skills-init", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json index 42aabbf2c..903ffc4e9 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json @@ -241,7 +241,7 @@ "command": [ "/bin/sh", "-c", - "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nfoo:latest\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/foo\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# Git clone and archives may leave root-owned mode 0700 trees; the main container often\n# runs as a different non-root UID and must traverse/read /skills (read-only mount).\nchmod -R a+rX /skills\n" + "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nfoo:latest\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/foo\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# The init process (git, krane, tar) and the main kagent run as different\n# UIDs. Clones and archives are commonly created as root with mode 0700, while the agent\n# runs as a non-root user and mounts /skills read-only—so it cannot chown. Relax perms\n# so the agent uid can list and read SKILL.md under /skills.\nchmod -R a+rX /skills\n" ], "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", "name": "skills-init", From f143778bf4ad013ac63a5c488a82829287decadd Mon Sep 17 00:00:00 2001 From: Peter Jausovec Date: Mon, 27 Apr 2026 09:30:24 -0700 Subject: [PATCH 3/3] revert the changes for skills permissions Signed-off-by: Peter Jausovec --- .../internal/controller/translator/agent/skills-init.sh.tmpl | 5 ----- .../agent/testdata/outputs/agent_with_git_skills.json | 2 +- .../translator/agent/testdata/outputs/agent_with_skills.json | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/go/core/internal/controller/translator/agent/skills-init.sh.tmpl b/go/core/internal/controller/translator/agent/skills-init.sh.tmpl index 6bc5c9f7a..4f0f3370e 100644 --- a/go/core/internal/controller/translator/agent/skills-init.sh.tmpl +++ b/go/core/internal/controller/translator/agent/skills-init.sh.tmpl @@ -82,8 +82,3 @@ mkdir -p "$_dest" tar xf '/tmp/oci-skill.tar' -C "$_dest" rm -f '/tmp/oci-skill.tar' {{- end }} -# The init process (git, krane, tar) and the main kagent run as different -# UIDs. Clones and archives are commonly created as root with mode 0700, while the agent -# runs as a non-root user and mounts /skills read-only—so it cannot chown. Relax perms -# so the agent uid can list and read SKILL.md under /skills. -chmod -R a+rX /skills diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json index 1c51c857d..98cc97bea 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json @@ -241,7 +241,7 @@ "command": [ "/bin/sh", "-c", - "set -e\n_auth_mount=\"$(cat \u003c\u003c'ENDVAL'\n/git-auth\nENDVAL\n)\"\nif [ -f \"${_auth_mount}/ssh-privatekey\" ]; then\n mkdir -p ~/.ssh\n chmod 700 ~/.ssh\n cp \"${_auth_mount}/ssh-privatekey\" ~/.ssh/id_rsa\n chmod 600 ~/.ssh/id_rsa\n touch ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\nelif [ -f \"${_auth_mount}/token\" ]; then\n git config --global credential.helper \"!f() { echo username=x-access-token; echo password=\\$(cat ${_auth_mount}/token); }; f\"\nfi\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/my-skills\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nv2.0.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/k8s-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_subpath=\"$(cat \u003c\u003c'ENDVAL'\nskills/k8s\nENDVAL\n)\"\n_tmp=\"$(mktemp -d)\"\ncp -rL \"${_dest}/${_subpath}/.\" \"$_tmp/\"\nrm -rf \"$_dest\"\nmv \"$_tmp\" \"$_dest\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/another-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nabc123def456abc123def456abc123def456abc1\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/another-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (commit ${_ref}) into ${_dest}\"\ngit clone -- \"$_url\" \"$_dest\"\ncd \"$_dest\" \u0026\u0026 git checkout \"$_ref\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/private-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nmain\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/private-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/oci-skill:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/oci-skill\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# The init process (git, krane, tar) and the main kagent run as different\n# UIDs. Clones and archives are commonly created as root with mode 0700, while the agent\n# runs as a non-root user and mounts /skills read-only—so it cannot chown. Relax perms\n# so the agent uid can list and read SKILL.md under /skills.\nchmod -R a+rX /skills\n" + "set -e\n_auth_mount=\"$(cat \u003c\u003c'ENDVAL'\n/git-auth\nENDVAL\n)\"\nif [ -f \"${_auth_mount}/ssh-privatekey\" ]; then\n mkdir -p ~/.ssh\n chmod 700 ~/.ssh\n cp \"${_auth_mount}/ssh-privatekey\" ~/.ssh/id_rsa\n chmod 600 ~/.ssh/id_rsa\n touch ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\nelif [ -f \"${_auth_mount}/token\" ]; then\n git config --global credential.helper \"!f() { echo username=x-access-token; echo password=\\$(cat ${_auth_mount}/token); }; f\"\nfi\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/my-skills\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nv2.0.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/k8s-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_subpath=\"$(cat \u003c\u003c'ENDVAL'\nskills/k8s\nENDVAL\n)\"\n_tmp=\"$(mktemp -d)\"\ncp -rL \"${_dest}/${_subpath}/.\" \"$_tmp/\"\nrm -rf \"$_dest\"\nmv \"$_tmp\" \"$_dest\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/another-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nabc123def456abc123def456abc123def456abc1\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/another-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (commit ${_ref}) into ${_dest}\"\ngit clone -- \"$_url\" \"$_dest\"\ncd \"$_dest\" \u0026\u0026 git checkout \"$_ref\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/private-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nmain\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/private-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/oci-skill:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/oci-skill\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n" ], "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", "name": "skills-init", diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json index 903ffc4e9..e65fa19d5 100644 --- a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json @@ -241,7 +241,7 @@ "command": [ "/bin/sh", "-c", - "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nfoo:latest\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/foo\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n# The init process (git, krane, tar) and the main kagent run as different\n# UIDs. Clones and archives are commonly created as root with mode 0700, while the agent\n# runs as a non-root user and mounts /skills read-only—so it cannot chown. Relax perms\n# so the agent uid can list and read SKILL.md under /skills.\nchmod -R a+rX /skills\n" + "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nfoo:latest\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/foo\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\n_uname=\"$(uname -m)\"\ncase \"$_uname\" in\n x86_64|amd64)\n _arch=\"amd64\"\n ;;\n aarch64|arm64)\n _arch=\"arm64\"\n ;;\n *)\n echo \"Unsupported architecture for OCI export: ${_uname}\" \u003e\u00262\n exit 1\n ;;\nesac\nkrane export --platform \"linux/${_arch}\" \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n" ], "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", "name": "skills-init",