diff --git a/extensions/memory-core/src/memory-extra-file-path.windows.test.ts b/extensions/memory-core/src/memory-extra-file-path.windows.test.ts new file mode 100644 index 000000000000..b0cc87a55de2 --- /dev/null +++ b/extensions/memory-core/src/memory-extra-file-path.windows.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + listMemoryFiles, + readMemoryFile, +} from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { scanMemorySources } from "./cli-runtime-common.js"; +import { resolveMemoryPathClassification } from "./memory/memory-path-provenance.js"; + +describe.skipIf(process.platform !== "win32")("Windows explicit memory extra-file casing", () => { + let fixtureRoot = ""; + let workspaceDir = ""; + let physicalPath = ""; + let configuredAlias = ""; + + beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-extra-file-case-")); + workspaceDir = path.join(fixtureRoot, "workspace"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + physicalPath = path.join(fixtureRoot, "shared-notes.md"); + configuredAlias = path.join(fixtureRoot, "SHARED-NOTES.MD"); + await fs.writeFile(physicalPath, "shared Windows memory", "utf8"); + + const [physicalStat, aliasStat] = await Promise.all([ + fs.stat(physicalPath), + fs.stat(configuredAlias), + ]); + expect({ dev: aliasStat.dev, ino: aliasStat.ino }).toEqual({ + dev: physicalStat.dev, + ino: physicalStat.ino, + }); + }); + + afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + }); + + it.each(["physical", "alias"] as const)( + "lists, reads, and scans the configured %s spelling", + async (spelling) => { + const configuredPath = spelling === "physical" ? physicalPath : configuredAlias; + await expect(listMemoryFiles(workspaceDir, [configuredPath])).resolves.toEqual([ + configuredPath, + ]); + await expect( + readMemoryFile({ + workspaceDir, + extraPaths: [configuredPath], + relPath: configuredPath, + }), + ).resolves.toMatchObject({ text: "shared Windows memory" }); + await expect( + scanMemorySources({ + workspaceDir, + agentId: "main", + sources: ["memory"], + extraPaths: [configuredPath], + }), + ).resolves.toMatchObject({ totalFiles: 1, issues: [] }); + }, + ); + + it("keeps the external file untrusted and built-in discovery case-exact", async () => { + await expect( + resolveMemoryPathClassification({ + absolutePath: configuredAlias, + source: "memory", + workspaceDir, + }), + ).resolves.toEqual({ curatedRoot: false, originClass: "untrusted" }); + + const canonicalRoot = path.join(workspaceDir, "MEMORY.md"); + const uppercaseBuiltIn = path.join(workspaceDir, "memory", "BUILTIN.MD"); + await fs.writeFile(canonicalRoot, "canonical", "utf8"); + await fs.writeFile(uppercaseBuiltIn, "excluded built-in alias", "utf8"); + await expect(listMemoryFiles(workspaceDir)).resolves.toEqual([canonicalRoot]); + }); +}); diff --git a/package.json b/package.json index 4e676ae718f6..eb33a3a7d2aa 100644 --- a/package.json +++ b/package.json @@ -1966,7 +1966,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/process-env.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/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.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 src/cli/mcp-cli.path-case.windows.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/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/process-env.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/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.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 src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.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/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/memory-host-sdk/src/host/explicit-extra-markdown.ts b/packages/memory-host-sdk/src/host/explicit-extra-markdown.ts new file mode 100644 index 000000000000..cc354a3ab4d1 --- /dev/null +++ b/packages/memory-host-sdk/src/host/explicit-extra-markdown.ts @@ -0,0 +1,8 @@ +export function isExplicitExtraMarkdownFilePath( + filePath: string, + platform: NodeJS.Platform = process.platform, +): boolean { + return ( + filePath.endsWith(".md") || (platform === "win32" && filePath.toLowerCase().endsWith(".md")) + ); +} diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 42bd0b172699..bc7567c13a05 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -19,6 +19,7 @@ import { stripMemoryAnnotationCarriers, } from "./internal.js"; import { normalizeMemoryMultimodalSettings, type MemoryMultimodalSettings } from "./multimodal.js"; +import { readMemoryFile } from "./read-file.js"; type FileEntry = NonNullable>>; type MultimodalIndexingChunk = NonNullable< @@ -94,6 +95,22 @@ const multimodal: MemoryMultimodalSettings = normalizeMemoryMultimodalSettings({ describe("memory host SDK package internals", () => { const getTmpDir = setupTempDirLifecycle("memory-package-"); + it.skipIf(process.platform === "win32")( + "rejects an uppercase explicit extra file on case-sensitive hosts", + async () => { + const tmpDir = getTmpDir(); + const workspaceDir = path.join(tmpDir, "workspace"); + const upperPath = path.join(tmpDir, "NOTES.MD"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + await fs.writeFile(upperPath, "not lowercase Markdown", "utf8"); + + await expect(listMemoryFiles(workspaceDir, [upperPath])).resolves.toEqual([]); + await expect( + readMemoryFile({ workspaceDir, extraPaths: [upperPath], relPath: upperPath }), + ).rejects.toThrow("path required"); + }, + ); + it("drains in-flight work before propagating a concurrency failure", async () => { const failure = new Error("embedding failed"); let releaseTask!: () => void; diff --git a/packages/memory-host-sdk/src/host/internal.ts b/packages/memory-host-sdk/src/host/internal.ts index af1b73fcb537..16ca96d91869 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -8,6 +8,7 @@ import { runWithConcurrency as runWithConcurrencyImpl } from "./concurrency.js"; import { MEMORY_HOST_ROOT_FILENAME, normalizeConfiguredMemoryExtraPaths } from "./config-utils.js"; import { estimateStructuredEmbeddingInputBytes } from "./embedding-input-limits.js"; import { buildTextEmbeddingInput, type EmbeddingInput } from "./embedding-inputs.js"; +import { isExplicitExtraMarkdownFilePath } from "./explicit-extra-markdown.js"; import { isFileMissingError, readRegularFile, @@ -257,7 +258,11 @@ export async function listMemoryFiles( ); continue; } - if (stat.isFile() && isAllowedMemoryFilePath(inputPath, multimodal)) { + if ( + stat.isFile() && + (isExplicitExtraMarkdownFilePath(inputPath) || + isAllowedMemoryFilePath(inputPath, multimodal)) + ) { result.push(inputPath); } } catch {} diff --git a/packages/memory-host-sdk/src/host/read-file.ts b/packages/memory-host-sdk/src/host/read-file.ts index 8dbd92c1f474..5b07c2a05105 100644 --- a/packages/memory-host-sdk/src/host/read-file.ts +++ b/packages/memory-host-sdk/src/host/read-file.ts @@ -7,6 +7,7 @@ import { resolveMemoryHostSearchPathConfig, type OpenClawConfig, } from "./config-utils.js"; +import { isExplicitExtraMarkdownFilePath } from "./explicit-extra-markdown.js"; import { assertNoSymlinkParents, isFileMissingError, @@ -88,7 +89,7 @@ export async function readMemoryFile(params: { const relPath = path.relative(params.workspaceDir, absPath).replace(/\\/g, "/"); const inWorkspace = relPath.length > 0 && !relPath.startsWith("..") && !path.isAbsolute(relPath); const allowedWorkspace = inWorkspace && isMemoryPath(relPath); - let allowedAdditional = false; + let allowedAdditional: false | "directory" | "file" = false; if (!allowedWorkspace && (params.extraPaths?.length ?? 0) > 0) { const additionalPaths = normalizeExtraMemoryPathEntries(params.workspaceDir, params.extraPaths); for (const additionalPath of additionalPaths) { @@ -106,13 +107,17 @@ export async function readMemoryFile(params: { if (candidateStat?.isSymbolicLink()) { continue; } - allowedAdditional = true; + allowedAdditional = "directory"; break; } continue; } - if (stat.isFile() && absPath === additionalPath.path && absPath.endsWith(".md")) { - allowedAdditional = true; + if ( + stat.isFile() && + absPath === additionalPath.path && + isExplicitExtraMarkdownFilePath(absPath) + ) { + allowedAdditional = "file"; break; } } catch {} @@ -121,7 +126,7 @@ export async function readMemoryFile(params: { if (!allowedWorkspace && !allowedAdditional) { throw new Error("path required"); } - if (!absPath.endsWith(".md")) { + if (!absPath.endsWith(".md") && allowedAdditional !== "file") { throw new Error("path required"); } if (allowedWorkspace) { diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index d140346d37ee..ab5692f13a8e 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -75,6 +75,8 @@ 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 WINDOWS_MCP_ENV_SCOPE_RE = /^src\/(?:cli\/mcp-cli(?:\.path-case\.windows)?\.test|cli\/mcp-cli|infra\/process-env(?:\.test)?)\.ts$/; +const WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE = + /^(?:packages\/memory-host-sdk\/src\/host\/(?:(?:internal|read-file)(?:\.test)?|explicit-extra-markdown)|extensions\/memory-core\/src\/(?:cli-runtime-common|memory-extra-file-path\.windows\.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?$/; @@ -186,7 +188,8 @@ export function detectChangedScope(changedPaths) { WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) || WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) || WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) || - WINDOWS_MCP_ENV_SCOPE_RE.test(path)) && + WINDOWS_MCP_ENV_SCOPE_RE.test(path) || + WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE.test(path)) && (!facts.isTestOnly || WINDOWS_TEST_SCOPE_RE.test(path) || WINDOWS_FILE_URL_SCOPE_RE.test(path) || @@ -195,7 +198,8 @@ export function detectChangedScope(changedPaths) { WINDOWS_USAGE_TEMPLATE_SCOPE_RE.test(path) || WINDOWS_MEDIA_UNDERSTANDING_FILE_URL_SCOPE_RE.test(path) || WINDOWS_HOME_DISPLAY_SCOPE_RE.test(path) || - WINDOWS_MCP_ENV_SCOPE_RE.test(path)) + WINDOWS_MCP_ENV_SCOPE_RE.test(path) || + WINDOWS_MEMORY_EXTRA_FILE_SCOPE_RE.test(path)) ) { runWindows = true; } diff --git a/src/scripts/ci-changed-scope.windows.test.ts b/src/scripts/ci-changed-scope.windows.test.ts index 422ba45a9627..87c1e2a0dd00 100644 --- a/src/scripts/ci-changed-scope.windows.test.ts +++ b/src/scripts/ci-changed-scope.windows.test.ts @@ -216,6 +216,22 @@ describe("detectChangedScope Windows routing", () => { } }); + it("routes explicit memory extra-file owners and native coverage to Windows", () => { + for (const memoryPath of [ + "packages/memory-host-sdk/src/host/explicit-extra-markdown.ts", + "packages/memory-host-sdk/src/host/internal.ts", + "packages/memory-host-sdk/src/host/internal.test.ts", + "packages/memory-host-sdk/src/host/read-file.ts", + "extensions/memory-core/src/cli-runtime-common.ts", + "extensions/memory-core/src/memory-extra-file-path.windows.test.ts", + ]) { + expect(detectChangedScope([memoryPath]), memoryPath).toMatchObject({ + runNode: true, + runWindows: true, + }); + } + }); + it("routes SecretRef path-security changes and focused owner coverage to Windows", () => { for (const secretRefPath of [ "src/commands/doctor-gateway-auth-token.ts", diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index cde47cf8cc64..a7d45635c8a1 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -356,4 +356,10 @@ describe("package scripts", () => { expect(script).toContain("src/infra/process-env.test.ts"); expect(script).toContain("src/cli/mcp-cli.path-case.windows.test.ts"); }); + + it("runs explicit memory extra-file casing coverage in Windows CI", () => { + expect(readPackageJson().scripts["test:windows:ci"]).toContain( + "extensions/memory-core/src/memory-extra-file-path.windows.test.ts", + ); + }); });