Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1306,7 +1306,7 @@ The operator follows a **secure-by-default** philosophy. Every instance ships wi
### Defaults

- **Non-root execution**: containers run as UID 1000; root (UID 0) is blocked by the validating webhook (exception: Ollama sidecar requires root per the official image)
- **Read-only root filesystem**: enabled by default for the main container and the Chromium sidecar; the PVC at `~/.openclaw/` provides writable home, and a `/tmp` emptyDir handles temp files
- **Read-only root filesystem**: enabled by default for the main container and the Chromium sidecar; the PVC at `~/.openclaw/` provides writable home, and a `/tmp` emptyDir handles temp files. The `init-tmp-dir` init container gives that `/tmp` the sticky bit (mode `1777`), which OpenClaw 2026.9.6 and later require before they create temporary workspaces; the root of an emptyDir is `2777` under `fsGroup` and cannot be changed from the pod spec
- **All capabilities dropped**: no ambient Linux capabilities
- **Seccomp RuntimeDefault**: syscall filtering enabled
- **Default-deny NetworkPolicy**: DNS (53) and HTTPS (443) egress allowed by default; set `security.networkPolicy.allowHTTPS: false` and use `additionalEgress` to restrict HTTPS destinations (see [Network Isolation](docs/architecture.md#network-isolation)). Ingress is limited to the same namespace. Metrics ingress is a separate rule that can be restricted independently -- see [Restricting who can scrape metrics](#restricting-who-can-scrape-metrics)
Expand Down
31 changes: 17 additions & 14 deletions internal/resources/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions internal/resources/statefulset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
}
Expand Down Expand Up @@ -674,6 +677,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))
Expand Down Expand Up @@ -1565,6 +1572,72 @@ 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)

// Run as the gateway's effective UID so the directory belongs to the user
// that writes to it (and so it matches the other operator init containers;
// see #619). Falls back to the pod UID when no container UID is set.
mainSC := buildContainerSecurityContext(instance)

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),
RunAsUser: mainSC.RunAsUser,
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"
Expand Down
173 changes: 173 additions & 0 deletions internal/resources/tmp_dir_test.go
Original file line number Diff line number Diff line change
@@ -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 <script>", c.Command)
}
script := c.Command[2]
for _, want := range []string{
"mkdir -p /tmp-volume/" + MainTmpSubPath,
"chmod 1777 /tmp-volume/" + MainTmpSubPath,
} {
if !strings.Contains(script, want) {
t.Errorf("script %q does not contain %q", script, want)
}
}

// The subdirectory must be created inside the volume the main container
// mounts, seen from its root.
if len(c.VolumeMounts) != 1 || c.VolumeMounts[0].Name != "tmp" || c.VolumeMounts[0].SubPath != "" {
t.Errorf("volume mounts = %+v, want the tmp volume root only", c.VolumeMounts)
}
if c.VolumeMounts[0].MountPath != "/tmp-volume" {
t.Errorf("mount path = %q, want /tmp-volume", c.VolumeMounts[0].MountPath)
}

// It has to own the directory it creates, so it must run as the pod UID
// (no runAsUser override), and it must stay admissible under the
// restricted Pod Security Standard.
sc := c.SecurityContext
if sc == nil {
t.Fatal("security context is nil")
}
if sc.RunAsUser != nil {
t.Errorf("runAsUser = %d, want unset so the pod UID owns the directory", *sc.RunAsUser)
}
if sc.RunAsNonRoot == nil || !*sc.RunAsNonRoot {
t.Error("runAsNonRoot should be true")
}
if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
t.Error("allowPrivilegeEscalation should be false")
}
if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem {
t.Error("readOnlyRootFilesystem should be true")
}
if sc.Capabilities == nil || len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" || len(sc.Capabilities.Add) != 0 {
t.Errorf("capabilities = %+v, want drop ALL and no additions", sc.Capabilities)
}
if sc.SeccompProfile == nil || sc.SeccompProfile.Type != corev1.SeccompProfileTypeRuntimeDefault {
t.Error("seccomp profile should be RuntimeDefault")
}

// Kubernetes defaults must be set explicitly to avoid reconcile loops.
if c.TerminationMessagePath != corev1.TerminationMessagePathDefault || c.TerminationMessagePolicy != corev1.TerminationMessageReadFile {
t.Error("termination message defaults should be set explicitly")
}
if c.ImagePullPolicy != corev1.PullIfNotPresent {
t.Errorf("imagePullPolicy = %q, want IfNotPresent", c.ImagePullPolicy)
}
}

func TestBuildStatefulSet_TmpDirInitContainer_AlwaysPresent(t *testing.T) {
// The main container cannot start without the subdirectory, so the init
// container must not depend on any optional feature, including the root
// ownership fix that restricted namespaces switch off.
instance := newTestInstance("sticky-tmp-minimal")
instance.Spec.Storage.FixOwnership = Ptr(false)
sts := BuildStatefulSet(instance, "", nil, nil, nil)
findInitContainer(t, sts.Spec.Template.Spec.InitContainers, TmpDirInitContainerName)
}

func TestBuildStatefulSet_TmpDirInitContainer_BeforeCustomInitContainers(t *testing.T) {
instance := newTestInstance("sticky-tmp-order")
instance.Spec.InitContainers = []corev1.Container{{Name: "user-init", Image: "busybox"}}
sts := BuildStatefulSet(instance, "", nil, nil, nil)
initContainers := sts.Spec.Template.Spec.InitContainers

tmpIdx, _ := findInitContainer(t, initContainers, TmpDirInitContainerName)
userIdx, _ := findInitContainer(t, initContainers, "user-init")
if tmpIdx > userIdx {
t.Errorf("init-tmp-dir (index %d) should run before custom init containers (index %d)", tmpIdx, userIdx)
}
}

func TestBuildStatefulSet_TmpDirInitContainer_RegistryOverride(t *testing.T) {
instance := newTestInstance("sticky-tmp-registry")
instance.Spec.Registry = "registry.example.com"
sts := BuildStatefulSet(instance, "", nil, nil, nil)
_, c := findInitContainer(t, sts.Spec.Template.Spec.InitContainers, TmpDirInitContainerName)
if !strings.HasPrefix(c.Image, "registry.example.com/") {
t.Errorf("image = %q, want the registry override applied", c.Image)
}
}
1 change: 1 addition & 0 deletions internal/webhook/openclawinstance_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,7 @@ var reservedInitContainerNames = map[string]bool{
"init-plugin-scratch": true,
"init-ollama": true,
"init-plugin-runtime-deps": true,
"init-tmp-dir": true,
}

// validateInitContainers checks custom init container names.
Expand Down
Loading
Loading