Skip to content
Merged
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
3 changes: 2 additions & 1 deletion cmd/claw/skill_data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,9 @@ Clawdapus refuses to start containers when:
- Bearer token is auto-injected; don't set it manually

### Config injection issues (OpenClaw)
- Config dir (`/app/config`) must be bind-mounted as directory, not file
- Config dir (`/root/.openclaw/config`) must be bind-mounted as directory, not file
- OpenClaw does atomic writes via rename — file-only mounts cause EBUSY
- OpenClaw home is canonical `~/.openclaw` (`/root/.openclaw`) rather than a separate `/app/state` shim
- Check generated `openclaw.json` in the runtime directory
- OpenClaw health: `claw health -f <pod>.yml`

Expand Down
2 changes: 1 addition & 1 deletion cmd/claw/spike_mixed_managed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ services:
if !openSvc.ReadOnly {
t.Fatalf("expected open service to remain read_only")
}
if openSvc.Environment["OPENCLAW_CONFIG_PATH"] != "/app/config/openclaw.json" {
if openSvc.Environment["OPENCLAW_CONFIG_PATH"] != "/root/.openclaw/config/openclaw.json" {
t.Fatalf("unexpected open OPENCLAW_CONFIG_PATH: %q", openSvc.Environment["OPENCLAW_CONFIG_PATH"])
}

Expand Down
8 changes: 4 additions & 4 deletions cmd/claw/spike_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,8 @@ func TestSpikeComposeUp(t *testing.T) {
// ── Verify compose.generated.yml ────────────────────────────────────────

composeSrc := spikeReadFile(t, generatedPath)
if !strings.Contains(composeSrc, "/app/config") {
t.Errorf("compose.generated.yml: expected to contain %q", "/app/config")
if !strings.Contains(composeSrc, "/root/.openclaw/config") {
t.Errorf("compose.generated.yml: expected to contain %q", "/root/.openclaw/config")
}
if !strings.Contains(composeSrc, "cllama:") {
t.Errorf("compose.generated.yml: expected cllama service")
Expand Down Expand Up @@ -409,15 +409,15 @@ func TestSpikeComposeUp(t *testing.T) {
spikeWaitRunning(t, containerName, 45*time.Second)

// Config file must be readable inside container and contain 'discord'
out, err := exec.Command("docker", "exec", containerName, "cat", "/app/config/openclaw.json").Output()
out, err := exec.Command("docker", "exec", containerName, "cat", "/root/.openclaw/config/openclaw.json").Output()
if err != nil {
t.Errorf("docker exec cat openclaw.json: %v", err)
} else if !strings.Contains(string(out), "discord") {
t.Errorf("openclaw.json in container doesn't contain 'discord': %q", string(out))
}

// jobs.json must be readable and contain the real channel ID
out2, err2 := exec.Command("docker", "exec", containerName, "cat", "/app/config/cron/jobs.json").Output()
out2, err2 := exec.Command("docker", "exec", containerName, "cat", "/root/.openclaw/config/cron/jobs.json").Output()
if err2 != nil {
t.Errorf("docker exec cat jobs.json: %v", err2)
} else if !strings.Contains(string(out2), channelID) {
Expand Down
4 changes: 2 additions & 2 deletions examples/trading-desk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ the required per-service Discord IDs/topology are missing.
| `openclaw.json` cllama rewrite | `models.providers.<provider>.baseUrl` points to `cllama`, `apiKey` is per-agent token |
| `jobs.json` structure | `agentTurn` payload, `delivery.to` = resolved channel ID |
| cllama context files | `.claw-runtime/context/<agent>/` contains `AGENTS.md`, `CLAWDAPUS.md`, `metadata.json` |
| Compose mounts | `/app/config` directory |
| Container readability | `/app/config/openclaw.json`, `/app/config/cron/jobs.json`, `/claw/AGENTS.md` |
| Compose mounts | `/root/.openclaw/config` directory |
| Container readability | `/root/.openclaw/config/openclaw.json`, `/root/.openclaw/config/cron/jobs.json`, `/claw/AGENTS.md` |
| Skills populated | `/claw/skills/` contains extracted skill files |
| Health check | Docker healthcheck reports healthy |
| Discord greetings | Messages appear in the channel via REST API polling |
1 change: 1 addition & 0 deletions internal/driver/openclaw/baseimage.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ wait "$GATEWAY_PID"
ENTRYPOINT_EOF
RUN chmod +x /usr/local/bin/openclaw-entrypoint.sh

USER root
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/openclaw-entrypoint.sh"]
`

Expand Down
3 changes: 3 additions & 0 deletions internal/driver/openclaw/baseimage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ func TestBaseImageProvider(t *testing.T) {
if !strings.Contains(dockerfile, "openclaw.ai/install.sh") {
t.Fatal("Dockerfile should install openclaw")
}
if !strings.Contains(dockerfile, "\nUSER root\n") {
t.Fatal("Dockerfile should explicitly run as root so ~/.openclaw resolves to /root/.openclaw")
}
if !strings.Contains(dockerfile, "/usr/local/bin/openclaw-entrypoint.sh") {
t.Fatal("Dockerfile should install the entrypoint outside /claw")
}
Expand Down
37 changes: 18 additions & 19 deletions internal/driver/openclaw/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import (

type Driver struct{}

const openclawWorkspaceTmpfs = "/claw:mode=1777,uid=1000,gid=1000"
const openclawStateTmpfs = "/app/state:mode=1777,uid=1000,gid=1000"
const openclawHomeDir = "/root/.openclaw"
const openclawConfigDir = openclawHomeDir + "/config"
const openclawConfigPath = openclawConfigDir + "/openclaw.json"
const openclawWorkspaceTmpfs = "/claw:mode=1777,uid=0,gid=0"
const openclawStateTmpfs = openclawHomeDir + ":mode=1777,uid=0,gid=0"

func init() {
driver.Register("openclaw", &Driver{})
Expand Down Expand Up @@ -85,8 +88,11 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt
{
// Bind-mount the directory (not the file) so openclaw can write temp files
// alongside the config during atomic save operations.
// This lives under the canonical ~/.openclaw home on purpose: state and config
// share the upstream layout, but OPENCLAW_CONFIG_PATH still keeps config access
// explicit rather than inferred from OPENCLAW_STATE_DIR.
HostPath: configDir,
ContainerPath: "/app/config",
ContainerPath: openclawConfigDir,
ReadOnly: false,
},
{
Expand All @@ -111,7 +117,7 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt

// Generate jobs.json if there are scheduled invocations.
// OpenClaw 2026.3.24 resolves its cron store under CONFIG_DIR/cron/jobs.json,
// so keep it under the writable config directory instead of /app/state.
// so keep it under the writable config directory inside the canonical home.
if len(rc.Invocations) > 0 {
jobsData, err := GenerateJobsJSON(rc)
if err != nil {
Expand Down Expand Up @@ -142,18 +148,11 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt
env := map[string]string{
"CLAW_MANAGED": "true",
shared.PortableMemoryEnv: shared.PortableMemoryDir,
"OPENCLAW_CONFIG_PATH": "/app/config/openclaw.json",
"OPENCLAW_STATE_DIR": "/app/state",
// SHIM(openclaw/openclaw#29736): exec-approvals path resolution uses OPENCLAW_HOME
// to expand ~/.openclaw/exec-approvals.{json,sock} before OPENCLAW_STATE_DIR is
// consulted. Without this the approval layer tries to mkdir inside the read-only
// container home dir and every exec tool call fails with ENOENT. Remove once the
// upstream issue is resolved and we've bumped past the fixed release.
"OPENCLAW_HOME": "/app/state",
// Some openclaw runtime paths still resolve through HOME rather than OPENCLAW_HOME.
// Keep HOME aligned with the writable state tmpfs so live Discord runtimes do not
// fall back to /root/.openclaw and fail closed on read-only filesystem state.
"HOME": "/app/state",
"OPENCLAW_CONFIG_PATH": openclawConfigPath,
"OPENCLAW_STATE_DIR": openclawHomeDir,
// Keep HOME aligned with the canonical writable openclaw home so upstream plugins
// and any os.UserHomeDir()/~ resolution land on the same tmpfs-backed path.
"HOME": "/root",
}
if rc.PersonaHostPath != "" {
env["CLAW_PERSONA_DIR"] = "/claw/persona"
Expand All @@ -168,9 +167,9 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt
openclawWorkspaceTmpfs,
"/tmp",
"/run",
// /app/state covers all openclaw state subdirs (identity, logs, memory, agents, etc.).
// OpenClaw 2026.3.24 runs as uid/gid 1000, so the tmpfs must be writable by that user
// or startup will fail on /app/state/{agents,canvas}.
// The canonical ~/.openclaw home covers all runtime state subdirs (identity, logs,
// memory, exec approvals, agents, canvas, etc.) while staying compatible with
// upstream home-dir assumptions.
openclawStateTmpfs,
},
ReadOnly: true,
Expand Down
39 changes: 21 additions & 18 deletions internal/driver/openclaw/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,25 +95,25 @@ func TestMaterializeWritesConfigAndReturnsResult(t *testing.T) {
}

// Verify env vars are set correctly
if result.Environment["OPENCLAW_CONFIG_PATH"] != "/app/config/openclaw.json" {
t.Errorf("expected OPENCLAW_CONFIG_PATH=/app/config/openclaw.json, got %q", result.Environment["OPENCLAW_CONFIG_PATH"])
if result.Environment["OPENCLAW_CONFIG_PATH"] != openclawConfigPath {
t.Errorf("expected OPENCLAW_CONFIG_PATH=%s, got %q", openclawConfigPath, result.Environment["OPENCLAW_CONFIG_PATH"])
}
if result.Environment["OPENCLAW_STATE_DIR"] != "/app/state" {
t.Errorf("expected OPENCLAW_STATE_DIR=/app/state, got %q", result.Environment["OPENCLAW_STATE_DIR"])
if result.Environment["OPENCLAW_STATE_DIR"] != openclawHomeDir {
t.Errorf("expected OPENCLAW_STATE_DIR=%s, got %q", openclawHomeDir, result.Environment["OPENCLAW_STATE_DIR"])
}
// Shim for openclaw/openclaw#29736: exec-approvals resolves paths via OPENCLAW_HOME.
if result.Environment["OPENCLAW_HOME"] != "/app/state" {
t.Errorf("expected OPENCLAW_HOME=/app/state (shim for openclaw#29736), got %q", result.Environment["OPENCLAW_HOME"])
if _, ok := result.Environment["OPENCLAW_HOME"]; ok {
t.Fatalf("OPENCLAW_HOME should not be set when using canonical ~/.openclaw state, got %q", result.Environment["OPENCLAW_HOME"])
}
if result.Environment["HOME"] != "/app/state" {
t.Errorf("expected HOME=/app/state so ~/.openclaw resolves onto writable state, got %q", result.Environment["HOME"])
if result.Environment["HOME"] != "/root" {
t.Errorf("expected HOME=/root so ~/.openclaw resolves onto writable state, got %q", result.Environment["HOME"])
}
if result.Environment[shared.PortableMemoryEnv] != shared.PortableMemoryDir {
t.Errorf("expected %s=%s, got %q", shared.PortableMemoryEnv, shared.PortableMemoryDir, result.Environment[shared.PortableMemoryEnv])
}

// /app/state must be a single tmpfs covering all openclaw state subdirs.
// The options are part of the contract because OpenClaw now runs as uid/gid 1000.
// ~/.openclaw must be a single tmpfs covering all openclaw state subdirs.
// The mount options are part of the contract because the container home stays writable
// even though the root filesystem is read-only.
tmpfsSet := make(map[string]bool, len(result.Tmpfs))
for _, p := range result.Tmpfs {
tmpfsSet[p] = true
Expand All @@ -122,10 +122,10 @@ func TestMaterializeWritesConfigAndReturnsResult(t *testing.T) {
t.Errorf("expected writable /claw tmpfs %q for workspace writes like SOUL.md, got %v", openclawWorkspaceTmpfs, result.Tmpfs)
}
if !tmpfsSet[openclawStateTmpfs] {
t.Errorf("expected writable /app/state tmpfs %q, got %v", openclawStateTmpfs, result.Tmpfs)
t.Errorf("expected writable %s tmpfs %q, got %v", openclawHomeDir, openclawStateTmpfs, result.Tmpfs)
}
if tmpfsSet["/root/.openclaw"] {
t.Error("unexpected tmpfs /root/.openclaw — should use /app/state now")
if tmpfsSet["/app/state"] {
t.Error("unexpected legacy /app/state tmpfs")
}

if result.Restart != "on-failure" {
Expand Down Expand Up @@ -279,7 +279,7 @@ func TestMaterializeWritesJobsUnderConfigDir(t *testing.T) {
}

// jobs.json must exist in the config/cron/ directory on the host.
// The parent /app/config bind mount covers this path inside the container.
// The parent ~/.openclaw/config bind mount covers this path inside the container.
jobsPath := filepath.Join(dir, "config", "cron", "jobs.json")
if _, err := os.Stat(jobsPath); err != nil {
t.Fatalf("jobs.json not written at config/cron/jobs.json: %v", err)
Expand All @@ -301,21 +301,24 @@ func TestMaterializeWritesJobsUnderConfigDir(t *testing.T) {

var configMount *driver.Mount
for i := range result.Mounts {
if result.Mounts[i].ContainerPath == "/app/config" {
if result.Mounts[i].ContainerPath == openclawConfigDir {
configMount = &result.Mounts[i]
break
}
}
if configMount == nil {
t.Fatal("expected /app/config mount to cover config/cron/jobs.json")
t.Fatalf("expected %s mount to cover config/cron/jobs.json", openclawConfigDir)
}
if configMount.ReadOnly {
t.Error("/app/config must be read-write so openclaw can update cron job state")
t.Errorf("%s must be read-write so openclaw can update cron job state", openclawConfigDir)
}
for i := range result.Mounts {
if result.Mounts[i].ContainerPath == "/app/state/cron" {
t.Fatal("unexpected legacy /app/state/cron mount")
}
if result.Mounts[i].ContainerPath == "/root/.openclaw/cron" {
t.Fatal("unexpected direct /root/.openclaw/cron mount; jobs should stay under config dir")
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions site/guide/drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Guild-level `policy` is rejected at config generation time rather than producing

cllama wiring for OpenClaw uses `models.providers.<provider>.{baseUrl, apiKey, api, models}` -- not `agents.defaults.model.baseURL/apiKey`.

OpenClaw state uses its canonical home directory inside the container: `~/.openclaw` (`/root/.openclaw`). Clawdapus mounts the generated config at `/root/.openclaw/config/openclaw.json` and keeps the full home writable on tmpfs so upstream plugins and `os.homedir()`-based paths resolve naturally.

### hermes

The Hermes driver writes a `SOUL.md` identity file during materialization to override the default Hermes runner identity. When a persona is configured, the persona's SOUL.md takes priority.
Expand Down
3 changes: 2 additions & 1 deletion skills/clawdapus/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,9 @@ Clawdapus refuses to start containers when:
- Bearer token is auto-injected; don't set it manually

### Config injection issues (OpenClaw)
- Config dir (`/app/config`) must be bind-mounted as directory, not file
- Config dir (`/root/.openclaw/config`) must be bind-mounted as directory, not file
- OpenClaw does atomic writes via rename — file-only mounts cause EBUSY
- OpenClaw home is canonical `~/.openclaw` (`/root/.openclaw`) rather than a separate `/app/state` shim
- Check generated `openclaw.json` in the runtime directory
- OpenClaw health: `claw health -f <pod>.yml`

Expand Down