fix(browser): allow inbound media uploads

Allow the browser upload tool to resolve OpenClaw-managed inbound media refs such as `media://inbound/<id>` and sandbox-relative `media/inbound/<id>` while preserving the existing upload-root path contract.

Keep upload-root files ahead of sandbox-relative inbound fallback, reject nested absolute inbound media files, and validate raw `media://` paths before URL normalization so traversal-shaped refs cannot resolve to direct media ids.

Verification:
- `OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs extensions/browser/src/browser/paths.test.ts --reporter=verbose`
- `OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs extensions/browser/src/browser/paths.test.ts --reporter=dot`
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo`
- `pnpm lint --threads=8`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- `git diff --check`
- GitHub PR checks on be08e6c8a8: dependency-guard, check-lint, check-test-types, check-additional-extension-bundled, checks-fast-contracts-plugins-a, checks-fast-contracts-plugins-b all passed.

Fixes #83544.

Co-authored-by: Zee Zheng <zheng.zuo0@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zee Zheng
2026-05-31 06:49:07 +08:00
committed by GitHub
parent d05e4a4bc6
commit 8be581cbf8
16 changed files with 736 additions and 61 deletions

View File

@@ -205,6 +205,7 @@ File + dialog helpers:
```bash
openclaw browser upload /tmp/openclaw/uploads/file.pdf --ref <ref>
openclaw browser upload media://inbound/file.pdf --ref <ref>
openclaw browser waitfordownload
openclaw browser download <ref> report.pdf
openclaw browser dialog --accept
@@ -215,6 +216,10 @@ Managed Chrome profiles save ordinary click-triggered downloads into the OpenCla
downloads directory (`/tmp/openclaw/downloads` by default, or the configured temp
root). Use `waitfordownload` or `download` when the agent needs to wait for a
specific file and return its path; those explicit waiters own the next download.
Uploads accept files from the OpenClaw temp uploads root and OpenClaw-managed
inbound media, including `media://inbound/<id>` and sandbox-relative
`media/inbound/<id>` references. Nested media refs, traversal, and arbitrary
local paths remain rejected.
When an action opens a modal dialog, the action response returns
`blockedByDialog` with `browserState.dialogs.pending`; pass `--dialog-id` to
answer it directly. Dialogs handled outside OpenClaw appear under

View File

@@ -191,6 +191,7 @@ openclaw browser select 9 OptionA OptionB
openclaw browser download e12 report.pdf
openclaw browser waitfordownload report.pdf
openclaw browser upload /tmp/openclaw/uploads/file.pdf
openclaw browser upload media://inbound/file.pdf
openclaw browser fill --fields '[{"ref":"1","type":"text","value":"Ada"}]'
openclaw browser dialog --accept
openclaw browser dialog --dismiss --dialog-id d1
@@ -232,7 +233,12 @@ Notes:
- `upload` and `dialog` are **arming** calls; run them before the click/press that triggers the chooser/dialog. If an action opens a modal, the action response includes `blockedByDialog` and `browserState.dialogs.pending`; pass that `dialogId` to respond directly. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
- `click`/`type`/etc require a `ref` from `snapshot` (numeric `12`, role ref `e12`, or actionable ARIA ref `ax12`). CSS selectors are intentionally not supported for actions. Use `click-coords` when the visible viewport position is the only reliable target.
- Download, trace, and upload paths are constrained to OpenClaw temp roots: `/tmp/openclaw{,/downloads,/uploads}` (fallback: `${os.tmpdir()}/openclaw/...`).
- Download and trace paths are constrained to OpenClaw temp roots: `/tmp/openclaw{,/downloads}` (fallback: `${os.tmpdir()}/openclaw/...`).
- `upload` accepts files from the OpenClaw temp uploads root and
OpenClaw-managed inbound media. Managed inbound media can be referenced as
`media://inbound/<id>`, sandbox-relative `media/inbound/<id>`, or a resolved
path inside the managed inbound media directory. Nested media refs,
traversal, symlinks, hardlinks, and arbitrary local paths are still rejected.
- `upload` can also set file inputs directly via `--input-ref` or `--element`.
Stable tab ids and labels survive Chromium raw-target replacement when OpenClaw

View File

