From ebe57304016aaadf69c0357d49ec1e36df6a5daa Mon Sep 17 00:00:00 2001 From: Oleg Kossoy Date: Sun, 8 Feb 2026 05:16:59 +0200 Subject: [PATCH] fix: use STATE_DIR instead of hardcoded ~/.openclaw for identity and canvas (#4824) * fix: use STATE_DIR instead of hardcoded ~/.openclaw for identity and canvas device-identity.ts and canvas-host/server.ts used hardcoded path.join(os.homedir(), '.openclaw', ...) ignoring OPENCLAW_STATE_DIR env var and the resolveStateDir() logic from config/paths.ts. This caused ~/.openclaw/identity and ~/.openclaw/canvas directories to be created even when state dir was overridden or resided elsewhere. * fix: format and remove duplicate imports * fix: scope state-dir patch + add regression tests (#4824) (thanks @kossoy) * fix: align state-dir fallbacks in hooks and agent paths (#4824) (thanks @kossoy) --------- Co-authored-by: Gustavo Madeira Santana --- CHANGELOG.md | 1 + src/agents/agent-scope.ts | 3 +- src/agents/sandbox/constants.ts | 6 +-- src/agents/workspace-run.test.ts | 6 ++- src/canvas-host/server.state-dir.test.ts | 48 +++++++++++++++++++++ src/canvas-host/server.ts | 4 +- src/cli/update-cli.ts | 4 +- src/commands/agents.test.ts | 5 ++- src/hooks/bundled/command-logger/handler.ts | 3 +- src/hooks/bundled/session-memory/handler.ts | 3 +- src/infra/device-identity.state-dir.test.ts | 40 +++++++++++++++++ src/infra/device-identity.ts | 4 +- src/test-helpers/state-dir-env.ts | 29 +++++++++++++ 13 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 src/canvas-host/server.state-dir.test.ts create mode 100644 src/infra/device-identity.state-dir.test.ts create mode 100644 src/test-helpers/state-dir-env.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f10d31e09..0dddd50f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. - Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705) - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. +- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. - Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. ## 2026.2.6 diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index a8096da74d..b8b96632d3 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -178,7 +178,8 @@ export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) { } return DEFAULT_AGENT_WORKSPACE_DIR; } - return path.join(os.homedir(), ".openclaw", `workspace-${id}`); + const stateDir = resolveStateDir(process.env, os.homedir); + return path.join(stateDir, `workspace-${id}`); } export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) { diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 7f565eb644..26a32054c9 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -1,9 +1,8 @@ -import os from "node:os"; import path from "node:path"; import { CHANNEL_IDS } from "../../channels/registry.js"; import { STATE_DIR } from "../../config/config.js"; -export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(os.homedir(), ".openclaw", "sandboxes"); +export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes"); export const DEFAULT_SANDBOX_IMAGE = "openclaw-sandbox:bookworm-slim"; export const DEFAULT_SANDBOX_CONTAINER_PREFIX = "openclaw-sbx-"; @@ -47,7 +46,6 @@ export const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000; export const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent"; -const resolvedSandboxStateDir = STATE_DIR ?? path.join(os.homedir(), ".openclaw"); -export const SANDBOX_STATE_DIR = path.join(resolvedSandboxStateDir, "sandbox"); +export const SANDBOX_STATE_DIR = path.join(STATE_DIR, "sandbox"); export const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json"); diff --git a/src/agents/workspace-run.test.ts b/src/agents/workspace-run.test.ts index bb99f51778..d67e6bd184 100644 --- a/src/agents/workspace-run.test.ts +++ b/src/agents/workspace-run.test.ts @@ -1,7 +1,7 @@ -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; import { resolveRunWorkspaceDir } from "./workspace-run.js"; import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js"; @@ -93,7 +93,9 @@ describe("resolveRunWorkspaceDir", () => { expect(result.agentId).toBe("research"); expect(result.agentIdSource).toBe("explicit"); - expect(result.workspaceDir).toBe(path.resolve(os.homedir(), ".openclaw", "workspace-research")); + expect(result.workspaceDir).toBe( + path.resolve(resolveStateDir(process.env), "workspace-research"), + ); }); it("throws for malformed agent session keys even when config has a default agent", () => { diff --git a/src/canvas-host/server.state-dir.test.ts b/src/canvas-host/server.state-dir.test.ts new file mode 100644 index 0000000000..5953464bfd --- /dev/null +++ b/src/canvas-host/server.state-dir.test.ts @@ -0,0 +1,48 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultRuntime } from "../runtime.js"; +import { + restoreStateDirEnv, + setStateDirEnv, + snapshotStateDirEnv, +} from "../test-helpers/state-dir-env.js"; + +describe("canvas host state dir defaults", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = snapshotStateDirEnv(); + }); + + afterEach(() => { + vi.resetModules(); + restoreStateDirEnv(envSnapshot); + }); + + it("uses OPENCLAW_STATE_DIR for the default canvas root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-state-")); + const stateDir = path.join(tempRoot, "state"); + setStateDirEnv(stateDir); + vi.resetModules(); + + const { createCanvasHostHandler } = await import("./server.js"); + const handler = await createCanvasHostHandler({ + runtime: defaultRuntime, + allowInTests: true, + }); + + try { + const expectedRoot = await fs.realpath(path.join(stateDir, "canvas")); + const actualRoot = await fs.realpath(handler.rootDir); + expect(actualRoot).toBe(expectedRoot); + const indexPath = path.join(expectedRoot, "index.html"); + const indexContents = await fs.readFile(indexPath, "utf8"); + expect(indexContents).toContain("OpenClaw Canvas"); + } finally { + await handler.close(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/canvas-host/server.ts b/src/canvas-host/server.ts index 2ba0fcf893..1ba3bc78ff 100644 --- a/src/canvas-host/server.ts +++ b/src/canvas-host/server.ts @@ -4,10 +4,10 @@ import chokidar from "chokidar"; import * as fsSync from "node:fs"; import fs from "node:fs/promises"; import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import os from "node:os"; import path from "node:path"; import { type WebSocket, WebSocketServer } from "ws"; import type { RuntimeEnv } from "../runtime.js"; +import { STATE_DIR } from "../config/paths.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js"; import { detectMime } from "../media/mime.js"; @@ -235,7 +235,7 @@ async function prepareCanvasRoot(rootDir: string) { } function resolveDefaultCanvasRoot(): string { - const candidates = [path.join(os.homedir(), ".openclaw", "canvas")]; + const candidates = [path.join(STATE_DIR, "canvas")]; const existing = candidates.find((dir) => { try { return fsSync.statSync(dir).isDirectory(); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 8aad9d06fc..62bd17f8be 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -15,6 +15,7 @@ import { resolveUpdateAvailability, } from "../commands/status.update.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import { trimLogTail } from "../infra/restart-sentinel.js"; import { parseSemver } from "../infra/runtime-guard.js"; @@ -126,7 +127,6 @@ const DEFAULT_PACKAGE_NAME = "openclaw"; const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); const CLI_NAME = resolveCliName(); const OPENCLAW_REPO_URL = "https://github.com/openclaw/openclaw.git"; -const DEFAULT_GIT_DIR = path.join(os.homedir(), ".openclaw"); function normalizeTag(value?: string | null): string | null { if (!value) { @@ -313,7 +313,7 @@ function resolveGitInstallDir(): string { } function resolveDefaultGitDir(): string { - return DEFAULT_GIT_DIR; + return resolveStateDir(process.env, os.homedir); } function resolveNodeRunner(): string { diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 83e7c6cd68..1becb77548 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; import { applyAgentBindings, applyAgentConfig, @@ -43,7 +44,9 @@ describe("agents helpers", () => { const work = summaries.find((summary) => summary.id === "work"); expect(main).toBeTruthy(); - expect(main?.workspace).toBe(path.join(os.homedir(), ".openclaw", "workspace-main")); + expect(main?.workspace).toBe( + path.join(resolveStateDir(process.env, os.homedir), "workspace-main"), + ); expect(main?.bindings).toBe(1); expect(main?.model).toBe("anthropic/claude"); expect(main?.agentDir.endsWith(path.join("agents", "main", "agent"))).toBe(true); diff --git a/src/hooks/bundled/command-logger/handler.ts b/src/hooks/bundled/command-logger/handler.ts index 16cd071ed4..0731296b0f 100644 --- a/src/hooks/bundled/command-logger/handler.ts +++ b/src/hooks/bundled/command-logger/handler.ts @@ -27,6 +27,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { HookHandler } from "../../hooks.js"; +import { resolveStateDir } from "../../../config/paths.js"; /** * Log all command events to a file @@ -39,7 +40,7 @@ const logCommand: HookHandler = async (event) => { try { // Create log directory - const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw"); + const stateDir = resolveStateDir(process.env, os.homedir); const logDir = path.join(stateDir, "logs"); await fs.mkdir(logDir, { recursive: true }); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 8fb0af4f62..27d937d532 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -11,6 +11,7 @@ import path from "node:path"; import type { OpenClawConfig } from "../../../config/config.js"; import type { HookHandler } from "../../hooks.js"; import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; +import { resolveStateDir } from "../../../config/paths.js"; import { createSubsystemLogger } from "../../../logging/subsystem.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; import { resolveHookConfig } from "../../config.js"; @@ -79,7 +80,7 @@ const saveSessionToMemory: HookHandler = async (event) => { const agentId = resolveAgentIdFromSessionKey(event.sessionKey); const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, agentId) - : path.join(os.homedir(), ".openclaw", "workspace"); + : path.join(resolveStateDir(process.env, os.homedir), "workspace"); const memoryDir = path.join(workspaceDir, "memory"); await fs.mkdir(memoryDir, { recursive: true }); diff --git a/src/infra/device-identity.state-dir.test.ts b/src/infra/device-identity.state-dir.test.ts new file mode 100644 index 0000000000..f549c10dde --- /dev/null +++ b/src/infra/device-identity.state-dir.test.ts @@ -0,0 +1,40 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + restoreStateDirEnv, + setStateDirEnv, + snapshotStateDirEnv, +} from "../test-helpers/state-dir-env.js"; + +describe("device identity state dir defaults", () => { + let envSnapshot: ReturnType; + + beforeEach(() => { + envSnapshot = snapshotStateDirEnv(); + }); + + afterEach(() => { + vi.resetModules(); + restoreStateDirEnv(envSnapshot); + }); + + it("writes the default identity file under OPENCLAW_STATE_DIR", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-identity-state-")); + const stateDir = path.join(tempRoot, "state"); + setStateDirEnv(stateDir); + vi.resetModules(); + + const { loadOrCreateDeviceIdentity } = await import("./device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + + try { + const identityPath = path.join(stateDir, "identity", "device.json"); + const raw = JSON.parse(await fs.readFile(identityPath, "utf8")) as { deviceId?: string }; + expect(raw.deviceId).toBe(identity.deviceId); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index a5502fb264..d152e26fed 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; +import { STATE_DIR } from "../config/paths.js"; export type DeviceIdentity = { deviceId: string; @@ -17,7 +17,7 @@ type StoredIdentity = { createdAtMs: number; }; -const DEFAULT_DIR = path.join(os.homedir(), ".openclaw", "identity"); +const DEFAULT_DIR = path.join(STATE_DIR, "identity"); const DEFAULT_FILE = path.join(DEFAULT_DIR, "device.json"); function ensureDir(filePath: string) { diff --git a/src/test-helpers/state-dir-env.ts b/src/test-helpers/state-dir-env.ts new file mode 100644 index 0000000000..3561c27af7 --- /dev/null +++ b/src/test-helpers/state-dir-env.ts @@ -0,0 +1,29 @@ +type StateDirEnvSnapshot = { + openclawStateDir: string | undefined; + clawdbotStateDir: string | undefined; +}; + +export function snapshotStateDirEnv(): StateDirEnvSnapshot { + return { + openclawStateDir: process.env.OPENCLAW_STATE_DIR, + clawdbotStateDir: process.env.CLAWDBOT_STATE_DIR, + }; +} + +export function restoreStateDirEnv(snapshot: StateDirEnvSnapshot): void { + if (snapshot.openclawStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = snapshot.openclawStateDir; + } + if (snapshot.clawdbotStateDir === undefined) { + delete process.env.CLAWDBOT_STATE_DIR; + } else { + process.env.CLAWDBOT_STATE_DIR = snapshot.clawdbotStateDir; + } +} + +export function setStateDirEnv(stateDir: string): void { + process.env.OPENCLAW_STATE_DIR = stateDir; + delete process.env.CLAWDBOT_STATE_DIR; +}