diff --git a/README.md b/README.md index cfe515797..715ffdf02 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,22 @@ kelos run -p "Fix the bug" --agent-config my-config - `plugins` are mounted as plugin directories and passed via `--plugin-dir`. - `mcpServers` are written to the agent's native MCP configuration. Supports `stdio`, `http`, and `sse` transport types. +To source agent settings from a Kanon repository instead, set `spec.kanon`. +The referenced repository must contain `kanon.yaml` at its root. This mode is +mutually exclusive with inline `agentsMD`, `plugins`, `skills`, and +`mcpServers` fields. + +```yaml +apiVersion: kelos.dev/v1alpha2 +kind: AgentConfig +metadata: + name: kanon-config +spec: + kanon: + repo: https://github.com/example/kanon-config.git + ref: main +``` + See the [full AgentConfig spec](docs/reference.md#agentconfig) for plugins, skills, and agents configuration. > Browse all ready-to-apply YAML manifests in the [`examples/`](examples/) directory. diff --git a/api/v1alpha2/agentconfig_types.go b/api/v1alpha2/agentconfig_types.go index 621124f83..a826d5a58 100644 --- a/api/v1alpha2/agentconfig_types.go +++ b/api/v1alpha2/agentconfig_types.go @@ -6,6 +6,8 @@ import ( ) // AgentConfigSpec defines the desired state of AgentConfig. +// +// +kubebuilder:validation:XValidation:rule="!has(self.kanon) || ((!has(self.agentsMD) || size(self.agentsMD) == 0) && (!has(self.plugins) || size(self.plugins) == 0) && (!has(self.skills) || size(self.skills) == 0) && (!has(self.mcpServers) || size(self.mcpServers) == 0))",message="kanon is mutually exclusive with agentsMD, plugins, skills, and mcpServers" type AgentConfigSpec struct { // AgentsMD is written to the agent's instruction file // (e.g., ~/.claude/CLAUDE.md for Claude Code). @@ -13,6 +15,13 @@ type AgentConfigSpec struct { // +optional AgentsMD string `json:"agentsMD,omitempty"` + // Kanon references a Kanon source repository. When set, the Task pod + // clones the repository and applies its root kanon.yaml to the agent's + // user-level configuration before the agent starts. + // Mutually exclusive with AgentsMD, Plugins, Skills, and MCPServers. + // +optional + Kanon *KanonSourceSpec `json:"kanon,omitempty"` + // Plugins defines plugin bundles containing skills and agents. // Each plugin is mounted as a directory and installed using the // agent's native mechanism (e.g., --plugin-dir for Claude Code, @@ -33,6 +42,23 @@ type AgentConfigSpec struct { MCPServers []MCPServerSpec `json:"mcpServers,omitempty"` } +// KanonSourceSpec references a Kanon source repository. +type KanonSourceSpec struct { + // Repo is the Git repository URL containing kanon.yaml at its root. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Repo string `json:"repo"` + + // Ref is an optional branch, tag, or commit to check out. + // +optional + Ref string `json:"ref,omitempty"` + + // SecretRef references a Secret containing GITHUB_TOKEN for cloning + // private Kanon source repositories. + // +optional + SecretRef *SecretReference `json:"secretRef,omitempty"` +} + // PluginSpec defines a plugin bundle containing skills and agents. type PluginSpec struct { // Name is the plugin name. Used as the plugin directory name diff --git a/api/v1alpha2/task_types.go b/api/v1alpha2/task_types.go index 2bd477c73..73369e743 100644 --- a/api/v1alpha2/task_types.go +++ b/api/v1alpha2/task_types.go @@ -170,22 +170,23 @@ type PodOverrides struct { // ContainerSecurityContext fields. // Container names must not use the Kelos-reserved "kelos-" prefix or // collide with a built-in init container name: git-clone, remote-setup, - // branch-setup, workspace-files, plugin-setup, skills-install. + // branch-setup, workspace-files, plugin-setup, skills-install, + // kanon-source. // +optional // +kubebuilder:validation:MaxItems=8 // +listType=map // +listMapKey=name // +kubebuilder:validation:XValidation:rule="self.all(c, c.name != '')",message="extraContainers[].name must not be empty" - // +kubebuilder:validation:XValidation:rule="self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', 'plugin-setup', 'skills-install']))",message="extraContainers[].name must not use the reserved 'kelos-' prefix or a built-in init container name" + // +kubebuilder:validation:XValidation:rule="self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', 'plugin-setup', 'skills-install', 'kanon-source']))",message="extraContainers[].name must not use the reserved 'kelos-' prefix or a built-in init container name" ExtraContainers []corev1.Container `json:"extraContainers,omitempty"` // ExtraInitContainers is a list of additional init containers to run // in the agent pod. They are placed after all Kelos built-in init // containers (git-clone, remote-setup, branch-setup, workspace-files, - // plugin-setup, skills-install), ensuring the workspace is ready - // before they start. Set restartPolicy: Always for sidecar semantics - // (long-running services like databases) or leave it unset for - // one-shot init tasks. + // plugin-setup, skills-install, kanon-source), ensuring the workspace + // and AgentConfig sources are ready before they start. Set + // restartPolicy: Always for sidecar semantics (long-running services + // like databases) or leave it unset for one-shot init tasks. // Containers can mount user-supplied volumes from the Volumes field // as well as Kelos-managed volumes (workspace, kelos-plugin). Note // that the workspace volume uses FSGroup-based permissions; containers @@ -193,13 +194,14 @@ type PodOverrides struct { // access to the workspace. // Container names must not use the Kelos-reserved "kelos-" prefix or // collide with a built-in init container name: git-clone, remote-setup, - // branch-setup, workspace-files, plugin-setup, skills-install. + // branch-setup, workspace-files, plugin-setup, skills-install, + // kanon-source. // +optional // +kubebuilder:validation:MaxItems=8 // +listType=map // +listMapKey=name // +kubebuilder:validation:XValidation:rule="self.all(c, c.name != '')",message="extraInitContainers[].name must not be empty" - // +kubebuilder:validation:XValidation:rule="self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', 'plugin-setup', 'skills-install']))",message="extraInitContainers[].name must not use the reserved 'kelos-' prefix or a built-in init container name" + // +kubebuilder:validation:XValidation:rule="self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', 'plugin-setup', 'skills-install', 'kanon-source']))",message="extraInitContainers[].name must not use the reserved 'kelos-' prefix or a built-in init container name" ExtraInitContainers []corev1.Container `json:"extraInitContainers,omitempty"` } @@ -241,7 +243,9 @@ type TaskSpec struct { // AgentConfigRefs references an ordered list of AgentConfig resources. // Configs are merged in order: agentsMD is concatenated, plugins/skills // are appended, mcpServers are appended with later entries winning on - // name collision. + // name collision. At most one referenced AgentConfig may set spec.kanon, + // and Kanon-backed configs cannot be combined with inline AgentConfig + // fields from the same or another referenced config. // +optional // +kubebuilder:validation:MinItems=1 AgentConfigRefs []AgentConfigReference `json:"agentConfigRefs,omitempty"` diff --git a/api/v1alpha2/taskspawner_types.go b/api/v1alpha2/taskspawner_types.go index 71daead40..21775aacf 100644 --- a/api/v1alpha2/taskspawner_types.go +++ b/api/v1alpha2/taskspawner_types.go @@ -838,7 +838,9 @@ type TaskTemplate struct { // AgentConfigRefs references an ordered list of AgentConfig resources. // Configs are merged in order: agentsMD is concatenated, plugins/skills // are appended, mcpServers are appended with later entries winning on - // name collision. + // name collision. At most one referenced AgentConfig may set spec.kanon, + // and Kanon-backed configs cannot be combined with inline AgentConfig + // fields from the same or another referenced config. // When set, spawned Tasks inherit this agent config reference list. // +optional // +kubebuilder:validation:MinItems=1 diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 834299e05..5db6ea8c4 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -103,6 +103,11 @@ func (in *AgentConfigReference) DeepCopy() *AgentConfigReference { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentConfigSpec) DeepCopyInto(out *AgentConfigSpec) { *out = *in + if in.Kanon != nil { + in, out := &in.Kanon, &out.Kanon + *out = new(KanonSourceSpec) + (*in).DeepCopyInto(*out) + } if in.Plugins != nil { in, out := &in.Plugins, &out.Plugins *out = make([]PluginSpec, len(*in)) @@ -618,6 +623,26 @@ func (in *Jira) DeepCopy() *Jira { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KanonSourceSpec) DeepCopyInto(out *KanonSourceSpec) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(SecretReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KanonSourceSpec. +func (in *KanonSourceSpec) DeepCopy() *KanonSourceSpec { + if in == nil { + return nil + } + out := new(KanonSourceSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LinearWebhook) DeepCopyInto(out *LinearWebhook) { *out = *in diff --git a/claude-code/Dockerfile b/claude-code/Dockerfile index 992f14f4d..b8bc2356e 100644 --- a/claude-code/Dockerfile +++ b/claude-code/Dockerfile @@ -27,6 +27,9 @@ RUN ARCH=$(dpkg --print-architecture) \ ENV PATH="/usr/local/go/bin:${PATH}" +ARG KANON_VERSION=3f2ad4b8171d46aaf7541e9efa61aef009c233e2 +RUN GOBIN=/usr/local/bin go install github.com/kelos-dev/kanon@${KANON_VERSION} + ARG CLAUDE_CODE_VERSION=2.1.177 RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} diff --git a/claude-code/kelos_entrypoint.sh b/claude-code/kelos_entrypoint.sh index e5e813a53..f267a4244 100755 --- a/claude-code/kelos_entrypoint.sh +++ b/claude-code/kelos_entrypoint.sh @@ -55,6 +55,17 @@ fs.writeFileSync(cfgPath, JSON.stringify(existing, null, 2)); ' fi +if [ -n "${KELOS_KANON_HOME:-}" ]; then + printf '\n---KELOS_KANON_APPLY_START---\n' >&2 + kanon apply --yes --home "$KELOS_KANON_HOME" --agent claude + KANON_EXIT_CODE=$? + if [ "$KANON_EXIT_CODE" -ne 0 ]; then + printf '\n---KELOS_KANON_APPLY_FAILED--- exit=%s\n' "$KANON_EXIT_CODE" >&2 + exit "$KANON_EXIT_CODE" + fi + printf '\n---KELOS_KANON_APPLY_DONE---\n' >&2 +fi + # Run pre-agent setup command if configured. KELOS_SETUP_COMMAND is the # JSON-encoded exec-form array from Workspace.spec.setupCommand. A non-zero # exit aborts the task before the agent starts. diff --git a/codex/Dockerfile b/codex/Dockerfile index e9d53a697..c3d90590d 100644 --- a/codex/Dockerfile +++ b/codex/Dockerfile @@ -27,6 +27,9 @@ RUN ARCH=$(dpkg --print-architecture) \ ENV PATH="/usr/local/go/bin:${PATH}" +ARG KANON_VERSION=3f2ad4b8171d46aaf7541e9efa61aef009c233e2 +RUN GOBIN=/usr/local/bin go install github.com/kelos-dev/kanon@${KANON_VERSION} + ARG CODEX_VERSION=0.139.0 RUN npm install -g @openai/codex@${CODEX_VERSION} diff --git a/codex/kelos_entrypoint.sh b/codex/kelos_entrypoint.sh index 7ca9c2488..86f538086 100755 --- a/codex/kelos_entrypoint.sh +++ b/codex/kelos_entrypoint.sh @@ -91,6 +91,17 @@ process.stdout.write(toml); ' >>~/.codex/config.toml fi +if [ -n "${KELOS_KANON_HOME:-}" ]; then + printf '\n---KELOS_KANON_APPLY_START---\n' >&2 + kanon apply --yes --home "$KELOS_KANON_HOME" --agent codex + KANON_EXIT_CODE=$? + if [ "$KANON_EXIT_CODE" -ne 0 ]; then + printf '\n---KELOS_KANON_APPLY_FAILED--- exit=%s\n' "$KANON_EXIT_CODE" >&2 + exit "$KANON_EXIT_CODE" + fi + printf '\n---KELOS_KANON_APPLY_DONE---\n' >&2 +fi + # Run pre-agent setup command if configured. KELOS_SETUP_COMMAND is the # JSON-encoded exec-form array from Workspace.spec.setupCommand. A non-zero # exit aborts the task before the agent starts. diff --git a/docs/agent-image-interface.md b/docs/agent-image-interface.md index 424d76974..b2641e892 100644 --- a/docs/agent-image-interface.md +++ b/docs/agent-image-interface.md @@ -45,14 +45,15 @@ Kelos sets the following reserved environment variables on agent containers: | `KELOS_BASE_BRANCH` | The base branch (workspace `ref`) for the task | When workspace has a non-empty `ref` | | `KELOS_AGENTS_MD` | User-level instructions from AgentConfig | When `agentConfigRefs` is set and `agentsMD` is non-empty | | `KELOS_PLUGIN_DIR` | Path to plugin directory containing skills and agents. Each subdirectory is one plugin in the `/skills//SKILL.md` layout; skills.sh packages from `spec.skills` appear under the `skills-sh` plugin | When `agentConfigRefs` is set and `plugins` or `skills` is non-empty | +| `KELOS_KANON_HOME` | Path to a cloned Kanon source repository. Codex and Claude Code entrypoints run `kanon apply --yes --home "$KELOS_KANON_HOME" --agent ` before starting the agent | When `agentConfigRefs` resolves to `spec.kanon` and the agent type is `codex` or `claude-code` | | `KELOS_SETUP_COMMAND` | JSON-encoded exec-form array from `Workspace.spec.setupCommand`, executed by the entrypoint before the agent starts | When the workspace defines `setupCommand` | > The names listed in this table are reserved for Kelos behavior. When Kelos > sets one on a Task, `PodOverrides.Env` entries that reuse the same name are > dropped so the built-in value wins; do not set them manually. -> `KELOS_SETUP_COMMAND` is additionally dropped from `PodOverrides.Env` even -> when the workspace does not define `setupCommand`, because it drives -> entrypoint command execution. +> `KELOS_SETUP_COMMAND` and `KELOS_KANON_HOME` are additionally dropped from +> `PodOverrides.Env` even when Kelos does not define them, because they drive +> entrypoint behavior. The bundled agent images handle `KELOS_EFFORT` as follows: @@ -62,6 +63,11 @@ The bundled agent images handle `KELOS_EFFORT` as follows: - `opencode`: writes agent model options including `reasoningEffort` and provider variants where available. - `cursor`: adds effort guidance to user-level instructions because Cursor CLI does not expose a documented effort flag. +The bundled Codex and Claude Code images include the Kanon CLI and apply +Kanon-backed AgentConfigs before running the agent. Custom images for those +agent types must include `kanon` on `PATH` and handle `KELOS_KANON_HOME` the +same way if they need to support `AgentConfig.spec.kanon`. + ### 4. User ID The agent image must be configured to run as **UID 61100**. This UID is shared diff --git a/docs/reference.md b/docs/reference.md index 47ac4e644..38ba529b5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -30,7 +30,7 @@ | `spec.podOverrides.volumeMounts` | Additional volume mounts on the agent container; names must reference either a user-supplied volume from `volumes` or a Kelos-managed volume (`workspace` or a `kelos-` volume such as `kelos-plugin`) | No | | `spec.podOverrides.podSecurityContext` | Pod-level security context applied to the agent pod. Fields set here override Kelos defaults; `fsGroup` retains the Kelos default when unset so the agent user keeps workspace access | No | | `spec.podOverrides.containerSecurityContext` | Security context applied to the agent container. Use to declare `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`, `readOnlyRootFilesystem: true`, etc., for PSS-restricted namespaces | No | -| `spec.podOverrides.extraContainers` | Additional containers to run alongside the agent container in the same pod (max 8). They share the pod's network namespace (reachable via `localhost`) and can mount user-supplied volumes from `volumes`. Use for sidecars such as a database for integration tests or a proxy. Names must not use the Kelos-reserved `kelos-` prefix, collide with a built-in init container name (`git-clone`, `remote-setup`, `branch-setup`, `workspace-files`, `plugin-setup`, `skills-install`), duplicate another entry, or appear in `extraInitContainers` (see [Extra Containers](#task-extra-containers) below) | No | +| `spec.podOverrides.extraContainers` | Additional containers to run alongside the agent container in the same pod (max 8). They share the pod's network namespace (reachable via `localhost`) and can mount user-supplied volumes from `volumes`. Use for sidecars such as a database for integration tests or a proxy. Names must not use the Kelos-reserved `kelos-` prefix, collide with a built-in init container name (`git-clone`, `remote-setup`, `branch-setup`, `workspace-files`, `plugin-setup`, `skills-install`, `kanon-source`), duplicate another entry, or appear in `extraInitContainers` (see [Extra Containers](#task-extra-containers) below) | No | | `spec.podOverrides.extraInitContainers` | Additional init containers (max 8), appended after all Kelos built-in init containers so the workspace is ready before they run. Set `restartPolicy: Always` for sidecar semantics (long-running services, K8s 1.29+) or leave it unset for one-shot pre-agent setup. They can mount user-supplied volumes from `volumes` as well as Kelos-managed volumes (`workspace` or a `kelos-` volume such as `kelos-plugin`); workspace write access requires running as a UID in the pod's `fsGroup`. Same name constraints as `extraContainers` (see [Extra Containers](#task-extra-containers) below) | No | ### Pod Override Volumes @@ -66,7 +66,7 @@ spec: `spec.podOverrides.extraContainers` and `spec.podOverrides.extraInitContainers` let a Task run user-defined containers alongside the agent. Both lists accept a standard Kubernetes [Container](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.30/#container-v1-core) and are subject to these constraints (validated by the API server and the controller): - Maximum 8 entries per list. -- Container names must not use the Kelos-reserved `kelos-` prefix, and must not collide with built-in init container names: `git-clone`, `remote-setup`, `branch-setup`, `workspace-files`, `plugin-setup`, `skills-install`. +- Container names must not use the Kelos-reserved `kelos-` prefix, and must not collide with built-in init container names: `git-clone`, `remote-setup`, `branch-setup`, `workspace-files`, `plugin-setup`, `skills-install`, `kanon-source`. - A name must not be duplicated within a list, and must not appear in both `extraContainers` and `extraInitContainers` (Kubernetes requires container names to be unique within a pod). - `extraInitContainers` run after every Kelos built-in init container, so the cloned workspace and installed plugins are already in place. Write access to the `workspace` volume requires running as a UID in the pod's `fsGroup` (the agent UID `61100` by default). @@ -240,6 +240,9 @@ GitHub Apps are preferred over PATs for production use because they offer fine-g | Field | Description | Required | |-------|-------------|----------| | `spec.agentsMD` | Agent instructions written to the agent's user-level instructions file, additive with repo files. The destination depends on the agent type: `~/.claude/CLAUDE.md` (Claude Code), `~/.gemini/GEMINI.md` (Gemini), `~/.codex/AGENTS.md` (Codex), `~/.config/opencode/AGENTS.md` (OpenCode), `~/.cursor/AGENTS.md` (Cursor) | No | +| `spec.kanon.repo` | Git repository URL for a Kanon source repository containing `kanon.yaml` at the repository root. Mutually exclusive with `agentsMD`, `plugins`, `skills`, and `mcpServers`. Applied to Codex and Claude Code user-level settings before the agent starts; ignored with a Warning event for other agent types | Yes (when `kanon` is set) | +| `spec.kanon.ref` | Branch, tag, or commit to check out from the Kanon source repository | No | +| `spec.kanon.secretRef.name` | Secret containing `GITHUB_TOKEN` for cloning a private Kanon source repository | No | | `spec.plugins[].name` | Plugin name (used as directory name and namespace) | Yes (per plugin) | | `spec.plugins[].skills[].name` | Skill name (becomes `skills//SKILL.md`) | Yes (per skill) | | `spec.plugins[].skills[].content` | Skill content (markdown with frontmatter) | Yes (per skill) | diff --git a/examples/14-agentconfig-kanon/README.md b/examples/14-agentconfig-kanon/README.md new file mode 100644 index 000000000..c992ad340 --- /dev/null +++ b/examples/14-agentconfig-kanon/README.md @@ -0,0 +1,64 @@ +# 14 — AgentConfig from Kanon + +Run a Task whose reusable agent settings come from a Kanon repository. Kelos +clones the repository, then the Codex or Claude Code entrypoint runs Kanon +before starting the agent. + +## What This Demonstrates + +- Referencing `AgentConfig.spec.kanon` +- Loading user-level agent settings from a repository with `kanon.yaml` at the root +- Keeping Kanon-backed config separate from inline AgentConfig fields + +## Resources + +| File | Kind | Purpose | +|------|------|---------| +| `credentials-secret.yaml` | Secret | Codex API key for the agent | +| `agentconfig.yaml` | AgentConfig | Points Kelos at the Kanon source repository | +| `task.yaml` | Task | Runs Codex with the Kanon-backed AgentConfig | + +## Usage + +1. Edit `credentials-secret.yaml` and set `CODEX_API_KEY`. +2. Edit `agentconfig.yaml` and set `spec.kanon.repo` to your Kanon repository. + Set `spec.kanon.ref` to a branch, tag, or commit. +3. Apply the resources: + +```bash +kubectl apply -f examples/14-agentconfig-kanon/ +``` + +4. Watch the Task: + +```bash +kubectl get tasks -w +``` + +5. Stream the logs: + +```bash +kelos logs kanon-configured-task -f +``` + +## Private Repositories + +For a private Kanon repository, create a Secret with a `GITHUB_TOKEN` key and +reference it from `spec.kanon.secretRef.name`. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: kanon-github-token +type: Opaque +stringData: + GITHUB_TOKEN: "ghp_REPLACE-ME" +``` + +## Notes + +- `spec.kanon` is supported for Codex and Claude Code Tasks. +- `spec.kanon` cannot be combined with inline `agentsMD`, `plugins`, `skills`, + or `mcpServers` fields. +- A Task can resolve at most one Kanon-backed AgentConfig. diff --git a/examples/14-agentconfig-kanon/agentconfig.yaml b/examples/14-agentconfig-kanon/agentconfig.yaml new file mode 100644 index 000000000..7c54bfe80 --- /dev/null +++ b/examples/14-agentconfig-kanon/agentconfig.yaml @@ -0,0 +1,9 @@ +apiVersion: kelos.dev/v1alpha2 +kind: AgentConfig +metadata: + name: kanon-config +spec: + # The repository must contain kanon.yaml at its root. + kanon: + repo: https://github.com/example/kanon-config.git + ref: main diff --git a/examples/14-agentconfig-kanon/credentials-secret.yaml b/examples/14-agentconfig-kanon/credentials-secret.yaml new file mode 100644 index 000000000..7fb45de4d --- /dev/null +++ b/examples/14-agentconfig-kanon/credentials-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: codex-api-key +type: Opaque +stringData: + # TODO: Replace with your OpenAI API key. + CODEX_API_KEY: "sk-REPLACE-ME" diff --git a/examples/14-agentconfig-kanon/task.yaml b/examples/14-agentconfig-kanon/task.yaml new file mode 100644 index 000000000..875277ecd --- /dev/null +++ b/examples/14-agentconfig-kanon/task.yaml @@ -0,0 +1,13 @@ +apiVersion: kelos.dev/v1alpha2 +kind: Task +metadata: + name: kanon-configured-task +spec: + type: codex + prompt: "Inspect the configured project guidance and summarize the expected workflow." + credentials: + type: api-key + secretRef: + name: codex-api-key + agentConfigRefs: + - name: kanon-config diff --git a/examples/README.md b/examples/README.md index ac79737c8..e38dd86b2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ Ready-to-use patterns and YAML manifests for orchestrating AI agents with Kelos. | [11-taskspawner-linear-webhook](11-taskspawner-linear-webhook/) | Respond to Linear webhook events (issues, comments) in real time | | [12-taskspawner-file-patterns](12-taskspawner-file-patterns/) | Filter GitHub PR / push webhooks by changed-file glob patterns | | [13-taskspawner-generic-webhook](13-taskspawner-generic-webhook/) | Respond to arbitrary HTTP POST events (Sentry, Notion, Slack, etc.) using JSONPath field mapping and filters | +| [14-agentconfig-kanon](14-agentconfig-kanon/) | Source agent settings from a Kanon repository | ## Additional Guides diff --git a/internal/cli/agentconfig_compat.go b/internal/cli/agentconfig_compat.go index a5a7a5c71..57542f834 100644 --- a/internal/cli/agentconfig_compat.go +++ b/internal/cli/agentconfig_compat.go @@ -69,7 +69,7 @@ func createAgentConfig(ctx context.Context, cl client.Client, ac *kelos.AgentCon } if !agentConfigFitsV1alpha1(ac) { - return fmt.Errorf("creating agentconfig %q requires kelos.dev/v1alpha2 CRDs because MCP env valueFrom is not supported by v1alpha1; run 'kelos install' to upgrade the CRDs", ac.Name) + return fmt.Errorf("creating agentconfig %q requires kelos.dev/v1alpha2 CRDs because it uses fields not supported by v1alpha1; run 'kelos install' to upgrade the CRDs", ac.Name) } v1 := &kelosv1alpha1.AgentConfig{} if err := conversion.AgentConfigFromV1alpha2(ctx, ac, v1); err != nil { @@ -96,6 +96,9 @@ func deleteAgentConfig(ctx context.Context, cl client.Client, name, namespace st } func agentConfigFitsV1alpha1(ac *kelos.AgentConfig) bool { + if ac.Spec.Kanon != nil { + return false + } for _, server := range ac.Spec.MCPServers { for _, env := range server.Env { if env.ValueFrom != nil { diff --git a/internal/cli/agentconfig_compat_test.go b/internal/cli/agentconfig_compat_test.go index ea0bbf306..5fc6f3abc 100644 --- a/internal/cli/agentconfig_compat_test.go +++ b/internal/cli/agentconfig_compat_test.go @@ -139,6 +139,28 @@ func TestCreateAgentConfigRejectsValueFromWithoutV1alpha2(t *testing.T) { } } +func TestCreateAgentConfigRejectsKanonWithoutV1alpha2(t *testing.T) { + ctx := context.Background() + cl := agentConfigV1alpha1OnlyClient() + ac := &kelos.AgentConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "kanon", Namespace: "default"}, + Spec: kelos.AgentConfigSpec{ + Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon-config.git"}, + }, + } + + err := createAgentConfig(ctx, cl, ac) + if err == nil { + t.Fatal("createAgentConfig returned nil, want v1alpha2 requirement error") + } + if !strings.Contains(err.Error(), "requires kelos.dev/v1alpha2 CRDs") { + t.Errorf("error = %v, want v1alpha2 requirement", err) + } + if !strings.Contains(err.Error(), "kanon") { + t.Errorf("error = %v, want AgentConfig name", err) + } +} + func TestDeleteAgentConfigFallsBackToV1alpha1(t *testing.T) { ctx := context.Background() cl := agentConfigV1alpha1OnlyClient( diff --git a/internal/cli/create_agentconfig.go b/internal/cli/create_agentconfig.go index 53c1c57e3..3fbf56250 100644 --- a/internal/cli/create_agentconfig.go +++ b/internal/cli/create_agentconfig.go @@ -18,6 +18,9 @@ func newCreateAgentConfigCommand(cfg *ClientConfig) *cobra.Command { agentFlags []string mcpFlags []string skillsShFlags []string + kanonRepo string + kanonRef string + kanonSecret string dryRun bool ) @@ -32,6 +35,12 @@ func newCreateAgentConfigCommand(cfg *ClientConfig) *cobra.Command { if len(args) > 1 { return fmt.Errorf("too many arguments: expected 1 agentconfig name, got %d\nUsage: %s", len(args), cmd.Use) } + if kanonRepo == "" && (kanonRef != "" || kanonSecret != "") { + return fmt.Errorf("--kanon-repo is required when --kanon-ref or --kanon-secret is set") + } + if kanonRepo != "" && (agentsMD != "" || len(skillFlags) > 0 || len(agentFlags) > 0 || len(mcpFlags) > 0 || len(skillsShFlags) > 0) { + return fmt.Errorf("--kanon-repo cannot be combined with --agents-md, --skill, --agent, --mcp, or --skills-sh") + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -44,6 +53,16 @@ func newCreateAgentConfigCommand(cfg *ClientConfig) *cobra.Command { acSpec := kelos.AgentConfigSpec{} + if kanonRepo != "" { + acSpec.Kanon = &kelos.KanonSourceSpec{ + Repo: kanonRepo, + Ref: kanonRef, + } + if kanonSecret != "" { + acSpec.Kanon.SecretRef = &kelos.SecretReference{Name: kanonSecret} + } + } + resolvedMD, err := resolveContent(agentsMD) if err != nil { return fmt.Errorf("resolving --agents-md: %w", err) @@ -130,6 +149,9 @@ func newCreateAgentConfigCommand(cfg *ClientConfig) *cobra.Command { cmd.Flags().StringArrayVar(&agentFlags, "agent", nil, "agent definition as name=content or name=@file") cmd.Flags().StringArrayVar(&mcpFlags, "mcp", nil, "MCP server as name=JSON or name=@file (e.g. github='{\"type\":\"http\",\"url\":\"https://api.githubcopilot.com/mcp/\"}')") cmd.Flags().StringArrayVar(&skillsShFlags, "skills-sh", nil, "skills.sh package as source or source:skill (e.g. vercel-labs/agent-skills:deploy)") + cmd.Flags().StringVar(&kanonRepo, "kanon-repo", "", "Kanon source repository URL containing kanon.yaml at the repository root") + cmd.Flags().StringVar(&kanonRef, "kanon-ref", "", "branch, tag, or commit to check out from --kanon-repo") + cmd.Flags().StringVar(&kanonSecret, "kanon-secret", "", "secret containing GITHUB_TOKEN for cloning a private Kanon source repository") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the resource that would be created without submitting it") return cmd diff --git a/internal/cli/dryrun_test.go b/internal/cli/dryrun_test.go index a0e33d552..136f11137 100644 --- a/internal/cli/dryrun_test.go +++ b/internal/cli/dryrun_test.go @@ -545,6 +545,83 @@ func TestCreateAgentConfigCommand_DryRun_FileReference(t *testing.T) { } } +func TestCreateAgentConfigCommand_DryRun_Kanon(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{ + "create", "agentconfig", "kanon-ac", + "--config", cfgPath, + "--dry-run", + "--kanon-repo", "https://github.com/example/kanon-config.git", + "--kanon-ref", "abc123", + "--kanon-secret", "kanon-token", + "--namespace", "test-ns", + }) + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + if err := cmd.Execute(); err != nil { + w.Close() + os.Stdout = old + t.Fatalf("unexpected error: %v", err) + } + + w.Close() + os.Stdout = old + var out bytes.Buffer + out.ReadFrom(r) + output := out.String() + + for _, expected := range []string{ + "kind: AgentConfig", + "name: kanon-ac", + "namespace: test-ns", + "kanon:", + "repo: https://github.com/example/kanon-config.git", + "ref: abc123", + "secretRef:", + "name: kanon-token", + } { + if !strings.Contains(output, expected) { + t.Errorf("expected %q in output, got:\n%s", expected, output) + } + } +} + +func TestCreateAgentConfigCommand_DryRun_KanonRejectsInlineConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + cmd := NewRootCommand() + cmd.SilenceUsage = true + cmd.SetArgs([]string{ + "create", "agentconfig", "kanon-ac", + "--config", cfgPath, + "--dry-run", + "--kanon-repo", "https://github.com/example/kanon-config.git", + "--agents-md", "inline", + "--namespace", "test-ns", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for mixed Kanon and inline config, got nil") + } + if !strings.Contains(err.Error(), "--kanon-repo cannot be combined") { + t.Fatalf("error = %v", err) + } +} + func TestCreateAgentConfigCommand_DryRun_SkillsSh(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") diff --git a/internal/cli/printer.go b/internal/cli/printer.go index ff2e14db5..7cdcadfc2 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -327,19 +327,23 @@ func printWorkspaceDetail(w io.Writer, ws *kelos.Workspace) { func printAgentConfigTable(w io.Writer, configs []kelos.AgentConfig, allNamespaces bool) { tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) if allNamespaces { - fmt.Fprintln(tw, "NAMESPACE\tNAME\tPLUGINS\tSKILLS\tMCP SERVERS\tAGE") + fmt.Fprintln(tw, "NAMESPACE\tNAME\tKANON\tPLUGINS\tSKILLS\tMCP SERVERS\tAGE") } else { - fmt.Fprintln(tw, "NAME\tPLUGINS\tSKILLS\tMCP SERVERS\tAGE") + fmt.Fprintln(tw, "NAME\tKANON\tPLUGINS\tSKILLS\tMCP SERVERS\tAGE") } for _, ac := range configs { age := duration.HumanDuration(time.Since(ac.CreationTimestamp.Time)) + kanon := "no" + if ac.Spec.Kanon != nil { + kanon = "yes" + } plugins := fmt.Sprintf("%d", len(ac.Spec.Plugins)) skills := fmt.Sprintf("%d", len(ac.Spec.Skills)) mcpServers := fmt.Sprintf("%d", len(ac.Spec.MCPServers)) if allNamespaces { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", ac.Namespace, ac.Name, plugins, skills, mcpServers, age) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", ac.Namespace, ac.Name, kanon, plugins, skills, mcpServers, age) } else { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", ac.Name, plugins, skills, mcpServers, age) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", ac.Name, kanon, plugins, skills, mcpServers, age) } } tw.Flush() @@ -356,6 +360,16 @@ func printAgentConfigDetail(w io.Writer, ac *kelos.AgentConfig) { } printField(w, "Agents MD", md) } + if ac.Spec.Kanon != nil { + detail := ac.Spec.Kanon.Repo + if ac.Spec.Kanon.Ref != "" { + detail += " (ref=" + ac.Spec.Kanon.Ref + ")" + } + if ac.Spec.Kanon.SecretRef != nil { + detail += " (secret=" + ac.Spec.Kanon.SecretRef.Name + ")" + } + printField(w, "Kanon", detail) + } if len(ac.Spec.Plugins) > 0 { for i, p := range ac.Spec.Plugins { var parts []string diff --git a/internal/cli/printer_test.go b/internal/cli/printer_test.go index e7d3711bc..c8f00aaa7 100644 --- a/internal/cli/printer_test.go +++ b/internal/cli/printer_test.go @@ -1163,7 +1163,9 @@ func TestPrintAgentConfigTable(t *testing.T) { Name: "config-two", CreationTimestamp: metav1.NewTime(time.Now().Add(-24 * time.Hour)), }, - Spec: kelos.AgentConfigSpec{}, + Spec: kelos.AgentConfigSpec{ + Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon-config.git"}, + }, }, } @@ -1171,7 +1173,7 @@ func TestPrintAgentConfigTable(t *testing.T) { printAgentConfigTable(&buf, configs, false) output := buf.String() - for _, header := range []string{"NAME", "PLUGINS", "MCP SERVERS", "AGE"} { + for _, header := range []string{"NAME", "KANON", "PLUGINS", "MCP SERVERS", "AGE"} { if !strings.Contains(output, header) { t.Errorf("expected header %s in output, got %q", header, output) } @@ -1185,6 +1187,9 @@ func TestPrintAgentConfigTable(t *testing.T) { if !strings.Contains(output, "config-two") { t.Errorf("expected config-two in output, got %q", output) } + if !strings.Contains(output, "yes") { + t.Errorf("expected Kanon-backed config to show yes, got %q", output) + } } func TestPrintAgentConfigTableAllNamespaces(t *testing.T) { @@ -1323,6 +1328,39 @@ func TestPrintAgentConfigDetailMinimal(t *testing.T) { } } +func TestPrintAgentConfigDetailKanon(t *testing.T) { + ac := &kelos.AgentConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kanon-config", + Namespace: "default", + }, + Spec: kelos.AgentConfigSpec{ + Kanon: &kelos.KanonSourceSpec{ + Repo: "https://github.com/example/kanon-config.git", + Ref: "abc123", + SecretRef: &kelos.SecretReference{ + Name: "kanon-token", + }, + }, + }, + } + + var buf bytes.Buffer + printAgentConfigDetail(&buf, ac) + output := buf.String() + + for _, expected := range []string{ + "Kanon:", + "https://github.com/example/kanon-config.git", + "ref=abc123", + "secret=kanon-token", + } { + if !strings.Contains(output, expected) { + t.Errorf("expected %q in output, got %q", expected, output) + } + } +} + func TestPrintTaskDetailMinimal(t *testing.T) { task := &kelos.Task{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/controller/agentconfig_merge.go b/internal/controller/agentconfig_merge.go index 630ad5c91..349573049 100644 --- a/internal/controller/agentconfig_merge.go +++ b/internal/controller/agentconfig_merge.go @@ -1,11 +1,17 @@ package controller import ( + "fmt" "strings" kelos "github.com/kelos-dev/kelos/api/v1alpha2" ) +type namedAgentConfigSpec struct { + Name string + Spec kelos.AgentConfigSpec +} + // MergeAgentConfigs merges multiple AgentConfigSpecs in order. // agentsMD values are concatenated with "\n\n", plugins and skills are // appended, and mcpServers are appended with later entries winning on @@ -22,6 +28,13 @@ func MergeAgentConfigs(configs []kelos.AgentConfigSpec) *kelos.AgentConfigSpec { merged := kelos.AgentConfigSpec{} var mdParts []string + for _, c := range configs { + if c.Kanon != nil { + kanon := *c.Kanon + merged.Kanon = &kanon + } + } + for _, c := range configs { if c.AgentsMD != "" { mdParts = append(mdParts, c.AgentsMD) @@ -52,6 +65,38 @@ func MergeAgentConfigs(configs []kelos.AgentConfigSpec) *kelos.AgentConfigSpec { return &merged } +func validateAgentConfigSpecs(configs []namedAgentConfigSpec) error { + var kanonNames []string + var inlineNames []string + for _, c := range configs { + hasKanon := c.Spec.Kanon != nil + hasInline := hasInlineAgentConfig(c.Spec) + if hasKanon { + kanonNames = append(kanonNames, c.Name) + } + if hasInline { + inlineNames = append(inlineNames, c.Name) + } + if hasKanon && hasInline { + return fmt.Errorf("agentConfig %q: spec.kanon is mutually exclusive with agentsMD, plugins, skills, and mcpServers", c.Name) + } + } + if len(kanonNames) > 1 { + return fmt.Errorf("multiple Kanon AgentConfigs are not supported: %s", strings.Join(kanonNames, ", ")) + } + if len(kanonNames) == 1 && len(inlineNames) > 0 { + return fmt.Errorf("Kanon AgentConfig %q cannot be combined with inline AgentConfigs: %s", kanonNames[0], strings.Join(inlineNames, ", ")) + } + return nil +} + +func hasInlineAgentConfig(spec kelos.AgentConfigSpec) bool { + return spec.AgentsMD != "" || + len(spec.Plugins) > 0 || + len(spec.Skills) > 0 || + len(spec.MCPServers) > 0 +} + // ResolveAgentConfigRefs returns the effective list of AgentConfigReference // values from a TaskSpec. func ResolveAgentConfigRefs(spec *kelos.TaskSpec) []kelos.AgentConfigReference { diff --git a/internal/controller/agentconfig_merge_test.go b/internal/controller/agentconfig_merge_test.go index 6495b364b..9c0726258 100644 --- a/internal/controller/agentconfig_merge_test.go +++ b/internal/controller/agentconfig_merge_test.go @@ -152,6 +152,90 @@ func TestMergeAgentConfigs_MCPServersOrderPreserved(t *testing.T) { } } +func TestMergeAgentConfigs_Kanon(t *testing.T) { + configs := []kelos.AgentConfigSpec{ + {Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon.git", Ref: "main"}}, + } + got := MergeAgentConfigs(configs) + if got == nil || got.Kanon == nil { + t.Fatal("Expected merged Kanon source") + } + if got.Kanon.Repo != "https://github.com/example/kanon.git" || got.Kanon.Ref != "main" { + t.Errorf("Kanon = %+v, want repo/ref preserved", got.Kanon) + } +} + +func TestValidateAgentConfigSpecs_KanonOnly(t *testing.T) { + configs := []namedAgentConfigSpec{ + { + Name: "kanon", + Spec: kelos.AgentConfigSpec{Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon.git"}}, + }, + } + if err := validateAgentConfigSpecs(configs); err != nil { + t.Fatalf("validateAgentConfigSpecs() error = %v", err) + } +} + +func TestValidateAgentConfigSpecs_KanonWithInlineSameConfig(t *testing.T) { + configs := []namedAgentConfigSpec{ + { + Name: "mixed", + Spec: kelos.AgentConfigSpec{ + Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon.git"}, + AgentsMD: "inline", + }, + }, + } + err := validateAgentConfigSpecs(configs) + if err == nil { + t.Fatal("validateAgentConfigSpecs() error = nil, want non-nil") + } + if err.Error() != `agentConfig "mixed": spec.kanon is mutually exclusive with agentsMD, plugins, skills, and mcpServers` { + t.Fatalf("error = %v", err) + } +} + +func TestValidateAgentConfigSpecs_KanonWithInlineOtherConfig(t *testing.T) { + configs := []namedAgentConfigSpec{ + { + Name: "kanon", + Spec: kelos.AgentConfigSpec{Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon.git"}}, + }, + { + Name: "inline", + Spec: kelos.AgentConfigSpec{AgentsMD: "inline"}, + }, + } + err := validateAgentConfigSpecs(configs) + if err == nil { + t.Fatal("validateAgentConfigSpecs() error = nil, want non-nil") + } + if err.Error() != `Kanon AgentConfig "kanon" cannot be combined with inline AgentConfigs: inline` { + t.Fatalf("error = %v", err) + } +} + +func TestValidateAgentConfigSpecs_MultipleKanon(t *testing.T) { + configs := []namedAgentConfigSpec{ + { + Name: "first", + Spec: kelos.AgentConfigSpec{Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon-a.git"}}, + }, + { + Name: "second", + Spec: kelos.AgentConfigSpec{Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon-b.git"}}, + }, + } + err := validateAgentConfigSpecs(configs) + if err == nil { + t.Fatal("validateAgentConfigSpecs() error = nil, want non-nil") + } + if err.Error() != "multiple Kanon AgentConfigs are not supported: first, second" { + t.Fatalf("error = %v", err) + } +} + func TestMergeAgentConfigs_ThreeConfigs(t *testing.T) { configs := []kelos.AgentConfigSpec{ { diff --git a/internal/controller/entrypoint_test.go b/internal/controller/entrypoint_test.go index 2879d3a14..399cb46c4 100644 --- a/internal/controller/entrypoint_test.go +++ b/internal/controller/entrypoint_test.go @@ -17,6 +17,8 @@ func TestAgentEntrypointsHandleKelosEffort(t *testing.T) { parts: []string{ "KELOS_EFFORT", "model_reasoning_effort", + "KELOS_KANON_HOME", + "kanon apply --yes --home \"$KELOS_KANON_HOME\" --agent codex", }, }, { @@ -24,6 +26,8 @@ func TestAgentEntrypointsHandleKelosEffort(t *testing.T) { parts: []string{ "KELOS_EFFORT", "--effort", + "KELOS_KANON_HOME", + "kanon apply --yes --home \"$KELOS_KANON_HOME\" --agent claude", }, }, { diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index c70f0d178..62f4bf552 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -60,6 +60,22 @@ const ( // PluginMountPath is the mount path for the plugin volume. PluginMountPath = "/kelos/plugin" + // KanonSourceContainerName is the init container that clones a Kanon + // source repository for Kanon-backed AgentConfigs. + KanonSourceContainerName = "kanon-source" + + // KanonVolumeName is the name of the Kanon source volume. + KanonVolumeName = "kanon-source" + + // KanonMountPath is the mount path for the Kanon source volume. + KanonMountPath = "/kelos/kanon" + + // KanonHomePath is the path passed to kanon as --home. + KanonHomePath = KanonMountPath + "/repo" + + // KanonHomeEnvName is the environment variable read by agent entrypoints. + KanonHomeEnvName = "KELOS_KANON_HOME" + // SkillsShPluginName is the plugin directory name under PluginMountPath // that skills.sh packages are installed into, so agent entrypoints // discover them the same way as inline plugins. @@ -92,6 +108,7 @@ const ( // inside the agent container before the user-supplied agent process runs. var reservedEnvNames = map[string]struct{}{ "KELOS_SETUP_COMMAND": {}, + KanonHomeEnvName: {}, } // JobBuilder constructs Kubernetes Jobs for Tasks. @@ -229,6 +246,10 @@ func ptr[T any](v T) *T { return &v } +func supportsKanonAgentType(agentType string) bool { + return agentType == AgentTypeCodex || agentType == AgentTypeClaudeCode +} + func effectiveWorkspaceRemotes(workspace *kelos.WorkspaceSpec) []kelos.GitRemote { if workspace == nil { return nil @@ -530,6 +551,31 @@ func (b *JobBuilder) buildAgentJob(task *kelos.Task, workspace *kelos.WorkspaceS // Inject AgentConfig: agentsMD env var and plugin volume/init container. if agentConfig != nil { + if agentConfig.Kanon != nil && supportsKanonAgentType(task.Spec.Type) { + volumes = append(volumes, corev1.Volume{ + Name: KanonVolumeName, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }) + kanonVolumeMount := corev1.VolumeMount{Name: KanonVolumeName, MountPath: KanonMountPath} + mainContainer.VolumeMounts = append(mainContainer.VolumeMounts, kanonVolumeMount) + mainContainer.Env = append(mainContainer.Env, corev1.EnvVar{ + Name: KanonHomeEnvName, + Value: KanonHomePath, + }) + initContainer, err := buildKanonSourceInitContainer(agentConfig.Kanon, agentUID, kanonVolumeMount) + if err != nil { + return nil, err + } + initContainers = append(initContainers, initContainer) + if podSecurityContext == nil { + podSecurityContext = &corev1.PodSecurityContext{FSGroup: &agentUID} + } else if podSecurityContext.FSGroup == nil { + merged := podSecurityContext.DeepCopy() + merged.FSGroup = &agentUID + podSecurityContext = merged + } + } + if agentConfig.AgentsMD != "" { mainContainer.Env = append(mainContainer.Env, corev1.EnvVar{ Name: "KELOS_AGENTS_MD", @@ -801,6 +847,59 @@ func buildWorkspaceFileInjectionScript(files []kelos.WorkspaceFile) (string, err return strings.Join(lines, "\n"), nil } +func buildKanonSourceInitContainer(kanon *kelos.KanonSourceSpec, agentUID int64, volumeMount corev1.VolumeMount) (corev1.Container, error) { + if kanon == nil { + return corev1.Container{}, fmt.Errorf("kanon source is nil") + } + if strings.TrimSpace(kanon.Repo) == "" { + return corev1.Container{}, fmt.Errorf("kanon.repo must not be empty") + } + + gitCommand := "git" + env := []corev1.EnvVar(nil) + if kanon.SecretRef != nil { + env = append(env, corev1.EnvVar{ + Name: "GITHUB_TOKEN", + ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: kanon.SecretRef.Name}, + Key: "GITHUB_TOKEN", + }}, + }) + credentialHelper := `!f() { echo "username=x-access-token"; echo "password=$GITHUB_TOKEN"; }; f` + gitCommand = fmt.Sprintf("git -c credential.helper= -c credential.helper=%s", shellQuote(credentialHelper)) + } + + var script string + if kanon.Ref == "" { + script = fmt.Sprintf( + "set -eu\n%s clone --depth 1 -- %s %s", + gitCommand, + shellQuote(kanon.Repo), + shellQuote(KanonHomePath), + ) + } else { + script = fmt.Sprintf( + "set -eu\n%s clone --no-checkout -- %s %s\ngit -C %s checkout --detach %s", + gitCommand, + shellQuote(kanon.Repo), + shellQuote(KanonHomePath), + shellQuote(KanonHomePath), + shellQuote(kanon.Ref), + ) + } + + return corev1.Container{ + Name: KanonSourceContainerName, + Image: GitCloneImage, + Command: []string{"sh", "-c", script}, + Env: env, + VolumeMounts: []corev1.VolumeMount{volumeMount}, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &agentUID, + }, + }, nil +} + func sanitizeWorkspaceFilePath(filePath string) (string, error) { if strings.TrimSpace(filePath) == "" { return "", fmt.Errorf("path is empty") @@ -844,6 +943,7 @@ func validatePodFailurePolicy(policy *batchv1.PodFailurePolicy) error { // or the Kelos-reserved volume prefix. var reservedVolumeNames = map[string]struct{}{ WorkspaceVolumeName: {}, + KanonVolumeName: {}, } // validateUserVolumes ensures no user-supplied volume name collides with @@ -872,12 +972,13 @@ func validateUserVolumes(volumes []corev1.Volume) error { // here, so agent-type literals (claude-code, codex, gemini, opencode, // cursor) are free for user-supplied containers. var reservedContainerNames = map[string]struct{}{ - "git-clone": {}, - "remote-setup": {}, - "branch-setup": {}, - "workspace-files": {}, - "plugin-setup": {}, - "skills-install": {}, + "git-clone": {}, + "remote-setup": {}, + "branch-setup": {}, + "workspace-files": {}, + "plugin-setup": {}, + "skills-install": {}, + KanonSourceContainerName: {}, } // validateExtraContainers ensures no user-supplied container name carries the diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index 7ea37ae39..9dcd03dfe 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -536,6 +536,40 @@ func TestBuildAgentJob_SetupCommandPodOverrideIsDroppedWhenWorkspaceUnset(t *tes } } +func TestBuildAgentJob_KanonHomePodOverrideIsDropped(t *testing.T) { + builder := NewJobBuilder() + task := &kelos.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kanon-env-override", + Namespace: "default", + }, + Spec: kelos.TaskSpec{ + Type: AgentTypeClaudeCode, + Prompt: "Hello", + Credentials: kelos.Credentials{ + Type: kelos.CredentialTypeAPIKey, + SecretRef: &kelos.SecretReference{Name: "my-secret"}, + }, + PodOverrides: &kelos.PodOverrides{ + Env: []corev1.EnvVar{ + {Name: KanonHomeEnvName, Value: "/tmp/attacker"}, + }, + }, + }, + } + + job, err := builder.Build(task, nil, nil, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + for _, env := range job.Spec.Template.Spec.Containers[0].Env { + if env.Name == KanonHomeEnvName { + t.Fatalf("%s must be dropped from PodOverrides.Env when spec.kanon is unset (got %q)", KanonHomeEnvName, env.Value) + } + } +} + func TestBuildClaudeCodeJob_CustomImageWithWorkspace(t *testing.T) { builder := NewJobBuilder() task := &kelos.Task{ @@ -2987,6 +3021,124 @@ func TestBuildJob_AgentConfigWithoutWorkspace(t *testing.T) { } } +func TestBuildJob_AgentConfigKanonCodex(t *testing.T) { + builder := NewJobBuilder() + task := &kelos.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kanon-codex", + Namespace: "default", + }, + Spec: kelos.TaskSpec{ + Type: AgentTypeCodex, + Prompt: "Fix issue", + Credentials: kelos.Credentials{ + Type: kelos.CredentialTypeAPIKey, + SecretRef: &kelos.SecretReference{Name: "my-secret"}, + }, + }, + } + + agentConfig := &kelos.AgentConfigSpec{ + Kanon: &kelos.KanonSourceSpec{ + Repo: "https://github.com/example/kanon.git", + Ref: "abc123", + SecretRef: &kelos.SecretReference{ + Name: "kanon-token", + }, + }, + } + + job, err := builder.Build(task, nil, agentConfig, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + if len(job.Spec.Template.Spec.Volumes) != 1 { + t.Fatalf("Expected 1 Kanon volume, got %d", len(job.Spec.Template.Spec.Volumes)) + } + if job.Spec.Template.Spec.Volumes[0].Name != KanonVolumeName { + t.Fatalf("Volume name = %q, want %q", job.Spec.Template.Spec.Volumes[0].Name, KanonVolumeName) + } + if job.Spec.Template.Spec.SecurityContext == nil || job.Spec.Template.Spec.SecurityContext.FSGroup == nil { + t.Fatal("Expected pod SecurityContext.FSGroup for Kanon source volume") + } + if *job.Spec.Template.Spec.SecurityContext.FSGroup != AgentUID { + t.Fatalf("FSGroup = %d, want %d", *job.Spec.Template.Spec.SecurityContext.FSGroup, AgentUID) + } + + if len(job.Spec.Template.Spec.InitContainers) != 1 { + t.Fatalf("Expected 1 Kanon init container, got %d", len(job.Spec.Template.Spec.InitContainers)) + } + initContainer := job.Spec.Template.Spec.InitContainers[0] + if initContainer.Name != KanonSourceContainerName { + t.Fatalf("Init container name = %q, want %q", initContainer.Name, KanonSourceContainerName) + } + if !strings.Contains(initContainer.Command[2], "git -C '/kelos/kanon/repo' checkout --detach 'abc123'") { + t.Errorf("Expected Kanon ref checkout in init script, got: %s", initContainer.Command[2]) + } + if len(initContainer.Env) != 1 || initContainer.Env[0].Name != "GITHUB_TOKEN" || initContainer.Env[0].ValueFrom.SecretKeyRef.Name != "kanon-token" { + t.Errorf("Expected GITHUB_TOKEN from kanon-token, got %#v", initContainer.Env) + } + + container := job.Spec.Template.Spec.Containers[0] + foundMount := false + for _, mount := range container.VolumeMounts { + if mount.Name == KanonVolumeName && mount.MountPath == KanonMountPath { + foundMount = true + } + } + if !foundMount { + t.Fatalf("Expected Kanon volume mount on main container, got %#v", container.VolumeMounts) + } + envMap := map[string]string{} + for _, env := range container.Env { + if env.Value != "" { + envMap[env.Name] = env.Value + } + } + if envMap[KanonHomeEnvName] != KanonHomePath { + t.Fatalf("%s = %q, want %q", KanonHomeEnvName, envMap[KanonHomeEnvName], KanonHomePath) + } +} + +func TestBuildJob_AgentConfigKanonUnsupportedAgentIgnored(t *testing.T) { + builder := NewJobBuilder() + task := &kelos.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-kanon-gemini", + Namespace: "default", + }, + Spec: kelos.TaskSpec{ + Type: AgentTypeGemini, + Prompt: "Fix issue", + Credentials: kelos.Credentials{ + Type: kelos.CredentialTypeAPIKey, + SecretRef: &kelos.SecretReference{Name: "my-secret"}, + }, + }, + } + agentConfig := &kelos.AgentConfigSpec{ + Kanon: &kelos.KanonSourceSpec{Repo: "https://github.com/example/kanon.git"}, + } + + job, err := builder.Build(task, nil, agentConfig, task.Spec.Prompt) + if err != nil { + t.Fatalf("Build() returned error: %v", err) + } + + if len(job.Spec.Template.Spec.InitContainers) != 0 { + t.Fatalf("Expected no Kanon init container for unsupported agent, got %#v", job.Spec.Template.Spec.InitContainers) + } + if len(job.Spec.Template.Spec.Volumes) != 0 { + t.Fatalf("Expected no Kanon volume for unsupported agent, got %#v", job.Spec.Template.Spec.Volumes) + } + for _, env := range job.Spec.Template.Spec.Containers[0].Env { + if env.Name == KanonHomeEnvName { + t.Fatalf("%s should not be set for unsupported agent", KanonHomeEnvName) + } + } +} + func TestBuildJob_AgentConfigCodex(t *testing.T) { builder := NewJobBuilder() task := &kelos.Task{ @@ -5076,6 +5228,10 @@ func TestBuildJob_PodOverridesVolumes_ReservedNameRejected(t *testing.T) { name: WorkspaceVolumeName, wantError: "Kelos-reserved volume name", }, + { + name: KanonVolumeName, + wantError: "Kelos-reserved volume name", + }, { name: PluginVolumeName, wantError: "reserved \"kelos-\" volume name prefix", @@ -5338,7 +5494,7 @@ func TestBuildJob_PodOverridesContainerSecurityContext(t *testing.T) { func TestBuildJob_ExtraContainers_ReservedNameRejected(t *testing.T) { builder := NewJobBuilder() - for _, name := range []string{"kelos-agent", "kelos-foo", "git-clone", "remote-setup", "branch-setup", "workspace-files", "plugin-setup", "skills-install"} { + for _, name := range []string{"kelos-agent", "kelos-foo", "git-clone", "remote-setup", "branch-setup", "workspace-files", "plugin-setup", "skills-install", "kanon-source"} { t.Run(name, func(t *testing.T) { task := &kelos.Task{ ObjectMeta: metav1.ObjectMeta{ @@ -5535,7 +5691,7 @@ func TestBuildJob_ExtraInitContainers_AppendedAfterBuiltins(t *testing.T) { func TestBuildJob_ExtraInitContainers_ReservedNameRejected(t *testing.T) { builder := NewJobBuilder() - for _, name := range []string{"kelos-agent", "kelos-bar", "git-clone", "plugin-setup", "skills-install"} { + for _, name := range []string{"kelos-agent", "kelos-bar", "git-clone", "plugin-setup", "skills-install", "kanon-source"} { t.Run(name, func(t *testing.T) { task := &kelos.Task{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/controller/task_controller.go b/internal/controller/task_controller.go index a2a1600a1..b5f043122 100644 --- a/internal/controller/task_controller.go +++ b/internal/controller/task_controller.go @@ -247,6 +247,7 @@ func (r *TaskReconciler) createJob(ctx context.Context, task *kelos.Task) (ctrl. var agentConfig *kelos.AgentConfigSpec if refs := ResolveAgentConfigRefs(&task.Spec); len(refs) > 0 { var specs []kelos.AgentConfigSpec + var namedSpecs []namedAgentConfigSpec for _, ref := range refs { ac, err := r.getAgentConfig(ctx, client.ObjectKey{ Namespace: task.Namespace, @@ -261,10 +262,34 @@ func (r *TaskReconciler) createJob(ctx context.Context, task *kelos.Task) (ctrl. return ctrl.Result{}, err } specs = append(specs, ac.Spec) + namedSpecs = append(namedSpecs, namedAgentConfigSpec{Name: ref.Name, Spec: ac.Spec}) + } + + if err := validateAgentConfigSpecs(namedSpecs); err != nil { + logger.Error(err, "Invalid AgentConfig combination") + r.recordEvent(task, corev1.EventTypeWarning, "AgentConfigInvalid", "Invalid AgentConfig: %v", err) + updateErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if getErr := r.Get(ctx, client.ObjectKeyFromObject(task), task); getErr != nil { + return getErr + } + task.Status.Phase = kelos.TaskPhaseFailed + task.Status.Message = fmt.Sprintf("Invalid AgentConfig: %v", err) + return r.Status().Update(ctx, task) + }) + if updateErr != nil { + logger.Error(updateErr, "Unable to update Task status") + } + return ctrl.Result{}, nil } agentConfig = MergeAgentConfigs(specs) + if agentConfig.Kanon != nil && !supportsKanonAgentType(task.Spec.Type) { + err := fmt.Errorf("Kanon does not support agent type %q", task.Spec.Type) + logger.Error(err, "Kanon config ignored", "agentType", task.Spec.Type) + r.recordEvent(task, corev1.EventTypeWarning, "KanonConfigIgnored", "Kanon config ignored for unsupported agent type %s", task.Spec.Type) + } + if len(agentConfig.MCPServers) > 0 { resolved, err := r.resolveMCPServerSecrets(ctx, task.Namespace, agentConfig.MCPServers) if err != nil { diff --git a/internal/manifests/charts/kelos/templates/crds/agentconfig-crd.yaml b/internal/manifests/charts/kelos/templates/crds/agentconfig-crd.yaml index f8787c6aa..46f0d84a4 100644 --- a/internal/manifests/charts/kelos/templates/crds/agentconfig-crd.yaml +++ b/internal/manifests/charts/kelos/templates/crds/agentconfig-crd.yaml @@ -271,6 +271,37 @@ spec: (e.g., ~/.claude/CLAUDE.md for Claude Code). This is additive and does not overwrite the repo's own instruction files. type: string + kanon: + description: |- + Kanon references a Kanon source repository. When set, the Task pod + clones the repository and applies its root kanon.yaml to the agent's + user-level configuration before the agent starts. + Mutually exclusive with AgentsMD, Plugins, Skills, and MCPServers. + properties: + ref: + description: Ref is an optional branch, tag, or commit to check + out. + type: string + repo: + description: Repo is the Git repository URL containing kanon.yaml + at its root. + minLength: 1 + type: string + secretRef: + description: |- + SecretRef references a Secret containing GITHUB_TOKEN for cloning + private Kanon source repositories. + properties: + name: + description: Name is the name of the secret. + minLength: 1 + type: string + required: + - name + type: object + required: + - repo + type: object mcpServers: description: |- MCPServers defines MCP (Model Context Protocol) servers to make @@ -625,6 +656,13 @@ spec: type: object type: array type: object + x-kubernetes-validations: + - message: kanon is mutually exclusive with agentsMD, plugins, skills, + and mcpServers + rule: '!has(self.kanon) || ((!has(self.agentsMD) || size(self.agentsMD) + == 0) && (!has(self.plugins) || size(self.plugins) == 0) && (!has(self.skills) + || size(self.skills) == 0) && (!has(self.mcpServers) || size(self.mcpServers) + == 0))' type: object served: true storage: true diff --git a/internal/manifests/charts/kelos/templates/crds/task-crd.yaml b/internal/manifests/charts/kelos/templates/crds/task-crd.yaml index 9b77a3877..f33c70664 100644 --- a/internal/manifests/charts/kelos/templates/crds/task-crd.yaml +++ b/internal/manifests/charts/kelos/templates/crds/task-crd.yaml @@ -7073,7 +7073,9 @@ spec: AgentConfigRefs references an ordered list of AgentConfig resources. Configs are merged in order: agentsMD is concatenated, plugins/skills are appended, mcpServers are appended with later entries winning on - name collision. + name collision. At most one referenced AgentConfig may set spec.kanon, + and Kanon-backed configs cannot be combined with inline AgentConfig + fields from the same or another referenced config. items: description: AgentConfigReference refers to an AgentConfig resource by name. @@ -8561,7 +8563,8 @@ spec: ContainerSecurityContext fields. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -10103,16 +10106,16 @@ spec: prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) extraInitContainers: description: |- ExtraInitContainers is a list of additional init containers to run in the agent pod. They are placed after all Kelos built-in init containers (git-clone, remote-setup, branch-setup, workspace-files, - plugin-setup, skills-install), ensuring the workspace is ready - before they start. Set restartPolicy: Always for sidecar semantics - (long-running services like databases) or leave it unset for - one-shot init tasks. + plugin-setup, skills-install, kanon-source), ensuring the workspace + and AgentConfig sources are ready before they start. Set + restartPolicy: Always for sidecar semantics (long-running services + like databases) or leave it unset for one-shot init tasks. Containers can mount user-supplied volumes from the Volumes field as well as Kelos-managed volumes (workspace, kelos-plugin). Note that the workspace volume uses FSGroup-based permissions; containers @@ -10120,7 +10123,8 @@ spec: access to the workspace. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -11662,7 +11666,7 @@ spec: 'kelos-' prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) imagePullSecrets: description: |- ImagePullSecrets specifies secrets used to pull container images diff --git a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml index b4f037a5c..5cebc0108 100644 --- a/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml +++ b/internal/manifests/charts/kelos/templates/crds/taskspawner-crd.yaml @@ -8223,7 +8223,9 @@ spec: AgentConfigRefs references an ordered list of AgentConfig resources. Configs are merged in order: agentsMD is concatenated, plugins/skills are appended, mcpServers are appended with later entries winning on - name collision. + name collision. At most one referenced AgentConfig may set spec.kanon, + and Kanon-backed configs cannot be combined with inline AgentConfig + fields from the same or another referenced config. When set, spawned Tasks inherit this agent config reference list. items: description: AgentConfigReference refers to an AgentConfig resource @@ -9898,7 +9900,8 @@ spec: ContainerSecurityContext fields. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -11446,16 +11449,16 @@ spec: 'kelos-' prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) extraInitContainers: description: |- ExtraInitContainers is a list of additional init containers to run in the agent pod. They are placed after all Kelos built-in init containers (git-clone, remote-setup, branch-setup, workspace-files, - plugin-setup, skills-install), ensuring the workspace is ready - before they start. Set restartPolicy: Always for sidecar semantics - (long-running services like databases) or leave it unset for - one-shot init tasks. + plugin-setup, skills-install, kanon-source), ensuring the workspace + and AgentConfig sources are ready before they start. Set + restartPolicy: Always for sidecar semantics (long-running services + like databases) or leave it unset for one-shot init tasks. Containers can mount user-supplied volumes from the Volumes field as well as Kelos-managed volumes (workspace, kelos-plugin). Note that the workspace volume uses FSGroup-based permissions; containers @@ -11463,7 +11466,8 @@ spec: access to the workspace. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -13011,7 +13015,7 @@ spec: 'kelos-' prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) imagePullSecrets: description: |- ImagePullSecrets specifies secrets used to pull container images diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 9965c070e..dfa7655f2 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -268,6 +268,37 @@ spec: (e.g., ~/.claude/CLAUDE.md for Claude Code). This is additive and does not overwrite the repo's own instruction files. type: string + kanon: + description: |- + Kanon references a Kanon source repository. When set, the Task pod + clones the repository and applies its root kanon.yaml to the agent's + user-level configuration before the agent starts. + Mutually exclusive with AgentsMD, Plugins, Skills, and MCPServers. + properties: + ref: + description: Ref is an optional branch, tag, or commit to check + out. + type: string + repo: + description: Repo is the Git repository URL containing kanon.yaml + at its root. + minLength: 1 + type: string + secretRef: + description: |- + SecretRef references a Secret containing GITHUB_TOKEN for cloning + private Kanon source repositories. + properties: + name: + description: Name is the name of the secret. + minLength: 1 + type: string + required: + - name + type: object + required: + - repo + type: object mcpServers: description: |- MCPServers defines MCP (Model Context Protocol) servers to make @@ -622,6 +653,13 @@ spec: type: object type: array type: object + x-kubernetes-validations: + - message: kanon is mutually exclusive with agentsMD, plugins, skills, + and mcpServers + rule: '!has(self.kanon) || ((!has(self.agentsMD) || size(self.agentsMD) + == 0) && (!has(self.plugins) || size(self.plugins) == 0) && (!has(self.skills) + || size(self.skills) == 0) && (!has(self.mcpServers) || size(self.mcpServers) + == 0))' type: object served: true storage: true @@ -7697,7 +7735,9 @@ spec: AgentConfigRefs references an ordered list of AgentConfig resources. Configs are merged in order: agentsMD is concatenated, plugins/skills are appended, mcpServers are appended with later entries winning on - name collision. + name collision. At most one referenced AgentConfig may set spec.kanon, + and Kanon-backed configs cannot be combined with inline AgentConfig + fields from the same or another referenced config. items: description: AgentConfigReference refers to an AgentConfig resource by name. @@ -9185,7 +9225,8 @@ spec: ContainerSecurityContext fields. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -10727,16 +10768,16 @@ spec: prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) extraInitContainers: description: |- ExtraInitContainers is a list of additional init containers to run in the agent pod. They are placed after all Kelos built-in init containers (git-clone, remote-setup, branch-setup, workspace-files, - plugin-setup, skills-install), ensuring the workspace is ready - before they start. Set restartPolicy: Always for sidecar semantics - (long-running services like databases) or leave it unset for - one-shot init tasks. + plugin-setup, skills-install, kanon-source), ensuring the workspace + and AgentConfig sources are ready before they start. Set + restartPolicy: Always for sidecar semantics (long-running services + like databases) or leave it unset for one-shot init tasks. Containers can mount user-supplied volumes from the Volumes field as well as Kelos-managed volumes (workspace, kelos-plugin). Note that the workspace volume uses FSGroup-based permissions; containers @@ -10744,7 +10785,8 @@ spec: access to the workspace. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -12286,7 +12328,7 @@ spec: 'kelos-' prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) imagePullSecrets: description: |- ImagePullSecrets specifies secrets used to pull container images @@ -22966,7 +23008,9 @@ spec: AgentConfigRefs references an ordered list of AgentConfig resources. Configs are merged in order: agentsMD is concatenated, plugins/skills are appended, mcpServers are appended with later entries winning on - name collision. + name collision. At most one referenced AgentConfig may set spec.kanon, + and Kanon-backed configs cannot be combined with inline AgentConfig + fields from the same or another referenced config. When set, spawned Tasks inherit this agent config reference list. items: description: AgentConfigReference refers to an AgentConfig resource @@ -24641,7 +24685,8 @@ spec: ContainerSecurityContext fields. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -26189,16 +26234,16 @@ spec: 'kelos-' prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) extraInitContainers: description: |- ExtraInitContainers is a list of additional init containers to run in the agent pod. They are placed after all Kelos built-in init containers (git-clone, remote-setup, branch-setup, workspace-files, - plugin-setup, skills-install), ensuring the workspace is ready - before they start. Set restartPolicy: Always for sidecar semantics - (long-running services like databases) or leave it unset for - one-shot init tasks. + plugin-setup, skills-install, kanon-source), ensuring the workspace + and AgentConfig sources are ready before they start. Set + restartPolicy: Always for sidecar semantics (long-running services + like databases) or leave it unset for one-shot init tasks. Containers can mount user-supplied volumes from the Volumes field as well as Kelos-managed volumes (workspace, kelos-plugin). Note that the workspace volume uses FSGroup-based permissions; containers @@ -26206,7 +26251,8 @@ spec: access to the workspace. Container names must not use the Kelos-reserved "kelos-" prefix or collide with a built-in init container name: git-clone, remote-setup, - branch-setup, workspace-files, plugin-setup, skills-install. + branch-setup, workspace-files, plugin-setup, skills-install, + kanon-source. items: description: A single application container that you want to run within a pod. @@ -27754,7 +27800,7 @@ spec: 'kelos-' prefix or a built-in init container name rule: self.all(c, !c.name.startsWith('kelos-') && !(c.name in ['git-clone', 'remote-setup', 'branch-setup', 'workspace-files', - 'plugin-setup', 'skills-install'])) + 'plugin-setup', 'skills-install', 'kanon-source'])) imagePullSecrets: description: |- ImagePullSecrets specifies secrets used to pull container images