fix(crestodian): bound rescue approval expiry

This commit is contained in:
Peter Steinberger
2026-05-30 14:28:25 -04:00
parent 53812bd8aa
commit 6a753ade78
2 changed files with 59 additions and 3 deletions

View File

@@ -297,6 +297,52 @@ describe("Crestodian rescue message", () => {
expect(audit.details?.senderId).toBe("user:owner");
});
it("does not queue persistent rescue approval when expiry would exceed the Date range", async () => {
const tempDir = await makeStateDir("overflow-expiry-");
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } };
await expect(
runRescue("/crestodian restart gateway", cfg, commandContext()),
).resolves.toContain("expiry clock is invalid");
await expect(fs.readdir(path.join(tempDir, "crestodian", "rescue-pending"))).rejects.toThrow(
/ENOENT/,
);
} finally {
vi.useRealTimers();
}
});
it("rejects pending rescue approvals with invalid persisted expiry", async () => {
const tempDir = await makeStateDir("invalid-expiry-");
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } };
const deps = { runGatewayRestart: vi.fn(async () => {}) };
await expect(
runRescue("/crestodian restart gateway", cfg, commandContext(), deps),
).resolves.toContain("Reply /crestodian yes to apply");
const pendingDir = path.join(tempDir, "crestodian", "rescue-pending");
const [pendingFile] = await fs.readdir(pendingDir);
if (!pendingFile) {
throw new Error("expected pending rescue file");
}
const pendingPath = path.join(pendingDir, pendingFile);
const pending = JSON.parse(await fs.readFile(pendingPath, "utf8")) as { expiresAt?: string };
pending.expiresAt = "not-a-date";
await fs.writeFile(pendingPath, `${JSON.stringify(pending, null, 2)}\n`, "utf8");
await expect(runRescue("/crestodian yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending Crestodian rescue change is waiting for approval.",
);
expect(deps.runGatewayRestart).not.toHaveBeenCalled();
await expect(fs.stat(pendingPath)).rejects.toThrow(/ENOENT/);
});
it("queues and applies agent creation through conversational approval", async () => {
const tempDir = await makeStateDir("agent-");
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);

View File

@@ -6,6 +6,7 @@ import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { tryReadJson, writeJson } from "../infra/json-files.js";
import type { RuntimeEnv } from "../runtime.js";
import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "../shared/number-coercion.js";
import {
executeCrestodianOperation,
formatCrestodianPersistentPlan,
@@ -86,7 +87,9 @@ async function readPending(
if (!parsed) {
return null;
}
if (Date.parse(parsed.expiresAt) <= now.getTime()) {
const expiresAtMs = asDateTimestampMs(Date.parse(parsed.expiresAt));
const nowMs = asDateTimestampMs(now.getTime());
if (expiresAtMs === undefined || nowMs === undefined || expiresAtMs <= nowMs) {
await fs.rm(pendingPath, { force: true });
return null;
}
@@ -182,11 +185,18 @@ export async function runCrestodianRescueMessage(
}
if (isPersistentCrestodianOperation(operation)) {
const now = new Date();
const expiresAt = new Date(now.getTime() + policy.pendingTtlMinutes * 60_000);
const nowMs = asDateTimestampMs(now.getTime());
const expiresAtMs =
nowMs === undefined
? undefined
: resolveExpiresAtMsFromDurationMs(policy.pendingTtlMinutes * 60_000, { nowMs });
if (expiresAtMs === undefined) {
return "Crestodian rescue could not create a pending approval because the expiry clock is invalid.";
}
await writePending(pendingPath, {
id: randomUUID(),
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
expiresAt: new Date(expiresAtMs).toISOString(),
operation,
auditDetails: buildAuditDetails(input),
});