diff --git a/openclaw.mjs b/openclaw.mjs index f1f18afd234c..fa0e73e9b85a 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -509,7 +509,7 @@ const resolveLauncherHomeDir = () => { const explicit = normalizeLauncherHomeValue(process.env.OPENCLAW_HOME); const rawHome = explicit && (explicit === "~" || explicit.startsWith("~/") || explicit.startsWith("~\\")) - ? explicit.replace(/^~(?=$|[\\/])/, resolveLauncherOsHomeDir()) + ? explicit.replace(/^~(?=$|[\\/])/, () => resolveLauncherOsHomeDir()) : (explicit ?? resolveLauncherOsHomeDir()); return path.resolve(rawHome); }; diff --git a/src/daemon/paths.ts b/src/daemon/paths.ts index a37d7efdcb19..af655f6cf01e 100644 --- a/src/daemon/paths.ts +++ b/src/daemon/paths.ts @@ -25,7 +25,7 @@ function resolveUserPathWithHome(input: string, home?: string): string { if (!home) { throw new Error("Missing HOME"); } - const expanded = trimmed.replace(/^~(?=$|[\\/])/, home); + const expanded = trimmed.replace(/^~(?=$|[\\/])/, () => home); return path.resolve(expanded); } if (windowsAbsolutePath.test(trimmed) || windowsUncPath.test(trimmed)) { diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 6ab72de89cc0..f8481ba3918b 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -970,6 +970,11 @@ describe("resolveGatewayStateDir", () => { expect(resolveGatewayStateDir(env)).toBe(path.resolve("/Users/test/openclaw-state")); }); + it("does not interpret $ patterns in HOME when expanding ~ in OPENCLAW_STATE_DIR", () => { + const env = { HOME: "/home/$&user", OPENCLAW_STATE_DIR: "~/openclaw-state" }; + expect(resolveGatewayStateDir(env)).toBe(path.resolve("/home/$&user/openclaw-state")); + }); + it("preserves Windows absolute paths without HOME", () => { const env = { OPENCLAW_STATE_DIR: "C:\\State\\openclaw" }; expect(resolveGatewayStateDir(env)).toBe("C:\\State\\openclaw"); diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index e32002b4b23f..3c2077048867 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -139,6 +139,15 @@ describe("resolveEffectiveHomeDir", () => { expect(resolveEffectiveHomeDir(env)).toBe(path.resolve("/home/alice/svc")); }); + + it("does not interpret $ patterns in HOME when expanding OPENCLAW_HOME tilde", () => { + const env = { + OPENCLAW_HOME: "~/state", + HOME: "/home/$&user", + } as NodeJS.ProcessEnv; + + expect(resolveEffectiveHomeDir(env)).toBe(path.resolve("/home/$&user/state")); + }); }); describe("resolveRequiredHomeDir", () => { @@ -230,6 +239,12 @@ describe("expandHomePrefix", () => { input: "/tmp/x", expected: "/tmp/x", }, + { + name: "does not interpret $ patterns in home when expanding tilde", + input: "~/x", + opts: { home: "/home/$&user" }, + expected: "/home/$&user/x", + }, ])("$name", ({ input, opts, expected }) => { expect(expandHomePrefix(input, opts)).toBe(expected); }); diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index 42305fdf4cd4..d3b784d7d646 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -48,7 +48,7 @@ function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): strin } if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { const fallbackHome = resolveRawOsHomeDir(env, homedir); - return fallbackHome ? explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome) : undefined; + return fallbackHome ? explicitHome.replace(/^~(?=$|[\\/])/, () => fallbackHome) : undefined; } return explicitHome; } @@ -117,7 +117,7 @@ export function expandHomePrefix( if (!home) { return input; } - return input.replace(/^~(?=$|[\\/])/, home); + return input.replace(/^~(?=$|[\\/])/, () => home); } /** Resolves a user-supplied path after trimming and expanding against the effective home. */ diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 83a3b7b4e840..7584859a84d7 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -634,6 +634,38 @@ describe("openclaw launcher", () => { expect(result.stdout).not.toContain("PRECOMPUTED"); }); + it("keeps literal $ patterns in HOME when expanding a tilde OPENCLAW_HOME", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const home = path.join(fixtureRoot, "home$&d"); + const configDir = path.join(home, "oc", ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ rootHelpText: "PRECOMPUTED memory help\n" }), + "utf8", + ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "process.stdout.write('RUNTIME ENTRY\\n');\n", + "utf8", + ); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ plugins: { slots: { memory: "memory-lancedb" } } }), + "utf8", + ); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + env: launcherEnv({ HOME: home, OPENCLAW_HOME: "~/oc" }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("RUNTIME ENTRY\n"); + expect(result.stdout).not.toContain("PRECOMPUTED"); + }); + it("checks legacy config candidates before using precomputed root help", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); const home = path.join(fixtureRoot, "home"); diff --git a/ui/src/e2e/chat-flow.local-media-dollar-home.e2e.test.ts b/ui/src/e2e/chat-flow.local-media-dollar-home.e2e.test.ts new file mode 100644 index 000000000000..c8ed635aac16 --- /dev/null +++ b/ui/src/e2e/chat-flow.local-media-dollar-home.e2e.test.ts @@ -0,0 +1,91 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { createChatFlowE2eSuite, installMockGateway } from "./chat-flow.test-support.ts"; + +const suite = createChatFlowE2eSuite(); + +suite.define(() => { + it("allows tilde local media previews when the preview root home contains a literal $ pattern", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const source = "~/media/report-voice.mp3"; + const requestedMediaUrls: URL[] = []; + + await page.route("**/__openclaw__/assistant-media?**", async (route) => { + const url = new URL(route.request().url()); + requestedMediaUrls.push(url); + if (url.searchParams.get("meta") === "1") { + expect(route.request().headers().authorization).toBe("Bearer e2e-device-token"); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + available: true, + mediaTicket: "ticket-dollar-home", + mediaTicketExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + }), + }); + return; + } + await route.fulfill({ + contentType: "audio/mpeg", + body: Buffer.from("ID3\u0003\u0000\u0000\u0000\u0000\u0000\u0000"), + }); + }); + + await installMockGateway(page, { + localMediaPreviewRoots: ["/home/us$&r/media"], + historyMessages: [ + { + id: "assistant-dollar-home-audio", + role: "assistant", + content: [ + { type: "text", text: "Your recording" }, + { + type: "attachment", + attachment: { + kind: "audio", + label: "report-voice.mp3", + mimeType: "audio/mpeg", + url: source, + }, + }, + ], + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + const attachment = page.locator(".chat-assistant-attachment-card--compact"); + await attachment.waitFor({ state: "visible", timeout: 10_000 }); + await expect.poll(() => requestedMediaUrls.length, { timeout: 10_000 }).toBe(1); + expect(requestedMediaUrls[0]?.searchParams.get("meta")).toBe("1"); + expect(requestedMediaUrls[0]?.searchParams.get("source")).toBe(source); + const downloadHref = await attachment + .locator(".chat-assistant-attachment-card__download") + .getAttribute("href"); + expect(downloadHref).toBeTruthy(); + const downloadUrl = new URL(downloadHref ?? "", suite.server.baseUrl); + expect(downloadUrl.searchParams.get("mediaTicket")).toBe("ticket-dollar-home"); + expect(downloadUrl.searchParams.get("source")).toBe(source); + expect(await attachment.locator("audio, video").count()).toBe(0); + expect(await page.getByText("Outside allowed folders").count()).toBe(0); + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "local-media-dollar-home-allowed.png"), + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); +}); diff --git a/ui/src/pages/chat/components/chat-message-local-media.test.ts b/ui/src/pages/chat/components/chat-message-local-media.test.ts new file mode 100644 index 000000000000..2e5e6baa67f8 --- /dev/null +++ b/ui/src/pages/chat/components/chat-message-local-media.test.ts @@ -0,0 +1,11 @@ +// Control UI tests cover local media preview path policy. +import { describe, expect, it } from "vitest"; +import { isLocalAttachmentPreviewAllowed } from "./chat-message-local-media.ts"; + +describe("isLocalAttachmentPreviewAllowed", () => { + it("keeps literal $ patterns in home when expanding tilde sources", () => { + const roots = ["/home/us$&r/media"]; + expect(isLocalAttachmentPreviewAllowed("~/media/report.png", roots)).toBe(true); + expect(isLocalAttachmentPreviewAllowed("~/elsewhere/report.png", roots)).toBe(false); + }); +}); diff --git a/ui/src/pages/chat/components/chat-message-local-media.ts b/ui/src/pages/chat/components/chat-message-local-media.ts index e67f3b31995e..2fadc0644b93 100644 --- a/ui/src/pages/chat/components/chat-message-local-media.ts +++ b/ui/src/pages/chat/components/chat-message-local-media.ts @@ -96,7 +96,7 @@ export function isLocalAttachmentPreviewAllowed( ? [canonicalizeLocalPathForComparison(normalizedSource)] : source.trim().startsWith("~") ? resolveHomeCandidatesFromRoots(localMediaPreviewRoots).map((home) => - canonicalizeLocalPathForComparison(source.trim().replace(/^~(?=$|[\\/])/, home)), + canonicalizeLocalPathForComparison(source.trim().replace(/^~(?=$|[\\/])/, () => home)), ) : []; if (comparableSources.length === 0) { diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 33a8ecf64b37..c0821616c8e6 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -346,6 +346,8 @@ export type ControlUiMockGatewayScenario = { cliAgentsEnabled?: boolean; workspace?: string; workspaceGit?: boolean; + /** Local media preview roots served in the bootstrap config; tilde sources expand against these. */ + localMediaPreviewRoots?: string[]; }; type NormalizedControlUiMockGatewayScenario = Required; @@ -919,6 +921,7 @@ function normalizeScenario( cliAgentsEnabled: scenario.cliAgentsEnabled ?? false, workspace: scenario.workspace ?? "", workspaceGit: scenario.workspaceGit ?? false, + localMediaPreviewRoots: scenario.localMediaPreviewRoots ?? [], }; } @@ -933,7 +936,7 @@ export function createControlUiMockBootstrapConfig(scenario: ControlUiMockGatewa basePath: normalizedScenario.basePath, devGitBranch: normalizedScenario.devGitBranch || undefined, embedSandbox: "scripts", - localMediaPreviewRoots: [], + localMediaPreviewRoots: normalizedScenario.localMediaPreviewRoots, serverVersion: normalizedScenario.serverVersion, serverBuildId: normalizedScenario.serverBuildId, terminalEnabled: normalizedScenario.terminalEnabled,