Files
openclaw/src/plugin-sdk/file-lock.test.ts
Peter Steinberger 9b1c36d23c fix: prevent exec approval revocation races (#103515)
* fix(security): serialize exec approval mutations

* fix(security): preserve additive approval writes

* test(cli): expect normalized approval shape

* fix(security): preserve exec approval compatibility

* test(security): exercise locked approval initialization

* test(security): mock serialized approval helpers

* test(exec): derive enforced command path from plan

* fix(gateway): always return approval CAS conflicts

* fix(macos): serialize exec approvals writes

* fix(security): repair approval build errors

* fix(security): serialize exec approval mutations

* fix(security): fail closed on approval persistence errors

* test(security): cover detached approval persistence failures

* fix(security): harden exec approval state

* style(macos): format exec approval sources

* fix(security): complete exec approval hardening

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(macos): preserve approved login-shell semantics

* fix(macos): keep login shell approvals one-shot

* fix(security): linearize exec authorization

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): preserve durable approval basis

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* fix(security): bind exec grants to current policy

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>

* test(security): fix exec revocation fixtures

* test(security): align gateway approval fixtures

* fix(macos): return approval decisions

* chore(i18n): sync native approval strings

* test(security): align approval hardening fixtures

* test(node): authorize completed event fixture

* test(security): fix approval decision fixtures

* test(security): await durable approval visibility

* fix(exec): preserve concurrent approval grants

* fix(exec): address exact-head CI failures

* fix(exec): preserve concurrent approval promotions

* fix(exec): make Swift shutdown state explicit

* test(macos): handle approval read failures

* fix(macos): harden approval socket paths

* fix(macos): preserve exact shell payload bytes

* test(macos): make approval fixtures explicit

* test(macos): fix approval suite compilation

* fix(macos): bound approval socket JSONL reads

* chore: move exec approval note to release process

* chore: move exec approval note to release process

---------

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
2026-07-10 21:35:05 +01:00

248 lines
7.4 KiB
TypeScript