@@ -53,7 +53,11 @@ export {
resolveGoogleChromeExecutableForPlatform,
} from "./browser/chrome.executables.js";
export { redactCdpUrl } from "./browser/cdp.helpers.js";
export { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./browser/paths.js";
export {
DEFAULT_UPLOAD_DIR,
resolveExistingPathsWithinRoot,
resolveExistingUploadPaths,
} from "./browser/paths.js";
export { getBrowserProfileCapabilities } from "./browser/profile-capabilities.js";
export { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./browser/proxy-files.js";
export {

View File

@@ -48,7 +48,7 @@ export {
} from "./browser/client.js";
export { resolveBrowserConfig, resolveProfile } from "./browser/config.js";
export { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./browser/constants.js";
export { DEFAULT_UPLOAD_DIR, resolveExistingPathsWithinRoot } from "./browser/paths.js";
export { resolveExistingUploadPaths } from "./browser/paths.js";
export { getBrowserProfileCapabilities } from "./browser/profile-capabilities.js";
export { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./browser/proxy-files.js";
export {

View File

@@ -137,6 +137,17 @@ vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", async () => {
};
});
const pathValidationMocks = vi.hoisted(() => ({
resolveExistingUploadPaths: vi.fn<
(args: {
requestedPaths: string[];
}) => Promise<{ ok: true; paths: string[] } | { ok: false; error: string }>
>(async ({ requestedPaths }) => ({
ok: true as const,
paths: requestedPaths,
})),
}));
const sessionTabRegistryMocks = vi.hoisted(() => ({
touchSessionBrowserTab: vi.fn(),
trackSessionBrowserTab: vi.fn(),
@@ -226,10 +237,7 @@ vi.mock("./browser-tool.runtime.js", () => {
},
readStringParam,
readStringValue,
resolveExistingPathsWithinRoot: vi.fn(async ({ requestedPaths }) => ({
ok: true,
paths: requestedPaths,
})),
resolveExistingUploadPaths: pathValidationMocks.resolveExistingUploadPaths,
resolveNodeIdFromList: (nodes: Array<Record<string, unknown>>, requested: string) => {
const node = nodes.find(
(entry) => entry.nodeId === requested || entry.displayName === requested,
@@ -1634,3 +1642,45 @@ describe("browser tool act stale target recovery", () => {
expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(1);
});
});
describe("browser tool upload inbound media fallback (#83544)", () => {
beforeEach(resetBrowserToolMocks);
afterEach(() => vi.restoreAllMocks());
it("resolves upload paths before arming the file chooser", async () => {
const inboundPath = "/home/user/.openclaw/media/inbound/report.pdf";
pathValidationMocks.resolveExistingUploadPaths.mockResolvedValue({
ok: true,
paths: [inboundPath],
});
browserActionsMocks.browserArmFileChooser.mockResolvedValue({ ok: true });
const tool = createBrowserTool();
const result = await tool.execute?.("call-upload-1", {
action: "upload",
paths: [inboundPath],
ref: "file-input-1",
});
expect(pathValidationMocks.resolveExistingUploadPaths).toHaveBeenCalledWith({
requestedPaths: [inboundPath],
});
expect(result?.content[0]).toHaveProperty("type", "text");
});
it("rejects files outside both uploads and inbound media directories", async () => {
pathValidationMocks.resolveExistingUploadPaths.mockResolvedValue({
ok: false as const,
error: "path outside allowed directories",
});
const tool = createBrowserTool();
await expect(
tool.execute?.("call-upload-2", {
action: "upload",
paths: ["/etc/passwd"],
ref: "file-input-1",
}),
).rejects.toThrow("path outside allowed directories");
});
});

View File

@@ -8,7 +8,6 @@ import {
import {
type AnyAgentTool,
type NodeListNode,
DEFAULT_UPLOAD_DIR,
BrowserToolSchema,
applyBrowserProxyPaths,
browserAct,
@@ -37,8 +36,8 @@ import {
readStringParam,
readStringValue,
resolveBrowserConfig,
resolveExistingUploadPaths,
resolveRuntimeImageSanitization,
resolveExistingPathsWithinRoot,
resolveNodeIdFromList,
resolveProfile,
selectDefaultNodeFromList,
@@ -821,15 +820,11 @@ export function createBrowserTool(opts?: {
if (paths.length === 0) {
throw new Error("paths required");
}
const uploadPathsResult = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
throw new Error(uploadPathsResult.error);
const resolvedResult = await resolveExistingUploadPaths({ requestedPaths: paths });
if (!resolvedResult.ok) {
throw new Error(resolvedResult.error);
}
const normalizedPaths = uploadPathsResult.paths;
const normalizedPaths = resolvedResult.paths;
const ref = readStringParam(params, "ref");
const inputRef = readStringParam(params, "inputRef");
const element = readStringParam(params, "element");

View File

@@ -4,21 +4,29 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
resolveExistingPathsWithinRoot,
resolveExistingUploadPaths,
resolvePathsWithinRoot,
resolvePathWithinRoot,
resolveStrictExistingPathsWithinRoot,
resolveStrictExistingUploadPaths,
resolveWritablePathWithinRoot,
} from "./paths.js";
async function createFixtureRoot(): Promise<{ baseDir: string; uploadsDir: string }> {
async function createFixtureRoot(): Promise<{
baseDir: string;
inboundMediaDir: string;
uploadsDir: string;
}> {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-browser-paths-"));
const uploadsDir = path.join(baseDir, "uploads");
const inboundMediaDir = path.join(baseDir, "media", "inbound");
await fs.mkdir(uploadsDir, { recursive: true });
return { baseDir, uploadsDir };
await fs.mkdir(inboundMediaDir, { recursive: true });
return { baseDir, inboundMediaDir, uploadsDir };
}
async function withFixtureRoot<T>(
run: (ctx: { baseDir: string; uploadsDir: string }) => Promise<T>,
run: (ctx: { baseDir: string; inboundMediaDir: string; uploadsDir: string }) => Promise<T>,
): Promise<T> {
const fixture = await createFixtureRoot();
try {
@@ -223,6 +231,177 @@ describe("resolveExistingPathsWithinRoot", () => {
);
});
describe("resolveExistingUploadPaths", () => {
it("falls back to inbound media when the uploads root rejects the file", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.writeFile(inboundFile, "pdf", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: [inboundFile],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(inboundFile)]);
}
});
});
it("resolves canonical inbound media URI references before root validation", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.writeFile(inboundFile, "pdf", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media://inbound/report.pdf"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(inboundFile)]);
}
});
});
it("falls back to sandbox-relative inbound media paths after root validation", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.writeFile(inboundFile, "pdf", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media/inbound/report.pdf"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(inboundFile)]);
}
});
});
it("keeps upload-root paths before sandbox-relative inbound media fallback", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const uploadFile = path.join(uploadsDir, "media", "inbound", "report.pdf");
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.mkdir(path.dirname(uploadFile), { recursive: true });
await fs.writeFile(uploadFile, "upload", "utf8");
await fs.writeFile(inboundFile, "inbound", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media/inbound/report.pdf"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(uploadFile)]);
}
});
});
it("accepts mixed upload-root and inbound-media files in one request", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const uploadFile = path.join(uploadsDir, "from-upload.txt");
const inboundFile = path.join(inboundMediaDir, "from-inbound.txt");
await fs.writeFile(uploadFile, "upload", "utf8");
await fs.writeFile(inboundFile, "inbound", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: [uploadFile, "media://inbound/from-inbound.txt"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([
await fs.realpath(uploadFile),
await fs.realpath(inboundFile),
]);
}
});
});
it("rejects nested inbound media URI references", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media://inbound/nested%2Fsecret.pdf"],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("Invalid media reference");
}
});
});
it("rejects traversal-shaped inbound media URI references before URL normalization", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.writeFile(inboundFile, "pdf", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media://inbound/nested/../report.pdf"],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("Invalid media reference");
}
});
});
it("rejects nested absolute inbound media paths", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const nestedDir = path.join(inboundMediaDir, "nested");
await fs.mkdir(nestedDir, { recursive: true });
const nestedFile = path.join(nestedDir, "secret.pdf");
await fs.writeFile(nestedFile, "secret", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: [nestedFile],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("direct child of inbound media directory");
}
});
});
it("rejects files outside both managed upload roots", async () => {
await withFixtureRoot(async ({ baseDir, inboundMediaDir, uploadsDir }) => {
const outsideFile = path.join(baseDir, "secret.txt");
await fs.writeFile(outsideFile, "secret", "utf8");
const result = await resolveExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: [outsideFile],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("inbound media directory");
}
});
});
});
describe("resolveStrictExistingPathsWithinRoot", () => {
function expectInvalidResult(
result: Awaited<ReturnType<typeof resolveStrictExistingPathsWithinRoot>>,
@@ -246,6 +425,141 @@ describe("resolveStrictExistingPathsWithinRoot", () => {
});
});
describe("resolveStrictExistingUploadPaths", () => {
it("falls back to inbound media for use-time upload validation", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.writeFile(inboundFile, "pdf", "utf8");
const result = await resolveStrictExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: [inboundFile],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(inboundFile)]);
}
});
});
it("resolves inbound media URI references for use-time upload validation", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.writeFile(inboundFile, "pdf", "utf8");
const result = await resolveStrictExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media://inbound/report.pdf"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(inboundFile)]);
}
});
});
it("falls back to sandbox-relative inbound media paths for use-time upload validation", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.writeFile(inboundFile, "pdf", "utf8");
const result = await resolveStrictExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media/inbound/report.pdf"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(inboundFile)]);
}
});
});
it("keeps upload-root paths before sandbox-relative inbound media fallback at use time", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const uploadFile = path.join(uploadsDir, "media", "inbound", "report.pdf");
const inboundFile = path.join(inboundMediaDir, "report.pdf");
await fs.mkdir(path.dirname(uploadFile), { recursive: true });
await fs.writeFile(uploadFile, "upload", "utf8");
await fs.writeFile(inboundFile, "inbound", "utf8");
const result = await resolveStrictExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["media/inbound/report.pdf"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([await fs.realpath(uploadFile)]);
}
});
});
it("accepts mixed upload-root and inbound-media files at use time", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const uploadFile = path.join(uploadsDir, "from-upload.txt");
const inboundFile = path.join(inboundMediaDir, "from-inbound.txt");
await fs.writeFile(uploadFile, "upload", "utf8");
await fs.writeFile(inboundFile, "inbound", "utf8");
const result = await resolveStrictExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: [uploadFile, "media/inbound/from-inbound.txt"],
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.paths).toEqual([
await fs.realpath(uploadFile),
await fs.realpath(inboundFile),
]);
}
});
});
it("rejects files missing from both managed upload roots", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const result = await resolveStrictExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: ["missing.txt"],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("regular non-symlink file");
}
});
});
it("rejects nested absolute inbound media paths at use time", async () => {
await withFixtureRoot(async ({ inboundMediaDir, uploadsDir }) => {
const nestedDir = path.join(inboundMediaDir, "nested");
await fs.mkdir(nestedDir, { recursive: true });
const nestedFile = path.join(nestedDir, "secret.pdf");
await fs.writeFile(nestedFile, "secret", "utf8");
const result = await resolveStrictExistingUploadPaths({
uploadDir: uploadsDir,
inboundMediaDir,
requestedPaths: [nestedFile],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("direct child of inbound media directory");
}
});
});
});
describe("resolvePathWithinRoot", () => {
it("uses default file name when requested path is blank", () => {
const result = resolvePathWithinRoot({

View File

@@ -1,13 +1,18 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export {
import {
resolveExistingPathsWithinRoot,
resolveStrictExistingPathsWithinRoot,
} from "../sdk-security-runtime.js";
import { CONFIG_DIR } from "../utils.js";
export {
pathScope,
resolvePathsWithinRoot,
resolvePathWithinRoot,
resolveStrictExistingPathsWithinRoot,
resolveWritablePathWithinRoot,
} from "../sdk-security-runtime.js";
export { resolveExistingPathsWithinRoot, resolveStrictExistingPathsWithinRoot };
const DEFAULT_FALLBACK_BROWSER_TMP_DIR = "/tmp/openclaw";
@@ -33,3 +38,235 @@ const DEFAULT_BROWSER_TMP_DIR = canUseNodeFs()
export const DEFAULT_TRACE_DIR = DEFAULT_BROWSER_TMP_DIR;
export const DEFAULT_DOWNLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
export const DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
export const DEFAULT_INBOUND_MEDIA_DIR = path.join(CONFIG_DIR, "media", "inbound");
type ExistingPathsResult = Awaited<ReturnType<typeof resolveExistingPathsWithinRoot>>;
type StrictExistingPathsResult = Awaited<ReturnType<typeof resolveStrictExistingPathsWithinRoot>>;
type UploadPathResolutionOptions = {
requestedPaths: string[];
uploadDir?: string;
inboundMediaDir?: string;
};
type ResolvedManagedInboundMediaRef =
| { ok: true; path: string; uploadRootPrecedence: boolean }
| { ok: false; error: string }
| null;
type DecodedInboundMediaId = { ok: true; path: string } | { ok: false; error: string };
function normalizeUploadPathSource(source: string): string {
const trimmed = source.trim();
if (/^media:\/\//i.test(trimmed)) {
return trimmed;
}
return trimmed.replace(/^\s*MEDIA\s*:\s*/i, "").trim();
}
function decodeInboundMediaId(value: string, source: string): DecodedInboundMediaId {
let id: string;
try {
id = decodeURIComponent(value);
} catch {
return { ok: false, error: `Invalid media reference: ${source}` };
}
if (
!id ||
id === "." ||
id === ".." ||
id.includes("/") ||
id.includes("\\") ||
id.includes("\0")
) {
return { ok: false, error: `Invalid media reference: ${source}` };
}
return { ok: true, path: id };
}
function resolveManagedInboundMediaRef(
source: string,
inboundMediaDir: string,
): ResolvedManagedInboundMediaRef {
const normalizedSource = normalizeUploadPathSource(source);
if (!normalizedSource) {
return null;
}
if (/^media:\/\//i.test(normalizedSource)) {
const rawUriMatch = /^media:\/\/[^/?#]*([^?#]*)/iu.exec(normalizedSource);
const rawPath = rawUriMatch?.[1] ?? "";
let parsed: URL;
try {
parsed = new URL(normalizedSource);
} catch {
return { ok: false, error: `Invalid media reference: ${normalizedSource}` };
}
if (parsed.hostname !== "inbound") {
return {
ok: false,
error: `Unsupported media reference location: ${parsed.hostname || "(missing)"}`,
};
}
if (!rawPath.startsWith("/") || rawPath.slice(1).includes("/") || rawPath.includes("\\")) {
return { ok: false, error: `Invalid media reference: ${normalizedSource}` };
}
const decoded = decodeInboundMediaId(rawPath.slice(1), normalizedSource);
return decoded?.ok
? {
ok: true,
path: path.join(inboundMediaDir, decoded.path),
uploadRootPrecedence: false,
}
: decoded;
}
const relativeMatch = /^(?:\.\/)?media\/inbound\/([^/\\]+)$/u.exec(normalizedSource);
if (!relativeMatch?.[1]) {
return null;
}
const decoded = decodeInboundMediaId(relativeMatch[1], normalizedSource);
return decoded?.ok
? {
ok: true,
path: path.join(inboundMediaDir, decoded.path),
uploadRootPrecedence: true,
}
: decoded;
}
async function isDirectInboundMediaFile(params: {
inboundMediaDir: string;
resolvedPath: string;
}): Promise<boolean> {
let inboundRoot: string;
try {
inboundRoot = await fs.realpath(params.inboundMediaDir);
} catch {
inboundRoot = path.resolve(params.inboundMediaDir);
}
const relativePath = path.relative(inboundRoot, params.resolvedPath);
return (
Boolean(relativePath) &&
relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath) &&
!relativePath.includes("/") &&
!relativePath.includes("\\")
);
}
async function resolveDirectInboundMediaPath(params: {
inboundMediaDir: string;
requestedPath: string;
strict: boolean;
}): Promise<ExistingPathsResult> {
const inboundPathsResult = params.strict
? await resolveStrictExistingPathsWithinRoot({
rootDir: params.inboundMediaDir,
requestedPaths: [params.requestedPath],
scopeLabel: `inbound media directory (${params.inboundMediaDir})`,
})
: await resolveExistingPathsWithinRoot({
rootDir: params.inboundMediaDir,
requestedPaths: [params.requestedPath],
scopeLabel: `inbound media directory (${params.inboundMediaDir})`,
});
if (!inboundPathsResult.ok) {
return inboundPathsResult;
}
const resolvedPath = inboundPathsResult.paths[0] ?? params.requestedPath;
if (
!(await isDirectInboundMediaFile({
inboundMediaDir: params.inboundMediaDir,
resolvedPath,
}))
) {
return {
ok: false,
error: `Invalid media reference: must be a direct child of inbound media directory (${params.inboundMediaDir})`,
};
}
return inboundPathsResult;
}
export async function resolveExistingUploadPaths({
requestedPaths,
uploadDir = DEFAULT_UPLOAD_DIR,
inboundMediaDir = DEFAULT_INBOUND_MEDIA_DIR,
}: UploadPathResolutionOptions): Promise<ExistingPathsResult> {
const paths: string[] = [];
for (const requestedPath of requestedPaths) {
const managedMediaPathResult = resolveManagedInboundMediaRef(requestedPath, inboundMediaDir);
if (managedMediaPathResult?.ok === false) {
return managedMediaPathResult;
}
if (managedMediaPathResult?.uploadRootPrecedence !== false) {
const uploadPathsResult =
managedMediaPathResult?.uploadRootPrecedence === true
? await resolveStrictExistingPathsWithinRoot({
rootDir: uploadDir,
requestedPaths: [requestedPath],
scopeLabel: `uploads directory (${uploadDir})`,
})
: await resolveExistingPathsWithinRoot({
rootDir: uploadDir,
requestedPaths: [requestedPath],
scopeLabel: `uploads directory (${uploadDir})`,
});
if (uploadPathsResult.ok) {
paths.push(uploadPathsResult.paths[0] ?? requestedPath);
continue;
}
}
const inboundPathsResult = await resolveDirectInboundMediaPath({
inboundMediaDir,
requestedPath: managedMediaPathResult?.path ?? requestedPath,
strict: false,
});
if (!inboundPathsResult.ok) {
return inboundPathsResult;
}
paths.push(inboundPathsResult.paths[0] ?? requestedPath);
}
return { ok: true, paths };
}
export async function resolveStrictExistingUploadPaths({
requestedPaths,
uploadDir = DEFAULT_UPLOAD_DIR,
inboundMediaDir = DEFAULT_INBOUND_MEDIA_DIR,
}: UploadPathResolutionOptions): Promise<StrictExistingPathsResult> {
const paths: string[] = [];
for (const requestedPath of requestedPaths) {
const managedMediaPathResult = resolveManagedInboundMediaRef(requestedPath, inboundMediaDir);
if (managedMediaPathResult?.ok === false) {
return managedMediaPathResult;
}
if (managedMediaPathResult?.uploadRootPrecedence !== false) {
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
rootDir: uploadDir,
requestedPaths: [requestedPath],
scopeLabel: `uploads directory (${uploadDir})`,
});
if (uploadPathsResult.ok) {
paths.push(uploadPathsResult.paths[0] ?? requestedPath);
continue;
}
}
const inboundPathsResult = await resolveDirectInboundMediaPath({
inboundMediaDir,
requestedPath: managedMediaPathResult?.path ?? requestedPath,
strict: true,
});
if (!inboundPathsResult.ok) {
return inboundPathsResult;
}
paths.push(inboundPathsResult.paths[0] ?? requestedPath);
}
return { ok: true, paths };
}

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import type { Page } from "playwright-core";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { writeExternalFileWithinOutputRoot } from "./output-files.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import { resolveStrictExistingUploadPaths } from "./paths.js";
import {
armObservedDialogResponseOnPage,
ensurePageState,
@@ -155,10 +155,8 @@ export async function armFileUploadViaPlaywright(opts: {
}
return;
}
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
const uploadPathsResult = await resolveStrictExistingUploadPaths({
requestedPaths: opts.paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
try {

View File

@@ -21,8 +21,8 @@ const refLocator = vi.fn(() => {
});
const forceDisconnectPlaywrightForTarget = vi.fn(async () => {});
const resolveStrictExistingPathsWithinRoot =
vi.fn<typeof import("./paths.js").resolveStrictExistingPathsWithinRoot>();
const resolveStrictExistingUploadPaths =
vi.fn<typeof import("./paths.js").resolveStrictExistingUploadPaths>();
vi.mock("./pw-session.js", () => {
return {
@@ -38,8 +38,7 @@ vi.mock("./pw-session.js", () => {
vi.mock("./paths.js", () => {
return {
DEFAULT_UPLOAD_DIR: "/tmp/openclaw/uploads",
resolveStrictExistingPathsWithinRoot,
resolveStrictExistingUploadPaths,
};
});
@@ -62,7 +61,7 @@ describe("setInputFilesViaPlaywright", () => {
vi.clearAllMocks();
page = null;
locator = null;
resolveStrictExistingPathsWithinRoot.mockResolvedValue({
resolveStrictExistingUploadPaths.mockResolvedValue({
ok: true,
paths: ["/private/tmp/openclaw/uploads/ok.txt"],
});
@@ -78,19 +77,17 @@ describe("setInputFilesViaPlaywright", () => {
paths: ["/tmp/openclaw/uploads/ok.txt"],
});
expect(resolveStrictExistingPathsWithinRoot).toHaveBeenCalledWith({
rootDir: "/tmp/openclaw/uploads",
expect(resolveStrictExistingUploadPaths).toHaveBeenCalledWith({
requestedPaths: ["/tmp/openclaw/uploads/ok.txt"],
scopeLabel: "uploads directory (/tmp/openclaw/uploads)",
});
expect(refLocator).toHaveBeenCalledWith(page, "e7");
expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]);
});
it("throws and skips setInputFiles when use-time validation fails", async () => {
resolveStrictExistingPathsWithinRoot.mockResolvedValueOnce({
resolveStrictExistingUploadPaths.mockResolvedValueOnce({
ok: false,
error: "Invalid path: must stay within uploads directory",
error: "Invalid path: must stay within inbound media directory",
});
const { setInputFiles } = seedSingleLocatorPage();
@@ -102,7 +99,7 @@ describe("setInputFilesViaPlaywright", () => {
element: "input[type=file]",
paths: ["/tmp/openclaw/uploads/missing.txt"],
}),
).rejects.toThrow("Invalid path: must stay within uploads directory");
).rejects.toThrow("Invalid path: must stay within inbound media directory");
expect(setInputFiles).not.toHaveBeenCalled();
});

View File

@@ -17,7 +17,7 @@ import {
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { DEFAULT_UPLOAD_DIR, resolveStrictExistingPathsWithinRoot } from "./paths.js";
import { resolveStrictExistingUploadPaths } from "./paths.js";
import {
assertPageNavigationCompletedSafely,
createObservedDialogAbortSignalForPage,
@@ -1396,15 +1396,11 @@ export async function setInputFilesViaPlaywright(opts: {
}
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: opts.paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
if (!uploadPathsResult.ok) {
throw new Error(uploadPathsResult.error);
const resolvedResult = await resolveStrictExistingUploadPaths({ requestedPaths: opts.paths });
if (!resolvedResult.ok) {
throw new Error(resolvedResult.error);
}
const resolvedPaths = uploadPathsResult.paths;
const resolvedPaths = resolvedResult.paths;
try {
await locator.setInputFiles(resolvedPaths);

View File

@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { installPwToolsCoreTestHooks, setPwToolsCoreCurrentPage } from "./pw-tools-core.test-harness.js";
const pathMocks = vi.hoisted(() => ({
resolveStrictExistingUploadPaths: vi.fn<
(args: { requestedPaths: string[] }) => Promise<
{ ok: true; paths: string[] } | { ok: false; error: string }
>
>(),
}));
vi.mock("./paths.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./paths.js")>();
return {
...actual,
resolveStrictExistingUploadPaths: pathMocks.resolveStrictExistingUploadPaths,
};
});
installPwToolsCoreTestHooks();
const { armFileUploadViaPlaywright } = await import("./pw-tools-core.downloads.js");
function createFileChooserPageMocks() {
const fileChooser = { setFiles: vi.fn(async () => {}) };
const press = vi.fn(async () => {});
const waitForEvent = vi.fn(async () => fileChooser);
setPwToolsCoreCurrentPage({
waitForEvent,
keyboard: { press },
});
return { fileChooser, press };
}
describe("armFileUploadViaPlaywright upload path validation", () => {
beforeEach(() => {
pathMocks.resolveStrictExistingUploadPaths.mockResolvedValue({
ok: true,
paths: ["/home/user/.openclaw/media/inbound/report.pdf"],
});
});
it("sets files using resolved inbound media paths", async () => {
const { fileChooser } = createFileChooserPageMocks();
await armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
paths: ["/home/user/.openclaw/media/inbound/report.pdf"],
});
await Promise.resolve();
await vi.waitFor(() => {
expect(fileChooser.setFiles).toHaveBeenCalledWith([
"/home/user/.openclaw/media/inbound/report.pdf",
]);
});
});
it("escapes the chooser when paths are outside managed upload roots", async () => {
pathMocks.resolveStrictExistingUploadPaths.mockResolvedValue({
ok: false,
error: "Invalid path: must stay within inbound media directory",
});
const { fileChooser, press } = createFileChooserPageMocks();
await armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
paths: ["/etc/passwd"],
});
await Promise.resolve();
await vi.waitFor(() => {
expect(press).toHaveBeenCalledWith("Escape");
});
expect(fileChooser.setFiles).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import { formatErrorMessage } from "../../infra/errors.js";
import { evaluateChromeMcpScript, uploadChromeMcpFile } from "../chrome-mcp.js";
import { resolveExistingUploadPaths } from "../paths.js";
import { getBrowserProfileCapabilities } from "../profile-capabilities.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
@@ -9,7 +10,6 @@ import {
withRouteTabContext,
} from "./agent.shared.js";
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
import { DEFAULT_UPLOAD_DIR, pathScope } from "./path-output.js";
import { readRouteTimerTimeoutMs } from "./route-numeric.js";
import type { BrowserRouteRegistrar } from "./types.js";
import {
@@ -49,14 +49,12 @@ export function registerBrowserAgentActHookRoutes(
ctx,
targetId,
run: async ({ profileCtx, cdpUrl, tab }) => {
const uploadPathsResult = await pathScope(DEFAULT_UPLOAD_DIR, {
label: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
}).existing(paths);
if (!uploadPathsResult.ok) {
res.status(400).json({ error: uploadPathsResult.error });
const resolvedResult = await resolveExistingUploadPaths({ requestedPaths: paths });
if (!resolvedResult.ok) {
res.status(400).json({ error: resolvedResult.error });
return;
}
const resolvedPaths = uploadPathsResult.paths;
const resolvedPaths = resolvedResult.paths;
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (element) {

View File

@@ -7,9 +7,8 @@ import {
} from "../browser-cli-shared.js";
import {
danger,
DEFAULT_UPLOAD_DIR,
defaultRuntime,
resolveExistingPathsWithinRoot,
resolveExistingUploadPaths,
shortenHomePath,
} from "../core-api.js";
import { resolveBrowserActionContext, withBrowserActionTimeoutSlack } from "./shared.js";
@@ -17,11 +16,7 @@ import { resolveBrowserActionContext, withBrowserActionTimeoutSlack } from "./sh
const DEFAULT_BROWSER_HOOK_TIMEOUT_MS = 120000;
async function normalizeUploadPaths(paths: string[]): Promise<string[]> {
const result = await resolveExistingPathsWithinRoot({
rootDir: DEFAULT_UPLOAD_DIR,
requestedPaths: paths,
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`,
});
const result = await resolveExistingUploadPaths({ requestedPaths: paths });
if (!result.ok) {
throw new Error(result.error);
}
@@ -95,7 +90,7 @@ export function registerBrowserFilesAndDownloadsCommands(
.description("Arm file upload for the next file chooser")
.argument(
"<paths...>",
"File paths to upload (must be within OpenClaw temp uploads dir, e.g. /tmp/openclaw/uploads/file.pdf)",
"File paths to upload from OpenClaw temp uploads or managed inbound media (e.g. /tmp/openclaw/uploads/file.pdf or media://inbound/<id>)",
)
.option("--ref <ref>", "Ref id from snapshot to click after arming")
.option("--input-ref <ref>", "Ref id for <input type=file> to set directly")

View File

@@ -27,6 +27,7 @@ export const browserActionExamples = [
"openclaw browser drag 10 11",
"openclaw browser select 9 OptionA OptionB",
"openclaw browser upload /tmp/openclaw/uploads/file.pdf",
"openclaw browser upload media://inbound/file.pdf",
'openclaw browser fill --fields \'[{"ref":"1","value":"Ada"}]\'',
"openclaw browser dialog --accept",
'openclaw browser wait --text "Done"',

View File

@@ -42,6 +42,7 @@ export {
resolveBrowserConfig,
resolveBrowserControlAuth,
resolveExistingPathsWithinRoot,
resolveExistingUploadPaths,
resolveProfile,
resolveRequestedBrowserProfile,
startBrowserControlServiceFromConfig,