mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-01 02:23:33 +00:00
A concurrent atomic rewrite (write-temp + rename) of a memory-wiki source page by the bridge re-export made fs-safe's opened-fd identity check fail with `path-mismatch`, which the page write rethrew as a fatal "Refusing to write" error and aborted the whole wiki_status / source-sync call. The race is transient and benign: the file is replaced under the open handle and the concurrent writer lands equivalent content. Retry briefly on `path-mismatch` (the rename window closes sub-ms) and rethrow unchanged on exhaustion, so persistent failures (directory collision, not-file) and symlink/path-alias swaps still hard-fail exactly as before. The identity guard is untouched; only the benign rename race is retried, matching the sibling read path that already treats path-mismatch as transient. Extracts the guarded-write logic duplicated by source-page-shared.ts and okf.ts into one writeGuardedVaultPage helper so both write paths get the fix and the copy is removed. Closes #92134 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
// Memory Wiki tests cover the shared guarded vault page write helper.
|
|
import { FsSafeError } from "openclaw/plugin-sdk/security-runtime";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { writeGuardedVaultPage } from "./vault-page-write.js";
|
|
|
|
type FakeVault = Parameters<typeof writeGuardedVaultPage>[0]["vault"];
|
|
|
|
function fakeVault(write: () => Promise<void>): {
|
|
vault: FakeVault;
|
|
remove: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const remove = vi.fn(async () => {});
|
|
return { vault: { write: vi.fn(write), remove } as unknown as FakeVault, remove };
|
|
}
|
|
|
|
describe("writeGuardedVaultPage", () => {
|
|
it("recovers from a transient concurrent-rewrite path-mismatch by retrying", async () => {
|
|
let attempts = 0;
|
|
const { vault } = fakeVault(async () => {
|
|
attempts += 1;
|
|
if (attempts < 3) {
|
|
// A concurrent atomic rewrite replaces the page mid-operation.
|
|
throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
|
|
}
|
|
});
|
|
|
|
await expect(
|
|
writeGuardedVaultPage({
|
|
vault,
|
|
pagePath: "sources/page.md",
|
|
content: "body",
|
|
pageStat: null,
|
|
pageLabel: "imported source page",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(attempts).toBe(3);
|
|
});
|
|
|
|
it("rethrows a labeled error when the path-mismatch persists across attempts", async () => {
|
|
const { vault } = fakeVault(async () => {
|
|
throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
|
|
});
|
|
|
|
await expect(
|
|
writeGuardedVaultPage({
|
|
vault,
|
|
pagePath: "sources/page.md",
|
|
content: "body",
|
|
pageStat: null,
|
|
pageLabel: "imported source page",
|
|
}),
|
|
).rejects.toThrow(
|
|
/Refusing to write imported source page \(path-mismatch\): sources\/page\.md/u,
|
|
);
|
|
});
|
|
|
|
it("does not retry persistent non-race guard failures and keeps fatal wording", async () => {
|
|
const { vault } = fakeVault(async () => {
|
|
throw new FsSafeError("not-file", "target is not a regular file");
|
|
});
|
|
|
|
await expect(
|
|
writeGuardedVaultPage({
|
|
vault,
|
|
pagePath: "concepts/page.md",
|
|
content: "body",
|
|
pageStat: null,
|
|
pageLabel: "OKF concept page",
|
|
}),
|
|
).rejects.toThrow(/Refusing to write OKF concept page \(not-file\): concepts\/page\.md/u);
|
|
expect((vault as unknown as { write: ReturnType<typeof vi.fn> }).write).toHaveBeenCalledTimes(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it("maps symlink and path-alias swaps to the symlink wording without retrying", async () => {
|
|
const { vault } = fakeVault(async () => {
|
|
throw new FsSafeError("symlink", "page resolved through a symlink");
|
|
});
|
|
|
|
await expect(
|
|
writeGuardedVaultPage({
|
|
vault,
|
|
pagePath: "sources/page.md",
|
|
content: "body",
|
|
pageStat: null,
|
|
pageLabel: "imported source page",
|
|
}),
|
|
).rejects.toThrow(/Refusing to write imported source page through symlink: sources\/page\.md/u);
|
|
expect((vault as unknown as { write: ReturnType<typeof vi.fn> }).write).toHaveBeenCalledTimes(
|
|
1,
|
|
);
|
|
});
|
|
});
|