From 58a17a2ffb8a1bc987d285783e98c43e7acecc04 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Sun, 12 Apr 2026 22:51:37 -0400 Subject: [PATCH] openclaw: use canonical ~/.openclaw home --- cmd/claw/skill_data/SKILL.md | 3 +- cmd/claw/spike_mixed_managed_test.go | 2 +- cmd/claw/spike_test.go | 8 ++--- examples/trading-desk/README.md | 4 +-- internal/driver/openclaw/baseimage.go | 1 + internal/driver/openclaw/baseimage_test.go | 3 ++ internal/driver/openclaw/driver.go | 37 ++++++++++---------- internal/driver/openclaw/driver_test.go | 39 ++++++++++++---------- site/guide/drivers.md | 2 ++ skills/clawdapus/SKILL.md | 3 +- 10 files changed, 56 insertions(+), 46 deletions(-) diff --git a/cmd/claw/skill_data/SKILL.md b/cmd/claw/skill_data/SKILL.md index 8146e98..018f358 100644 --- a/cmd/claw/skill_data/SKILL.md +++ b/cmd/claw/skill_data/SKILL.md @@ -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 .yml` diff --git a/cmd/claw/spike_mixed_managed_test.go b/cmd/claw/spike_mixed_managed_test.go index ac2d341..7a17780 100644 --- a/cmd/claw/spike_mixed_managed_test.go +++ b/cmd/claw/spike_mixed_managed_test.go @@ -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"]) } diff --git a/cmd/claw/spike_test.go b/cmd/claw/spike_test.go index e551d8d..c423b77 100644 --- a/cmd/claw/spike_test.go +++ b/cmd/claw/spike_test.go @@ -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") @@ -409,7 +409,7 @@ 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") { @@ -417,7 +417,7 @@ func TestSpikeComposeUp(t *testing.T) { } // 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) { diff --git a/examples/trading-desk/README.md b/examples/trading-desk/README.md index 051b33d..2943860 100644 --- a/examples/trading-desk/README.md +++ b/examples/trading-desk/README.md @@ -106,8 +106,8 @@ the required per-service Discord IDs/topology are missing. | `openclaw.json` cllama rewrite | `models.providers..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//` 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 | diff --git a/internal/driver/openclaw/baseimage.go b/internal/driver/openclaw/baseimage.go index 7558e5f..60d45e1 100644 --- a/internal/driver/openclaw/baseimage.go +++ b/internal/driver/openclaw/baseimage.go @@ -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"] ` diff --git a/internal/driver/openclaw/baseimage_test.go b/internal/driver/openclaw/baseimage_test.go index 2ac83e1..03b8e1c 100644 --- a/internal/driver/openclaw/baseimage_test.go +++ b/internal/driver/openclaw/baseimage_test.go @@ -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") } diff --git a/internal/driver/openclaw/driver.go b/internal/driver/openclaw/driver.go index 04e9d5c..029d1bd 100644 --- a/internal/driver/openclaw/driver.go +++ b/internal/driver/openclaw/driver.go @@ -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{}) @@ -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, }, { @@ -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 { @@ -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" @@ -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, diff --git a/internal/driver/openclaw/driver_test.go b/internal/driver/openclaw/driver_test.go index 92f91c8..f963f6e 100644 --- a/internal/driver/openclaw/driver_test.go +++ b/internal/driver/openclaw/driver_test.go @@ -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 @@ -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" { @@ -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) @@ -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") + } } } diff --git a/site/guide/drivers.md b/site/guide/drivers.md index 18a5cce..5871626 100644 --- a/site/guide/drivers.md +++ b/site/guide/drivers.md @@ -47,6 +47,8 @@ Guild-level `policy` is rejected at config generation time rather than producing cllama wiring for OpenClaw uses `models.providers..{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. diff --git a/skills/clawdapus/SKILL.md b/skills/clawdapus/SKILL.md index 8146e98..018f358 100644 --- a/skills/clawdapus/SKILL.md +++ b/skills/clawdapus/SKILL.md @@ -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 .yml`