From 92327b334704d32bd6083b3fe67a9a3924ce4e1d Mon Sep 17 00:00:00 2001 From: Will Drach Date: Fri, 5 Jun 2026 07:11:25 -0600 Subject: [PATCH 01/10] feat(api-rs): serve tools + gerard overlay to agent sandboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api-rs sandboxes had no tools and no overlay. Give api-rs-spawned agents the same base + overlay tools and overlay system-prompt the chart already wires for the api-rs pod, using upstream's CLI-shim tool model rather than a sidecar. Upstream direction: tools are shell CLI shims, not an HTTP registry. The agent image's install-tool-shims (services/sandbox/install_tool_shims.py) scans TOOL_DIRS at entrypoint and `uvx`-installs each pyproject [project.scripts] as a CLI; the SYSTEM_PROMPT points agents at those CLIs and `centaur-tools list`. The old `call ` HTTP registry is deprecated to control-plane-only. Tool secrets are already handled upstream: codex_app_server_env_template pushes the tool placeholder creds onto the agent env, iron-control grants the per-sandbox principal the real secrets, and Postgres rides proxied `*_DSN` env from apply_proxy_env. So the agent needs only the tool SOURCES at the right paths — no sidecar, no HMAC sandbox token, no loopback tool server. - tools.rs (replaces tool_server.rs): a `tools-bootstrap` init container copies /app/tools out of the shared centaur-api image into an emptyDir mounted at /app/tools in the agent, and an `overlay-bootstrap` init container copies the org overlay tree into overlay-root mounted at overlay.mountPath (the same path the api-rs Deployment uses) and stages the overlay's SYSTEM_PROMPT.md as $HOME/AGENTS_OVERLAY.md, which the sandbox entrypoint appends to the base prompt. TOOL_DIRS is set on the agent env to /app/tools (or /app/tools:/tools with the overlay) — identical to the value the api-rs pod computes for its own tool discovery, set deterministically in the spec builder rather than via passthrough env. - lib.rs: build_agent_sandbox layers the tools/overlay env over spec.env, mounts the bootstrapped sources read-only into the agent, and appends the tools-bootstrap + overlay-bootstrap init containers and their volumes. No sidecar container, no token minting. - args.rs: a minimal ToolsArgs (source image/pull-policy, reusing the KUBERNETES_TOOL_SERVER_IMAGE* env the chart sets from the shared api image) and OverlayArgs (image/pull-policy/source-path/mount-path) wired into AgentSandboxConfig. Explicit clap arg ids avoid id collisions with the other flattened arg structs. - chart apirs.yaml: render the tools source image (api.image.*, gated on toolServer.enabled) and overlay (overlay.*) onto the api-rs env, replacing the KUBERNETES_TOOL_SERVER_* sidecar block. Gone vs the sidecar port: tool_server.rs, the sbx1 HMAC token minting and its SANDBOX_SIGNING_KEY requirement, CENTAUR_TOOLS_URL, the sidecar pg-DSN/proxy-env collection, and the hmac/base64/sha2 dependency additions (nothing else in the agent-k8s crate uses them). Warm-pool sandboxes route through the same build_agent_sandbox path, so they get the tools/overlay init containers and volumes for free. Co-Authored-By: Claude Opus 4.8 (1M context) --- contrib/chart/templates/apirs.yaml | 22 ++ .../crates/centaur-api-server/src/args.rs | 112 ++++++- .../centaur-sandbox-agent-k8s/src/lib.rs | 79 ++++- .../centaur-sandbox-agent-k8s/src/tools.rs | 308 ++++++++++++++++++ 4 files changed, 512 insertions(+), 9 deletions(-) create mode 100644 services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 6a58b3684..23fd41251 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -235,6 +235,28 @@ spec: name: {{ include "centaur.secretEnvName" . }} key: {{ printf "%sIRON_CONTROL_INITIAL_API_KEY" .Values.secretManager.envPrefix }} {{- end }} +{{- if .Values.toolServer.enabled }} + # Base tools source image (the shared api image). A tools-bootstrap + # init container copies its /app/tools into each sandbox so the agent + # installs the same tool CLI shims api-rs discovers, at the same path. + - name: KUBERNETES_TOOL_SERVER_IMAGE + value: {{ printf "%s:%s" .Values.api.image.repository .Values.api.image.tag | quote }} + - name: KUBERNETES_TOOL_SERVER_IMAGE_PULL_POLICY + value: {{ .Values.api.image.pullPolicy | quote }} +{{- end }} +{{- if .Values.overlay.image.repository }} + # Org overlay (tools/workflows/skills/system-prompt) mounted into + # api-rs sandboxes at overlay.mountPath via an overlay-bootstrap init + # container — the same path api-rs's own TOOL_DIRS points at. + - name: CENTAUR_OVERLAY_IMAGE + value: {{ printf "%s:%s" .Values.overlay.image.repository .Values.overlay.image.tag | quote }} + - name: CENTAUR_OVERLAY_IMAGE_PULL_POLICY + value: {{ .Values.overlay.image.pullPolicy | quote }} + - name: CENTAUR_OVERLAY_IMAGE_SOURCE_PATH + value: {{ .Values.overlay.image.sourcePath | quote }} + - name: CENTAUR_OVERLAY_MOUNT_PATH + value: {{ .Values.overlay.mountPath | quote }} +{{- end }} {{- range $name, $value := .Values.apiRs.extraEnv }} - name: {{ $name }} value: {{ $value | quote }} diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 81f34d603..2f8dafd64 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -15,7 +15,8 @@ use centaur_iron_proxy::{ ProxyFragment, SourceKind, SourcePolicy, harness_auth_fragment, infra_fragment, }; use centaur_sandbox_agent_k8s::{ - AgentSandboxBackend, AgentSandboxConfig, IronControlSettings, IronProxyConfig, + AgentSandboxBackend, AgentSandboxConfig, IronControlSettings, IronProxyConfig, OverlayConfig, + ToolsConfig, }; use centaur_sandbox_core::{Mount, MountKind, OverlayImage, SandboxSpec}; use centaur_sandbox_local::LocalSandboxBackend; @@ -223,6 +224,10 @@ struct SandboxArgs { workflow_host_image: Option, #[arg(long = "workflow-host-command", env = "WORKFLOW_HOST_COMMAND")] workflow_host_command: Option, + #[command(flatten)] + tools_source: ToolsArgs, + #[command(flatten)] + overlay: OverlayArgs, } impl SandboxArgs { @@ -668,6 +673,8 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig { proxy.fragments = fragments; } config.iron_control = args.iron_control.settings(); + config.tools = args.tools_source.to_config(); + config.overlay = args.overlay.to_config(); // iron-control is the only proxy mode: a per-sandbox proxy syncs its // secrets from the control plane, so configuring iron-proxy without // iron-control would produce a non-functional proxy. Fail fast. @@ -681,6 +688,85 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig { } } +#[derive(Debug, ClapArgs)] +struct ToolsArgs { + // Explicit `id`s avoid clap arg-id collisions with the other flattened + // structs (IronProxyArgs/OverlayArgs also carry `image`/`image_pull_policy`). + // The source image (the shared `centaur-api` image) carries `/app/tools`, + // which a `tools-bootstrap` init container copies into each sandbox. + #[arg( + id = "tools_source_image", + long = "kubernetes-tool-server-image", + env = "KUBERNETES_TOOL_SERVER_IMAGE" + )] + image: Option, + #[arg( + id = "tools_source_image_pull_policy", + long = "kubernetes-tool-server-image-pull-policy", + env = "KUBERNETES_TOOL_SERVER_IMAGE_PULL_POLICY" + )] + image_pull_policy: Option, +} + +impl ToolsArgs { + /// `None` when no source image is configured (tools disabled). + fn to_config(&self) -> Option { + let image = clean_optional_value(self.image.as_deref())?; + let mut config = ToolsConfig::new(image); + config.image_pull_policy = self.image_pull_policy.clone(); + Some(config) + } +} + +#[derive(Debug, ClapArgs)] +struct OverlayArgs { + #[arg( + id = "overlay_image", + long = "centaur-overlay-image", + env = "CENTAUR_OVERLAY_IMAGE" + )] + image: Option, + #[arg( + id = "overlay_image_pull_policy", + long = "centaur-overlay-image-pull-policy", + env = "CENTAUR_OVERLAY_IMAGE_PULL_POLICY" + )] + image_pull_policy: Option, + #[arg( + id = "overlay_image_source_path", + long = "centaur-overlay-image-source-path", + env = "CENTAUR_OVERLAY_IMAGE_SOURCE_PATH", + default_value = "/overlay" + )] + source_path: String, + // The overlay tree mounts at the same path the api-rs pod uses + // (`overlay.mountPath`), so the agent's `/tools` matches the path + // api-rs discovered tools at. + #[arg( + id = "overlay_mount_path", + long = "centaur-overlay-mount-path", + env = "CENTAUR_OVERLAY_MOUNT_PATH", + default_value = "/app/overlay/org" + )] + mount_path: String, +} + +impl OverlayArgs { + /// `None` when no overlay image is configured (overlay disabled). + fn to_config(&self) -> Option { + let image = clean_optional_value(self.image.as_deref())?; + let mut config = OverlayConfig::new(image); + config.image_pull_policy = self.image_pull_policy.clone(); + if let Some(path) = clean_optional_value(Some(self.source_path.as_str())) { + config.source_path = path; + } + if let Some(path) = clean_optional_value(Some(self.mount_path.as_str())) { + config.mount_path = path; + } + Some(config) + } +} + #[derive(Debug, ClapArgs)] struct IronProxyArgs { #[arg( @@ -1170,6 +1256,30 @@ mod tests { assert!(config.iron_proxy.is_none()); } + #[test] + fn tools_and_overlay_config_read_from_flags() { + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-sandbox-backend", + "agent-k8s", + "--kubernetes-sandbox-iron-proxy-mode", + "disabled", + "--kubernetes-tool-server-image", + "centaur-api:test", + "--centaur-overlay-image", + "centaur-overlay:test", + ]) + .unwrap(); + let config = AgentSandboxConfig::try_from(&args.sandbox).unwrap(); + let tools = config.tools.expect("tools should be Some"); + assert_eq!(tools.image, "centaur-api:test"); + let overlay = config.overlay.expect("overlay should be Some"); + assert_eq!(overlay.image, "centaur-overlay:test"); + assert_eq!(overlay.mount_path, "/app/overlay/org"); + } + #[test] fn agent_k8s_workflow_host_mounts_overlay_workflows() { let _overlay_image = EnvGuard::set("CENTAUR_OVERLAY_IMAGE", "ghcr.io/example/overlay:test"); diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index d41834551..e7fa96c19 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -25,9 +25,11 @@ use tokio::time::{Instant, sleep}; pub use generated::agents_x_k8s_io as crd; pub use iron_proxy::IronProxyConfig; +pub use tools::{OverlayConfig, ToolsConfig}; pub mod generated; mod iron_proxy; +mod tools; const BACKEND_NAME: &str = "agent-sandbox-k8s"; const DEFAULT_CONTAINER_NAME: &str = "agent"; @@ -53,6 +55,13 @@ pub struct AgentSandboxConfig { pub state_volume: Option, pub iron_proxy: Option, pub iron_control: Option, + /// When set, every sandbox gets a `tools-bootstrap` init container that + /// copies the source image's `/app/tools` into the agent's `/app/tools`, and + /// `TOOL_DIRS` is set so the agent's shim installer finds them. + pub tools: Option, + /// When set, the gerard overlay tree is mounted into the sandbox (tools, + /// workflows, skills, system-prompt overlay). + pub overlay: Option, pub ready_timeout: Duration, } @@ -83,6 +92,8 @@ impl AgentSandboxConfig { state_volume: None, iron_proxy: None, iron_control: None, + tools: None, + overlay: None, ready_timeout: Duration::from_secs(60), } } @@ -101,6 +112,16 @@ impl AgentSandboxConfig { self.iron_control = Some(iron_control); self } + + pub fn tools(mut self, tools: ToolsConfig) -> Self { + self.tools = Some(tools); + self + } + + pub fn overlay(mut self, overlay: OverlayConfig) -> Self { + self.overlay = Some(overlay); + self + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -453,13 +474,26 @@ fn build_agent_sandbox( "args", (!spec.args.is_empty()).then(|| spec.args.clone()), ); + // Agent container env: spec env + tools/overlay wiring (deduped). `TOOL_DIRS` + // is set deterministically here (not via passthrough) so it always matches + // the value the api-rs pod computes for its own tool discovery. + let mut agent_env: Vec<(String, String)> = spec + .env + .iter() + .map(|env| (env.name.clone(), env.value.clone())) + .collect(); + if config.tools.is_some() { + for (name, value) in tools::agent_env(config.overlay.as_ref()) { + upsert_env(&mut agent_env, &name, value); + } + } insert_optional( &mut container, "env", - (!spec.env.is_empty()).then(|| { - spec.env + (!agent_env.is_empty()).then(|| { + agent_env .iter() - .map(|env| json!({ "name": env.name, "value": env.value })) + .map(|(name, value)| json!({ "name": name, "value": value })) .collect::>() }), ); @@ -467,7 +501,7 @@ fn build_agent_sandbox( insert_optional(&mut container, "resources", resources_json(spec)); let (mut volumes, mut volume_mounts) = mount_json(spec); - let init_containers = overlay_json(spec, &mut volumes, &mut volume_mounts); + let mut init_containers = overlay_json(spec, &mut volumes, &mut volume_mounts); if let Some(state_volume) = &config.state_volume { volume_mounts.push(json!({ "name": "state", @@ -478,12 +512,31 @@ fn build_agent_sandbox( volume_mounts.push(iron_proxy::sandbox_ca_volume_mount_json()); volumes.push(iron_proxy::sandbox_ca_volume_json(iron_proxy)); } + // Tool sources (and the overlay tree) are bootstrapped into emptyDirs by init + // containers and mounted read-only into the agent at the same paths `TOOL_DIRS` + // points at. + if config.tools.is_some() { + volume_mounts.extend(tools::agent_volume_mounts_json( + true, + config.overlay.as_ref(), + )); + volumes.extend(tools::volumes_json(true, config.overlay.is_some())); + } insert_optional( &mut container, "volumeMounts", (!volume_mounts.is_empty()).then_some(volume_mounts), ); + // Init containers: tools-bootstrap copies /app/tools out of the source image; + // overlay-bootstrap populates the overlay tree and stages AGENTS_OVERLAY.md. + if let Some(tools) = &config.tools { + init_containers.push(tools::tools_init_container_json(tools)); + } + if let Some(overlay) = &config.overlay { + init_containers.push(tools::overlay_init_container_json(overlay)); + } + let mut pod_spec = json!({ "containers": [container], "restartPolicy": "Never", @@ -492,13 +545,13 @@ fn build_agent_sandbox( }); insert_optional( &mut pod_spec, - "volumes", - (!volumes.is_empty()).then(|| std::mem::take(&mut volumes)), + "initContainers", + (!init_containers.is_empty()).then_some(init_containers), ); insert_optional( &mut pod_spec, - "initContainers", - (!init_containers.is_empty()).then_some(init_containers), + "volumes", + (!volumes.is_empty()).then(|| std::mem::take(&mut volumes)), ); insert_optional( &mut pod_spec, @@ -673,6 +726,16 @@ where } } +/// Override-or-append an env entry, so the agent container never emits a +/// duplicate env name when we layer tools/overlay wiring over `spec.env`. +fn upsert_env(env: &mut Vec<(String, String)>, name: &str, value: String) { + if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == name) { + entry.1 = value; + } else { + env.push((name.to_owned(), value)); + } +} + fn next_sandbox_name() -> String { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs new file mode 100644 index 000000000..7996a5568 --- /dev/null +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -0,0 +1,308 @@ +//! Tool sources + gerard overlay wiring for agent sandboxes. +//! +//! api-rs serves no `/tools` HTTP registry and the agent's `call ` HTTP +//! registry is deprecated upstream (control-plane-only). Instead the agent +//! image installs each tool as a shell CLI shim at entrypoint +//! (`services/sandbox/install_tool_shims.py`) by scanning `TOOL_DIRS` for +//! `pyproject.toml [project.scripts]` and `uvx`-installing each. Secrets ride +//! proxied env (tool placeholder creds + `*_DSN` from `apply_proxy_env`, +//! granted per-sandbox by iron-control) — none of that lives here. +//! +//! What this module provides is the *sources* the shims install from, mounted +//! INTO the agent container at the SAME paths the api-rs pod's own `TOOL_DIRS` +//! points at (so api-rs's `tool_discovery` and the agent agree on tool paths): +//! +//! * a `tools-bootstrap` init container copies `/app/tools` out of the shared +//! `centaur-api` image into an emptyDir mounted at `/app/tools`; +//! * an `overlay-bootstrap` init container copies the org overlay image's tree +//! into the overlay-root emptyDir, mounted at the overlay `mount_path` (and +//! stages the overlay's `SYSTEM_PROMPT.md` as `$HOME/AGENTS_OVERLAY.md`, which +//! the sandbox entrypoint appends to the base prompt). +//! +//! `TOOL_DIRS` is set explicitly on the agent env to `/app/tools` (or +//! `/app/tools:/tools` when the overlay is configured), matching the +//! value the api-rs Deployment computes for itself. + +use serde_json::{Value, json}; + +const AGENT_UID: i64 = 1001; + +/// Base tools path inside both the api-rs pod and the agent sandbox. +pub(crate) const BASE_TOOL_DIR: &str = "/app/tools"; +/// emptyDir the `tools-bootstrap` init container populates from the source image. +const TOOLS_VOLUME: &str = "tools-root"; + +/// Shared overlay-tree volume (populated by `overlay-bootstrap`). +const OVERLAY_VOLUME: &str = "overlay-root"; + +// The overlay's `SYSTEM_PROMPT.md` is staged by the init container into a tiny +// shared volume and surfaced to the agent at `$HOME/AGENTS_OVERLAY.md`, which the +// sandbox entrypoint appends to the base prompt. +const OVERLAY_PROMPT_VOLUME: &str = "overlay-prompt"; +const OVERLAY_PROMPT_DIR: &str = "/overlay-prompt"; +const OVERLAY_PROMPT_FILE: &str = "AGENTS_OVERLAY.md"; +const AGENT_OVERLAY_PROMPT_PATH: &str = "/home/agent/AGENTS_OVERLAY.md"; +const OVERLAY_SYSTEM_PROMPT_REL: &str = "services/sandbox/SYSTEM_PROMPT.md"; + +/// Source image carrying the base tools at `/app/tools` (the shared +/// `centaur-api` image). When set, every sandbox gets a `tools-bootstrap` init +/// container that copies those tools into the agent's `/app/tools`. +#[derive(Clone, Debug)] +pub struct ToolsConfig { + pub image: String, + pub image_pull_policy: Option, +} + +impl ToolsConfig { + pub fn new(image: impl Into) -> Self { + Self { + image: image.into(), + image_pull_policy: None, + } + } +} + +/// Org overlay image + where its tree lands in the sandbox. `mount_path` matches +/// the api-rs pod's `overlay.mountPath` so the agent's `/tools` is +/// the same path api-rs discovered tools at. +#[derive(Clone, Debug)] +pub struct OverlayConfig { + pub image: String, + pub image_pull_policy: Option, + /// Path the overlay tree is copied from inside the overlay image. + pub source_path: String, + /// Path the overlay tree is mounted at in the sandbox (e.g. `/app/overlay/org`). + pub mount_path: String, +} + +impl OverlayConfig { + pub fn new(image: impl Into) -> Self { + Self { + image: image.into(), + image_pull_policy: None, + source_path: "/overlay".to_owned(), + mount_path: "/app/overlay/org".to_owned(), + } + } + + /// Parent dir the overlay-root emptyDir is mounted at (so the copy lands at + /// `mount_path`). Falls back to `mount_path` itself if it has no parent. + fn overlay_root(&self) -> &str { + match self.mount_path.rfind('/') { + Some(0) | None => &self.mount_path, + Some(idx) => &self.mount_path[..idx], + } + } +} + +fn security_context_json() -> Value { + json!({ + "allowPrivilegeEscalation": false, + "capabilities": {"drop": ["ALL"]}, + "runAsGroup": AGENT_UID, + "runAsNonRoot": true, + "runAsUser": AGENT_UID, + "seccompProfile": {"type": "RuntimeDefault"}, + }) +} + +/// `TOOL_DIRS` for the agent: base tools plus the overlay's tools when present. +/// Matches the value the api-rs Deployment computes for its own `TOOL_DIRS`. +pub(crate) fn agent_tool_dirs(overlay: Option<&OverlayConfig>) -> String { + match overlay { + Some(overlay) => format!("{BASE_TOOL_DIR}:{}/tools", overlay.mount_path), + None => BASE_TOOL_DIR.to_owned(), + } +} + +/// Agent env added for tools/overlay wiring: `TOOL_DIRS` (always) and +/// `CENTAUR_OVERLAY_DIR` (when the overlay is configured). +pub(crate) fn agent_env(overlay: Option<&OverlayConfig>) -> Vec<(String, String)> { + let mut env = vec![("TOOL_DIRS".to_owned(), agent_tool_dirs(overlay))]; + if let Some(overlay) = overlay { + env.push(("CENTAUR_OVERLAY_DIR".to_owned(), overlay.mount_path.clone())); + } + env +} + +/// The `tools-bootstrap` init container: copies `/app/tools` out of the source +/// image into the shared `tools-root` emptyDir mounted at `/app/tools`. +pub(crate) fn tools_init_container_json(tools: &ToolsConfig) -> Value { + let script = format!( + "src=\"{BASE_TOOL_DIR}\"\n\ + target=\"{BASE_TOOL_DIR}\"\n\ + mkdir -p \"$target\"\n\ + cp -R \"$src\"/. \"$target\"/", + ); + let mut container = json!({ + "name": "tools-bootstrap", + "image": tools.image, + "command": ["/bin/sh", "-ec", script], + "volumeMounts": [ + {"name": TOOLS_VOLUME, "mountPath": BASE_TOOL_DIR}, + ], + "securityContext": security_context_json(), + }); + if let Some(policy) = &tools.image_pull_policy { + container["imagePullPolicy"] = json!(policy); + } + container +} + +/// The `overlay-bootstrap` init container: copies the overlay image's tree into +/// the shared `overlay-root` emptyDir, and stages the overlay's +/// `SYSTEM_PROMPT.md` as `AGENTS_OVERLAY.md` in a small shared volume. +pub(crate) fn overlay_init_container_json(overlay: &OverlayConfig) -> Value { + let script = format!( + "src=\"{src}\"\n\ + target=\"{target}\"\n\ + mkdir -p \"$target\"\n\ + cp -R \"$src\"/. \"$target\"/\n\ + if [ -f \"$target/{prompt_rel}\" ]; then\n\ + \x20 cp \"$target/{prompt_rel}\" \"{prompt_dir}/{prompt_file}\"\n\ + else\n\ + \x20 : > \"{prompt_dir}/{prompt_file}\"\n\ + fi", + src = overlay.source_path, + target = overlay.mount_path, + prompt_rel = OVERLAY_SYSTEM_PROMPT_REL, + prompt_dir = OVERLAY_PROMPT_DIR, + prompt_file = OVERLAY_PROMPT_FILE, + ); + let mut container = json!({ + "name": "overlay-bootstrap", + "image": overlay.image, + "command": ["/bin/sh", "-ec", script], + "volumeMounts": [ + {"name": OVERLAY_VOLUME, "mountPath": overlay.overlay_root()}, + {"name": OVERLAY_PROMPT_VOLUME, "mountPath": OVERLAY_PROMPT_DIR}, + ], + "securityContext": security_context_json(), + }); + if let Some(policy) = &overlay.image_pull_policy { + container["imagePullPolicy"] = json!(policy); + } + container +} + +/// Volumes added to the pod for tool sources (and, when enabled, the overlay +/// tree + prompt-handoff volume). +pub(crate) fn volumes_json(tools: bool, overlay: bool) -> Vec { + let mut volumes = Vec::new(); + if tools { + volumes.push(json!({"name": TOOLS_VOLUME, "emptyDir": {}})); + } + if overlay { + volumes.push(json!({"name": OVERLAY_VOLUME, "emptyDir": {}})); + volumes.push(json!({"name": OVERLAY_PROMPT_VOLUME, "emptyDir": {}})); + } + volumes +} + +/// Volume mounts added to the AGENT container: the base tools tree at +/// `/app/tools` and, when the overlay is enabled, the overlay tree plus the +/// staged overlay prompt at `$HOME/AGENTS_OVERLAY.md`. +pub(crate) fn agent_volume_mounts_json(tools: bool, overlay: Option<&OverlayConfig>) -> Vec { + let mut mounts = Vec::new(); + if tools { + mounts.push(json!({"name": TOOLS_VOLUME, "mountPath": BASE_TOOL_DIR, "readOnly": true})); + } + if let Some(overlay) = overlay { + mounts.push(json!({ + "name": OVERLAY_VOLUME, + "mountPath": overlay.overlay_root(), + "readOnly": true, + })); + mounts.push(json!({ + "name": OVERLAY_PROMPT_VOLUME, + "mountPath": AGENT_OVERLAY_PROMPT_PATH, + "subPath": OVERLAY_PROMPT_FILE, + "readOnly": true, + })); + } + mounts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_dirs_match_api_rs_pod_value() { + assert_eq!(agent_tool_dirs(None), "/app/tools"); + let overlay = OverlayConfig::new("centaur-overlay:test"); + assert_eq!( + agent_tool_dirs(Some(&overlay)), + "/app/tools:/app/overlay/org/tools" + ); + } + + #[test] + fn agent_env_sets_tool_dirs_and_overlay_dir() { + let env = agent_env(None); + assert_eq!(env, vec![("TOOL_DIRS".to_owned(), "/app/tools".to_owned())]); + + let overlay = OverlayConfig::new("centaur-overlay:test"); + let env = agent_env(Some(&overlay)); + assert!(env.contains(&( + "TOOL_DIRS".to_owned(), + "/app/tools:/app/overlay/org/tools".to_owned() + ))); + assert!(env.contains(&( + "CENTAUR_OVERLAY_DIR".to_owned(), + "/app/overlay/org".to_owned() + ))); + } + + #[test] + fn overlay_root_is_mount_path_parent() { + let overlay = OverlayConfig::new("img"); + assert_eq!(overlay.overlay_root(), "/app/overlay"); + + let mut shallow = OverlayConfig::new("img"); + shallow.mount_path = "/overlay".to_owned(); + assert_eq!(shallow.overlay_root(), "/overlay"); + } + + #[test] + fn tools_init_copies_base_tools_into_emptydir() { + let tools = ToolsConfig::new("centaur-api:test"); + let c = tools_init_container_json(&tools); + assert_eq!(c["name"], "tools-bootstrap"); + assert_eq!(c["image"], "centaur-api:test"); + let mount = &c["volumeMounts"][0]; + assert_eq!(mount["name"], TOOLS_VOLUME); + assert_eq!(mount["mountPath"], "/app/tools"); + } + + #[test] + fn overlay_init_stages_prompt_and_mounts_root() { + let overlay = OverlayConfig::new("centaur-overlay:test"); + let c = overlay_init_container_json(&overlay); + assert_eq!(c["name"], "overlay-bootstrap"); + let script = c["command"][2].as_str().unwrap(); + assert!(script.contains("target=\"/app/overlay/org\"")); + assert!(script.contains("services/sandbox/SYSTEM_PROMPT.md")); + assert!(script.contains("AGENTS_OVERLAY.md")); + let root_mount = &c["volumeMounts"][0]; + assert_eq!(root_mount["mountPath"], "/app/overlay"); + } + + #[test] + fn agent_mounts_tools_and_overlay_prompt() { + let overlay = OverlayConfig::new("centaur-overlay:test"); + let mounts = agent_volume_mounts_json(true, Some(&overlay)); + // base tools, overlay tree, overlay prompt + assert_eq!(mounts.len(), 3); + assert!(mounts.iter().any(|m| m["mountPath"] == "/app/tools")); + assert!(mounts.iter().any(|m| m["mountPath"] == "/app/overlay")); + let prompt = mounts + .iter() + .find(|m| m["mountPath"] == AGENT_OVERLAY_PROMPT_PATH) + .unwrap(); + assert_eq!(prompt["subPath"], "AGENTS_OVERLAY.md"); + + let mounts = agent_volume_mounts_json(true, None); + assert_eq!(mounts.len(), 1); + } +} From 16b5f6f20eab126eaec07a0d1adbe67464a96833 Mon Sep 17 00:00:00 2001 From: Will Drach Date: Sat, 6 Jun 2026 12:46:45 +0000 Subject: [PATCH 02/10] fix(api-rs): stage tools-bootstrap copy outside /app/tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tools-bootstrap init container mounted the tools emptyDir at /app/tools — the same path it copies FROM. The mount shadows the source image's tools tree, so the script self-copies the empty volume and GNU cp rejects it (exit 1); every sandbox dies with 'reached terminal state before running' and no agent ever starts. Mount the volume at /tools-bootstrap instead (mirroring how overlay-bootstrap stages to a distinct target) and copy the image's /app/tools into it. The agent container keeps mounting the same volume at /app/tools, so TOOL_DIRS and the shim installer are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../centaur-sandbox-agent-k8s/src/tools.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index 7996a5568..6dd773367 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -31,6 +31,12 @@ const AGENT_UID: i64 = 1001; pub(crate) const BASE_TOOL_DIR: &str = "/app/tools"; /// emptyDir the `tools-bootstrap` init container populates from the source image. const TOOLS_VOLUME: &str = "tools-root"; +/// Staging path where `tools-bootstrap` mounts the tools emptyDir. Must differ +/// from `BASE_TOOL_DIR`: mounting the volume at `/app/tools` would shadow the +/// source image's own tools tree, so the copy would read the empty volume and +/// `cp` would reject the self-copy (exit 1, sandbox never starts). The agent +/// container mounts the same volume at `BASE_TOOL_DIR`. +const TOOLS_BOOTSTRAP_DIR: &str = "/tools-bootstrap"; /// Shared overlay-tree volume (populated by `overlay-bootstrap`). const OVERLAY_VOLUME: &str = "overlay-root"; @@ -130,7 +136,7 @@ pub(crate) fn agent_env(overlay: Option<&OverlayConfig>) -> Vec<(String, String) pub(crate) fn tools_init_container_json(tools: &ToolsConfig) -> Value { let script = format!( "src=\"{BASE_TOOL_DIR}\"\n\ - target=\"{BASE_TOOL_DIR}\"\n\ + target=\"{TOOLS_BOOTSTRAP_DIR}\"\n\ mkdir -p \"$target\"\n\ cp -R \"$src\"/. \"$target\"/", ); @@ -139,7 +145,7 @@ pub(crate) fn tools_init_container_json(tools: &ToolsConfig) -> Value { "image": tools.image, "command": ["/bin/sh", "-ec", script], "volumeMounts": [ - {"name": TOOLS_VOLUME, "mountPath": BASE_TOOL_DIR}, + {"name": TOOLS_VOLUME, "mountPath": TOOLS_BOOTSTRAP_DIR}, ], "securityContext": security_context_json(), }); @@ -270,9 +276,14 @@ mod tests { let c = tools_init_container_json(&tools); assert_eq!(c["name"], "tools-bootstrap"); assert_eq!(c["image"], "centaur-api:test"); + let script = c["command"][2].as_str().unwrap(); + assert!(script.contains("src=\"/app/tools\"")); + assert!(script.contains("target=\"/tools-bootstrap\"")); + // The staging mount must NOT shadow the source image's /app/tools — + // that would make the copy a self-copy of the empty volume. let mount = &c["volumeMounts"][0]; assert_eq!(mount["name"], TOOLS_VOLUME); - assert_eq!(mount["mountPath"], "/app/tools"); + assert_eq!(mount["mountPath"], "/tools-bootstrap"); } #[test] From 9fc9b7e0ec2a2ba4b58765f31bb6f4ba815f72e8 Mon Sep 17 00:00:00 2001 From: Will Drach Date: Mon, 8 Jun 2026 11:16:14 -0600 Subject: [PATCH 03/10] fix: wire sandbox overlays without tools Gate overlay env, volumes, and mounts independently from the tools source image so overlay-only sandbox configs produce valid pod specs. --- .../centaur-sandbox-agent-k8s/src/lib.rs | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index e7fa96c19..171e2f584 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -482,7 +482,7 @@ fn build_agent_sandbox( .iter() .map(|env| (env.name.clone(), env.value.clone())) .collect(); - if config.tools.is_some() { + if config.tools.is_some() || config.overlay.is_some() { for (name, value) in tools::agent_env(config.overlay.as_ref()) { upsert_env(&mut agent_env, &name, value); } @@ -512,15 +512,18 @@ fn build_agent_sandbox( volume_mounts.push(iron_proxy::sandbox_ca_volume_mount_json()); volumes.push(iron_proxy::sandbox_ca_volume_json(iron_proxy)); } - // Tool sources (and the overlay tree) are bootstrapped into emptyDirs by init - // containers and mounted read-only into the agent at the same paths `TOOL_DIRS` - // points at. - if config.tools.is_some() { + // Tool sources and overlay sources are bootstrapped independently into + // emptyDirs by init containers and mounted read-only into the agent at the + // same paths `TOOL_DIRS` points at. + if config.tools.is_some() || config.overlay.is_some() { volume_mounts.extend(tools::agent_volume_mounts_json( - true, + config.tools.is_some(), config.overlay.as_ref(), )); - volumes.extend(tools::volumes_json(true, config.overlay.is_some())); + volumes.extend(tools::volumes_json( + config.tools.is_some(), + config.overlay.is_some(), + )); } insert_optional( &mut container, @@ -861,6 +864,46 @@ mod tests { assert_eq!(overlay_mount.read_only, Some(true)); } + #[test] + fn builds_agent_sandbox_spec_with_overlay_without_tools() { + let spec = SandboxSpec::new("centaur-agent:latest"); + let config = AgentSandboxConfig::new("centaur").overlay(OverlayConfig::new("overlay:test")); + + let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); + let pod_spec = &sandbox.spec.pod_template.spec; + let container = &pod_spec.containers[0]; + + let env = container.env.as_ref().unwrap(); + assert!(env.iter().any(|env| { + env.name == "TOOL_DIRS" + && env.value.as_deref() == Some("/app/tools:/app/overlay/org/tools") + })); + assert!(env.iter().any(|env| { + env.name == "CENTAUR_OVERLAY_DIR" && env.value.as_deref() == Some("/app/overlay/org") + })); + + let volumes = pod_spec.volumes.as_ref().unwrap(); + assert!( + volumes + .iter() + .any(|volume| { volume.name == "overlay-root" && volume.empty_dir.is_some() }) + ); + assert!( + volumes + .iter() + .any(|volume| { volume.name == "overlay-prompt" && volume.empty_dir.is_some() }) + ); + assert!(!volumes.iter().any(|volume| volume.name == "tools-root")); + + let mounts = container.volume_mounts.as_ref().unwrap(); + assert!(mounts.iter().any(|mount| mount.name == "overlay-root")); + assert!(mounts.iter().any(|mount| mount.name == "overlay-prompt")); + + let init_containers = pod_spec.init_containers.as_ref().unwrap(); + assert_eq!(init_containers.len(), 1); + assert_eq!(init_containers[0].name, "overlay-bootstrap"); + } + #[test] fn maps_agent_sandbox_replicas_and_pod_readiness_to_status() { let ready_pod = pod_with_phase_and_ready("Running", true); From 6db8ca87bd36a350100940e0b016b86866bd9a40 Mon Sep 17 00:00:00 2001 From: Will Drach Date: Mon, 8 Jun 2026 11:17:37 -0600 Subject: [PATCH 04/10] fix: make sandbox bootstrap volumes writable Set an fsGroup on sandbox pods that use tools or overlays so non-root bootstrap init containers can populate their emptyDir mounts. --- .../centaur-sandbox-agent-k8s/src/lib.rs | 19 +++++++++++++++++++ .../centaur-sandbox-agent-k8s/src/tools.rs | 7 +++++++ 2 files changed, 26 insertions(+) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index 171e2f584..7d59f2de2 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -546,6 +546,9 @@ fn build_agent_sandbox( "automountServiceAccountToken": false, "enableServiceLinks": false, }); + if config.tools.is_some() || config.overlay.is_some() { + pod_spec["securityContext"] = tools::pod_security_context_json(); + } insert_optional( &mut pod_spec, "initContainers", @@ -904,6 +907,22 @@ mod tests { assert_eq!(init_containers[0].name, "overlay-bootstrap"); } + #[test] + fn bootstrap_empty_dirs_are_writable_by_agent_uid() { + let spec = SandboxSpec::new("centaur-agent:latest"); + let config = AgentSandboxConfig::new("centaur").tools(ToolsConfig::new("api:test")); + + let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); + let pod_spec = &sandbox.spec.pod_template.spec; + + let security_context = pod_spec.security_context.as_ref().unwrap(); + assert_eq!(security_context.fs_group, Some(1001)); + assert_eq!( + security_context.fs_group_change_policy.as_deref(), + Some("OnRootMismatch") + ); + } + #[test] fn maps_agent_sandbox_replicas_and_pod_readiness_to_status() { let ready_pod = pod_with_phase_and_ready("Running", true); diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index 6dd773367..dcee8edeb 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -112,6 +112,13 @@ fn security_context_json() -> Value { }) } +pub(crate) fn pod_security_context_json() -> Value { + json!({ + "fsGroup": AGENT_UID, + "fsGroupChangePolicy": "OnRootMismatch", + }) +} + /// `TOOL_DIRS` for the agent: base tools plus the overlay's tools when present. /// Matches the value the api-rs Deployment computes for its own `TOOL_DIRS`. pub(crate) fn agent_tool_dirs(overlay: Option<&OverlayConfig>) -> String { From cf9ac33145111ca4418aea7864973383d259b555 Mon Sep 17 00:00:00 2001 From: Will Drach Date: Mon, 8 Jun 2026 12:43:15 -0600 Subject: [PATCH 05/10] fix(api-rs): source sandbox tools image from the api-rs image The tools-bootstrap init container copied /app/tools from .Values.api.image (centaur-api), but api-rs discovers its tools from /app/tools in its own container (.Values.apiRs.image). Sourcing from a different image risked the agent installing a different tool set than api-rs granted per-sandbox creds for. Source from the same api-rs image the Deployment runs so the two match by construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- contrib/chart/templates/apirs.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 23fd41251..47faee658 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -236,13 +236,15 @@ spec: key: {{ printf "%sIRON_CONTROL_INITIAL_API_KEY" .Values.secretManager.envPrefix }} {{- end }} {{- if .Values.toolServer.enabled }} - # Base tools source image (the shared api image). A tools-bootstrap - # init container copies its /app/tools into each sandbox so the agent - # installs the same tool CLI shims api-rs discovers, at the same path. + # Base tools source image: the same api-rs image this Deployment runs. + # api-rs discovers its tools from /app/tools in its own container, so + # sourcing the tools-bootstrap copy from the identical image guarantees + # each sandbox installs the exact tool set api-rs granted creds for — + # any other image risks a silent /app/tools content drift. - name: KUBERNETES_TOOL_SERVER_IMAGE - value: {{ printf "%s:%s" .Values.api.image.repository .Values.api.image.tag | quote }} + value: {{ printf "%s:%s" .Values.apiRs.image.repository .Values.apiRs.image.tag | quote }} - name: KUBERNETES_TOOL_SERVER_IMAGE_PULL_POLICY - value: {{ .Values.api.image.pullPolicy | quote }} + value: {{ .Values.apiRs.image.pullPolicy | quote }} {{- end }} {{- if .Values.overlay.image.repository }} # Org overlay (tools/workflows/skills/system-prompt) mounted into From 90e32feb9ff92c0996b4367093f85cb6503d4813 Mon Sep 17 00:00:00 2001 From: Will Drach Date: Tue, 9 Jun 2026 12:31:32 -0600 Subject: [PATCH 06/10] feat(api-rs): clone sandbox tools from a repo instead of baking them in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replumb the tools-bootstrap init container to git-clone the tools repo at a pinned ref into each sandbox's /app/tools (sparse on the tools subdir; GitHub token via askpass for private repos), instead of copying /app/tools out of the api-rs image. Mirrors the repo-cache architecture — clone a repo into a pre-provisioned directory — without sharing its node-level cache, so adding a tool is a push to the repo rather than an api-rs image rebuild. api-rs still discovers its own /app/tools to grant proxy creds, so pin toolServer.ref to the tool set the image carries to avoid drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- contrib/chart/templates/apirs.yaml | 36 +++- contrib/chart/values.yaml | 32 ++- .../crates/centaur-api-server/src/args.rs | 92 +++++++-- .../centaur-sandbox-agent-k8s/src/lib.rs | 13 +- .../centaur-sandbox-agent-k8s/src/tools.rs | 192 +++++++++++++++--- 5 files changed, 296 insertions(+), 69 deletions(-) diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 47faee658..6034271a0 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -236,15 +236,33 @@ spec: key: {{ printf "%sIRON_CONTROL_INITIAL_API_KEY" .Values.secretManager.envPrefix }} {{- end }} {{- if .Values.toolServer.enabled }} - # Base tools source image: the same api-rs image this Deployment runs. - # api-rs discovers its tools from /app/tools in its own container, so - # sourcing the tools-bootstrap copy from the identical image guarantees - # each sandbox installs the exact tool set api-rs granted creds for — - # any other image risks a silent /app/tools content drift. - - name: KUBERNETES_TOOL_SERVER_IMAGE - value: {{ printf "%s:%s" .Values.apiRs.image.repository .Values.apiRs.image.tag | quote }} - - name: KUBERNETES_TOOL_SERVER_IMAGE_PULL_POLICY - value: {{ .Values.apiRs.image.pullPolicy | quote }} +{{- if not .Values.toolServer.repo }} +{{- fail "toolServer.repo (owner/name) must be set when toolServer.enabled=true" }} +{{- end }} + # Base tools are git-cloned into each sandbox at boot by a + # tools-bootstrap init container (the repo-cache architecture, without + # the shared node cache), so adding a tool is a push to the repo, not + # an api-rs image rebuild. api-rs still discovers its own /app/tools to + # grant proxy creds, so pin toolServer.ref to the tool set that image + # carries to avoid drift between granted creds and installed tools. + - name: KUBERNETES_TOOLS_REPO + value: {{ .Values.toolServer.repo | quote }} +{{- if .Values.toolServer.ref }} + - name: KUBERNETES_TOOLS_REF + value: {{ .Values.toolServer.ref | quote }} +{{- end }} + - name: KUBERNETES_TOOLS_SUBDIR + value: {{ .Values.toolServer.subdir | quote }} + - name: KUBERNETES_TOOLS_RUNNER_IMAGE + value: {{ printf "%s:%s" (default .Values.sandbox.image.repository .Values.toolServer.runnerImage.repository) (default .Values.sandbox.image.tag .Values.toolServer.runnerImage.tag) | quote }} + - name: KUBERNETES_TOOLS_RUNNER_IMAGE_PULL_POLICY + value: {{ default .Values.sandbox.image.pullPolicy .Values.toolServer.runnerImage.pullPolicy | quote }} +{{- if .Values.toolServer.githubToken.existingSecretName }} + - name: KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET + value: {{ .Values.toolServer.githubToken.existingSecretName | quote }} + - name: KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET_KEY + value: {{ .Values.toolServer.githubToken.secretKey | quote }} +{{- end }} {{- end }} {{- if .Values.overlay.image.repository }} # Org overlay (tools/workflows/skills/system-prompt) mounted into diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index ced47fc48..d450a93cb 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -31,15 +31,35 @@ ironProxy: secretSource: onepassword secretTtl: 10m -# Tool-server sidecar — runs alongside the sandbox container in each sandbox -# Pod and exposes the same /tools/* surface the API used to. Same image as -# the API; the sidecar runs uvicorn against api.tool_server_app. -# When toolServer.enabled is false (default), the sandbox pod has no -# sidecar — currently the API still serves /tools so this is a no-op -# until the API drops its router (Phase 5). +# Base tools delivery for api-rs sandboxes. When enabled, a tools-bootstrap init +# container git-clones `repo` at `ref` into each sandbox's /app/tools (the +# repo-cache architecture — clone a repo into a pre-provisioned dir — without the +# shared node cache), so adding a tool is a push to the repo, not an image +# rebuild. `port` is legacy (the deprecated tool-server sidecar) and unused here. toolServer: enabled: true port: 8001 + # owner/name of the GitHub repo carrying the tools tree. REQUIRED when enabled; + # override for forks. Private repos additionally need githubToken below. + repo: paradigmxyz/centaur + # Branch, tag, or commit to check out; empty = the repo's default branch. Pin + # this to the tool set the api-rs image was built with so the creds api-rs + # grants match the tools the sandbox installs. + ref: "" + # Subdirectory in the repo holding the tools tree (mounted at /app/tools). + subdir: tools + # Git-capable image the clone init container runs in; empty fields fall back to + # sandbox.image (which carries git). + runnerImage: + repository: "" + tag: "" + pullPolicy: "" + # GitHub token for private-repo clones (fed to git via GIT_ASKPASS). Leave the + # secret name empty for public repos; point it at the repo-cache token secret + # to reuse the same credential. + githubToken: + existingSecretName: "" + secretKey: token # iron-control — Rails control plane for authenticated API access + encrypted # secret storage. Off by default; enable per-environment. Uses a dedicated diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 2f8dafd64..41c6f2108 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -15,8 +15,8 @@ use centaur_iron_proxy::{ ProxyFragment, SourceKind, SourcePolicy, harness_auth_fragment, infra_fragment, }; use centaur_sandbox_agent_k8s::{ - AgentSandboxBackend, AgentSandboxConfig, IronControlSettings, IronProxyConfig, OverlayConfig, - ToolsConfig, + AgentSandboxBackend, AgentSandboxConfig, GitHubTokenRef, IronControlSettings, IronProxyConfig, + OverlayConfig, ToolsConfig, }; use centaur_sandbox_core::{Mount, MountKind, OverlayImage, SandboxSpec}; use centaur_sandbox_local::LocalSandboxBackend; @@ -690,30 +690,76 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig { #[derive(Debug, ClapArgs)] struct ToolsArgs { - // Explicit `id`s avoid clap arg-id collisions with the other flattened - // structs (IronProxyArgs/OverlayArgs also carry `image`/`image_pull_policy`). - // The source image (the shared `centaur-api` image) carries `/app/tools`, - // which a `tools-bootstrap` init container copies into each sandbox. + // Tools are git-cloned into each sandbox at boot by a `tools-bootstrap` init + // container (repo-cache-style) rather than baked into an image, so adding a + // tool needs no image rebuild. Explicit `id`s avoid clap arg-id collisions + // with sibling flattened structs. #[arg( - id = "tools_source_image", - long = "kubernetes-tool-server-image", - env = "KUBERNETES_TOOL_SERVER_IMAGE" + id = "tools_source_repo", + long = "kubernetes-tools-repo", + env = "KUBERNETES_TOOLS_REPO" + )] + repo: Option, + #[arg( + id = "tools_source_ref", + long = "kubernetes-tools-ref", + env = "KUBERNETES_TOOLS_REF" + )] + git_ref: Option, + #[arg( + id = "tools_source_subdir", + long = "kubernetes-tools-subdir", + env = "KUBERNETES_TOOLS_SUBDIR", + default_value = "tools" + )] + source_subdir: String, + // Git-capable image the clone init container runs (the sandbox image carries git). + #[arg( + id = "tools_runner_image", + long = "kubernetes-tools-runner-image", + env = "KUBERNETES_TOOLS_RUNNER_IMAGE" )] image: Option, #[arg( - id = "tools_source_image_pull_policy", - long = "kubernetes-tool-server-image-pull-policy", - env = "KUBERNETES_TOOL_SERVER_IMAGE_PULL_POLICY" + id = "tools_runner_image_pull_policy", + long = "kubernetes-tools-runner-image-pull-policy", + env = "KUBERNETES_TOOLS_RUNNER_IMAGE_PULL_POLICY" )] image_pull_policy: Option, + // Secret + key holding a GitHub token for private-repo clones (optional). + #[arg( + id = "tools_github_token_secret", + long = "kubernetes-tools-github-token-secret", + env = "KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET" + )] + github_token_secret: Option, + #[arg( + id = "tools_github_token_secret_key", + long = "kubernetes-tools-github-token-secret-key", + env = "KUBERNETES_TOOLS_GITHUB_TOKEN_SECRET_KEY", + default_value = "token" + )] + github_token_secret_key: String, } impl ToolsArgs { - /// `None` when no source image is configured (tools disabled). + /// `None` when no repo or runner image is configured (tools disabled). fn to_config(&self) -> Option { + let repo = clean_optional_value(self.repo.as_deref())?; let image = clean_optional_value(self.image.as_deref())?; - let mut config = ToolsConfig::new(image); + let mut config = ToolsConfig::new(repo, image); config.image_pull_policy = self.image_pull_policy.clone(); + config.git_ref = clean_optional_value(self.git_ref.as_deref()); + if let Some(subdir) = clean_optional_value(Some(self.source_subdir.as_str())) { + config.source_subdir = subdir; + } + if let Some(secret_name) = clean_optional_value(self.github_token_secret.as_deref()) { + config.github_token = Some(GitHubTokenRef { + secret_name, + secret_key: clean_optional_value(Some(self.github_token_secret_key.as_str())) + .unwrap_or_else(|| "token".to_owned()), + }); + } Some(config) } } @@ -1266,15 +1312,27 @@ mod tests { "agent-k8s", "--kubernetes-sandbox-iron-proxy-mode", "disabled", - "--kubernetes-tool-server-image", - "centaur-api:test", + "--kubernetes-tools-repo", + "paradigmxyz/centaur", + "--kubernetes-tools-ref", + "main", + "--kubernetes-tools-runner-image", + "centaur-agent:test", + "--kubernetes-tools-github-token-secret", + "centaur-repo-cache-github-token", "--centaur-overlay-image", "centaur-overlay:test", ]) .unwrap(); let config = AgentSandboxConfig::try_from(&args.sandbox).unwrap(); let tools = config.tools.expect("tools should be Some"); - assert_eq!(tools.image, "centaur-api:test"); + assert_eq!(tools.repo, "paradigmxyz/centaur"); + assert_eq!(tools.git_ref.as_deref(), Some("main")); + assert_eq!(tools.source_subdir, "tools"); + assert_eq!(tools.image, "centaur-agent:test"); + let token = tools.github_token.expect("token should be Some"); + assert_eq!(token.secret_name, "centaur-repo-cache-github-token"); + assert_eq!(token.secret_key, "token"); let overlay = config.overlay.expect("overlay should be Some"); assert_eq!(overlay.image, "centaur-overlay:test"); assert_eq!(overlay.mount_path, "/app/overlay/org"); diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index 7d59f2de2..edc73c0ac 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -25,7 +25,7 @@ use tokio::time::{Instant, sleep}; pub use generated::agents_x_k8s_io as crd; pub use iron_proxy::IronProxyConfig; -pub use tools::{OverlayConfig, ToolsConfig}; +pub use tools::{GitHubTokenRef, OverlayConfig, ToolsConfig}; pub mod generated; mod iron_proxy; @@ -56,8 +56,8 @@ pub struct AgentSandboxConfig { pub iron_proxy: Option, pub iron_control: Option, /// When set, every sandbox gets a `tools-bootstrap` init container that - /// copies the source image's `/app/tools` into the agent's `/app/tools`, and - /// `TOOL_DIRS` is set so the agent's shim installer finds them. + /// git-clones the tools repo into the agent's `/app/tools`, and `TOOL_DIRS` + /// is set so the agent's shim installer finds them. pub tools: Option, /// When set, the gerard overlay tree is mounted into the sandbox (tools, /// workflows, skills, system-prompt overlay). @@ -521,7 +521,7 @@ fn build_agent_sandbox( config.overlay.as_ref(), )); volumes.extend(tools::volumes_json( - config.tools.is_some(), + config.tools.as_ref(), config.overlay.is_some(), )); } @@ -531,7 +531,7 @@ fn build_agent_sandbox( (!volume_mounts.is_empty()).then_some(volume_mounts), ); - // Init containers: tools-bootstrap copies /app/tools out of the source image; + // Init containers: tools-bootstrap git-clones the tools repo into /app/tools; // overlay-bootstrap populates the overlay tree and stages AGENTS_OVERLAY.md. if let Some(tools) = &config.tools { init_containers.push(tools::tools_init_container_json(tools)); @@ -910,7 +910,8 @@ mod tests { #[test] fn bootstrap_empty_dirs_are_writable_by_agent_uid() { let spec = SandboxSpec::new("centaur-agent:latest"); - let config = AgentSandboxConfig::new("centaur").tools(ToolsConfig::new("api:test")); + let config = + AgentSandboxConfig::new("centaur").tools(ToolsConfig::new("paradigmxyz/centaur", "api:test")); let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); let pod_spec = &sandbox.spec.pod_template.spec; diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index dcee8edeb..c3ced7d46 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -12,8 +12,11 @@ //! INTO the agent container at the SAME paths the api-rs pod's own `TOOL_DIRS` //! points at (so api-rs's `tool_discovery` and the agent agree on tool paths): //! -//! * a `tools-bootstrap` init container copies `/app/tools` out of the shared -//! `centaur-api` image into an emptyDir mounted at `/app/tools`; +//! * a `tools-bootstrap` init container git-clones the tools repo at a pinned +//! ref and copies its `source_subdir` into an emptyDir mounted at `/app/tools` +//! — the same repo-cache architecture (clone a repo into a pre-provisioned +//! directory, no Dockerfile rebuild to add a tool) without sharing the +//! repo-cache DaemonSet's node-level cache; //! * an `overlay-bootstrap` init container copies the org overlay image's tree //! into the overlay-root emptyDir, mounted at the overlay `mount_path` (and //! stages the overlay's `SYSTEM_PROMPT.md` as `$HOME/AGENTS_OVERLAY.md`, which @@ -29,14 +32,15 @@ const AGENT_UID: i64 = 1001; /// Base tools path inside both the api-rs pod and the agent sandbox. pub(crate) const BASE_TOOL_DIR: &str = "/app/tools"; -/// emptyDir the `tools-bootstrap` init container populates from the source image. +/// emptyDir the `tools-bootstrap` init container clones the tools tree into. const TOOLS_VOLUME: &str = "tools-root"; -/// Staging path where `tools-bootstrap` mounts the tools emptyDir. Must differ -/// from `BASE_TOOL_DIR`: mounting the volume at `/app/tools` would shadow the -/// source image's own tools tree, so the copy would read the empty volume and -/// `cp` would reject the self-copy (exit 1, sandbox never starts). The agent -/// container mounts the same volume at `BASE_TOOL_DIR`. +/// Staging path where `tools-bootstrap` mounts the tools emptyDir. The agent +/// container mounts the same volume read-only at `BASE_TOOL_DIR`. const TOOLS_BOOTSTRAP_DIR: &str = "/tools-bootstrap"; +/// Volume + mount carrying the GitHub token for private-repo clones (askpass). +const GITHUB_TOKEN_VOLUME: &str = "tools-github-token"; +const GITHUB_TOKEN_DIR: &str = "/tools-github-token"; +const GITHUB_TOKEN_FILE: &str = "token"; /// Shared overlay-tree volume (populated by `overlay-bootstrap`). const OVERLAY_VOLUME: &str = "overlay-root"; @@ -50,20 +54,41 @@ const OVERLAY_PROMPT_FILE: &str = "AGENTS_OVERLAY.md"; const AGENT_OVERLAY_PROMPT_PATH: &str = "/home/agent/AGENTS_OVERLAY.md"; const OVERLAY_SYSTEM_PROMPT_REL: &str = "services/sandbox/SYSTEM_PROMPT.md"; -/// Source image carrying the base tools at `/app/tools` (the shared -/// `centaur-api` image). When set, every sandbox gets a `tools-bootstrap` init -/// container that copies those tools into the agent's `/app/tools`. +/// Git source for the base tools tree. When set, every sandbox gets a +/// `tools-bootstrap` init container that clones `repo` at `git_ref` and copies +/// its `source_subdir` into the agent's `/app/tools` — so adding a tool is a +/// push to the repo, not an image rebuild. #[derive(Clone, Debug)] pub struct ToolsConfig { + /// `owner/name` GitHub repo carrying the tools tree. + pub repo: String, + /// Branch, tag, or commit to check out. `None` => the repo's default branch. + pub git_ref: Option, + /// Subdirectory within the repo holding the tools (copied to `/app/tools`). + pub source_subdir: String, + /// Git-capable image the clone init container runs (e.g. the sandbox image). pub image: String, pub image_pull_policy: Option, + /// GitHub token secret for private-repo clones. `None` => unauthenticated clone. + pub github_token: Option, +} + +/// A Kubernetes Secret key holding a GitHub token, fed to `git` via `GIT_ASKPASS`. +#[derive(Clone, Debug)] +pub struct GitHubTokenRef { + pub secret_name: String, + pub secret_key: String, } impl ToolsConfig { - pub fn new(image: impl Into) -> Self { + pub fn new(repo: impl Into, image: impl Into) -> Self { Self { + repo: repo.into(), + git_ref: None, + source_subdir: "tools".to_owned(), image: image.into(), image_pull_policy: None, + github_token: None, } } } @@ -138,22 +163,66 @@ pub(crate) fn agent_env(overlay: Option<&OverlayConfig>) -> Vec<(String, String) env } -/// The `tools-bootstrap` init container: copies `/app/tools` out of the source -/// image into the shared `tools-root` emptyDir mounted at `/app/tools`. +/// The `tools-bootstrap` init container: clones `repo` at `git_ref` (sparse, on +/// `source_subdir`) and copies that subtree into the shared `tools-root` emptyDir +/// the agent mounts read-only at `/app/tools`. pub(crate) fn tools_init_container_json(tools: &ToolsConfig) -> Value { + let repo_url = format!("https://github.com/{}.git", tools.repo); + let subdir = &tools.source_subdir; + + // GIT_ASKPASS feeds the token as the HTTPS password (user x-access-token), + // matching the repo-cache DaemonSet. Wired only when a token secret is mounted. + let askpass = if tools.github_token.is_some() { + format!( + "printf '#!/bin/sh\\ncase \"$1\" in *Username*) echo x-access-token;; \ + *Password*) cat {GITHUB_TOKEN_DIR}/{GITHUB_TOKEN_FILE};; *) echo;; esac\\n' \ + > /tmp/git-askpass\n\ + chmod 0700 /tmp/git-askpass\n\ + export GIT_ASKPASS=/tmp/git-askpass\n" + ) + } else { + String::new() + }; + + // `--filter=blob:none --no-checkout` + sparse-checkout fetches only the tools + // subtree's blobs. With a ref we fetch it explicitly (branch/tag/sha); + // without one we check out the cloned default branch. + let checkout = match &tools.git_ref { + Some(git_ref) => format!( + "git -C \"$src\" -c gc.auto=0 fetch --quiet origin {git_ref}\n\ + git -C \"$src\" checkout --quiet --detach FETCH_HEAD" + ), + None => "git -C \"$src\" checkout --quiet".to_owned(), + }; + let script = format!( - "src=\"{BASE_TOOL_DIR}\"\n\ + "set -e\n\ + {askpass}\ + export GIT_TERMINAL_PROMPT=0\n\ + git config --global --add safe.directory '*'\n\ + src=\"$(mktemp -d)\"\n\ + git clone --quiet --filter=blob:none --no-checkout {repo_url} \"$src\"\n\ + git -C \"$src\" sparse-checkout set {subdir}\n\ + {checkout}\n\ target=\"{TOOLS_BOOTSTRAP_DIR}\"\n\ mkdir -p \"$target\"\n\ - cp -R \"$src\"/. \"$target\"/", + cp -R \"$src/{subdir}/.\" \"$target\"/" ); + + let mut volume_mounts = vec![json!({"name": TOOLS_VOLUME, "mountPath": TOOLS_BOOTSTRAP_DIR})]; + if tools.github_token.is_some() { + volume_mounts.push(json!({ + "name": GITHUB_TOKEN_VOLUME, + "mountPath": GITHUB_TOKEN_DIR, + "readOnly": true, + })); + } + let mut container = json!({ "name": "tools-bootstrap", "image": tools.image, "command": ["/bin/sh", "-ec", script], - "volumeMounts": [ - {"name": TOOLS_VOLUME, "mountPath": TOOLS_BOOTSTRAP_DIR}, - ], + "volumeMounts": volume_mounts, "securityContext": security_context_json(), }); if let Some(policy) = &tools.image_pull_policy { @@ -200,10 +269,20 @@ pub(crate) fn overlay_init_container_json(overlay: &OverlayConfig) -> Value { /// Volumes added to the pod for tool sources (and, when enabled, the overlay /// tree + prompt-handoff volume). -pub(crate) fn volumes_json(tools: bool, overlay: bool) -> Vec { +pub(crate) fn volumes_json(tools: Option<&ToolsConfig>, overlay: bool) -> Vec { let mut volumes = Vec::new(); - if tools { + if let Some(tools) = tools { volumes.push(json!({"name": TOOLS_VOLUME, "emptyDir": {}})); + if let Some(token) = &tools.github_token { + volumes.push(json!({ + "name": GITHUB_TOKEN_VOLUME, + "secret": { + "secretName": token.secret_name, + "defaultMode": 0o400, + "items": [{"key": token.secret_key, "path": GITHUB_TOKEN_FILE}], + }, + })); + } } if overlay { volumes.push(json!({"name": OVERLAY_VOLUME, "emptyDir": {}})); @@ -278,19 +357,70 @@ mod tests { } #[test] - fn tools_init_copies_base_tools_into_emptydir() { - let tools = ToolsConfig::new("centaur-api:test"); + fn tools_init_clones_repo_into_emptydir() { + let mut tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); + tools.git_ref = Some("main".to_owned()); let c = tools_init_container_json(&tools); assert_eq!(c["name"], "tools-bootstrap"); - assert_eq!(c["image"], "centaur-api:test"); + assert_eq!(c["image"], "centaur-agent:test"); let script = c["command"][2].as_str().unwrap(); - assert!(script.contains("src=\"/app/tools\"")); - assert!(script.contains("target=\"/tools-bootstrap\"")); - // The staging mount must NOT shadow the source image's /app/tools — - // that would make the copy a self-copy of the empty volume. - let mount = &c["volumeMounts"][0]; - assert_eq!(mount["name"], TOOLS_VOLUME); - assert_eq!(mount["mountPath"], "/tools-bootstrap"); + assert!(script.contains("git clone --quiet --filter=blob:none --no-checkout https://github.com/paradigmxyz/centaur.git")); + assert!(script.contains("sparse-checkout set tools")); + assert!(script.contains("fetch --quiet origin main")); + assert!(script.contains("cp -R \"$src/tools/.\" \"$target\"/")); + // No token configured => no askpass, single (tools) volume mount. + assert!(!script.contains("GIT_ASKPASS")); + assert_eq!(c["volumeMounts"].as_array().unwrap().len(), 1); + assert_eq!(c["volumeMounts"][0]["mountPath"], "/tools-bootstrap"); + } + + #[test] + fn tools_init_default_ref_checks_out_clone_head() { + let tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); + let script = tools_init_container_json(&tools)["command"][2] + .as_str() + .unwrap() + .to_owned(); + // Default branch: plain checkout, no explicit ref fetch. + assert!(script.contains("git -C \"$src\" checkout --quiet\n")); + assert!(!script.contains("fetch --quiet origin")); + } + + #[test] + fn tools_init_with_token_wires_askpass_and_secret_volume() { + let mut tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); + tools.github_token = Some(GitHubTokenRef { + secret_name: "centaur-repo-cache-github-token".to_owned(), + secret_key: "token".to_owned(), + }); + let c = tools_init_container_json(&tools); + let script = c["command"][2].as_str().unwrap(); + assert!(script.contains("GIT_ASKPASS=/tmp/git-askpass")); + assert!(script.contains("/tools-github-token/token")); + let mounts = c["volumeMounts"].as_array().unwrap(); + assert_eq!(mounts.len(), 2); + assert!(mounts.iter().any(|m| m["mountPath"] == "/tools-github-token")); + + // The pod gets a secret-backed volume projecting the token to `token`. + let volumes = volumes_json(Some(&tools), false); + let token_vol = volumes + .iter() + .find(|v| v["name"] == GITHUB_TOKEN_VOLUME) + .expect("token volume"); + assert_eq!( + token_vol["secret"]["secretName"], + "centaur-repo-cache-github-token" + ); + assert_eq!(token_vol["secret"]["items"][0]["path"], "token"); + } + + #[test] + fn volumes_without_token_are_just_emptydirs() { + let tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); + let volumes = volumes_json(Some(&tools), false); + assert_eq!(volumes.len(), 1); + assert_eq!(volumes[0]["name"], TOOLS_VOLUME); + assert!(volumes[0]["emptyDir"].is_object()); } #[test] From 7579b6a22df09fd18a59784b0fc66d4c7a90e5f5 Mon Sep 17 00:00:00 2001 From: Will Drach Date: Tue, 9 Jun 2026 13:14:38 -0600 Subject: [PATCH 07/10] fix(api-rs): route the tools clone through the per-sandbox iron-proxy The sandbox NetworkPolicy only allows egress to the sandbox's iron-proxy, api-rs, and DNS, so the tools-bootstrap init container's direct git clone to github.com is blocked whenever iron-proxy is enabled. Route the clone through the proxy like all other sandbox egress: export HTTPS_PROXY (the resolved per-sandbox proxy URL apply_proxy_env already put on the spec) and GIT_SSL_CAINFO, and mount the pod's existing firewall-ca volume into the init container. github.com/api.github.com are already in the baseline proxy allowlist, so no policy or allowlist changes are needed. Without iron-proxy the clone still goes direct. Co-Authored-By: Claude Fable 5 --- .../src/iron_proxy.rs | 2 +- .../centaur-sandbox-agent-k8s/src/lib.rs | 60 +++++++++++++++- .../centaur-sandbox-agent-k8s/src/tools.rs | 72 +++++++++++++++++-- 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs index 9c999cdea..76dbc6600 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/iron_proxy.rs @@ -27,7 +27,7 @@ use crate::{ const IRON_PROXY_LABEL: &str = "centaur.ai/iron-proxy"; const IRON_CONTROL_PROXY_ID_ANNOTATION: &str = "centaur.ai/iron-control-proxy-id"; const FIREWALL_CA_MOUNT_PATH: &str = "/firewall-certs"; -const FIREWALL_CA_CERT_PATH: &str = "/firewall-certs/ca-cert.pem"; +pub(crate) const FIREWALL_CA_CERT_PATH: &str = "/firewall-certs/ca-cert.pem"; const PROXY_MANAGEMENT_PORT: u16 = 9092; const PROXY_HEALTH_PORT: u16 = 9090; // Managed-mode proxies carry no rendered config; these local listen/TLS diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index edc73c0ac..13420b4a0 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -534,7 +534,21 @@ fn build_agent_sandbox( // Init containers: tools-bootstrap git-clones the tools repo into /app/tools; // overlay-bootstrap populates the overlay tree and stages AGENTS_OVERLAY.md. if let Some(tools) = &config.tools { - init_containers.push(tools::tools_init_container_json(tools)); + // The sandbox NetworkPolicy only allows egress to the per-sandbox proxy + // (plus api-rs and DNS), so when iron-proxy is on the clone must ride it. + // `apply_proxy_env` ran before this builder, so the resolved proxy URL is + // on the spec env; absent (proxy disabled/unresolved) the clone goes direct. + let clone_proxy = config.iron_proxy.as_ref().and_then(|_| { + spec.env + .iter() + .find(|env| env.name == "HTTPS_PROXY") + .map(|env| tools::CloneProxy { + https_proxy: env.value.clone(), + ca_cert_path: iron_proxy::FIREWALL_CA_CERT_PATH.to_owned(), + ca_volume_mount: iron_proxy::sandbox_ca_volume_mount_json(), + }) + }); + init_containers.push(tools::tools_init_container_json(tools, clone_proxy.as_ref())); } if let Some(overlay) = &config.overlay { init_containers.push(tools::overlay_init_container_json(overlay)); @@ -907,6 +921,50 @@ mod tests { assert_eq!(init_containers[0].name, "overlay-bootstrap"); } + #[test] + fn tools_clone_rides_iron_proxy_when_enabled() { + // apply_proxy_env runs before build_agent_sandbox in create(), so the + // resolved per-sandbox proxy URL arrives on the spec env. + let spec = SandboxSpec::new("centaur-agent:latest") + .env("HTTPS_PROXY", "http://asbx-test-iron-proxy:8080"); + let config = AgentSandboxConfig::new("centaur") + .tools(ToolsConfig::new("paradigmxyz/centaur", "api:test")) + .iron_proxy(IronProxyConfig::new("proxy:test", "ca-cert", "ca-key")); + + let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); + let pod_spec = &sandbox.spec.pod_template.spec; + let bootstrap = &pod_spec.init_containers.as_ref().unwrap()[0]; + assert_eq!(bootstrap.name, "tools-bootstrap"); + let script = &bootstrap.command.as_ref().unwrap()[2]; + assert!(script.contains("export HTTPS_PROXY=\"http://asbx-test-iron-proxy:8080\"")); + assert!(script.contains("export GIT_SSL_CAINFO=\"/firewall-certs/ca-cert.pem\"")); + assert!( + bootstrap + .volume_mounts + .as_ref() + .unwrap() + .iter() + .any(|mount| mount.name == "firewall-ca") + ); + + // Without iron-proxy the clone goes direct: no proxy exports, no CA mount. + let spec = SandboxSpec::new("centaur-agent:latest"); + let config = AgentSandboxConfig::new("centaur") + .tools(ToolsConfig::new("paradigmxyz/centaur", "api:test")); + let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); + let bootstrap = &sandbox.spec.pod_template.spec.init_containers.as_ref().unwrap()[0]; + let script = &bootstrap.command.as_ref().unwrap()[2]; + assert!(!script.contains("HTTPS_PROXY")); + assert!( + !bootstrap + .volume_mounts + .as_ref() + .unwrap() + .iter() + .any(|mount| mount.name == "firewall-ca") + ); + } + #[test] fn bootstrap_empty_dirs_are_writable_by_agent_uid() { let spec = SandboxSpec::new("centaur-agent:latest"); diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index c3ced7d46..ba7573be7 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -163,13 +163,42 @@ pub(crate) fn agent_env(overlay: Option<&OverlayConfig>) -> Vec<(String, String) env } +/// Routes the tools clone through the per-sandbox egress proxy. The sandbox +/// NetworkPolicy only allows egress to the proxy, api-rs, and DNS — a direct +/// clone to github.com is blocked whenever iron-proxy is enabled. The proxy +/// MITMs TLS (github.com is in the baseline allowlist), so git must trust the +/// firewall CA it re-signs with. +pub(crate) struct CloneProxy { + /// Per-sandbox proxy URL (the `HTTPS_PROXY` value `apply_proxy_env` set). + pub https_proxy: String, + /// Path to the firewall CA cert inside the container. + pub ca_cert_path: String, + /// Mount of the pod's existing `firewall-ca` volume for the init container. + pub ca_volume_mount: Value, +} + /// The `tools-bootstrap` init container: clones `repo` at `git_ref` (sparse, on /// `source_subdir`) and copies that subtree into the shared `tools-root` emptyDir -/// the agent mounts read-only at `/app/tools`. -pub(crate) fn tools_init_container_json(tools: &ToolsConfig) -> Value { +/// the agent mounts read-only at `/app/tools`. With a `CloneProxy`, the clone +/// rides the per-sandbox iron-proxy like all other sandbox egress. +pub(crate) fn tools_init_container_json( + tools: &ToolsConfig, + clone_proxy: Option<&CloneProxy>, +) -> Value { let repo_url = format!("https://github.com/{}.git", tools.repo); let subdir = &tools.source_subdir; + let proxy_exports = match clone_proxy { + Some(proxy) => format!( + "export HTTPS_PROXY=\"{https_proxy}\"\n\ + export https_proxy=\"{https_proxy}\"\n\ + export GIT_SSL_CAINFO=\"{ca_cert_path}\"\n", + https_proxy = proxy.https_proxy, + ca_cert_path = proxy.ca_cert_path, + ), + None => String::new(), + }; + // GIT_ASKPASS feeds the token as the HTTPS password (user x-access-token), // matching the repo-cache DaemonSet. Wired only when a token secret is mounted. let askpass = if tools.github_token.is_some() { @@ -197,6 +226,7 @@ pub(crate) fn tools_init_container_json(tools: &ToolsConfig) -> Value { let script = format!( "set -e\n\ + {proxy_exports}\ {askpass}\ export GIT_TERMINAL_PROMPT=0\n\ git config --global --add safe.directory '*'\n\ @@ -217,6 +247,9 @@ pub(crate) fn tools_init_container_json(tools: &ToolsConfig) -> Value { "readOnly": true, })); } + if let Some(proxy) = clone_proxy { + volume_mounts.push(proxy.ca_volume_mount.clone()); + } let mut container = json!({ "name": "tools-bootstrap", @@ -360,7 +393,7 @@ mod tests { fn tools_init_clones_repo_into_emptydir() { let mut tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); tools.git_ref = Some("main".to_owned()); - let c = tools_init_container_json(&tools); + let c = tools_init_container_json(&tools, None); assert_eq!(c["name"], "tools-bootstrap"); assert_eq!(c["image"], "centaur-agent:test"); let script = c["command"][2].as_str().unwrap(); @@ -374,10 +407,39 @@ mod tests { assert_eq!(c["volumeMounts"][0]["mountPath"], "/tools-bootstrap"); } + #[test] + fn tools_init_with_proxy_exports_proxy_env_and_mounts_ca() { + let tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); + let proxy = CloneProxy { + https_proxy: "http://asbx-test-iron-proxy:8080".to_owned(), + ca_cert_path: "/firewall-certs/ca-cert.pem".to_owned(), + ca_volume_mount: json!({ + "name": "firewall-ca", + "mountPath": "/firewall-certs", + "readOnly": true, + }), + }; + let c = tools_init_container_json(&tools, Some(&proxy)); + let script = c["command"][2].as_str().unwrap(); + // Proxy exports come before the clone so git CONNECTs through iron-proxy + // and trusts the CA it re-signs TLS with. + assert!(script.contains("export HTTPS_PROXY=\"http://asbx-test-iron-proxy:8080\"")); + assert!(script.contains("export https_proxy=\"http://asbx-test-iron-proxy:8080\"")); + assert!(script.contains("export GIT_SSL_CAINFO=\"/firewall-certs/ca-cert.pem\"")); + assert!(script.find("export HTTPS_PROXY").unwrap() < script.find("git clone").unwrap()); + let mounts = c["volumeMounts"].as_array().unwrap(); + assert_eq!(mounts.len(), 2); + assert!( + mounts + .iter() + .any(|m| m["name"] == "firewall-ca" && m["mountPath"] == "/firewall-certs") + ); + } + #[test] fn tools_init_default_ref_checks_out_clone_head() { let tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); - let script = tools_init_container_json(&tools)["command"][2] + let script = tools_init_container_json(&tools, None)["command"][2] .as_str() .unwrap() .to_owned(); @@ -393,7 +455,7 @@ mod tests { secret_name: "centaur-repo-cache-github-token".to_owned(), secret_key: "token".to_owned(), }); - let c = tools_init_container_json(&tools); + let c = tools_init_container_json(&tools, None); let script = c["command"][2].as_str().unwrap(); assert!(script.contains("GIT_ASKPASS=/tmp/git-askpass")); assert!(script.contains("/tools-github-token/token")); From 016190e80e13c51790a6d8d244c42387b74f1ca3 Mon Sep 17 00:00:00 2001 From: Will Drach Date: Tue, 9 Jun 2026 14:07:19 -0600 Subject: [PATCH 08/10] fix(api-rs): quote repo/ref/subdir in the tools-bootstrap script These are operator config (helm values -> env -> clap), not user input, but interpolating them bare into the /bin/sh -ec script means a stray space or metacharacter breaks in the shell instead of loudly in git. Quote them at every interpolation site. Co-Authored-By: Claude Fable 5 --- .../centaur-sandbox-agent-k8s/src/tools.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index ba7573be7..3d375a25c 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -218,12 +218,14 @@ pub(crate) fn tools_init_container_json( // without one we check out the cloned default branch. let checkout = match &tools.git_ref { Some(git_ref) => format!( - "git -C \"$src\" -c gc.auto=0 fetch --quiet origin {git_ref}\n\ + "git -C \"$src\" -c gc.auto=0 fetch --quiet origin \"{git_ref}\"\n\ git -C \"$src\" checkout --quiet --detach FETCH_HEAD" ), None => "git -C \"$src\" checkout --quiet".to_owned(), }; + // repo/ref/subdir are operator config, but quote them anyway so a stray + // space or metacharacter breaks loudly in git instead of in the shell. let script = format!( "set -e\n\ {proxy_exports}\ @@ -231,8 +233,8 @@ pub(crate) fn tools_init_container_json( export GIT_TERMINAL_PROMPT=0\n\ git config --global --add safe.directory '*'\n\ src=\"$(mktemp -d)\"\n\ - git clone --quiet --filter=blob:none --no-checkout {repo_url} \"$src\"\n\ - git -C \"$src\" sparse-checkout set {subdir}\n\ + git clone --quiet --filter=blob:none --no-checkout \"{repo_url}\" \"$src\"\n\ + git -C \"$src\" sparse-checkout set \"{subdir}\"\n\ {checkout}\n\ target=\"{TOOLS_BOOTSTRAP_DIR}\"\n\ mkdir -p \"$target\"\n\ @@ -397,9 +399,11 @@ mod tests { assert_eq!(c["name"], "tools-bootstrap"); assert_eq!(c["image"], "centaur-agent:test"); let script = c["command"][2].as_str().unwrap(); - assert!(script.contains("git clone --quiet --filter=blob:none --no-checkout https://github.com/paradigmxyz/centaur.git")); - assert!(script.contains("sparse-checkout set tools")); - assert!(script.contains("fetch --quiet origin main")); + assert!(script.contains( + "git clone --quiet --filter=blob:none --no-checkout \"https://github.com/paradigmxyz/centaur.git\"" + )); + assert!(script.contains("sparse-checkout set \"tools\"")); + assert!(script.contains("fetch --quiet origin \"main\"")); assert!(script.contains("cp -R \"$src/tools/.\" \"$target\"/")); // No token configured => no askpass, single (tools) volume mount. assert!(!script.contains("GIT_ASKPASS")); From f1e41be5c5896180e32767f17be7b3f98e42a92e Mon Sep 17 00:00:00 2001 From: Will Drach Date: Tue, 9 Jun 2026 19:35:58 +0000 Subject: [PATCH 09/10] fix(api-rs): retry the tools clone through the proxy's startup window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-sandbox iron-proxy is created in the same reconcile as the Sandbox CR and isn't accepting connections yet when the tools-bootstrap init container first runs — the clone dies with connection-refused, and an init failure is terminal for the Sandbox (no kubelet retry), so every cold spawn failed with 'reached terminal state before running'. Wrap the clone/sparse-checkout/ref fetch in a bounded retry loop (30 x 2s) so the init container rides out the proxy's startup instead of killing the sandbox. Co-Authored-By: Claude Fable 5 (cherry picked from commit b1f274db53bca25494d6f5a12d6c3b79d72b6409) --- .../centaur-sandbox-agent-k8s/src/tools.rs | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index 3d375a25c..f5c0b0ceb 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -218,12 +218,16 @@ pub(crate) fn tools_init_container_json( // without one we check out the cloned default branch. let checkout = match &tools.git_ref { Some(git_ref) => format!( - "git -C \"$src\" -c gc.auto=0 fetch --quiet origin \"{git_ref}\"\n\ + "git -C \"$src\" -c gc.auto=0 fetch --quiet origin \"{git_ref}\" && \ git -C \"$src\" checkout --quiet --detach FETCH_HEAD" ), None => "git -C \"$src\" checkout --quiet".to_owned(), }; + // The per-sandbox proxy is created in the same reconcile as the Sandbox and + // may not be accepting connections when this init container first runs — and + // an init failure is terminal for the Sandbox (no kubelet retry), so the + // clone must retry through the connection-refused window rather than die. // repo/ref/subdir are operator config, but quote them anyway so a stray // space or metacharacter breaks loudly in git instead of in the shell. let script = format!( @@ -232,10 +236,16 @@ pub(crate) fn tools_init_container_json( {askpass}\ export GIT_TERMINAL_PROMPT=0\n\ git config --global --add safe.directory '*'\n\ - src=\"$(mktemp -d)\"\n\ - git clone --quiet --filter=blob:none --no-checkout \"{repo_url}\" \"$src\"\n\ - git -C \"$src\" sparse-checkout set \"{subdir}\"\n\ - {checkout}\n\ + attempt=0\n\ + until src=\"$(mktemp -d)\" && \ + git clone --quiet --filter=blob:none --no-checkout \"{repo_url}\" \"$src\" && \ + git -C \"$src\" sparse-checkout set \"{subdir}\" && \ + {checkout}; do\n\ + attempt=$((attempt + 1))\n\ + if [ \"$attempt\" -ge 30 ]; then echo \"tools clone failed after $attempt attempts\" >&2; exit 1; fi\n\ + rm -rf \"$src\"\n\ + sleep 2\n\ + done\n\ target=\"{TOOLS_BOOTSTRAP_DIR}\"\n\ mkdir -p \"$target\"\n\ cp -R \"$src/{subdir}/.\" \"$target\"/" @@ -411,6 +421,25 @@ mod tests { assert_eq!(c["volumeMounts"][0]["mountPath"], "/tools-bootstrap"); } + #[test] + fn tools_init_retries_clone_until_proxy_accepts() { + // The per-sandbox proxy may not be listening when the init container + // first runs, and an init failure is terminal for the Sandbox — the + // clone (and the ref fetch/checkout chained into the same condition) + // must sit in a bounded retry loop, with the copy AFTER the loop. + let mut tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); + tools.git_ref = Some("main".to_owned()); + let script = tools_init_container_json(&tools, None)["command"][2] + .as_str() + .unwrap() + .to_owned(); + assert!(script.contains("until src=\"$(mktemp -d)\"")); + assert!(script.contains("checkout --quiet --detach FETCH_HEAD; do")); + assert!(script.contains("if [ \"$attempt\" -ge 30 ]")); + assert!(script.contains("sleep 2")); + assert!(script.find("done").unwrap() < script.find("cp -R").unwrap()); + } + #[test] fn tools_init_with_proxy_exports_proxy_env_and_mounts_ca() { let tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); @@ -448,7 +477,7 @@ mod tests { .unwrap() .to_owned(); // Default branch: plain checkout, no explicit ref fetch. - assert!(script.contains("git -C \"$src\" checkout --quiet\n")); + assert!(script.contains("git -C \"$src\" checkout --quiet; do")); assert!(!script.contains("fetch --quiet origin")); } From 59c62a74a2e6d36a79809ef942d445550f3b7feb Mon Sep 17 00:00:00 2001 From: Will Drach Date: Wed, 10 Jun 2026 13:52:11 -0600 Subject: [PATCH 10/10] refactor(api-rs): converge sandbox overlay on the spec-level overlay-image plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base branch grew its own overlay mechanism (SandboxSpec.overlay + overlay_json) for workflow-host sandboxes, configured by the same CENTAUR_OVERLAY_* env this branch's OverlayArgs read — so a workflow-host pod with an overlay configured got two init containers and two volumes with identical names, which Kubernetes rejects. Adopt the upstream plumbing wholesale: the backend default is now an OverlayImage from the same env helper the workflow host uses (the OverlayArgs flags are gone), a spec-level overlay takes precedence over the backend default so only one overlay-bootstrap/overlay-root pair ever exists, and agent sandboxes mount the overlay at /opt/centaur/overlay like workflow hosts do. The AGENTS_OVERLAY.md prompt staging moves into the shared overlay_json path, and the chart's duplicated CENTAUR_OVERLAY_* env block is dropped — the upstream block already feeds it. Co-Authored-By: Claude Fable 5 --- contrib/chart/templates/apirs.yaml | 13 -- .../crates/centaur-api-server/src/args.rs | 84 +++---- .../centaur-sandbox-agent-k8s/src/lib.rs | 164 ++++++++++---- .../centaur-sandbox-agent-k8s/src/tools.rs | 205 ++++-------------- 4 files changed, 187 insertions(+), 279 deletions(-) diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 6034271a0..55d3ae726 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -264,19 +264,6 @@ spec: value: {{ .Values.toolServer.githubToken.secretKey | quote }} {{- end }} {{- end }} -{{- if .Values.overlay.image.repository }} - # Org overlay (tools/workflows/skills/system-prompt) mounted into - # api-rs sandboxes at overlay.mountPath via an overlay-bootstrap init - # container — the same path api-rs's own TOOL_DIRS points at. - - name: CENTAUR_OVERLAY_IMAGE - value: {{ printf "%s:%s" .Values.overlay.image.repository .Values.overlay.image.tag | quote }} - - name: CENTAUR_OVERLAY_IMAGE_PULL_POLICY - value: {{ .Values.overlay.image.pullPolicy | quote }} - - name: CENTAUR_OVERLAY_IMAGE_SOURCE_PATH - value: {{ .Values.overlay.image.sourcePath | quote }} - - name: CENTAUR_OVERLAY_MOUNT_PATH - value: {{ .Values.overlay.mountPath | quote }} -{{- end }} {{- range $name, $value := .Values.apiRs.extraEnv }} - name: {{ $name }} value: {{ $value | quote }} diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 41c6f2108..d1a13c325 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -16,7 +16,7 @@ use centaur_iron_proxy::{ }; use centaur_sandbox_agent_k8s::{ AgentSandboxBackend, AgentSandboxConfig, GitHubTokenRef, IronControlSettings, IronProxyConfig, - OverlayConfig, ToolsConfig, + ToolsConfig, }; use centaur_sandbox_core::{Mount, MountKind, OverlayImage, SandboxSpec}; use centaur_sandbox_local::LocalSandboxBackend; @@ -226,8 +226,6 @@ struct SandboxArgs { workflow_host_command: Option, #[command(flatten)] tools_source: ToolsArgs, - #[command(flatten)] - overlay: OverlayArgs, } impl SandboxArgs { @@ -390,7 +388,7 @@ impl SandboxArgs { .read_only(), ); } - if let Some(overlay) = workflow_overlay_image_from_env() { + if let Some(overlay) = overlay_image_from_env() { spec = spec.overlay_image(overlay); } } @@ -674,7 +672,10 @@ impl TryFrom<&SandboxArgs> for AgentSandboxConfig { } config.iron_control = args.iron_control.settings(); config.tools = args.tools_source.to_config(); - config.overlay = args.overlay.to_config(); + // The same org overlay (and the same CENTAUR_OVERLAY_* env the chart + // already sets) serves every sandbox; workflow hosts set it spec-level + // via `workflow_host_spec`, which then takes precedence in the backend. + config.overlay = overlay_image_from_env(); // iron-control is the only proxy mode: a per-sandbox proxy syncs its // secrets from the control plane, so configuring iron-proxy without // iron-control would produce a non-functional proxy. Fail fast. @@ -764,55 +765,6 @@ impl ToolsArgs { } } -#[derive(Debug, ClapArgs)] -struct OverlayArgs { - #[arg( - id = "overlay_image", - long = "centaur-overlay-image", - env = "CENTAUR_OVERLAY_IMAGE" - )] - image: Option, - #[arg( - id = "overlay_image_pull_policy", - long = "centaur-overlay-image-pull-policy", - env = "CENTAUR_OVERLAY_IMAGE_PULL_POLICY" - )] - image_pull_policy: Option, - #[arg( - id = "overlay_image_source_path", - long = "centaur-overlay-image-source-path", - env = "CENTAUR_OVERLAY_IMAGE_SOURCE_PATH", - default_value = "/overlay" - )] - source_path: String, - // The overlay tree mounts at the same path the api-rs pod uses - // (`overlay.mountPath`), so the agent's `/tools` matches the path - // api-rs discovered tools at. - #[arg( - id = "overlay_mount_path", - long = "centaur-overlay-mount-path", - env = "CENTAUR_OVERLAY_MOUNT_PATH", - default_value = "/app/overlay/org" - )] - mount_path: String, -} - -impl OverlayArgs { - /// `None` when no overlay image is configured (overlay disabled). - fn to_config(&self) -> Option { - let image = clean_optional_value(self.image.as_deref())?; - let mut config = OverlayConfig::new(image); - config.image_pull_policy = self.image_pull_policy.clone(); - if let Some(path) = clean_optional_value(Some(self.source_path.as_str())) { - config.source_path = path; - } - if let Some(path) = clean_optional_value(Some(self.mount_path.as_str())) { - config.mount_path = path; - } - Some(config) - } -} - #[derive(Debug, ClapArgs)] struct IronProxyArgs { #[arg( @@ -1118,7 +1070,10 @@ fn default_workflow_host_path() -> String { .to_string() } -fn workflow_overlay_image_from_env() -> Option { +/// The org overlay image, from the `CENTAUR_OVERLAY_*` env the chart sets. +/// Shared by workflow-host specs and the agent-sandbox backend default — every +/// sandbox mounts the same overlay tree at the same path. +fn overlay_image_from_env() -> Option { let image = env::var("CENTAUR_OVERLAY_IMAGE").ok()?; let source_path = env::var("CENTAUR_OVERLAY_IMAGE_SOURCE_PATH").unwrap_or_else(|_| "/overlay".to_owned()); @@ -1195,6 +1150,16 @@ fn parse_label_selector_arg(value: &str) -> Result, Str mod tests { use super::*; + // Process env is global: tests that set CENTAUR_OVERLAY_* via EnvGuard must + // not interleave, or one test observes another's values. + static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + ENV_MUTEX + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + struct EnvGuard { name: &'static str, old_value: Option, @@ -1304,6 +1269,10 @@ mod tests { #[test] fn tools_and_overlay_config_read_from_flags() { + // The overlay rides the same CENTAUR_OVERLAY_* env the workflow host + // reads — one overlay convention for every sandbox. + let _env_lock = env_lock(); + let _overlay_image = EnvGuard::set("CENTAUR_OVERLAY_IMAGE", "centaur-overlay:test"); let args = Args::try_parse_from([ "centaur-api-server", "--database-url", @@ -1320,8 +1289,6 @@ mod tests { "centaur-agent:test", "--kubernetes-tools-github-token-secret", "centaur-repo-cache-github-token", - "--centaur-overlay-image", - "centaur-overlay:test", ]) .unwrap(); let config = AgentSandboxConfig::try_from(&args.sandbox).unwrap(); @@ -1335,11 +1302,12 @@ mod tests { assert_eq!(token.secret_key, "token"); let overlay = config.overlay.expect("overlay should be Some"); assert_eq!(overlay.image, "centaur-overlay:test"); - assert_eq!(overlay.mount_path, "/app/overlay/org"); + assert_eq!(overlay.mount_path, "/opt/centaur/overlay"); } #[test] fn agent_k8s_workflow_host_mounts_overlay_workflows() { + let _env_lock = env_lock(); let _overlay_image = EnvGuard::set("CENTAUR_OVERLAY_IMAGE", "ghcr.io/example/overlay:test"); let _overlay_pull_policy = EnvGuard::set("CENTAUR_OVERLAY_IMAGE_PULL_POLICY", "Always"); let _overlay_source_path = EnvGuard::set("CENTAUR_OVERLAY_IMAGE_SOURCE_PATH", "/overlay"); diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs index 13420b4a0..d002e4073 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/lib.rs @@ -12,8 +12,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use centaur_iron_control::IronControlClient; use centaur_sandbox_core::{ - MountKind, ObservedSandbox, SandboxBackend, SandboxError, SandboxHandle, SandboxId, SandboxIo, - SandboxResult, SandboxSpec, SandboxStatus, + MountKind, ObservedSandbox, OverlayImage, SandboxBackend, SandboxError, SandboxHandle, + SandboxId, SandboxIo, SandboxResult, SandboxSpec, SandboxStatus, }; use k8s_openapi::api::core::v1::{PersistentVolumeClaim, Pod}; use kube::api::{AttachParams, DeleteParams, ListParams, Patch, PatchParams, PostParams}; @@ -25,7 +25,7 @@ use tokio::time::{Instant, sleep}; pub use generated::agents_x_k8s_io as crd; pub use iron_proxy::IronProxyConfig; -pub use tools::{GitHubTokenRef, OverlayConfig, ToolsConfig}; +pub use tools::{GitHubTokenRef, ToolsConfig}; pub mod generated; mod iron_proxy; @@ -59,9 +59,11 @@ pub struct AgentSandboxConfig { /// git-clones the tools repo into the agent's `/app/tools`, and `TOOL_DIRS` /// is set so the agent's shim installer finds them. pub tools: Option, - /// When set, the gerard overlay tree is mounted into the sandbox (tools, - /// workflows, skills, system-prompt overlay). - pub overlay: Option, + /// When set, the org overlay tree (tools, workflows, skills, system-prompt + /// overlay) is mounted into every sandbox this backend creates, via the same + /// spec-level [`OverlayImage`] plumbing workflow-host sandboxes use. A + /// spec-level overlay takes precedence over this default. + pub overlay: Option, pub ready_timeout: Duration, } @@ -118,7 +120,7 @@ impl AgentSandboxConfig { self } - pub fn overlay(mut self, overlay: OverlayConfig) -> Self { + pub fn overlay(mut self, overlay: OverlayImage) -> Self { self.overlay = Some(overlay); self } @@ -474,16 +476,21 @@ fn build_agent_sandbox( "args", (!spec.args.is_empty()).then(|| spec.args.clone()), ); + // One overlay per sandbox: a spec-level overlay (workflow hosts) wins over + // the backend-wide default, so the two never stack into duplicate + // `overlay-bootstrap`/`overlay-root` names. + let overlay = spec.overlay.as_ref().or(config.overlay.as_ref()); + // Agent container env: spec env + tools/overlay wiring (deduped). `TOOL_DIRS` // is set deterministically here (not via passthrough) so it always matches - // the value the api-rs pod computes for its own tool discovery. + // the paths the bootstrap init containers actually populate in this pod. let mut agent_env: Vec<(String, String)> = spec .env .iter() .map(|env| (env.name.clone(), env.value.clone())) .collect(); - if config.tools.is_some() || config.overlay.is_some() { - for (name, value) in tools::agent_env(config.overlay.as_ref()) { + if config.tools.is_some() || overlay.is_some() { + for (name, value) in tools::agent_env(overlay) { upsert_env(&mut agent_env, &name, value); } } @@ -501,7 +508,7 @@ fn build_agent_sandbox( insert_optional(&mut container, "resources", resources_json(spec)); let (mut volumes, mut volume_mounts) = mount_json(spec); - let mut init_containers = overlay_json(spec, &mut volumes, &mut volume_mounts); + let mut init_containers = overlay_json(overlay, &mut volumes, &mut volume_mounts); if let Some(state_volume) = &config.state_volume { volume_mounts.push(json!({ "name": "state", @@ -512,18 +519,11 @@ fn build_agent_sandbox( volume_mounts.push(iron_proxy::sandbox_ca_volume_mount_json()); volumes.push(iron_proxy::sandbox_ca_volume_json(iron_proxy)); } - // Tool sources and overlay sources are bootstrapped independently into - // emptyDirs by init containers and mounted read-only into the agent at the - // same paths `TOOL_DIRS` points at. - if config.tools.is_some() || config.overlay.is_some() { - volume_mounts.extend(tools::agent_volume_mounts_json( - config.tools.is_some(), - config.overlay.as_ref(), - )); - volumes.extend(tools::volumes_json( - config.tools.as_ref(), - config.overlay.is_some(), - )); + // Tool sources are bootstrapped into an emptyDir by an init container and + // mounted read-only into the agent at the same path `TOOL_DIRS` points at. + if config.tools.is_some() { + volume_mounts.extend(tools::agent_volume_mounts_json(config.tools.is_some())); + volumes.extend(tools::volumes_json(config.tools.as_ref())); } insert_optional( &mut container, @@ -531,8 +531,8 @@ fn build_agent_sandbox( (!volume_mounts.is_empty()).then_some(volume_mounts), ); - // Init containers: tools-bootstrap git-clones the tools repo into /app/tools; - // overlay-bootstrap populates the overlay tree and stages AGENTS_OVERLAY.md. + // tools-bootstrap git-clones the tools repo into /app/tools (the overlay's + // own overlay-bootstrap init container came from `overlay_json` above). if let Some(tools) = &config.tools { // The sandbox NetworkPolicy only allows egress to the per-sandbox proxy // (plus api-rs and DNS), so when iron-proxy is on the clone must ride it. @@ -548,10 +548,10 @@ fn build_agent_sandbox( ca_volume_mount: iron_proxy::sandbox_ca_volume_mount_json(), }) }); - init_containers.push(tools::tools_init_container_json(tools, clone_proxy.as_ref())); - } - if let Some(overlay) = &config.overlay { - init_containers.push(tools::overlay_init_container_json(overlay)); + init_containers.push(tools::tools_init_container_json( + tools, + clone_proxy.as_ref(), + )); } let mut pod_spec = json!({ @@ -560,7 +560,7 @@ fn build_agent_sandbox( "automountServiceAccountToken": false, "enableServiceLinks": false, }); - if config.tools.is_some() || config.overlay.is_some() { + if config.tools.is_some() || overlay.is_some() { pod_spec["securityContext"] = tools::pod_security_context_json(); } insert_optional( @@ -619,12 +619,25 @@ fn build_agent_sandbox( Ok(sandbox) } +// The overlay's `SYSTEM_PROMPT.md` is staged by the init container into a tiny +// shared volume and surfaced to the agent at `$HOME/AGENTS_OVERLAY.md`, which +// the sandbox entrypoint appends to the base prompt. +const OVERLAY_PROMPT_VOLUME: &str = "overlay-prompt"; +const OVERLAY_PROMPT_DIR: &str = "/overlay-prompt"; +const OVERLAY_PROMPT_FILE: &str = "AGENTS_OVERLAY.md"; +const AGENT_OVERLAY_PROMPT_PATH: &str = "/home/agent/AGENTS_OVERLAY.md"; +const OVERLAY_SYSTEM_PROMPT_REL: &str = "services/sandbox/SYSTEM_PROMPT.md"; + +/// The shared overlay plumbing: an `overlay-bootstrap` init container copies the +/// overlay image's tree into the `overlay-root` emptyDir mounted read-only into +/// the main container at `mount_path`, and stages the overlay's +/// `SYSTEM_PROMPT.md` as `$HOME/AGENTS_OVERLAY.md`. fn overlay_json( - spec: &SandboxSpec, + overlay: Option<&OverlayImage>, volumes: &mut Vec, volume_mounts: &mut Vec, ) -> Vec { - let Some(overlay) = &spec.overlay else { + let Some(overlay) = overlay else { return Vec::new(); }; @@ -634,25 +647,45 @@ fn overlay_json( "name": volume_name, "emptyDir": {}, })); + volumes.push(json!({ + "name": OVERLAY_PROMPT_VOLUME, + "emptyDir": {}, + })); volume_mounts.push(json!({ "name": volume_name, "mountPath": overlay.mount_path, "readOnly": true, })); + volume_mounts.push(json!({ + "name": OVERLAY_PROMPT_VOLUME, + "mountPath": AGENT_OVERLAY_PROMPT_PATH, + "subPath": OVERLAY_PROMPT_FILE, + "readOnly": true, + })); let mut init_container = json!({ "name": "overlay-bootstrap", "image": overlay.image, "command": ["/bin/sh", "-ec"], "args": [format!( - "src={}; target={}; mkdir -p \"$target\"; cp -R \"$src\"/. \"$target\"/", + "src={}; target={}; mkdir -p \"$target\"; cp -R \"$src\"/. \"$target\"/; \ + if [ -f \"$target/{OVERLAY_SYSTEM_PROMPT_REL}\" ]; then \ + cp \"$target/{OVERLAY_SYSTEM_PROMPT_REL}\" '{OVERLAY_PROMPT_DIR}/{OVERLAY_PROMPT_FILE}'; \ + else : > '{OVERLAY_PROMPT_DIR}/{OVERLAY_PROMPT_FILE}'; fi", shell_quote(&overlay.source_path), shell_quote(init_mount_path), )], - "volumeMounts": [{ - "name": volume_name, - "mountPath": init_mount_path, - }], + "volumeMounts": [ + { + "name": volume_name, + "mountPath": init_mount_path, + }, + { + "name": OVERLAY_PROMPT_VOLUME, + "mountPath": OVERLAY_PROMPT_DIR, + }, + ], + "securityContext": tools::security_context_json(), }); insert_optional( &mut init_container, @@ -884,7 +917,11 @@ mod tests { #[test] fn builds_agent_sandbox_spec_with_overlay_without_tools() { let spec = SandboxSpec::new("centaur-agent:latest"); - let config = AgentSandboxConfig::new("centaur").overlay(OverlayConfig::new("overlay:test")); + let config = AgentSandboxConfig::new("centaur").overlay(OverlayImage::new( + "overlay:test", + "/overlay", + "/opt/centaur/overlay", + )); let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); let pod_spec = &sandbox.spec.pod_template.spec; @@ -893,10 +930,11 @@ mod tests { let env = container.env.as_ref().unwrap(); assert!(env.iter().any(|env| { env.name == "TOOL_DIRS" - && env.value.as_deref() == Some("/app/tools:/app/overlay/org/tools") + && env.value.as_deref() == Some("/app/tools:/opt/centaur/overlay/tools") })); assert!(env.iter().any(|env| { - env.name == "CENTAUR_OVERLAY_DIR" && env.value.as_deref() == Some("/app/overlay/org") + env.name == "CENTAUR_OVERLAY_DIR" + && env.value.as_deref() == Some("/opt/centaur/overlay") })); let volumes = pod_spec.volumes.as_ref().unwrap(); @@ -921,6 +959,40 @@ mod tests { assert_eq!(init_containers[0].name, "overlay-bootstrap"); } + #[test] + fn spec_level_overlay_wins_over_backend_default() { + // A workflow-host spec carries its own overlay while the backend has a + // default — the two must collapse into ONE overlay-bootstrap/overlay-root + // pair (duplicate names are an invalid pod), with the spec's winning. + let spec = SandboxSpec::new("centaur-agent:latest").overlay_image(OverlayImage::new( + "overlay:spec", + "/overlay", + "/opt/centaur/overlay", + )); + let config = AgentSandboxConfig::new("centaur").overlay(OverlayImage::new( + "overlay:config", + "/overlay", + "/opt/centaur/overlay", + )); + + let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); + let pod_spec = &sandbox.spec.pod_template.spec; + + let init_containers = pod_spec.init_containers.as_ref().unwrap(); + assert_eq!(init_containers.len(), 1); + assert_eq!(init_containers[0].name, "overlay-bootstrap"); + assert_eq!(init_containers[0].image.as_deref(), Some("overlay:spec")); + + let overlay_volumes = pod_spec + .volumes + .as_ref() + .unwrap() + .iter() + .filter(|volume| volume.name == "overlay-root") + .count(); + assert_eq!(overlay_volumes, 1); + } + #[test] fn tools_clone_rides_iron_proxy_when_enabled() { // apply_proxy_env runs before build_agent_sandbox in create(), so the @@ -952,7 +1024,13 @@ mod tests { let config = AgentSandboxConfig::new("centaur") .tools(ToolsConfig::new("paradigmxyz/centaur", "api:test")); let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); - let bootstrap = &sandbox.spec.pod_template.spec.init_containers.as_ref().unwrap()[0]; + let bootstrap = &sandbox + .spec + .pod_template + .spec + .init_containers + .as_ref() + .unwrap()[0]; let script = &bootstrap.command.as_ref().unwrap()[2]; assert!(!script.contains("HTTPS_PROXY")); assert!( @@ -968,8 +1046,8 @@ mod tests { #[test] fn bootstrap_empty_dirs_are_writable_by_agent_uid() { let spec = SandboxSpec::new("centaur-agent:latest"); - let config = - AgentSandboxConfig::new("centaur").tools(ToolsConfig::new("paradigmxyz/centaur", "api:test")); + let config = AgentSandboxConfig::new("centaur") + .tools(ToolsConfig::new("paradigmxyz/centaur", "api:test")); let sandbox = build_agent_sandbox(&SandboxId::new("asbx-test"), &spec, &config).unwrap(); let pod_spec = &sandbox.spec.pod_template.spec; diff --git a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs index f5c0b0ceb..98a1d5a47 100644 --- a/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs +++ b/services/api-rs/crates/centaur-sandbox-agent-k8s/src/tools.rs @@ -8,24 +8,26 @@ //! proxied env (tool placeholder creds + `*_DSN` from `apply_proxy_env`, //! granted per-sandbox by iron-control) — none of that lives here. //! -//! What this module provides is the *sources* the shims install from, mounted -//! INTO the agent container at the SAME paths the api-rs pod's own `TOOL_DIRS` -//! points at (so api-rs's `tool_discovery` and the agent agree on tool paths): +//! What this module provides is the *sources* the shims install from — the same +//! trees api-rs's own `tool_discovery` scans, so the creds api-rs grants match +//! the tools the agent installs: //! //! * a `tools-bootstrap` init container git-clones the tools repo at a pinned //! ref and copies its `source_subdir` into an emptyDir mounted at `/app/tools` //! — the same repo-cache architecture (clone a repo into a pre-provisioned //! directory, no Dockerfile rebuild to add a tool) without sharing the //! repo-cache DaemonSet's node-level cache; -//! * an `overlay-bootstrap` init container copies the org overlay image's tree -//! into the overlay-root emptyDir, mounted at the overlay `mount_path` (and -//! stages the overlay's `SYSTEM_PROMPT.md` as `$HOME/AGENTS_OVERLAY.md`, which -//! the sandbox entrypoint appends to the base prompt). +//! * the org overlay rides the shared spec-level [`OverlayImage`] plumbing +//! (`overlay_json` in `lib.rs`, the same mechanism and `/opt/centaur/overlay` +//! mount workflow-host sandboxes use), which also stages the overlay's +//! `SYSTEM_PROMPT.md` as `$HOME/AGENTS_OVERLAY.md` for the sandbox entrypoint +//! to append to the base prompt. //! //! `TOOL_DIRS` is set explicitly on the agent env to `/app/tools` (or -//! `/app/tools:/tools` when the overlay is configured), matching the -//! value the api-rs Deployment computes for itself. +//! `/app/tools:/tools` when the overlay is configured), pointing at +//! the paths the init containers populate in this pod. +use centaur_sandbox_core::OverlayImage; use serde_json::{Value, json}; const AGENT_UID: i64 = 1001; @@ -42,18 +44,6 @@ const GITHUB_TOKEN_VOLUME: &str = "tools-github-token"; const GITHUB_TOKEN_DIR: &str = "/tools-github-token"; const GITHUB_TOKEN_FILE: &str = "token"; -/// Shared overlay-tree volume (populated by `overlay-bootstrap`). -const OVERLAY_VOLUME: &str = "overlay-root"; - -// The overlay's `SYSTEM_PROMPT.md` is staged by the init container into a tiny -// shared volume and surfaced to the agent at `$HOME/AGENTS_OVERLAY.md`, which the -// sandbox entrypoint appends to the base prompt. -const OVERLAY_PROMPT_VOLUME: &str = "overlay-prompt"; -const OVERLAY_PROMPT_DIR: &str = "/overlay-prompt"; -const OVERLAY_PROMPT_FILE: &str = "AGENTS_OVERLAY.md"; -const AGENT_OVERLAY_PROMPT_PATH: &str = "/home/agent/AGENTS_OVERLAY.md"; -const OVERLAY_SYSTEM_PROMPT_REL: &str = "services/sandbox/SYSTEM_PROMPT.md"; - /// Git source for the base tools tree. When set, every sandbox gets a /// `tools-bootstrap` init container that clones `repo` at `git_ref` and copies /// its `source_subdir` into the agent's `/app/tools` — so adding a tool is a @@ -93,40 +83,7 @@ impl ToolsConfig { } } -/// Org overlay image + where its tree lands in the sandbox. `mount_path` matches -/// the api-rs pod's `overlay.mountPath` so the agent's `/tools` is -/// the same path api-rs discovered tools at. -#[derive(Clone, Debug)] -pub struct OverlayConfig { - pub image: String, - pub image_pull_policy: Option, - /// Path the overlay tree is copied from inside the overlay image. - pub source_path: String, - /// Path the overlay tree is mounted at in the sandbox (e.g. `/app/overlay/org`). - pub mount_path: String, -} - -impl OverlayConfig { - pub fn new(image: impl Into) -> Self { - Self { - image: image.into(), - image_pull_policy: None, - source_path: "/overlay".to_owned(), - mount_path: "/app/overlay/org".to_owned(), - } - } - - /// Parent dir the overlay-root emptyDir is mounted at (so the copy lands at - /// `mount_path`). Falls back to `mount_path` itself if it has no parent. - fn overlay_root(&self) -> &str { - match self.mount_path.rfind('/') { - Some(0) | None => &self.mount_path, - Some(idx) => &self.mount_path[..idx], - } - } -} - -fn security_context_json() -> Value { +pub(crate) fn security_context_json() -> Value { json!({ "allowPrivilegeEscalation": false, "capabilities": {"drop": ["ALL"]}, @@ -146,7 +103,7 @@ pub(crate) fn pod_security_context_json() -> Value { /// `TOOL_DIRS` for the agent: base tools plus the overlay's tools when present. /// Matches the value the api-rs Deployment computes for its own `TOOL_DIRS`. -pub(crate) fn agent_tool_dirs(overlay: Option<&OverlayConfig>) -> String { +pub(crate) fn agent_tool_dirs(overlay: Option<&OverlayImage>) -> String { match overlay { Some(overlay) => format!("{BASE_TOOL_DIR}:{}/tools", overlay.mount_path), None => BASE_TOOL_DIR.to_owned(), @@ -155,7 +112,7 @@ pub(crate) fn agent_tool_dirs(overlay: Option<&OverlayConfig>) -> String { /// Agent env added for tools/overlay wiring: `TOOL_DIRS` (always) and /// `CENTAUR_OVERLAY_DIR` (when the overlay is configured). -pub(crate) fn agent_env(overlay: Option<&OverlayConfig>) -> Vec<(String, String)> { +pub(crate) fn agent_env(overlay: Option<&OverlayImage>) -> Vec<(String, String)> { let mut env = vec![("TOOL_DIRS".to_owned(), agent_tool_dirs(overlay))]; if let Some(overlay) = overlay { env.push(("CENTAUR_OVERLAY_DIR".to_owned(), overlay.mount_path.clone())); @@ -276,45 +233,8 @@ pub(crate) fn tools_init_container_json( container } -/// The `overlay-bootstrap` init container: copies the overlay image's tree into -/// the shared `overlay-root` emptyDir, and stages the overlay's -/// `SYSTEM_PROMPT.md` as `AGENTS_OVERLAY.md` in a small shared volume. -pub(crate) fn overlay_init_container_json(overlay: &OverlayConfig) -> Value { - let script = format!( - "src=\"{src}\"\n\ - target=\"{target}\"\n\ - mkdir -p \"$target\"\n\ - cp -R \"$src\"/. \"$target\"/\n\ - if [ -f \"$target/{prompt_rel}\" ]; then\n\ - \x20 cp \"$target/{prompt_rel}\" \"{prompt_dir}/{prompt_file}\"\n\ - else\n\ - \x20 : > \"{prompt_dir}/{prompt_file}\"\n\ - fi", - src = overlay.source_path, - target = overlay.mount_path, - prompt_rel = OVERLAY_SYSTEM_PROMPT_REL, - prompt_dir = OVERLAY_PROMPT_DIR, - prompt_file = OVERLAY_PROMPT_FILE, - ); - let mut container = json!({ - "name": "overlay-bootstrap", - "image": overlay.image, - "command": ["/bin/sh", "-ec", script], - "volumeMounts": [ - {"name": OVERLAY_VOLUME, "mountPath": overlay.overlay_root()}, - {"name": OVERLAY_PROMPT_VOLUME, "mountPath": OVERLAY_PROMPT_DIR}, - ], - "securityContext": security_context_json(), - }); - if let Some(policy) = &overlay.image_pull_policy { - container["imagePullPolicy"] = json!(policy); - } - container -} - -/// Volumes added to the pod for tool sources (and, when enabled, the overlay -/// tree + prompt-handoff volume). -pub(crate) fn volumes_json(tools: Option<&ToolsConfig>, overlay: bool) -> Vec { +/// Volumes added to the pod for tool sources. +pub(crate) fn volumes_json(tools: Option<&ToolsConfig>) -> Vec { let mut volumes = Vec::new(); if let Some(tools) = tools { volumes.push(json!({"name": TOOLS_VOLUME, "emptyDir": {}})); @@ -329,35 +249,17 @@ pub(crate) fn volumes_json(tools: Option<&ToolsConfig>, overlay: bool) -> Vec) -> Vec { - let mut mounts = Vec::new(); +/// `/app/tools`. (Overlay mounts ride the shared spec-level overlay plumbing.) +pub(crate) fn agent_volume_mounts_json(tools: bool) -> Vec { if tools { - mounts.push(json!({"name": TOOLS_VOLUME, "mountPath": BASE_TOOL_DIR, "readOnly": true})); - } - if let Some(overlay) = overlay { - mounts.push(json!({ - "name": OVERLAY_VOLUME, - "mountPath": overlay.overlay_root(), - "readOnly": true, - })); - mounts.push(json!({ - "name": OVERLAY_PROMPT_VOLUME, - "mountPath": AGENT_OVERLAY_PROMPT_PATH, - "subPath": OVERLAY_PROMPT_FILE, - "readOnly": true, - })); + vec![json!({"name": TOOLS_VOLUME, "mountPath": BASE_TOOL_DIR, "readOnly": true})] + } else { + Vec::new() } - mounts } #[cfg(test)] @@ -365,12 +267,12 @@ mod tests { use super::*; #[test] - fn tool_dirs_match_api_rs_pod_value() { + fn tool_dirs_include_overlay_tools_at_its_mount_path() { assert_eq!(agent_tool_dirs(None), "/app/tools"); - let overlay = OverlayConfig::new("centaur-overlay:test"); + let overlay = OverlayImage::new("centaur-overlay:test", "/overlay", "/opt/centaur/overlay"); assert_eq!( agent_tool_dirs(Some(&overlay)), - "/app/tools:/app/overlay/org/tools" + "/app/tools:/opt/centaur/overlay/tools" ); } @@ -379,28 +281,18 @@ mod tests { let env = agent_env(None); assert_eq!(env, vec![("TOOL_DIRS".to_owned(), "/app/tools".to_owned())]); - let overlay = OverlayConfig::new("centaur-overlay:test"); + let overlay = OverlayImage::new("centaur-overlay:test", "/overlay", "/opt/centaur/overlay"); let env = agent_env(Some(&overlay)); assert!(env.contains(&( "TOOL_DIRS".to_owned(), - "/app/tools:/app/overlay/org/tools".to_owned() + "/app/tools:/opt/centaur/overlay/tools".to_owned() ))); assert!(env.contains(&( "CENTAUR_OVERLAY_DIR".to_owned(), - "/app/overlay/org".to_owned() + "/opt/centaur/overlay".to_owned() ))); } - #[test] - fn overlay_root_is_mount_path_parent() { - let overlay = OverlayConfig::new("img"); - assert_eq!(overlay.overlay_root(), "/app/overlay"); - - let mut shallow = OverlayConfig::new("img"); - shallow.mount_path = "/overlay".to_owned(); - assert_eq!(shallow.overlay_root(), "/overlay"); - } - #[test] fn tools_init_clones_repo_into_emptydir() { let mut tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); @@ -494,10 +386,14 @@ mod tests { assert!(script.contains("/tools-github-token/token")); let mounts = c["volumeMounts"].as_array().unwrap(); assert_eq!(mounts.len(), 2); - assert!(mounts.iter().any(|m| m["mountPath"] == "/tools-github-token")); + assert!( + mounts + .iter() + .any(|m| m["mountPath"] == "/tools-github-token") + ); // The pod gets a secret-backed volume projecting the token to `token`. - let volumes = volumes_json(Some(&tools), false); + let volumes = volumes_json(Some(&tools)); let token_vol = volumes .iter() .find(|v| v["name"] == GITHUB_TOKEN_VOLUME) @@ -512,40 +408,19 @@ mod tests { #[test] fn volumes_without_token_are_just_emptydirs() { let tools = ToolsConfig::new("paradigmxyz/centaur", "centaur-agent:test"); - let volumes = volumes_json(Some(&tools), false); + let volumes = volumes_json(Some(&tools)); assert_eq!(volumes.len(), 1); assert_eq!(volumes[0]["name"], TOOLS_VOLUME); assert!(volumes[0]["emptyDir"].is_object()); } #[test] - fn overlay_init_stages_prompt_and_mounts_root() { - let overlay = OverlayConfig::new("centaur-overlay:test"); - let c = overlay_init_container_json(&overlay); - assert_eq!(c["name"], "overlay-bootstrap"); - let script = c["command"][2].as_str().unwrap(); - assert!(script.contains("target=\"/app/overlay/org\"")); - assert!(script.contains("services/sandbox/SYSTEM_PROMPT.md")); - assert!(script.contains("AGENTS_OVERLAY.md")); - let root_mount = &c["volumeMounts"][0]; - assert_eq!(root_mount["mountPath"], "/app/overlay"); - } - - #[test] - fn agent_mounts_tools_and_overlay_prompt() { - let overlay = OverlayConfig::new("centaur-overlay:test"); - let mounts = agent_volume_mounts_json(true, Some(&overlay)); - // base tools, overlay tree, overlay prompt - assert_eq!(mounts.len(), 3); - assert!(mounts.iter().any(|m| m["mountPath"] == "/app/tools")); - assert!(mounts.iter().any(|m| m["mountPath"] == "/app/overlay")); - let prompt = mounts - .iter() - .find(|m| m["mountPath"] == AGENT_OVERLAY_PROMPT_PATH) - .unwrap(); - assert_eq!(prompt["subPath"], "AGENTS_OVERLAY.md"); - - let mounts = agent_volume_mounts_json(true, None); + fn agent_mounts_tools_read_only() { + let mounts = agent_volume_mounts_json(true); assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0]["mountPath"], "/app/tools"); + assert_eq!(mounts[0]["readOnly"], true); + + assert!(agent_volume_mounts_json(false).is_empty()); } }