diff --git a/package.json b/package.json index 7eee0f3eb5e7..6747a6575fed 100644 --- a/package.json +++ b/package.json @@ -1959,7 +1959,7 @@ "test:unit:fast:audit": "node --import tsx scripts/test-unit-fast-audit.mts", "test:voicecall:closedloop": "node --import tsx scripts/test-voicecall-closedloop.mts", "test:watch": "node --import tsx scripts/test-projects.mts --watch", - "test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", + "test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/doctor-auth-secretref-checks.e2e.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts", "test:windows:schtasks:integration": "node --import tsx scripts/run-with-env.mts CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts", "tool-display:check": "node --import tsx scripts/tool-display.ts --check", "tool-display:write": "node --import tsx scripts/tool-display.ts --write", diff --git a/packages/terminal-core/src/display-string.test.ts b/packages/terminal-core/src/display-string.test.ts index df712523b639..a449bac9ab0e 100644 --- a/packages/terminal-core/src/display-string.test.ts +++ b/packages/terminal-core/src/display-string.test.ts @@ -1,4 +1,6 @@ // Terminal Core tests cover display-safe path shortening. +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { displayString } from "./display-string.js"; @@ -49,6 +51,25 @@ describe("displayString", () => { const home = path.resolve("test-home", `${pattern}user`); stubHome(home, "~/state"); - expect(displayString(`${home}/state/project`)).toBe("$OPENCLAW_HOME/project"); + expect(displayString(path.join(home, "state", "project"))).toBe( + `$OPENCLAW_HOME${path.sep}project`, + ); }); + + it.skipIf(process.platform !== "win32")( + "shortens real Windows home casing aliases inside table display text", + () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-display-")); + try { + const homeAlias = home.toUpperCase(); + expect(fs.statSync(homeAlias).isDirectory()).toBe(true); + stubHome(home); + + expect(displayString(`Workspace: ${homeAlias}\\project`)).toBe("Workspace: ~\\project"); + expect(displayString(`İ Workspace: ${homeAlias}\\project`)).toBe("İ Workspace: ~\\project"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }, + ); }); diff --git a/packages/terminal-core/src/display-string.ts b/packages/terminal-core/src/display-string.ts index 4fafcd60b42a..7537a87d4f76 100644 --- a/packages/terminal-core/src/display-string.ts +++ b/packages/terminal-core/src/display-string.ts @@ -1,6 +1,7 @@ // Terminal Core module implements display string behavior. import os from "node:os"; import path from "node:path"; +import { lowercasePreservingWhitespace } from "@openclaw/normalization-core/string-coerce"; // Display-safe string helpers for shortening user home paths. @@ -73,13 +74,30 @@ function resolveHomeDisplayPrefix(): { home: string; prefix: string } | undefine return explicitHome ? { home, prefix: "$OPENCLAW_HOME" } : { home, prefix: "~" }; } +/** Find a case-insensitive Windows path without changing offsets in the original string. */ +function indexOfWindowsPath(input: string, home: string, cursor: number): number { + const foldedHome = lowercasePreservingWhitespace(home); + // Folding the whole display can expand Unicode and shift indices. Fixed-width slices keep + // replacement offsets anchored to the original string while retaining Windows casing rules. + for (let index = cursor; index <= input.length - home.length; index += 1) { + if (lowercasePreservingWhitespace(input.slice(index, index + home.length)) === foldedHome) { + return index; + } + } + return -1; +} + /** Replace a whole-value home or child path without clipping sibling path prefixes. */ function replaceHomePath(input: string, display: { home: string; prefix: string }): string { let output = ""; let cursor = 0; - + // terminal-core is standalone, so it keeps only its token-aware scan local; + // app-level home selection and path rendering remain owned by core. while (cursor < input.length) { - const index = input.indexOf(display.home, cursor); + const index = + process.platform === "win32" + ? indexOfWindowsPath(input, display.home, cursor) + : input.indexOf(display.home, cursor); if (index < 0) { return `${output}${input.slice(cursor)}`; } diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index b26582054ac4..e77da8629aae 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -71,6 +71,8 @@ const WINDOWS_USAGE_TEMPLATE_SCOPE_RE = /^src\/auto-reply\/usage-bar\/template(?:\.windows\.test)?\.ts$/; const WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE = /^src\/media-understanding\/attachments\.(?:cache(?:\.test)?|file-url\.windows\.test|normalize(?:\.test)?)\.ts$/; +const WINDOWS_HOME_DISPLAY_SCOPE_RE = + /^(?:src\/(?:utils(?:\.test)?|infra\/(?:home-display|path-guards)|commands\/agents\.commands\.list(?:\.test)?|cli\/daemon-cli\/status\.print(?:\.test)?|agents\/(?:sandbox\/fs-paths|sessions\/tools\/render-utils)(?:\.test)?)|packages\/terminal-core\/src\/display-string(?:\.test)?)\.ts$/; const CONTROL_UI_I18N_SCOPE_RE = /^(ui\/src\/i18n\/|ui\/config\/control-ui-locales\.ts$|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:catalog|config|raw-copy|sync-plan)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/; const CONTROL_UI_RAW_COPY_SOURCE_RE = /^ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?$/; @@ -180,14 +182,16 @@ export function detectChangedScope(changedPaths) { WINDOWS_SECRETREF_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path) || WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) || - WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path)) && + WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) || + WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path)) && (!facts.isTestOnly || WINDOWS_TEST_SCOPE_RE.test(path) || WINDOWS_FILE_URL_SCOPE_RE.test(path) || WINDOWS_SECRETREF_TEST_SCOPE_RE.test(path) || WINDOWS_DAEMON_SCOPE_RE.test(path) || WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) || - WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path)) + WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) || + WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path)) ) { runWindows = true; } diff --git a/src/agents/sandbox/fs-paths.test.ts b/src/agents/sandbox/fs-paths.test.ts index bcbc05030ea8..60db7c712672 100644 --- a/src/agents/sandbox/fs-paths.test.ts +++ b/src/agents/sandbox/fs-paths.test.ts @@ -56,7 +56,9 @@ describe("sandbox bind mounts", () => { }); it("detects bind mounts whose container path differs from the host path", () => { - expect(hasSandboxBindContainerPathAliases(["/tmp/data:/tmp/data:rw"])).toBe(false); + expect(hasSandboxBindContainerPathAliases(["/tmp/data:/tmp/data:rw"])).toBe( + process.platform === "win32", + ); expect(hasSandboxBindContainerPathAliases(["/tmp/data:/data:rw"])).toBe(true); expect(hasSandboxBindContainerPathAliases(["invalid-bind"])).toBe(false); }); @@ -181,12 +183,37 @@ describe("resolveSandboxFsPathWithMounts", () => { expect(thrown).toBeInstanceOf(Error); const message = (thrown as Error).message; expect(message).toContain( - "Path escapes sandbox root (~/workspace-coder; container root /workspace): /tmp/outside", + `Path escapes sandbox root (~${path.sep}workspace-coder; container root /workspace): /tmp/outside`, ); expect(message).toContain("Use a path under /workspace/ instead."); expect(message).not.toContain(os.homedir()); }); + it.skipIf(process.platform !== "win32")( + "does not expose real Windows home casing aliases in escape errors", + () => { + const homeAlias = os.homedir().toUpperCase(); + expect(fs.statSync(homeAlias).isDirectory()).toBe(true); + const workspaceDir = path.join(homeAlias, "workspace-coder"); + const sandbox = createSandbox({ + workspaceDir, + agentWorkspaceDir: workspaceDir, + }); + + expect(() => + resolveSandboxFsPathWithMounts({ + filePath: "C:\\outside\\secret.txt", + cwd: sandbox.workspaceDir, + defaultWorkspaceRoot: sandbox.workspaceDir, + defaultContainerRoot: sandbox.containerWorkdir, + mounts: buildSandboxFsMounts(sandbox), + }), + ).toThrow( + `Path escapes sandbox root (~${path.sep}workspace-coder; container root /workspace)`, + ); + }, + ); + it("prefers custom bind mounts over default workspace mount at /workspace", () => { const sandbox = createSandbox({ docker: { diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index 247f15951ea2..8fca42afe223 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { shortenPathWithHome } from "../../infra/home-display.js"; import { isPathInside } from "../../infra/path-guards.js"; import { resolveSandboxInputPath, resolveSandboxPath } from "../sandbox-paths.js"; import type { SandboxFsBridgeContext } from "./backend-handle.types.js"; @@ -315,11 +316,7 @@ function formatSandboxRootEscapeMessage(params: { } function shortenHomePath(value: string): string { - const home = os.homedir(); - if (value === home || value.startsWith(`${home}${path.sep}`)) { - return `~${value.slice(home.length)}`; - } - return value; + return shortenPathWithHome(value, { home: os.homedir(), prefix: "~" }); } function compareMountsByContainerPath(a: SandboxFsMount, b: SandboxFsMount): number { diff --git a/src/agents/sessions/tools/render-utils.test.ts b/src/agents/sessions/tools/render-utils.test.ts index f8d99d041f28..5d28a87809d8 100644 --- a/src/agents/sessions/tools/render-utils.test.ts +++ b/src/agents/sessions/tools/render-utils.test.ts @@ -1,4 +1,6 @@ +import fs from "node:fs"; import * as os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { appendSessionToolTruncationWarning, shortenPath } from "./render-utils.js"; @@ -45,6 +47,15 @@ describe("shortenPath", () => { expect(shortenPath("/var/log/syslog")).toBe("/var/log/syslog"); }); + it.skipIf(process.platform !== "win32")("shortens real Windows home casing aliases", () => { + const homeAlias = home.toUpperCase(); + expect(fs.statSync(homeAlias).isDirectory()).toBe(true); + + expect(shortenPath(path.join(homeAlias, "projects", "app.ts"))).toBe( + `~${path.sep}projects${path.sep}app.ts`, + ); + }); + it("returns an empty string for non-string input", () => { expect(shortenPath(undefined)).toBe(""); }); diff --git a/src/agents/sessions/tools/render-utils.ts b/src/agents/sessions/tools/render-utils.ts index 7758d7e993d1..bc42f87f9fc5 100644 --- a/src/agents/sessions/tools/render-utils.ts +++ b/src/agents/sessions/tools/render-utils.ts @@ -5,6 +5,7 @@ */ import * as os from "node:os"; import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui"; +import { shortenPathWithHome } from "../../../infra/home-display.js"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import { sanitizeBinaryOutput } from "../../shell-utils.js"; @@ -16,14 +17,7 @@ export function shortenPath(path: unknown): string { if (typeof path !== "string") { return ""; } - const home = os.homedir(); - if (path === home) { - return "~"; - } - if (path.startsWith(`${home}/`) || path.startsWith(`${home}\\`)) { - return `~${path.slice(home.length)}`; - } - return path; + return shortenPathWithHome(path, { home: os.homedir(), prefix: "~" }); } /** Returns a display string for string/nullish values, or null for unsupported values. */ diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index 1ecca51a1e9b..b8dc56af81fa 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -1,5 +1,9 @@ // Daemon status print tests cover user-facing service status formatting. +import fs from "node:fs"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../../test-helpers/temp-dir.js"; +import { withEnv } from "../../test-utils/env.js"; import { formatCliCommand } from "../command-format.js"; import { printDaemonStatus } from "./status.print.js"; @@ -131,6 +135,41 @@ describe("printDaemonStatus", () => { expectMockLineContains(runtime.log, "8192 MiB constrained memory"); }); + it.skipIf(process.platform !== "win32")( + "shortens real Windows home casing aliases in human status", + async () => { + await withTempDir({ prefix: "openclaw-home-display-" }, async (home) => { + const logFile = path.join(home, "logs", "gateway.log"); + await fs.promises.mkdir(path.dirname(logFile), { recursive: true }); + await fs.promises.writeFile(logFile, "ready", "utf8"); + const logFileAlias = logFile.toUpperCase(); + expect(fs.statSync(logFileAlias).isFile()).toBe(true); + + await withEnv({ OPENCLAW_HOME: home }, async () => { + printDaemonStatus( + { + service: { + label: "Scheduled Task", + loaded: true, + loadedText: "registered", + notLoadedText: "not registered", + }, + logFile: logFileAlias, + extraServices: [], + }, + { json: false }, + ); + }); + + expectMockLineContains( + runtime.log, + `File logs: $OPENCLAW_HOME${path.sep}LOGS${path.sep}GATEWAY.LOG`, + ); + expect(runtime.log.mock.calls.flat().join("\n")).not.toContain(home.toUpperCase()); + }); + }, + ); + it("prints stale gateway pid guidance when runtime does not own the listener", () => { printDaemonStatus( { diff --git a/src/commands/agents.commands.list.test.ts b/src/commands/agents.commands.list.test.ts index dd530e13ebf3..0f1c90ed2ed5 100644 --- a/src/commands/agents.commands.list.test.ts +++ b/src/commands/agents.commands.list.test.ts @@ -1,7 +1,11 @@ // Agent command-list tests cover provider metadata and command output for configured agents. +import fs from "node:fs"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OutputRuntimeEnv } from "../runtime.js"; +import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withEnvAsync } from "../test-utils/env.js"; const { buildProviderStatusIndexMock, @@ -127,8 +131,8 @@ describe("agentsListCommand", () => { [ "Agents:", "- main (default)", - " Workspace: ~/.openclaw/workspace", - " Agent dir: ~/.openclaw/agents/main/agent", + ` Workspace: ~${path.sep}.openclaw${path.sep}workspace`, + ` Agent dir: ~${path.sep}.openclaw${path.sep}agents${path.sep}main${path.sep}agent`, " Routing rules: 1", " Routing: Telegram default", " Providers:", @@ -139,4 +143,43 @@ describe("agentsListCommand", () => { ], ]); }); + + it.skipIf(process.platform !== "win32")( + "shortens real Windows home casing aliases in human output", + async () => { + await withTempDir({ prefix: "openclaw-home-display-" }, async (home) => { + const workspace = path.join(home, "workspace"); + const agentDir = path.join(home, "agents", "main", "agent"); + await fs.promises.mkdir(workspace, { recursive: true }); + await fs.promises.mkdir(agentDir, { recursive: true }); + const homeAlias = home.toUpperCase(); + expect(fs.statSync(homeAlias).isDirectory()).toBe(true); + + requireValidConfigMock.mockResolvedValueOnce({ + agents: { + list: [ + { + id: "main", + default: true, + workspace: path.join(homeAlias, "workspace"), + agentDir: path.join(homeAlias, "agents", "main", "agent"), + }, + ], + }, + } satisfies OpenClawConfig); + const runtime = createRuntime(); + + await withEnvAsync({ OPENCLAW_HOME: home }, async () => { + await agentsListCommand({}, runtime); + }); + + const output = vi.mocked(runtime.log).mock.calls.flat().join("\n"); + expect(output).toContain(`Workspace: $OPENCLAW_HOME${path.sep}workspace`); + expect(output).toContain( + `Agent dir: $OPENCLAW_HOME${path.sep}agents${path.sep}main${path.sep}agent`, + ); + expect(output).not.toContain(homeAlias); + }); + }, + ); }); diff --git a/src/infra/home-display.ts b/src/infra/home-display.ts new file mode 100644 index 000000000000..a11ea986ed77 --- /dev/null +++ b/src/infra/home-display.ts @@ -0,0 +1,24 @@ +// Shared home-path display policy for core owners with distinct home selection contracts. +import path from "node:path"; +import { isPathInside, normalizeWindowsPathPreservingCase } from "./path-guards.js"; + +/** Replace an absolute home path with its display prefix without clipping sibling paths. */ +export function shortenPathWithHome( + input: string, + { home, prefix }: { home: string; prefix: string }, +): string { + if (input === home) { + return prefix; + } + if (input.startsWith(`${home}/`) || input.startsWith(`${home}\\`)) { + return `${prefix}${input.slice(home.length)}`; + } + if (process.platform === "win32" && path.win32.isAbsolute(input) && isPathInside(home, input)) { + const relative = path.win32.relative( + normalizeWindowsPathPreservingCase(home), + normalizeWindowsPathPreservingCase(input), + ); + return path.win32.join(prefix, relative); + } + return input; +} diff --git a/src/scripts/ci-changed-scope.windows.test.ts b/src/scripts/ci-changed-scope.windows.test.ts index 068e52b96a6d..06dd22526cc6 100644 --- a/src/scripts/ci-changed-scope.windows.test.ts +++ b/src/scripts/ci-changed-scope.windows.test.ts @@ -165,6 +165,30 @@ describe("detectChangedScope Windows routing", () => { } }); + it("routes shared home display owners and visible command coverage to Windows", () => { + for (const displayPath of [ + "src/utils.ts", + "src/utils.test.ts", + "src/infra/home-display.ts", + "src/infra/path-guards.ts", + "src/commands/agents.commands.list.ts", + "src/commands/agents.commands.list.test.ts", + "src/cli/daemon-cli/status.print.ts", + "src/cli/daemon-cli/status.print.test.ts", + "packages/terminal-core/src/display-string.ts", + "packages/terminal-core/src/display-string.test.ts", + "src/agents/sandbox/fs-paths.ts", + "src/agents/sandbox/fs-paths.test.ts", + "src/agents/sessions/tools/render-utils.ts", + "src/agents/sessions/tools/render-utils.test.ts", + ]) { + expect(detectChangedScope([displayPath]), displayPath).toMatchObject({ + runNode: true, + runWindows: true, + }); + } + }); + it("routes SecretRef path-security changes and native fixtures to Windows", () => { for (const secretRefPath of [ "src/commands/doctor-gateway-auth-token.ts", diff --git a/src/utils.test.ts b/src/utils.test.ts index 6d0123ae147d..202a6bf64a78 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -197,6 +197,36 @@ describe("shortenHomePath", () => { ); }); }); + + it.skipIf(process.platform === "win32")("keeps POSIX home matching case-sensitive", () => { + withEnv({ OPENCLAW_HOME: "/srv/OpenClaw-Home", HOME: "/home/other" }, () => { + expect(shortenHomePath("/srv/openclaw-home/workspace")).toBe("/srv/openclaw-home/workspace"); + }); + }); + + it.skipIf(process.platform !== "win32")("keeps relative Windows paths relative", () => { + withEnv({ OPENCLAW_HOME: process.cwd() }, () => { + expect(shortenHomePath(`relative${path.sep}workspace`)).toBe(`relative${path.sep}workspace`); + }); + }); + + it.skipIf(process.platform !== "win32")( + "shortens real extended-length Windows home aliases without exposing the absolute path", + async () => { + await withTempDir({ prefix: "openclaw-home-display-" }, async (home) => { + const workspace = path.join(home, "workspace"); + await fs.promises.mkdir(workspace); + const extendedAlias = `\\\\?\\${workspace.toUpperCase()}`; + expect(fs.statSync(extendedAlias).isDirectory()).toBe(true); + + withEnv({ OPENCLAW_HOME: home }, () => { + const display = shortenHomePath(extendedAlias); + expect(display).toBe(`$OPENCLAW_HOME${path.sep}WORKSPACE`); + expect(display).not.toContain(home.toUpperCase()); + }); + }); + }, + ); }); describe("shortenHomeInString", () => { @@ -209,6 +239,33 @@ describe("shortenHomeInString", () => { ).toBe("config: $OPENCLAW_HOME/.openclaw/openclaw.json"); }); }); + + it.skipIf(process.platform === "win32")( + "keeps embedded POSIX home matching case-sensitive", + () => { + withEnv({ OPENCLAW_HOME: "/srv/OpenClaw-Home", HOME: "/home/other" }, () => { + expect(shortenHomeInString("config: /srv/openclaw-home/openclaw.json")).toBe( + "config: /srv/openclaw-home/openclaw.json", + ); + }); + }, + ); + + it.skipIf(process.platform !== "win32")( + "shortens real Windows home casing aliases inside diagnostic text", + async () => { + await withTempDir({ prefix: "openclaw-home-display-" }, async (home) => { + const homeAlias = home.toUpperCase(); + expect(fs.statSync(homeAlias).isDirectory()).toBe(true); + + withEnv({ OPENCLAW_HOME: home }, () => { + expect(shortenHomeInString(`config: ${homeAlias}\\openclaw.json`)).toBe( + "config: $OPENCLAW_HOME\\openclaw.json", + ); + }); + }); + }, + ); }); describe("resolveUserPath", () => { diff --git a/src/utils.ts b/src/utils.ts index d161692193b8..b9a6dd9d127c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,7 +8,9 @@ import { resolveRequiredHomeDir, resolveUserPath, } from "./infra/home-dir.js"; +import { shortenPathWithHome } from "./infra/home-display.js"; import { isPlainObject } from "./infra/plain-object.js"; +import { escapeRegExp as escapeRegExpValue } from "./shared/regexp.js"; export { escapeRegExp } from "./shared/regexp.js"; export { sleep } from "./utils/sleep.js"; export { isRecord } from "@openclaw/normalization-core/record-coerce"; @@ -102,21 +104,11 @@ function resolveHomeDisplayPrefix(): { home: string; prefix: string } | undefine /** Replaces the leading home directory in a path with `~` or `$OPENCLAW_HOME`. */ export function shortenHomePath(input: string): string { - if (!input) { - return input; - } const display = resolveHomeDisplayPrefix(); if (!display) { return input; } - const { home, prefix } = display; - if (input === home) { - return prefix; - } - if (input.startsWith(`${home}/`) || input.startsWith(`${home}\\`)) { - return `${prefix}${input.slice(home.length)}`; - } - return input; + return shortenPathWithHome(input, display); } /** Replaces all effective-home occurrences inside a diagnostic string. */ @@ -128,6 +120,9 @@ export function shortenHomeInString(input: string): string { if (!display) { return input; } + if (process.platform === "win32") { + return input.replace(new RegExp(escapeRegExpValue(display.home), "giu"), display.prefix); + } return input.split(display.home).join(display.prefix); } diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 60c088c4e1b0..39dfb076389a 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -338,4 +338,15 @@ describe("package scripts", () => { "src/media-understanding/attachments.file-url.windows.test.ts", ); }); + + it("runs shared home display and visible command coverage in Windows CI", () => { + const script = readPackageJson().scripts["test:windows:ci"]; + + expect(script).toContain("src/utils.test.ts"); + expect(script).toContain("src/commands/agents.commands.list.test.ts"); + expect(script).toContain("src/cli/daemon-cli/status.print.test.ts"); + expect(script).toContain("packages/terminal-core/src/display-string.test.ts"); + expect(script).toContain("src/agents/sandbox/fs-paths.test.ts"); + expect(script).toContain("src/agents/sessions/tools/render-utils.test.ts"); + }); });