diff --git a/src/browser/pw-tools-core.downloads.ts b/src/browser/pw-tools-core.downloads.ts index 1f029a483..a2884d4eb 100644 --- a/src/browser/pw-tools-core.downloads.ts +++ b/src/browser/pw-tools-core.downloads.ts @@ -1,8 +1,8 @@ import type { Page } from "playwright-core"; import crypto from "node:crypto"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { ensurePageState, getPageForTargetId, @@ -21,7 +21,7 @@ import { function buildTempDownloadPath(fileName: string): string { const id = crypto.randomUUID(); const safeName = fileName.trim() ? fileName.trim() : "download.bin"; - return path.join(os.tmpdir(), "openclaw", "downloads", `${id}-${safeName}`); + return path.join(resolvePreferredOpenClawTmpDir(), "downloads", `${id}-${safeName}`); } function createPageDownloadWaiter(page: Page, timeoutMs: number) { diff --git a/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index e30d3ebfe..2e22749aa 100644 --- a/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -29,6 +29,10 @@ const sessionMocks = vi.hoisted(() => ({ })); vi.mock("./pw-session.js", () => sessionMocks); +const tmpDirMocks = vi.hoisted(() => ({ + resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"), +})); +vi.mock("../infra/tmp-openclaw-dir.js", () => tmpDirMocks); async function importModule() { return await import("./pw-tools-core.js"); @@ -47,6 +51,10 @@ describe("pw-tools-core", () => { for (const fn of Object.values(sessionMocks)) { fn.mockClear(); } + for (const fn of Object.values(tmpDirMocks)) { + fn.mockClear(); + } + tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue("/tmp/openclaw"); }); it("waits for the next download and saves it", async () => { @@ -125,6 +133,43 @@ describe("pw-tools-core", () => { expect(saveAs).toHaveBeenCalledWith(targetPath); expect(res.path).toBe(targetPath); }); + it("uses preferred tmp dir when waiting for download without explicit path", async () => { + let downloadHandler: ((download: unknown) => void) | undefined; + const on = vi.fn((event: string, handler: (download: unknown) => void) => { + if (event === "download") { + downloadHandler = handler; + } + }); + const off = vi.fn(); + + const saveAs = vi.fn(async () => {}); + const download = { + url: () => "https://example.com/file.bin", + suggestedFilename: () => "file.bin", + saveAs, + }; + + tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue("/tmp/openclaw-preferred"); + currentPage = { on, off }; + + const mod = await importModule(); + const p = mod.waitForDownloadViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + timeoutMs: 1000, + }); + + await Promise.resolve(); + downloadHandler?.(download); + + const res = await p; + const outPath = vi.mocked(saveAs).mock.calls[0]?.[0]; + expect(typeof outPath).toBe("string"); + expect(String(outPath)).toContain("/tmp/openclaw-preferred/downloads/"); + expect(String(outPath)).toContain("-file.bin"); + expect(res.path).toContain("/tmp/openclaw-preferred/downloads/"); + expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled(); + }); it("waits for a matching response and returns its body", async () => { let responseHandler: ((resp: unknown) => void) | undefined; const on = vi.fn((event: string, handler: (resp: unknown) => void) => { diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts index ec4c944c9..7ba0ed52a 100644 --- a/src/browser/routes/agent.debug.ts +++ b/src/browser/routes/agent.debug.ts @@ -1,12 +1,14 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import type { BrowserRouteContext } from "../server-context.js"; import type { BrowserRouteRegistrar } from "./types.js"; +import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js"; import { toBoolean, toStringOrEmpty } from "./utils.js"; +const DEFAULT_TRACE_DIR = resolvePreferredOpenClawTmpDir(); + export function registerBrowserAgentDebugRoutes( app: BrowserRouteRegistrar, ctx: BrowserRouteContext, @@ -132,7 +134,7 @@ export function registerBrowserAgentDebugRoutes( return; } const id = crypto.randomUUID(); - const dir = path.join(os.tmpdir(), "openclaw"); + const dir = DEFAULT_TRACE_DIR; await fs.mkdir(dir, { recursive: true }); const tracePath = out.trim() || path.join(dir, `browser-trace-${id}.zip`); await pw.traceStopViaPlaywright({ diff --git a/src/cli/browser-cli-actions-input/register.files-downloads.ts b/src/cli/browser-cli-actions-input/register.files-downloads.ts index 316faae3e..0827079ba 100644 --- a/src/cli/browser-cli-actions-input/register.files-downloads.ts +++ b/src/cli/browser-cli-actions-input/register.files-downloads.ts @@ -57,7 +57,10 @@ export function registerBrowserFilesAndDownloadsCommands( browser .command("waitfordownload") .description("Wait for the next download (and save it)") - .argument("[path]", "Save path (default: os.tmpdir()/openclaw/downloads/...)") + .argument( + "[path]", + "Save path (default: /tmp/openclaw/downloads/...; fallback: os.tmpdir()/openclaw/downloads/...)", + ) .option("--target-id ", "CDP target id (or unique prefix)") .option( "--timeout-ms ", diff --git a/src/infra/tmp-openclaw-dir.test.ts b/src/infra/tmp-openclaw-dir.test.ts new file mode 100644 index 000000000..1eea9a1bb --- /dev/null +++ b/src/infra/tmp-openclaw-dir.test.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; + +describe("resolvePreferredOpenClawTmpDir", () => { + it("prefers /tmp/openclaw when it already exists and is writable", () => { + const accessSync = vi.fn(); + const statSync = vi.fn(() => ({ isDirectory: () => true })); + const tmpdir = vi.fn(() => "/var/fallback"); + + const resolved = resolvePreferredOpenClawTmpDir({ accessSync, statSync, tmpdir }); + + expect(statSync).toHaveBeenCalledTimes(1); + expect(accessSync).toHaveBeenCalledTimes(1); + expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); + expect(tmpdir).not.toHaveBeenCalled(); + }); + + it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => { + const accessSync = vi.fn(); + const statSync = vi.fn(() => { + const err = new Error("missing") as Error & { code?: string }; + err.code = "ENOENT"; + throw err; + }); + const tmpdir = vi.fn(() => "/var/fallback"); + + const resolved = resolvePreferredOpenClawTmpDir({ accessSync, statSync, tmpdir }); + + expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); + expect(accessSync).toHaveBeenCalledWith("/tmp", expect.any(Number)); + expect(tmpdir).not.toHaveBeenCalled(); + }); + + it("falls back to os.tmpdir()/openclaw when /tmp/openclaw is not a directory", () => { + const accessSync = vi.fn(); + const statSync = vi.fn(() => ({ isDirectory: () => false })); + const tmpdir = vi.fn(() => "/var/fallback"); + + const resolved = resolvePreferredOpenClawTmpDir({ accessSync, statSync, tmpdir }); + + expect(resolved).toBe(path.join("/var/fallback", "openclaw")); + expect(tmpdir).toHaveBeenCalledTimes(1); + }); + + it("falls back to os.tmpdir()/openclaw when /tmp is not writable", () => { + const accessSync = vi.fn((target: string) => { + if (target === "/tmp") { + throw new Error("read-only"); + } + }); + const statSync = vi.fn(() => { + const err = new Error("missing") as Error & { code?: string }; + err.code = "ENOENT"; + throw err; + }); + const tmpdir = vi.fn(() => "/var/fallback"); + + const resolved = resolvePreferredOpenClawTmpDir({ accessSync, statSync, tmpdir }); + + expect(resolved).toBe(path.join("/var/fallback", "openclaw")); + expect(tmpdir).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts new file mode 100644 index 000000000..ab4038b7c --- /dev/null +++ b/src/infra/tmp-openclaw-dir.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw"; + +type ResolvePreferredOpenClawTmpDirOptions = { + accessSync?: (path: string, mode?: number) => void; + statSync?: (path: string) => { isDirectory(): boolean }; + tmpdir?: () => string; +}; + +type MaybeNodeError = { code?: string }; + +function isNodeErrorWithCode(err: unknown, code: string): err is MaybeNodeError { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as MaybeNodeError).code === code + ); +} + +export function resolvePreferredOpenClawTmpDir( + options: ResolvePreferredOpenClawTmpDirOptions = {}, +): string { + const accessSync = options.accessSync ?? fs.accessSync; + const statSync = options.statSync ?? fs.statSync; + const tmpdir = options.tmpdir ?? os.tmpdir; + + try { + const preferred = statSync(POSIX_OPENCLAW_TMP_DIR); + if (!preferred.isDirectory()) { + return path.join(tmpdir(), "openclaw"); + } + accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); + return POSIX_OPENCLAW_TMP_DIR; + } catch (err) { + if (!isNodeErrorWithCode(err, "ENOENT")) { + return path.join(tmpdir(), "openclaw"); + } + } + + try { + accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK); + return POSIX_OPENCLAW_TMP_DIR; + } catch { + return path.join(tmpdir(), "openclaw"); + } +} diff --git a/src/logging/logger.import-side-effects.test.ts b/src/logging/logger.import-side-effects.test.ts new file mode 100644 index 000000000..712892e8c --- /dev/null +++ b/src/logging/logger.import-side-effects.test.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("logger import side effects", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not mkdir at import time", async () => { + const mkdirSpy = vi.spyOn(fs, "mkdirSync"); + + await import("./logger.js"); + + expect(mkdirSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index eef171fa0..63de56aed 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,26 +1,15 @@ import fs from "node:fs"; import { createRequire } from "node:module"; -import os from "node:os"; import path from "node:path"; import { Logger as TsLogger } from "tslog"; import type { OpenClawConfig } from "../config/types.js"; import type { ConsoleStyle } from "./console.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { readLoggingConfig } from "./config.js"; import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js"; import { loggingState } from "./state.js"; -// Prefer /tmp/openclaw so macOS Debug UI and docs match, but fall back to -// os.tmpdir() on platforms where /tmp is read-only (e.g. Termux/Android). -function resolveDefaultLogDir(): string { - try { - fs.mkdirSync("/tmp/openclaw", { recursive: true }); - return "/tmp/openclaw"; - } catch { - return path.join(os.tmpdir(), "openclaw"); - } -} - -export const DEFAULT_LOG_DIR = resolveDefaultLogDir(); +export const DEFAULT_LOG_DIR = resolvePreferredOpenClawTmpDir(); export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "openclaw.log"); // legacy single-file path const LOG_PREFIX = "openclaw";