fix(memory): accept Windows extra-file Markdown casing (#121976)

* fix(memory): accept Windows extra-file Markdown casing

* fix(memory): keep extra-file casing policy private
This commit is contained in:
Peter Steinberger
2026-08-11 04:21:15 -07:00
committed by GitHub
parent 2b0ceb5622
commit 03f56cfbc3
9 changed files with 150 additions and 9 deletions
@@ -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]);
});
});
+1 -1
View File
@@ -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",
@@ -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"))
);
}
@@ -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<Awaited<ReturnType<typeof buildFileEntry>>>;
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;
@@ -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 {}
+10 -5
View File
@@ -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) {
+6 -2
View File
@@ -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;
}
@@ -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",
+6
View File
@@ -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",
);
});
});