From 2b514fa09cb3e4f2ff68e656023c2662ec3c73fb Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Sun, 4 Oct 2026 01:09:44 -0400 Subject: [PATCH 1/5] fix: give the main container a sticky /tmp --- internal/resources/resources_test.go | 31 ++--- internal/resources/statefulset.go | 67 +++++++++++ internal/resources/tmp_dir_test.go | 173 +++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 internal/resources/tmp_dir_test.go diff --git a/internal/resources/resources_test.go b/internal/resources/resources_test.go index ed0242a8..609de344 100644 --- a/internal/resources/resources_test.go +++ b/internal/resources/resources_test.go @@ -879,8 +879,8 @@ func TestBuildStatefulSet_ConfigVolume_RawConfig(t *testing.T) { // Init container should copy config from ConfigMap to data volume initContainers := sts.Spec.Template.Spec.InitContainers - if len(initContainers) != 5 { - t.Fatalf("expected 5 init containers (init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps), got %d", len(initContainers)) + if len(initContainers) != 6 { + t.Fatalf("expected 6 init containers (init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps + init-tmp-dir), got %d", len(initContainers)) } initC := initContainers[1] if initC.Name != "init-config" { @@ -924,8 +924,8 @@ func TestBuildStatefulSet_ConfigVolume_ConfigMapRef(t *testing.T) { // The controller reads the external CM and writes enriched content into the // operator-managed CM under "openclaw.json", so the init container always uses that key. initContainers := sts.Spec.Template.Spec.InitContainers - if len(initContainers) != 5 { - t.Fatalf("expected 5 init containers (init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps), got %d", len(initContainers)) + if len(initContainers) != 6 { + t.Fatalf("expected 6 init containers (init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps + init-tmp-dir), got %d", len(initContainers)) } initC := initContainers[1] assertVolumeMount(t, initC.VolumeMounts, "data", "/data") @@ -959,8 +959,8 @@ func TestBuildStatefulSet_ConfigMapRef_DefaultKey(t *testing.T) { // Init container should use "openclaw.json" (operator-managed key) initContainers := sts.Spec.Template.Spec.InitContainers - if len(initContainers) != 5 { - t.Fatalf("expected 5 init containers (init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps), got %d", len(initContainers)) + if len(initContainers) != 6 { + t.Fatalf("expected 6 init containers (init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps + init-tmp-dir), got %d", len(initContainers)) } expectedPrefix := "cp /config/'openclaw.json' /data/openclaw.json" if !strings.HasPrefix(initContainers[1].Command[2], expectedPrefix) { @@ -983,9 +983,9 @@ func TestBuildStatefulSet_VanillaDeployment_HasInitContainer(t *testing.T) { sts := BuildStatefulSet(instance, "", nil, nil, nil) - // Vanilla deployments get init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps - if len(sts.Spec.Template.Spec.InitContainers) != 5 { - t.Fatalf("expected 5 init containers for vanilla deployment, got %d", len(sts.Spec.Template.Spec.InitContainers)) + // Vanilla deployments get init-data-owner + init-config + init-uv + init-pip + init-plugin-runtime-deps + init-tmp-dir + if len(sts.Spec.Template.Spec.InitContainers) != 6 { + t.Fatalf("expected 6 init containers for vanilla deployment, got %d", len(sts.Spec.Template.Spec.InitContainers)) } if sts.Spec.Template.Spec.InitContainers[1].Name != "init-config" { t.Errorf("init container name = %q, want %q", sts.Spec.Template.Spec.InitContainers[1].Name, "init-config") @@ -8029,8 +8029,8 @@ func TestBuildStatefulSet_CustomInitContainers_AfterOperatorManaged(t *testing.T sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers - if len(initContainers) != 7 { - t.Fatalf("expected 7 init containers, got %d", len(initContainers)) + if len(initContainers) != 8 { + t.Fatalf("expected 8 init containers, got %d", len(initContainers)) } if initContainers[0].Name != "init-data-owner" { t.Errorf("initContainers[0] = %q, want init-data-owner", initContainers[0].Name) @@ -8050,8 +8050,11 @@ func TestBuildStatefulSet_CustomInitContainers_AfterOperatorManaged(t *testing.T if initContainers[5].Name != "init-skills" { t.Errorf("initContainers[5] = %q, want init-skills", initContainers[5].Name) } - if initContainers[6].Name != "user-init" { - t.Errorf("initContainers[6] = %q, want user-init", initContainers[6].Name) + if initContainers[6].Name != "init-tmp-dir" { + t.Errorf("initContainers[6] = %q, want init-tmp-dir", initContainers[6].Name) + } + if initContainers[7].Name != "user-init" { + t.Errorf("initContainers[7] = %q, want user-init", initContainers[7].Name) } } @@ -8422,7 +8425,7 @@ func TestBuildStatefulSet_RuntimeDeps_InitContainerOrder(t *testing.T) { sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers - expected := []string{"init-data-owner", "init-config", "init-uv", "init-pip", "init-plugin-runtime-deps", "init-pnpm", "init-python", "init-skills", "user-init"} + expected := []string{"init-data-owner", "init-config", "init-uv", "init-pip", "init-plugin-runtime-deps", "init-pnpm", "init-python", "init-skills", "init-tmp-dir", "user-init"} if len(initContainers) != len(expected) { t.Fatalf("expected %d init containers, got %d: %v", len(expected), len(initContainers), func() []string { diff --git a/internal/resources/statefulset.go b/internal/resources/statefulset.go index 85c79c93..217bda94 100644 --- a/internal/resources/statefulset.go +++ b/internal/resources/statefulset.go @@ -337,9 +337,12 @@ func buildMainContainer(instance *openclawv1alpha1.OpenClawInstance, gatewayToke MountPath: "/home/openclaw/.config", SubPath: ".config", }, + // Mounted through a subdirectory that init-tmp-dir creates with the + // sticky bit. See buildTmpDirInitContainer. { Name: "tmp", MountPath: "/tmp", + SubPath: MainTmpSubPath, }, }, } @@ -671,6 +674,10 @@ func buildInitContainers(instance *openclawv1alpha1.OpenClawInstance, externalWo } } + // Sticky /tmp for the main container. Always present: the main container + // mounts the subdirectory this creates. + initContainers = append(initContainers, buildTmpDirInitContainer(instance)) + // Ollama model-pulling init container (only if enabled and models are specified) if instance.Spec.Ollama.Enabled && len(instance.Spec.Ollama.Models) > 0 { initContainers = append(initContainers, buildOllamaModelPullInitContainer(instance)) @@ -1544,6 +1551,66 @@ pnpm --version` } } +// TmpDirInitContainerName is the name of the init container that prepares the +// main container's /tmp. +const TmpDirInitContainerName = "init-tmp-dir" + +// MainTmpSubPath is the subdirectory of the "tmp" emptyDir that the main +// container mounts at /tmp. +const MainTmpSubPath = "tmp" + +// buildTmpDirInitContainer creates the init container that prepares a /tmp +// with the sticky bit for the main container. +// +// The main container has a read-only root filesystem, so /tmp is an emptyDir. +// With fsGroup set, the kubelet makes the root of an emptyDir group-owned by +// fsGroup with mode 2777: world-writable, setgid, no sticky bit. OpenClaw +// 2026.9.6 and later refuse to create a temporary workspace below such a +// directory ("temp workspace ancestor is group/world writable without sticky +// protection"). The startup doctor needs that workspace whenever it has to +// install a configured plugin, so the gateway exits and the pod crash-loops. +// #616 fixed the same check for init-plugins, which can use the image's own +// /tmp because its root filesystem is writable. +// +// Nothing in the pod spec can set the mode of a volume root, and the pod UID +// does not own it, so it cannot chmod it either. A directory the pod UID +// creates inside the volume is its own, though. This container creates one +// with mode 1777 and the main container mounts it via subPath, which hides the +// 2777 volume root from the main container's path ancestry. +// +// It runs as the pod UID with no capabilities, so it is admitted under the +// "restricted" Pod Security Standard. +func buildTmpDirInitContainer(instance *openclawv1alpha1.OpenClawInstance) corev1.Container { + // chmod after mkdir: the setgid volume root and the umask both influence + // the mode mkdir produces, and a restart of the pod sandbox finds the + // directory already present. + script := fmt.Sprintf("set -e\nmkdir -p /tmp-volume/%[1]s\nchmod 1777 /tmp-volume/%[1]s", MainTmpSubPath) + + return corev1.Container{ + Name: TmpDirInitContainerName, + Image: ApplyRegistryOverride("docker.io/library/busybox:1.37", instance.Spec.Registry), + Command: []string{"sh", "-c", script}, + ImagePullPolicy: corev1.PullIfNotPresent, + TerminationMessagePath: corev1.TerminationMessagePathDefault, + TerminationMessagePolicy: corev1.TerminationMessageReadFile, + Resources: corev1.ResourceRequirements{}, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: Ptr(false), + ReadOnlyRootFilesystem: Ptr(true), + RunAsNonRoot: Ptr(true), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: "tmp", MountPath: "/tmp-volume"}, + }, + } +} + // DataOwnerInitContainerName is the name of the init container that fixes // ownership of the data volume root. const DataOwnerInitContainerName = "init-data-owner" diff --git a/internal/resources/tmp_dir_test.go b/internal/resources/tmp_dir_test.go new file mode 100644 index 00000000..ad1ef752 --- /dev/null +++ b/internal/resources/tmp_dir_test.go @@ -0,0 +1,173 @@ +/* +Copyright 2026 Paperclip Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resources + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +// OpenClaw >= 2026.9.6 refuses to create a temporary workspace below a +// directory that is group/world writable without the sticky bit. The root of +// an emptyDir is exactly that (mode 2777) once fsGroup is applied, so the main +// container must get its /tmp from a subdirectory that init-tmp-dir creates +// with mode 1777. + +func findInitContainer(t *testing.T, containers []corev1.Container, name string) (int, *corev1.Container) { + t.Helper() + for i := range containers { + if containers[i].Name == name { + return i, &containers[i] + } + } + t.Fatalf("init container %q not found", name) + return -1, nil +} + +func TestBuildStatefulSet_MainTmpIsMountedThroughStickySubPath(t *testing.T) { + instance := newTestInstance("sticky-tmp") + sts := BuildStatefulSet(instance, "", nil, nil, nil) + + var main *corev1.Container + for i := range sts.Spec.Template.Spec.Containers { + if sts.Spec.Template.Spec.Containers[i].Name == "openclaw" { + main = &sts.Spec.Template.Spec.Containers[i] + } + } + if main == nil { + t.Fatal("openclaw container not found") + } + + var tmpMounts int + for _, m := range main.VolumeMounts { + if m.MountPath != "/tmp" { + continue + } + tmpMounts++ + if m.Name != "tmp" { + t.Errorf("/tmp volume = %q, want tmp", m.Name) + } + if m.SubPath != MainTmpSubPath { + t.Errorf("/tmp subPath = %q, want %q: mounting the emptyDir root exposes its 2777 mode", m.SubPath, MainTmpSubPath) + } + } + if tmpMounts != 1 { + t.Fatalf("main container has %d mounts at /tmp, want 1", tmpMounts) + } + + tmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "tmp") + if tmpVol == nil || tmpVol.EmptyDir == nil { + t.Fatal("tmp volume should be an emptyDir") + } +} + +func TestBuildStatefulSet_TmpDirInitContainer(t *testing.T) { + instance := newTestInstance("sticky-tmp-init") + sts := BuildStatefulSet(instance, "", nil, nil, nil) + _, c := findInitContainer(t, sts.Spec.Template.Spec.InitContainers, TmpDirInitContainerName) + + if len(c.Command) != 3 { + t.Fatalf("command = %v, want sh -c