mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(security): lock sandbox tmp media paths to openclaw roots
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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/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.
|
- 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.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
||||||
// NOTE: This extension is intended to be bundled with OpenClaw.
|
// NOTE: This extension is intended to be bundled with OpenClaw.
|
||||||
// When running from source (tests/dev), OpenClaw internals live under src/.
|
// When running from source (tests/dev), OpenClaw internals live under src/.
|
||||||
// When running from a built install, internals live under dist/ (no src/ tree).
|
// 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;
|
let tmpDir: string | null = null;
|
||||||
try {
|
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 sessionId = `llm-task-${Date.now()}`;
|
||||||
const sessionFile = path.join(tmpDir, "session.json");
|
const sessionFile = path.join(tmpDir, "session.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": "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",
|
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
"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: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",
|
"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",
|
"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:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
||||||
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
||||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
"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",
|
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
|
||||||
"mac:open": "open dist/OpenClaw.app",
|
"mac:open": "open dist/OpenClaw.app",
|
||||||
"mac:package": "bash scripts/package-mac-app.sh",
|
"mac:package": "bash scripts/package-mac-app.sh",
|
||||||
|
|||||||
173
scripts/check-no-random-messaging-tmp.mjs
Normal file
173
scripts/check-no-random-messaging-tmp.mjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { resolveSandboxedMediaSource } from "./sandbox-paths.js";
|
import { resolveSandboxedMediaSource } from "./sandbox-paths.js";
|
||||||
|
|
||||||
async function withSandboxRoot<T>(run: (sandboxDir: string) => Promise<T>) {
|
async function withSandboxRoot<T>(run: (sandboxDir: string) => Promise<T>) {
|
||||||
@@ -24,22 +25,24 @@ function isPathInside(root: string, target: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveSandboxedMediaSource", () => {
|
describe("resolveSandboxedMediaSource", () => {
|
||||||
|
const openClawTmpDir = resolvePreferredOpenClawTmpDir();
|
||||||
|
|
||||||
// Group 1: /tmp paths (the bug fix)
|
// Group 1: /tmp paths (the bug fix)
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "absolute paths under os.tmpdir()",
|
name: "absolute paths under preferred OpenClaw tmp root",
|
||||||
media: path.join(os.tmpdir(), "image.png"),
|
media: path.join(openClawTmpDir, "image.png"),
|
||||||
expected: path.join(os.tmpdir(), "image.png"),
|
expected: path.join(openClawTmpDir, "image.png"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "file:// URLs pointing to os.tmpdir()",
|
name: "file:// URLs pointing to preferred OpenClaw tmp root",
|
||||||
media: pathToFileURL(path.join(os.tmpdir(), "photo.png")).href,
|
media: pathToFileURL(path.join(openClawTmpDir, "photo.png")).href,
|
||||||
expected: path.join(os.tmpdir(), "photo.png"),
|
expected: path.join(openClawTmpDir, "photo.png"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nested paths under os.tmpdir()",
|
name: "nested paths under preferred OpenClaw tmp root",
|
||||||
media: path.join(os.tmpdir(), "subdir", "deep", "file.png"),
|
media: path.join(openClawTmpDir, "subdir", "deep", "file.png"),
|
||||||
expected: path.join(os.tmpdir(), "subdir", "deep", "file.png"),
|
expected: path.join(openClawTmpDir, "subdir", "deep", "file.png"),
|
||||||
},
|
},
|
||||||
])("allows $name", async ({ media, expected }) => {
|
])("allows $name", async ({ media, expected }) => {
|
||||||
await withSandboxRoot(async (sandboxDir) => {
|
await withSandboxRoot(async (sandboxDir) => {
|
||||||
@@ -96,7 +99,12 @@ describe("resolveSandboxedMediaSource", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "path traversal through tmpdir",
|
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,
|
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") {
|
if (process.platform === "win32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const outsideTmpTarget = path.resolve(process.cwd(), "package.json");
|
const outsideTmpTarget = path.resolve(process.cwd(), "package.json");
|
||||||
if (isPathInside(os.tmpdir(), outsideTmpTarget)) {
|
if (isPathInside(openClawTmpDir, outsideTmpTarget)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await withSandboxRoot(async (sandboxDir) => {
|
await withSandboxRoot(async (sandboxDir) => {
|
||||||
await fs.access(outsideTmpTarget);
|
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 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(() => {});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath, URL } from "node:url";
|
import { fileURLToPath, URL } from "node:url";
|
||||||
import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
|
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 UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||||
const HTTP_URL_RE = /^https?:\/\//i;
|
const HTTP_URL_RE = /^https?:\/\//i;
|
||||||
@@ -181,11 +182,11 @@ async function resolveAllowedTmpMediaPath(params: {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot));
|
const resolved = path.resolve(resolveSandboxInputPath(params.candidate, params.sandboxRoot));
|
||||||
const tmpDir = path.resolve(os.tmpdir());
|
const openClawTmpDir = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||||
if (!isPathInside(tmpDir, resolved)) {
|
if (!isPathInside(openClawTmpDir, resolved)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
await assertNoSymlinkEscape(path.relative(tmpDir, resolved), tmpDir);
|
await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c
|
|||||||
import { withEnvAsync } from "../../test-utils/env.js";
|
import { withEnvAsync } from "../../test-utils/env.js";
|
||||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
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 () => {
|
it("uses signal media maxBytes from config", async () => {
|
||||||
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
|
const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 });
|
||||||
const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } };
|
const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } };
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
|||||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||||
import { loadWebMedia } from "../../web/media.js";
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
|
||||||
import { runMessageAction } from "./message-action-runner.js";
|
import { runMessageAction } from "./message-action-runner.js";
|
||||||
|
|
||||||
vi.mock("../../web/media.js", async () => {
|
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-"));
|
const sandboxDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-sandbox-"));
|
||||||
try {
|
try {
|
||||||
const tmpFile = path.join(os.tmpdir(), "test-media-image.png");
|
const tmpFile = path.join(tmpRoot, "test-media-image.png");
|
||||||
const result = await runMessageAction({
|
const result = await runMessageAction({
|
||||||
cfg: slackConfig,
|
cfg: slackConfig,
|
||||||
action: "send",
|
action: "send",
|
||||||
@@ -644,6 +647,21 @@ describe("runMessageAction sandboxed media validation", () => {
|
|||||||
throw new Error("expected send result");
|
throw new Error("expected send result");
|
||||||
}
|
}
|
||||||
expect(result.sendResult?.mediaUrl).toBe(tmpFile);
|
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 {
|
} finally {
|
||||||
await fs.rm(sandboxDir, { recursive: true, force: true });
|
await fs.rm(sandboxDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
collectProviderApiKeysForExecution,
|
collectProviderApiKeysForExecution,
|
||||||
@@ -14,6 +13,7 @@ import type {
|
|||||||
MediaUnderstandingModelConfig,
|
MediaUnderstandingModelConfig,
|
||||||
} from "../config/types.tools.js";
|
} from "../config/types.tools.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { runExec } from "../process/exec.js";
|
import { runExec } from "../process/exec.js";
|
||||||
import { MediaAttachmentCache } from "./attachments.js";
|
import { MediaAttachmentCache } from "./attachments.js";
|
||||||
import {
|
import {
|
||||||
@@ -566,7 +566,9 @@ export async function runCliEntry(params: {
|
|||||||
maxBytes,
|
maxBytes,
|
||||||
timeoutMs,
|
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 mediaPath = pathResult.path;
|
||||||
const outputBase = path.join(outputDir, path.parse(mediaPath).name);
|
const outputBase = path.join(outputDir, path.parse(mediaPath).name);
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export { createLoggerBackedRuntime } from "./runtime.js";
|
|||||||
export { chunkTextForOutbound } from "./text-chunking.js";
|
export { chunkTextForOutbound } from "./text-chunking.js";
|
||||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||||
export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
|
export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
|
||||||
|
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
export {
|
export {
|
||||||
runPluginCommandWithTimeout,
|
runPluginCommandWithTimeout,
|
||||||
type PluginCommandRunOptions,
|
type PluginCommandRunOptions,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
|
import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
|
||||||
|
|
||||||
describe("buildRandomTempFilePath", () => {
|
describe("buildRandomTempFilePath", () => {
|
||||||
@@ -17,13 +17,13 @@ describe("buildRandomTempFilePath", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sanitizes prefix and extension to avoid path traversal segments", () => {
|
it("sanitizes prefix and extension to avoid path traversal segments", () => {
|
||||||
|
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||||
const result = buildRandomTempFilePath({
|
const result = buildRandomTempFilePath({
|
||||||
prefix: "../../line/../media",
|
prefix: "../../line/../media",
|
||||||
extension: "/../.jpg",
|
extension: "/../.jpg",
|
||||||
now: 123,
|
now: 123,
|
||||||
uuid: "abc",
|
uuid: "abc",
|
||||||
});
|
});
|
||||||
const tmpRoot = path.resolve(os.tmpdir());
|
|
||||||
const resolved = path.resolve(result);
|
const resolved = path.resolve(result);
|
||||||
const rel = path.relative(tmpRoot, resolved);
|
const rel = path.relative(tmpRoot, resolved);
|
||||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
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" });
|
await expect(fs.stat(capturedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitizes prefix and fileName", async () => {
|
it("sanitizes prefix and fileName", async () => {
|
||||||
|
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||||
let capturedPath = "";
|
let capturedPath = "";
|
||||||
await withTempDownloadPath(
|
await withTempDownloadPath(
|
||||||
{
|
{
|
||||||
@@ -61,7 +62,6 @@ describe("withTempDownloadPath", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const tmpRoot = path.resolve(os.tmpdir());
|
|
||||||
const resolved = path.resolve(capturedPath);
|
const resolved = path.resolve(capturedPath);
|
||||||
const rel = path.relative(tmpRoot, resolved);
|
const rel = path.relative(tmpRoot, resolved);
|
||||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||||
|
|
||||||
function sanitizePrefix(prefix: string): string {
|
function sanitizePrefix(prefix: string): string {
|
||||||
const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
@@ -27,6 +27,10 @@ function sanitizeFileName(fileName: string): string {
|
|||||||
return normalized || "download.bin";
|
return normalized || "download.bin";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTempRoot(tmpDir?: string): string {
|
||||||
|
return tmpDir ?? resolvePreferredOpenClawTmpDir();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRandomTempFilePath(params: {
|
export function buildRandomTempFilePath(params: {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
extension?: string;
|
extension?: string;
|
||||||
@@ -42,7 +46,7 @@ export function buildRandomTempFilePath(params: {
|
|||||||
? Math.trunc(nowCandidate)
|
? Math.trunc(nowCandidate)
|
||||||
: Date.now();
|
: Date.now();
|
||||||
const uuid = params.uuid?.trim() || crypto.randomUUID();
|
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<T>(
|
export async function withTempDownloadPath<T>(
|
||||||
@@ -53,7 +57,7 @@ export async function withTempDownloadPath<T>(
|
|||||||
},
|
},
|
||||||
fn: (tmpPath: string) => Promise<T>,
|
fn: (tmpPath: string) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const tempRoot = params.tmpDir ?? os.tmpdir();
|
const tempRoot = resolveTempRoot(params.tmpDir);
|
||||||
const prefix = `${sanitizePrefix(params.prefix)}-`;
|
const prefix = `${sanitizePrefix(params.prefix)}-`;
|
||||||
const dir = await mkdtemp(path.join(tempRoot, prefix));
|
const dir = await mkdtemp(path.join(tempRoot, prefix));
|
||||||
const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
|
const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
|
||||||
|
|||||||
36
test/scripts/check-no-random-messaging-tmp.test.ts
Normal file
36
test/scripts/check-no-random-messaging-tmp.test.ts
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user