diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index f68b4695c64..dbc32bb7805 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -269,6 +269,25 @@ describe("phone-control plugin", () => { }); }); + it("rejects arm requests when the expiry would exceed a valid Date", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(8_640_000_000_000_000)); + try { + await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { + const res = await command.handler({ + ...createCommandContext("arm writes 30s"), + channel: "webchat", + gatewayClientScopes: ["operator.admin"], + }); + + expect(res?.text ?? "").toContain("Invalid duration"); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + } finally { + vi.useRealTimers(); + } + }); + it("allows external owner callers without gateway scopes to mutate phone control", async () => { await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => { const res = await command.handler({ diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 085aa48dcc0..331d40e87dd 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -1,6 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "openclaw/plugin-sdk/number-runtime"; import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty, @@ -294,14 +298,38 @@ function lacksAdminToMutatePhoneControl(params: { return senderIsOwner !== true; } +function resolveArmExpiryStatus(state: ArmStateFile, nowRaw = Date.now()): string { + if (state.expiresAtMs == null) { + return "manual disarm required"; + } + const now = asDateTimestampMs(nowRaw); + if (now === undefined) { + return "expiry unavailable"; + } + const expiresAt = asDateTimestampMs(state.expiresAtMs); + if (expiresAt === undefined || expiresAt <= now) { + return "expired"; + } + return `expires in ${formatDuration(expiresAt - now)}`; +} + +function isArmStateExpired(state: ArmStateFile, nowRaw = Date.now()): boolean { + if (state.expiresAtMs == null) { + return false; + } + const now = asDateTimestampMs(nowRaw); + if (now === undefined) { + return false; + } + const expiresAt = asDateTimestampMs(state.expiresAtMs); + return expiresAt === undefined || expiresAt <= now; +} + function formatStatus(state: ArmStateFile | null): string { if (!state) { return "Phone control: disarmed."; } - const until = - state.expiresAtMs == null - ? "manual disarm required" - : `expires in ${formatDuration(Math.max(0, state.expiresAtMs - Date.now()))}`; + const until = resolveArmExpiryStatus(state); const cmds = uniqSorted( state.version === 1 ? state.removedFromDeny @@ -329,7 +357,7 @@ export default definePluginEntry({ if (!state || state.expiresAtMs == null) { return; } - if (Date.now() < state.expiresAtMs) { + if (!isArmStateExpired(state)) { return; } await disarmNow({ @@ -430,7 +458,14 @@ export default definePluginEntry({ if (durationMs === null) { return { text: "Invalid duration. Use values like 30s, 10m, 2h, or 1d." }; } - const expiresAtMs = Date.now() + durationMs; + const armedAtMs = asDateTimestampMs(Date.now()); + const expiresAtMs = + armedAtMs === undefined + ? undefined + : resolveExpiresAtMsFromDurationMs(durationMs, { nowMs: armedAtMs }); + if (armedAtMs === undefined || expiresAtMs === undefined) { + return { text: "Invalid duration. Use values like 30s, 10m, 2h, or 1d." }; + } const commands = resolveCommandsForGroup(group); const cfg = api.runtime.config.current() as OpenClawConfig; @@ -461,7 +496,7 @@ export default definePluginEntry({ await writeArmState(statePath, { version: STATE_VERSION, - armedAtMs: Date.now(), + armedAtMs, expiresAtMs, group, armedCommands: uniqSorted(commands),