fix(phone-control): bound arm expiry timestamps

This commit is contained in:
Peter Steinberger
2026-05-30 11:24:31 -04:00
parent 37b33d11ce
commit 9988a37d37
2 changed files with 61 additions and 7 deletions

View File

@@ -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({

View File

@@ -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),