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
21 changes: 21 additions & 0 deletions api/v1alpha1/claw_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,8 +408,15 @@ type InlineSource struct {
Content string `json:"content"`

// Mode controls seeding behavior. Default: overwrite.
// Ignored when ReadOnly is true.
// +optional
Mode SeedMode `json:"mode,omitempty"`

// ReadOnly mounts this file directly into the workspace via a
// read-only Kubernetes volume mount instead of seeding it onto the PVC.
// The OpenClaw process cannot modify or delete the file at runtime.
// +optional
ReadOnly bool `json:"readOnly,omitempty"`
}

// ConfigMapRef references a ConfigMap in the same namespace.
Expand Down Expand Up @@ -450,8 +457,15 @@ type ConfigMapSource struct {

// Mode controls seeding behavior for all items in this source.
// Individual items can override this. Default: overwrite.
// Ignored when ReadOnly is true.
// +optional
Mode SeedMode `json:"mode,omitempty"`

// ReadOnly mounts all items directly into the workspace via
// read-only Kubernetes volume mounts instead of seeding them onto the PVC.
// The OpenClaw process cannot modify or delete these files at runtime.
// +optional
ReadOnly bool `json:"readOnly,omitempty"`
}

// GitItem maps a file in a Git repository to a workspace path.
Expand Down Expand Up @@ -496,8 +510,15 @@ type GitSource struct {

// Mode controls seeding behavior for all items in this source.
// Individual items can override this. Default: overwrite.
// Ignored when ReadOnly is true.
// +optional
Mode SeedMode `json:"mode,omitempty"`

// ReadOnly mounts all items directly into the workspace via
// read-only Kubernetes volume mounts instead of seeding them onto the PVC.
// The OpenClaw process cannot modify or delete these files at runtime.
// +optional
ReadOnly bool `json:"readOnly,omitempty"`
}

// WorkspaceSpec configures workspace file seeding.
Expand Down
24 changes: 23 additions & 1 deletion config/crd/bases/claw.sandbox.redhat.com_claws.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -901,10 +901,17 @@ spec:
description: |-
Mode controls seeding behavior for all items in this source.
Individual items can override this. Default: overwrite.
Ignored when ReadOnly is true.
enum:
- overwrite
- seedIfMissing
type: string
readOnly:
description: |-
ReadOnly mounts all items directly into the workspace via
read-only Kubernetes volume mounts instead of seeding them onto the PVC.
The OpenClaw process cannot modify or delete these files at runtime.
type: boolean
required:
- configMapRef
- items
Expand Down Expand Up @@ -957,10 +964,17 @@ spec:
description: |-
Mode controls seeding behavior for all items in this source.
Individual items can override this. Default: overwrite.
Ignored when ReadOnly is true.
enum:
- overwrite
- seedIfMissing
type: string
readOnly:
description: |-
ReadOnly mounts all items directly into the workspace via
read-only Kubernetes volume mounts instead of seeding them onto the PVC.
The OpenClaw process cannot modify or delete these files at runtime.
type: boolean
ref:
description: |-
Ref is the Git reference to check out: branch name, tag, or commit SHA.
Expand Down Expand Up @@ -1009,7 +1023,9 @@ spec:
description: Content is the inline file content.
type: string
mode:
description: 'Mode controls seeding behavior. Default: overwrite.'
description: |-
Mode controls seeding behavior. Default: overwrite.
Ignored when ReadOnly is true.
enum:
- overwrite
- seedIfMissing
Expand All @@ -1019,6 +1035,12 @@ spec:
"SOUL.md", "docs/guide.md").
minLength: 1
type: string
readOnly:
description: |-
ReadOnly mounts this file directly into the workspace via a
read-only Kubernetes volume mount instead of seeding it onto the PVC.
The OpenClaw process cannot modify or delete the file at runtime.
type: boolean
required:
- content
- path
Expand Down
3 changes: 3 additions & 0 deletions internal/controller/claw_resource_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,9 @@ func (r *ClawResourceReconciler) enrichWorkspaceSources(
if err := injectSeedInitContainer(objects, instance, gatewayImage); err != nil {
return fmt.Errorf("failed to inject seed init container: %w", err)
}
if err := injectReadOnlyWorkspaceMounts(objects, instance); err != nil {
return fmt.Errorf("failed to inject read-only workspace mounts: %w", err)
}
return nil
}

Expand Down
104 changes: 104 additions & 0 deletions internal/controller/claw_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
gitSourceMountFn = "/git-sources/"
commitSHALength = 40
configMountPath = "/config/"
workspaceMountPath = "/home/node/.openclaw/workspace/"
)

