diff --git a/CHANGELOG.md b/CHANGELOG.md index a87de8b23ac..cf40d8a20aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. - Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18. - Security/Workspace FS: normalize `@`-prefixed paths before workspace-boundary checks (including workspace-only read/write/edit and sandbox mount path guards), preventing absolute-path escape attempts from bypassing guard validation. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 87588d7adbd..6a58118618c 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk"; // NOTE: This extension is intended to be bundled with OpenClaw. // When running from source (tests/dev), OpenClaw internals live under src/. // When running from a built install, internals live under dist/ (no src/ tree). @@ -180,7 +180,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { let tmpDir: string | null = null; try { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-")); + tmpDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-llm-task-"), + ); const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); diff --git a/package.json b/package.json index 66a60a5dc00..69657a04cf2 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", "deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused", @@ -93,6 +93,7 @@ "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", diff --git a/scripts/check-no-random-messaging-tmp.mjs b/scripts/check-no-random-messaging-tmp.mjs new file mode 100644 index 00000000000..c2d6395f4dd --- /dev/null +++ b/scripts/check-no-random-messaging-tmp.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [ + path.join(repoRoot, "src", "channels"), + path.join(repoRoot, "src", "infra", "outbound"), + path.join(repoRoot, "src", "line"), + path.join(repoRoot, "src", "media-understanding"), + path.join(repoRoot, "extensions"), +]; +const allowedCallsites = new Set([path.join(repoRoot, "extensions", "feishu", "src", "dedup.ts")]); + +function isTestLikeFile(filePath) { + return ( + filePath.endsWith(".test.ts") || + filePath.endsWith(".test-utils.ts") || + filePath.endsWith(".test-harness.ts") || + filePath.endsWith(".e2e-harness.ts") + ); +} + +async function collectTypeScriptFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await collectTypeScriptFiles(entryPath))); + continue; + } + if (!entry.isFile()) { + continue; + } + if (!entryPath.endsWith(".ts")) { + continue; + } + if (isTestLikeFile(entryPath)) { + continue; + } + out.push(entryPath); + } + return out; +} + +function collectNodeOsImports(sourceFile) { + const osNamespaceOrDefault = new Set(); + const namedTmpdir = new Set(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + if (!statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + if (statement.moduleSpecifier.text !== "node:os") { + continue; + } + const clause = statement.importClause; + if (clause.name) { + osNamespaceOrDefault.add(clause.name.text); + } + if (!clause.namedBindings) { + continue; + } + if (ts.isNamespaceImport(clause.namedBindings)) { + osNamespaceOrDefault.add(clause.namedBindings.name.text); + continue; + } + for (const element of clause.namedBindings.elements) { + if ((element.propertyName?.text ?? element.name.text) === "tmpdir") { + namedTmpdir.add(element.name.text); + } + } + } + return { osNamespaceOrDefault, namedTmpdir }; +} + +function unwrapExpression(expression) { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + current = current.expression; + continue; + } + if (ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + return current; + } +} + +export function findMessagingTmpdirCallLines(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const { osNamespaceOrDefault, namedTmpdir } = collectNodeOsImports(sourceFile); + const lines = []; + + const visit = (node) => { + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "tmpdir" && + ts.isIdentifier(callee.expression) && + osNamespaceOrDefault.has(callee.expression.text) + ) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } else if (ts.isIdentifier(callee) && namedTmpdir.has(callee.text)) { + const line = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile)).line + 1; + lines.push(line); + } + } + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return lines; +} + +export async function main() { + const files = ( + await Promise.all(sourceRoots.map(async (dir) => await collectTypeScriptFiles(dir))) + ).flat(); + const violations = []; + + for (const filePath of files) { + if (allowedCallsites.has(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + for (const line of findMessagingTmpdirCallLines(content, filePath)) { + violations.push(`${path.relative(repoRoot, filePath)}:${line}`); + } + } + + if (violations.length === 0) { + return; + } + + console.error("Found os.tmpdir()/tmpdir() usage in messaging/channel runtime sources:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Use resolvePreferredOpenClawTmpDir() or plugin-sdk temp helpers instead of host tmp defaults.", + ); + process.exit(1); +} + +const isDirectExecution = (() => { + const entry = process.argv[1]; + if (!entry) { + return false; + } + return path.resolve(entry) === fileURLToPath(import.meta.url); +})(); + +if (isDirectExecution) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index de317320a80..6111980e138 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { resolveSandboxedMediaSource } from "./sandbox-paths.js"; async function withSandboxRoot(run: (sandboxDir: string) => Promise) { @@ -24,22 +25,24 @@ function isPathInside(root: string, target: string): boolean { } describe("resolveSandboxedMediaSource", () => { + const openClawTmpDir = resolvePreferredOpenClawTmpDir(); + // Group 1: /tmp paths (the bug fix) it.each([ { - name: "absolute paths under os.tmpdir()", - media: path.join(os.tmpdir(), "image.png"), - expected: path.join(os.tmpdir(), "image.png"), + name: "absolute paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "image.png"), + expected: path.join(openClawTmpDir, "image.png"), }, { - name: "file:// URLs pointing to os.tmpdir()", - media: pathToFileURL(path.join(os.tmpdir(), "photo.png")).href, - expected: path.join(os.tmpdir(), "photo.png"), + name: "file:// URLs pointing to preferred OpenClaw tmp root", + media: pathToFileURL(path.join(openClawTmpDir, "photo.png")).href, + expected: path.join(openClawTmpDir, "photo.png"), }, { - name: "nested paths under os.tmpdir()", - media: path.join(os.tmpdir(), "subdir", "deep", "file.png"), - expected: path.join(os.tmpdir(), "subdir", "deep", "file.png"), + name: "nested paths under preferred OpenClaw tmp root", + media: path.join(openClawTmpDir, "subdir", "deep", "file.png"), + expected: path.join(openClawTmpDir, "subdir", "deep", "file.png"), }, ])("allows $name", async ({ media, expected }) => { await withSandboxRoot(async (sandboxDir) => { @@ -96,7 +99,12 @@ describe("resolveSandboxedMediaSource", () => { }, { name: "path traversal through tmpdir", - media: path.join(os.tmpdir(), "..", "etc", "passwd"), + media: path.join(openClawTmpDir, "..", "etc", "passwd"), + expected: /sandbox/i, + }, + { + name: "absolute paths under host tmp outside openclaw tmp root", + media: path.join(os.tmpdir(), "outside-openclaw", "passwd"), expected: /sandbox/i, }, { @@ -120,20 +128,25 @@ describe("resolveSandboxedMediaSource", () => { }); }); - it("rejects symlinked tmpdir paths escaping tmpdir", async () => { + it("rejects symlinked OpenClaw tmp paths escaping tmp root", async () => { if (process.platform === "win32") { return; } const outsideTmpTarget = path.resolve(process.cwd(), "package.json"); - if (isPathInside(os.tmpdir(), outsideTmpTarget)) { + if (isPathInside(openClawTmpDir, outsideTmpTarget)) { return; } await withSandboxRoot(async (sandboxDir) => { await fs.access(outsideTmpTarget); - const symlinkPath = path.join(sandboxDir, "tmp-link-escape"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + const symlinkPath = path.join(openClawTmpDir, `tmp-link-escape-${process.pid}`); await fs.symlink(outsideTmpTarget, symlinkPath); - await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + try { + await expectSandboxRejection(symlinkPath, sandboxDir, /symlink|sandbox/i); + } finally { + await fs.unlink(symlinkPath).catch(() => {}); + } }); }); diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index 5b684bbe425..f5ae24ac16a 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath, URL } from "node:url"; import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const HTTP_URL_RE = /^https?:\/\//i; @@ -181,11 +182,11 @@ async function resolveAllowedTmpMediaPath(params: { return undefined; } const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot)); - const tmpDir = path.resolve(os.tmpdir()); - if (!isPathInside(tmpDir, resolved)) { + const openClawTmpDir = path.resolve(resolvePreferredOpenClawTmpDir()); + if (!isPathInside(openClawTmpDir, resolved)) { return undefined; } - await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir); + await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir); return resolved; } diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index c39d966b804..94b5bee9891 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -11,6 +11,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c import { withEnvAsync } from "../../test-utils/env.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; const mocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), @@ -202,6 +203,86 @@ describe("deliverOutboundPayloads", () => { ); }); + it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + + await deliverOutboundPayloads({ + cfg: telegramChunkConfig, + channel: "telegram", + to: "123", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledWith( + "123", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in signal mediaLocalRoots", async () => { + const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); + + await deliverOutboundPayloads({ + cfg: { channels: { signal: {} } }, + channel: "signal", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendSignal }, + }); + + expect(sendSignal).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in whatsapp mediaLocalRoots", async () => { + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" }); + + await deliverOutboundPayloads({ + cfg: whatsappChunkConfig, + channel: "whatsapp", + to: "+1555", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendWhatsApp }, + }); + + expect(sendWhatsApp).toHaveBeenCalledWith( + "+1555", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + + it("includes OpenClaw tmp root in imessage mediaLocalRoots", async () => { + const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1", chatId: "chat-1" }); + + await deliverOutboundPayloads({ + cfg: {}, + channel: "imessage", + to: "imessage:+15551234567", + payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }], + deps: { sendIMessage }, + }); + + expect(sendIMessage).toHaveBeenCalledWith( + "imessage:+15551234567", + "hi", + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([resolvePreferredOpenClawTmpDir()]), + }), + ); + }); + it("uses signal media maxBytes from config", async () => { const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } }; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index f2c36464e97..26591ff23c9 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -12,6 +12,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; import { loadWebMedia } from "../../web/media.js"; +import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js"; import { runMessageAction } from "./message-action-runner.js"; vi.mock("../../web/media.js", async () => { @@ -622,10 +623,12 @@ describe("runMessageAction sandboxed media validation", () => { }); }); - it("allows media paths under os.tmpdir()", async () => { + it("allows media paths under preferred OpenClaw tmp root", async () => { + const tmpRoot = resolvePreferredOpenClawTmpDir(); + await fs.mkdir(tmpRoot, { recursive: true }); const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-")); try { - const tmpFile = path.join(os.tmpdir(), "test-media-image.png"); + const tmpFile = path.join(tmpRoot, "test-media-image.png"); const result = await runMessageAction({ cfg: slackConfig, action: "send", @@ -644,6 +647,21 @@ describe("runMessageAction sandboxed media validation", () => { throw new Error("expected send result"); } expect(result.sendResult?.mediaUrl).toBe(tmpFile); + const hostTmpOutsideOpenClaw = path.join(os.tmpdir(), "outside-openclaw", "test-media.png"); + await expect( + runMessageAction({ + cfg: slackConfig, + action: "send", + params: { + channel: "slack", + target: "#C12345678", + media: hostTmpOutsideOpenClaw, + message: "", + }, + sandboxRoot: sandboxDir, + dryRun: true, + }), + ).rejects.toThrow(/sandbox/i); } finally { await fs.rm(sandboxDir, { recursive: true, force: true }); } diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 3e80caae9bc..36e6a89b438 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { collectProviderApiKeysForExecution, @@ -14,6 +13,7 @@ import type { MediaUnderstandingModelConfig, } from "../config/types.tools.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runExec } from "../process/exec.js"; import { MediaAttachmentCache } from "./attachments.js"; import { @@ -566,7 +566,9 @@ export async function runCliEntry(params: { maxBytes, timeoutMs, }); - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cli-")); + const outputDir = await fs.mkdtemp( + path.join(resolvePreferredOpenClawTmpDir(), "openclaw-media-cli-"), + ); const mediaPath = pathResult.path; const outputBase = path.join(outputDir, path.parse(mediaPath).name); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 461370054b0..9c54fe175f6 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -200,6 +200,7 @@ export { createLoggerBackedRuntime } from "./runtime.js"; export { chunkTextForOutbound } from "./text-chunking.js"; export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; +export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; export { runPluginCommandWithTimeout, type PluginCommandRunOptions, diff --git a/src/plugin-sdk/temp-path.test.ts b/src/plugin-sdk/temp-path.test.ts index dbd2d46ee0f..166a2373b15 100644 --- a/src/plugin-sdk/temp-path.test.ts +++ b/src/plugin-sdk/temp-path.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js"; describe("buildRandomTempFilePath", () => { @@ -17,13 +17,13 @@ describe("buildRandomTempFilePath", () => { }); it("sanitizes prefix and extension to avoid path traversal segments", () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); const result = buildRandomTempFilePath({ prefix: "../../line/../media", extension: "/../.jpg", now: 123, uuid: "abc", }); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(result); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); @@ -45,11 +45,12 @@ describe("withTempDownloadPath", () => { }, ); - expect(capturedPath).toContain(path.join(os.tmpdir(), "line-media-")); + expect(capturedPath).toContain(path.join(resolvePreferredOpenClawTmpDir(), "line-media-")); await expect(fs.stat(capturedPath)).rejects.toMatchObject({ code: "ENOENT" }); }); it("sanitizes prefix and fileName", async () => { + const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir()); let capturedPath = ""; await withTempDownloadPath( { @@ -61,7 +62,6 @@ describe("withTempDownloadPath", () => { }, ); - const tmpRoot = path.resolve(os.tmpdir()); const resolved = path.resolve(capturedPath); const rel = path.relative(tmpRoot, resolved); expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false); diff --git a/src/plugin-sdk/temp-path.ts b/src/plugin-sdk/temp-path.ts index ed1b149135a..f0ca73b2109 100644 --- a/src/plugin-sdk/temp-path.ts +++ b/src/plugin-sdk/temp-path.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; function sanitizePrefix(prefix: string): string { const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); @@ -27,6 +27,10 @@ function sanitizeFileName(fileName: string): string { return normalized || "download.bin"; } +function resolveTempRoot(tmpDir?: string): string { + return tmpDir ?? resolvePreferredOpenClawTmpDir(); +} + export function buildRandomTempFilePath(params: { prefix: string; extension?: string; @@ -42,7 +46,7 @@ export function buildRandomTempFilePath(params: { ? Math.trunc(nowCandidate) : Date.now(); const uuid = params.uuid?.trim() || crypto.randomUUID(); - return path.join(params.tmpDir ?? os.tmpdir(), `${prefix}-${now}-${uuid}${extension}`); + return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`); } export async function withTempDownloadPath( @@ -53,7 +57,7 @@ export async function withTempDownloadPath( }, fn: (tmpPath: string) => Promise, ): Promise { - const tempRoot = params.tmpDir ?? os.tmpdir(); + const tempRoot = resolveTempRoot(params.tmpDir); const prefix = `${sanitizePrefix(params.prefix)}-`; const dir = await mkdtemp(path.join(tempRoot, prefix)); const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin")); diff --git a/test/scripts/check-no-random-messaging-tmp.test.ts b/test/scripts/check-no-random-messaging-tmp.test.ts new file mode 100644 index 00000000000..01495b2b09b --- /dev/null +++ b/test/scripts/check-no-random-messaging-tmp.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { findMessagingTmpdirCallLines } from "../../scripts/check-no-random-messaging-tmp.mjs"; + +describe("check-no-random-messaging-tmp", () => { + it("finds os.tmpdir calls imported from node:os", () => { + const source = ` + import os from "node:os"; + const dir = os.tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("finds tmpdir named import calls from node:os", () => { + const source = ` + import { tmpdir } from "node:os"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([3]); + }); + + it("ignores mentions in comments and strings", () => { + const source = ` + // os.tmpdir() + const text = "tmpdir()"; + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); + + it("ignores tmpdir symbols that are not imported from node:os", () => { + const source = ` + const tmpdir = () => "/tmp"; + const dir = tmpdir(); + `; + expect(findMessagingTmpdirCallLines(source)).toEqual([]); + }); +});