/**
* Tests plugin SDK file lock retry, stale lock, and cleanup behavior.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
acquireFileLock,
drainFileLockStateForTest,
FILE_LOCK_STALE_ERROR_CODE,
FILE_LOCK_TIMEOUT_ERROR_CODE,
resetFileLockStateForTest,
} from "./file-lock.js";
describe("acquireFileLock", () => {
let tempDir = "";
beforeEach(async () => {
resetFileLockStateForTest();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-file-lock-"));
});
afterEach(async () => {
await drainFileLockStateForTest();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("respects the configured retry budget even when stale windows are much larger", async () => {
const filePath = path.join(tempDir, "oauth-refresh");
const lockPath = `${filePath}.lock`;
const options = {
retries: {
retries: 1,
factor: 1,
minTimeout: 20,
maxTimeout: 20,
},
stale: 100,
} as const;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
"utf8",
);
let caught: { code?: string; lockPath?: string } | undefined;
try {
await acquireFileLock(filePath, options);
} catch (error) {
caught = error as { code?: string; lockPath?: string };
}
expect(caught?.code).toBe(FILE_LOCK_TIMEOUT_ERROR_CODE);
expect(caught?.lockPath ? path.relative(await fs.realpath(tempDir), caught.lockPath) : "").toBe(
"oauth-refresh.lock",
);
}, 5_000);
it("reclaims a stale lock when its owner pid is dead", async () => {
const filePath = path.join(tempDir, "auth-profiles.json");
const lockPath = `${filePath}.lock`;
const options = {
retries: {
retries: 0,
factor: 1,
minTimeout: 1,
maxTimeout: 1,
},
stale: 10,
} as const;
const deadPid = 2 ** 30;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: deadPid, createdAt: new Date(Date.now() - 60_000).toISOString() }),
"utf8",
);
const lock = await acquireFileLock(filePath, options);
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid": ${process.pid}`);
await lock.release();
});
it("fails closed for a security-sensitive stale lock", async () => {
const filePath = path.join(tempDir, "exec-approvals.json");
const lockPath = `${filePath}.lock`;
const deadPid = 2 ** 30;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: deadPid, createdAt: new Date(Date.now() - 60_000).toISOString() }),
"utf8",
);
await expect(
acquireFileLock(filePath, {
retries: { retries: 0, factor: 1, minTimeout: 1, maxTimeout: 1 },
stale: 10,
staleRecovery: "fail-closed",
}),
).rejects.toMatchObject({ code: FILE_LOCK_STALE_ERROR_CODE });
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid":${deadPid}`);
});
it("keeps a fresh lock when its payload is not readable", async () => {
const filePath = path.join(tempDir, "payload-pending");
const lockPath = `${filePath}.lock`;
const options = {
retries: {
retries: 0,
factor: 1,
minTimeout: 1,
maxTimeout: 1,
},
stale: 60_000,
} as const;
await fs.writeFile(lockPath, "{", "utf8");
let caught: { lockPath?: string } | undefined;
await expect(
(async () => {
try {
await acquireFileLock(filePath, options);
} catch (err) {
caught = err as { lockPath?: string };
throw err;
}
})(),
).rejects.toMatchObject({
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
});
await expect(fs.realpath(caught?.lockPath ?? "")).resolves.toBe(await fs.realpath(lockPath));
await expect(fs.readFile(lockPath, "utf8")).resolves.toBe("{");
});
it("does not unlink an ownerless sidecar while its creator still holds it", async () => {
const filePath = path.join(tempDir, "payload-pending-open");
const lockPath = `${filePath}.lock`;
const owner = await fs.open(lockPath, "wx");
try {
const staleAt = new Date(Date.now() - 60_000);
await owner.utimes(staleAt, staleAt);
const before = await owner.stat();
await expect(
acquireFileLock(filePath, {
retries: { retries: 0, factor: 1, minTimeout: 1, maxTimeout: 1 },
stale: 10,
}),
).rejects.toMatchObject({ code: FILE_LOCK_TIMEOUT_ERROR_CODE });
const after = await fs.stat(lockPath);
expect({ dev: after.dev, ino: after.ino }).toEqual({ dev: before.dev, ino: before.ino });
await owner.writeFile(`${JSON.stringify({ pid: process.pid })}\n`, "utf8");
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid":${process.pid}`);
} finally {
await owner.close();
await fs.rm(lockPath, { force: true });
}
});
it("keeps an expired malformed lock because ownership cannot be proven", async () => {
const filePath = path.join(tempDir, "payload-crashed");
const lockPath = `${filePath}.lock`;
const options = {
retries: {
retries: 0,
factor: 1,
minTimeout: 1,
maxTimeout: 1,
},
stale: 10,
} as const;
await fs.writeFile(lockPath, "{", "utf8");
const staleAt = new Date(Date.now() - 60_000);
await fs.utimes(lockPath, staleAt, staleAt);
await expect(acquireFileLock(filePath, options)).rejects.toMatchObject({
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
});
await expect(fs.readFile(lockPath, "utf8")).resolves.toBe("{");
});
it("keeps an expired lock when its live owner has no starttime proof", async () => {
const filePath = path.join(tempDir, "live-owner");
const lockPath = `${filePath}.lock`;
const options = {
retries: {
retries: 0,
factor: 1,
minTimeout: 1,
maxTimeout: 1,
},
stale: 10,
} as const;
await fs.writeFile(
lockPath,
JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 60_000).toISOString() }),
"utf8",
);
let caught: { lockPath?: string } | undefined;
await expect(
(async () => {
try {
await acquireFileLock(filePath, options);
} catch (err) {
caught = err as { lockPath?: string };
throw err;
}
})(),
).rejects.toMatchObject({
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
});
await expect(fs.realpath(caught?.lockPath ?? "")).resolves.toBe(await fs.realpath(lockPath));
await expect(fs.readFile(lockPath, "utf8")).resolves.toContain(`"pid":${process.pid}`);
});
it("closes an opened lock handle when writing the owner payload fails", async () => {
const filePath = path.join(tempDir, "write-fails");
const writeError = new Error("owner write failed");
const close = vi.fn().mockResolvedValue(undefined);
vi.spyOn(fs, "open").mockResolvedValue({
close,
writeFile: vi.fn().mockRejectedValue(writeError),
} as unknown as Awaited<ReturnType<typeof fs.open>>);
await expect(
acquireFileLock(filePath, {
retries: {
retries: 0,
factor: 1,
minTimeout: 1,
maxTimeout: 1,
},
stale: 100,
}),
).rejects.toThrow(writeError);
expect(close).toHaveBeenCalledTimes(1);
});
});