From 058e0bf2ee62d02a3f19735a101232a7555be598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Reme=C5=A1?= Date: Wed, 1 Jul 2026 14:19:16 +0200 Subject: [PATCH 1/3] fix: add writable home volume for agent container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code Bash tool requires a writable ~/.claude directory on startup. With readOnlyRootFilesystem: true and no writable volume at /home/agent, executions fail with EACCES when the tool tries to mkdir /home/agent/.claude. Add an emptyDir volume "home" mounted at /home/agent in the bootstrap SandboxTemplate, bare-pod PodSpecBuilder, and e2e test sandbox template. Signed-off-by: Tomáš Remeš Assisted-by: Claude Code:claude-opus-4-6 --- controller/proposal/podspec_builder.go | 9 +++++++++ controller/sandbox/bootstrap.go | 10 ++++++++++ test/agent/sandboxtemplate/sandboxtemplate.yaml | 5 +++++ 3 files changed, 24 insertions(+) diff --git a/controller/proposal/podspec_builder.go b/controller/proposal/podspec_builder.go index 1bc3b105..ebd9da66 100644 --- a/controller/proposal/podspec_builder.go +++ b/controller/proposal/podspec_builder.go @@ -61,6 +61,15 @@ func (b *PodSpecBuilder) Build( var volumes []corev1.Volume + volumes = append(volumes, corev1.Volume{ + Name: "home", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }) + container.VolumeMounts = append(container.VolumeMounts, corev1.VolumeMount{ + Name: "home", + MountPath: "/home/agent", + }) + container.Env = append(container.Env, corev1.EnvVar{Name: "LIGHTSPEED_PROVIDER", Value: providerTypeString(llm.Spec.Type)}, corev1.EnvVar{Name: "LIGHTSPEED_MODEL", Value: agent.Spec.Model}, diff --git a/controller/sandbox/bootstrap.go b/controller/sandbox/bootstrap.go index 35b64746..5615102c 100644 --- a/controller/sandbox/bootstrap.go +++ b/controller/sandbox/bootstrap.go @@ -130,6 +130,12 @@ func ensureSandboxTemplate(ctx context.Context, c client.Client, image, namespac "type": "RuntimeDefault", }, }, + "volumeMounts": []any{ + map[string]any{ + "name": "home", + "mountPath": "/home/agent", + }, + }, }, }, "volumes": []any{ @@ -139,6 +145,10 @@ func ensureSandboxTemplate(ctx context.Context, c client.Client, image, namespac "reference": "placeholder:latest", }, }, + map[string]any{ + "name": "home", + "emptyDir": map[string]any{}, + }, }, }, }, diff --git a/test/agent/sandboxtemplate/sandboxtemplate.yaml b/test/agent/sandboxtemplate/sandboxtemplate.yaml index 3cc7d050..35eddce6 100644 --- a/test/agent/sandboxtemplate/sandboxtemplate.yaml +++ b/test/agent/sandboxtemplate/sandboxtemplate.yaml @@ -39,7 +39,12 @@ spec: readOnlyRootFilesystem: true seccompProfile: type: RuntimeDefault + volumeMounts: + - name: home + mountPath: /home/agent volumes: - name: skills image: reference: placeholder:latest + - name: home + emptyDir: {} From 0958f7c5b7508af308d9878b0e76d21439fc711a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Reme=C5=A1?= Date: Fri, 3 Jul 2026 11:02:00 +0200 Subject: [PATCH 2/3] fix: add writable skills-workdir for SDK staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI Agents SDK creates a .agents directory inside the read-only skills image volume at runtime to stage loaded skills. Add a skills-workdir emptyDir volume mounted at /app/skills/.agents so the SDK can write there while keeping skill content read-only. Covered by new assertions in podspec_builder and bootstrap tests. Signed-off-by: Tomáš Remeš Assisted-by: Claude Code:claude-opus-4-6 --- controller/proposal/podspec_builder.go | 11 ++++- controller/proposal/podspec_builder_test.go | 48 ++++++++++++++++++- controller/sandbox/bootstrap.go | 8 ++++ controller/sandbox/bootstrap_test.go | 22 +++++++++ .../sandboxtemplate/sandboxtemplate.yaml | 4 ++ 5 files changed, 90 insertions(+), 3 deletions(-) diff --git a/controller/proposal/podspec_builder.go b/controller/proposal/podspec_builder.go index ebd9da66..ba65f1ee 100644 --- a/controller/proposal/podspec_builder.go +++ b/controller/proposal/podspec_builder.go @@ -214,8 +214,15 @@ func (b *PodSpecBuilder) buildSkills(skills []agenticv1alpha1.SkillsSource) ([]c }, }, } + workdirVol := corev1.Volume{ + Name: "skills-workdir", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + } - var mounts []corev1.VolumeMount + mounts := []corev1.VolumeMount{{ + Name: "skills-workdir", + MountPath: "/app/skills/.agents", + }} if len(s.Paths) > 0 { baseMountPath := "/app/skills" for _, p := range s.Paths { @@ -230,7 +237,7 @@ func (b *PodSpecBuilder) buildSkills(skills []agenticv1alpha1.SkillsSource) ([]c } } - return []corev1.Volume{vol}, mounts + return []corev1.Volume{vol, workdirVol}, mounts } func (b *PodSpecBuilder) buildMCPServers(servers []agenticv1alpha1.MCPServerConfig) ([]corev1.Volume, []corev1.VolumeMount, []corev1.EnvVar, error) { diff --git a/controller/proposal/podspec_builder_test.go b/controller/proposal/podspec_builder_test.go index 3959f38a..4370f136 100644 --- a/controller/proposal/podspec_builder_test.go +++ b/controller/proposal/podspec_builder_test.go @@ -327,6 +327,7 @@ func TestPodSpecBuilder_Skills(t *testing.T) { } foundVolume := false + foundWorkdirVolume := false for _, v := range podSpec.Volumes { if v.Name == "skills" { foundVolume = true @@ -339,12 +340,34 @@ func TestPodSpecBuilder_Skills(t *testing.T) { if v.Image.PullPolicy != corev1.PullAlways { t.Errorf("skills pullPolicy = %v, want PullAlways", v.Image.PullPolicy) } - break + } + if v.Name == "skills-workdir" { + foundWorkdirVolume = true + if v.EmptyDir == nil { + t.Fatal("skills-workdir volume should be emptyDir") + } } } if !foundVolume { t.Error("missing skills volume") } + if !foundWorkdirVolume { + t.Error("missing skills-workdir volume") + } + + container := podSpec.Containers[0] + foundWorkdirMount := false + for _, m := range container.VolumeMounts { + if m.Name == "skills-workdir" && m.MountPath == "/app/skills/.agents" { + foundWorkdirMount = true + if m.ReadOnly { + t.Error("skills-workdir mount should be writable") + } + } + } + if !foundWorkdirMount { + t.Error("missing skills-workdir mount at /app/skills/.agents") + } } func TestPodSpecBuilder_SkillsWithPaths(t *testing.T) { @@ -372,6 +395,7 @@ func TestPodSpecBuilder_SkillsWithPaths(t *testing.T) { foundSearch := false foundAnalyze := false + foundWorkdirMount := false for _, m := range container.VolumeMounts { if m.Name == "skills" { if m.MountPath == "/app/skills/search.md" && m.SubPath == "skills/search.md" { @@ -384,6 +408,12 @@ func TestPodSpecBuilder_SkillsWithPaths(t *testing.T) { t.Error("skills mount should be readOnly") } } + if m.Name == "skills-workdir" && m.MountPath == "/app/skills/.agents" { + foundWorkdirMount = true + if m.ReadOnly { + t.Error("skills-workdir mount should be writable") + } + } } if !foundSearch { t.Error("missing search.md skill mount") @@ -391,6 +421,22 @@ func TestPodSpecBuilder_SkillsWithPaths(t *testing.T) { if !foundAnalyze { t.Error("missing analyze.md skill mount") } + if !foundWorkdirMount { + t.Error("missing skills-workdir mount at /app/skills/.agents") + } + + foundWorkdirVolume := false + for _, v := range podSpec.Volumes { + if v.Name == "skills-workdir" { + foundWorkdirVolume = true + if v.EmptyDir == nil { + t.Fatal("skills-workdir volume should be emptyDir") + } + } + } + if !foundWorkdirVolume { + t.Error("missing skills-workdir volume") + } } func TestPodSpecBuilder_RequiredSecrets_EnvVar(t *testing.T) { diff --git a/controller/sandbox/bootstrap.go b/controller/sandbox/bootstrap.go index 5615102c..30f3f0e2 100644 --- a/controller/sandbox/bootstrap.go +++ b/controller/sandbox/bootstrap.go @@ -135,6 +135,10 @@ func ensureSandboxTemplate(ctx context.Context, c client.Client, image, namespac "name": "home", "mountPath": "/home/agent", }, + map[string]any{ + "name": "skills-workdir", + "mountPath": "/app/skills/.agents", + }, }, }, }, @@ -149,6 +153,10 @@ func ensureSandboxTemplate(ctx context.Context, c client.Client, image, namespac "name": "home", "emptyDir": map[string]any{}, }, + map[string]any{ + "name": "skills-workdir", + "emptyDir": map[string]any{}, + }, }, }, }, diff --git a/controller/sandbox/bootstrap_test.go b/controller/sandbox/bootstrap_test.go index 66da24af..27c1d601 100644 --- a/controller/sandbox/bootstrap_test.go +++ b/controller/sandbox/bootstrap_test.go @@ -71,15 +71,37 @@ func TestEnsureBootstrapResources_CreatesResources(t *testing.T) { volumes, _, _ := unstructured.NestedSlice(tmpl.Object, "spec", "podTemplate", "spec", "volumes") foundSkills := false + foundWorkdirVolume := false for _, v := range volumes { vol := v.(map[string]any) if vol["name"] == "skills" { foundSkills = true } + if vol["name"] == "skills-workdir" { + foundWorkdirVolume = true + if vol["emptyDir"] == nil { + t.Error("skills-workdir volume should be emptyDir") + } + } } if !foundSkills { t.Error("SandboxTemplate missing skills volume") } + if !foundWorkdirVolume { + t.Error("SandboxTemplate missing skills-workdir volume") + } + + mounts, _, _ := unstructured.NestedSlice(container, "volumeMounts") + foundWorkdirMount := false + for _, m := range mounts { + mount := m.(map[string]any) + if mount["name"] == "skills-workdir" && mount["mountPath"] == "/app/skills/.agents" { + foundWorkdirMount = true + } + } + if !foundWorkdirMount { + t.Error("SandboxTemplate missing skills-workdir mount at /app/skills/.agents") + } lbls := tmpl.GetLabels() if lbls["app.kubernetes.io/managed-by"] != "lightspeed-operator" { diff --git a/test/agent/sandboxtemplate/sandboxtemplate.yaml b/test/agent/sandboxtemplate/sandboxtemplate.yaml index 35eddce6..984ac011 100644 --- a/test/agent/sandboxtemplate/sandboxtemplate.yaml +++ b/test/agent/sandboxtemplate/sandboxtemplate.yaml @@ -42,9 +42,13 @@ spec: volumeMounts: - name: home mountPath: /home/agent + - name: skills-workdir + mountPath: /app/skills/.agents volumes: - name: skills image: reference: placeholder:latest - name: home emptyDir: {} + - name: skills-workdir + emptyDir: {} From 537034d01ada8003d11ff26af83c45bb86acc111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Reme=C5=A1?= Date: Tue, 7 Jul 2026 12:23:18 +0200 Subject: [PATCH 3/3] test: add unit test coverage for home emptyDir volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert that the writable home volume (emptyDir at /home/agent) exists in both PodSpecBuilder output and bootstrap SandboxTemplate, matching the existing coverage for skills-workdir. Signed-off-by: Tomáš Remeš Assisted-by: Claude Code:claude-opus-4-6 --- controller/proposal/podspec_builder_test.go | 27 +++++++++++++++++++++ controller/sandbox/bootstrap_test.go | 17 +++++++++++++ 2 files changed, 44 insertions(+) diff --git a/controller/proposal/podspec_builder_test.go b/controller/proposal/podspec_builder_test.go index 4370f136..4187c45e 100644 --- a/controller/proposal/podspec_builder_test.go +++ b/controller/proposal/podspec_builder_test.go @@ -100,6 +100,18 @@ func TestPodSpecBuilder_Anthropic(t *testing.T) { t.Errorf("missing credential volume mount at %s", llmCredsMountPath) } + // Verify home volume mount + foundHomeMount := false + for _, m := range container.VolumeMounts { + if m.Name == "home" && m.MountPath == "/home/agent" { + foundHomeMount = true + break + } + } + if !foundHomeMount { + t.Error("missing home volume mount at /home/agent") + } + // Verify volume foundVolume := false for _, v := range podSpec.Volumes { @@ -118,6 +130,21 @@ func TestPodSpecBuilder_Anthropic(t *testing.T) { t.Error("missing llm-credentials volume") } + // Verify home volume + foundHomeVolume := false + for _, v := range podSpec.Volumes { + if v.Name == "home" { + foundHomeVolume = true + if v.EmptyDir == nil { + t.Fatal("home volume should be emptyDir") + } + break + } + } + if !foundHomeVolume { + t.Error("missing home emptyDir volume") + } + // Verify readiness probe if container.ReadinessProbe == nil { t.Fatal("readinessProbe is nil") diff --git a/controller/sandbox/bootstrap_test.go b/controller/sandbox/bootstrap_test.go index 27c1d601..284fcc0a 100644 --- a/controller/sandbox/bootstrap_test.go +++ b/controller/sandbox/bootstrap_test.go @@ -71,12 +71,19 @@ func TestEnsureBootstrapResources_CreatesResources(t *testing.T) { volumes, _, _ := unstructured.NestedSlice(tmpl.Object, "spec", "podTemplate", "spec", "volumes") foundSkills := false + foundHomeVolume := false foundWorkdirVolume := false for _, v := range volumes { vol := v.(map[string]any) if vol["name"] == "skills" { foundSkills = true } + if vol["name"] == "home" { + foundHomeVolume = true + if vol["emptyDir"] == nil { + t.Error("home volume should be emptyDir") + } + } if vol["name"] == "skills-workdir" { foundWorkdirVolume = true if vol["emptyDir"] == nil { @@ -87,18 +94,28 @@ func TestEnsureBootstrapResources_CreatesResources(t *testing.T) { if !foundSkills { t.Error("SandboxTemplate missing skills volume") } + if !foundHomeVolume { + t.Error("SandboxTemplate missing home volume") + } if !foundWorkdirVolume { t.Error("SandboxTemplate missing skills-workdir volume") } mounts, _, _ := unstructured.NestedSlice(container, "volumeMounts") + foundHomeMount := false foundWorkdirMount := false for _, m := range mounts { mount := m.(map[string]any) + if mount["name"] == "home" && mount["mountPath"] == "/home/agent" { + foundHomeMount = true + } if mount["name"] == "skills-workdir" && mount["mountPath"] == "/app/skills/.agents" { foundWorkdirMount = true } } + if !foundHomeMount { + t.Error("SandboxTemplate missing home mount at /home/agent") + } if !foundWorkdirMount { t.Error("SandboxTemplate missing skills-workdir mount at /app/skills/.agents") }