// seedManifestEntry describes a single file to seed into the workspace.
Expand Down Expand Up @@ -226,6 +227,9 @@ func generateSeedManifest(ws *clawv1alpha1.WorkspaceSpec) []seedManifestEntry {
}

for _, src := range ws.InlineSources {
if src.ReadOnly {
continue
}
entries = append(entries, seedManifestEntry{
Source: configMountPath + workspaceKeyPrefix + encodeWorkspacePath(src.Path),
Target: src.Path,
Expand All @@ -234,6 +238,9 @@ func generateSeedManifest(ws *clawv1alpha1.WorkspaceSpec) []seedManifestEntry {
}

for _, cms := range ws.ConfigMapSources {
if cms.ReadOnly {
continue
}
volName := configMapSourceVolumeName(cms.ConfigMapRef.Name)
for _, item := range cms.Items {
entries = append(entries, seedManifestEntry{
Expand All @@ -245,6 +252,9 @@ func generateSeedManifest(ws *clawv1alpha1.WorkspaceSpec) []seedManifestEntry {
}

for i, gs := range ws.GitSources {
if gs.ReadOnly {
continue
}
for _, item := range gs.Items {
entries = append(entries, seedManifestEntry{
Source: fmt.Sprintf("%s%d/%s", gitSourceMountFn, i, item.RepoPath),
Expand Down Expand Up @@ -709,6 +719,100 @@ func injectSeedInitContainer(
return fmt.Errorf("claw deployment not found in manifests")
}

// injectReadOnlyWorkspaceMounts adds per-item read-only volumeMounts to the
// gateway container for all workspace sources that have ReadOnly set to true.
// Instead of seeding these files onto the PVC, they are mounted directly from
// the source volume (config, ws-cm-*, ws-git-*), giving OS-level EROFS
// enforcement that the OpenClaw process cannot circumvent.
func injectReadOnlyWorkspaceMounts(objects []*unstructured.Unstructured, instance *clawv1alpha1.Claw) error {
ws := instance.Spec.Workspace
if ws == nil {
return nil
}

var mounts []any

for _, src := range ws.InlineSources {
if !src.ReadOnly {
continue
}
mounts = append(mounts, map[string]any{
"name": "config",
"mountPath": workspaceMountPath + src.Path,
"subPath": workspaceKeyPrefix + encodeWorkspacePath(src.Path),
"readOnly": true,
})
}

for _, cms := range ws.ConfigMapSources {
if !cms.ReadOnly {
continue
}
volName := configMapSourceVolumeName(cms.ConfigMapRef.Name)
for _, item := range cms.Items {
mounts = append(mounts, map[string]any{
"name": volName,
"mountPath": workspaceMountPath + item.Path,
"subPath": item.Key,
"readOnly": true,
})
}
}

for i, gs := range ws.GitSources {
if !gs.ReadOnly {
continue
}
volName := gitSourceVolumeName(i)
for _, item := range gs.Items {
mounts = append(mounts, map[string]any{
"name": volName,
"mountPath": workspaceMountPath + item.Path,
"subPath": item.RepoPath,
"readOnly": true,
})
}
}

if len(mounts) == 0 {
return nil
}

gatewayName := getClawDeploymentName(instance.Name)
for _, obj := range objects {
if obj.GetKind() != DeploymentKind || obj.GetName() != gatewayName {
continue
}

containers, found, err := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers")
if err != nil {
return fmt.Errorf("failed to get containers from gateway deployment: %w", err)
}
if !found {
return fmt.Errorf("containers field not found in gateway deployment")
}

for i, c := range containers {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if name, _, _ := unstructured.NestedString(cm, "name"); name != ClawGatewayContainerName {
continue
}
volumeMounts, _, _ := unstructured.NestedSlice(cm, "volumeMounts")
volumeMounts = append(volumeMounts, mounts...)
if err := unstructured.SetNestedSlice(cm, volumeMounts, "volumeMounts"); err != nil {
return fmt.Errorf("failed to set volumeMounts on gateway container: %w", err)
}
containers[i] = cm
return unstructured.SetNestedSlice(obj.Object, containers, "spec", "template", "spec", "containers")
}
return fmt.Errorf("container %q not found in gateway deployment", ClawGatewayContainerName)
}
return fmt.Errorf("gateway deployment %s not found in manifests", gatewayName)
}

// injectSkillFiles validates skill names and writes _skill_ prefixed keys
// into the gateway ConfigMap.
func injectSkillFiles(objects []*unstructured.Unstructured, instance *clawv1alpha1.Claw) error {
Expand Down
Loading
Loading