Files
openclaw/extensions/phone-control/index.test.ts
Chunyue Wang c0195f7ed5 fix(diagnostics): clear embedded-run activity when recovery declares lane idle (#88820)
* fix(diagnostics): clear embedded-run activity when recovery declares lane idle

Stuck-session recovery transitions a lane to idle via the recovery
coordinator, but only mutated the session-state store. When an aborted
embedded run was removed without markDiagnosticEmbeddedRunEnded, the
activity store kept hasActiveEmbeddedRun set, so the liveness sweep
reported idle/embedded_run and isIdleQueuedRecoverableSessionStall
re-triggered recovery indefinitely.

Reconcile the activity store from the authoritative idle declaration by
clearing the session's embedded-run owners. The existing generation
guard already excludes any newer run that re-armed activity, so a live
requeued run is preserved.

* fix(diagnostics): reconcile tool/model activity on authoritative idle cleanup

clearDiagnosticEmbeddedRunActivityForSession (renamed from
clearDiagnosticEmbeddedRunsForSession) now clears the aborted run's tool and
model markers alongside the embedded-run owners, matching the default
markDiagnosticEmbeddedRunEnded teardown. Clearing only the owner set left the
lane as idle + orphaned tool/model activity, which
isIdleQueuedRecoverableSessionStall still treats as recoverable while work is
queued, so the liveness sweep kept re-triggering recovery instead of converging.
Adds regression cases with stale tool and model markers plus queued work.

* test(phone-control): align service mocks with keyed store API

* fix(diagnostics): preserve rearmed recovery activity

* fix(diagnostics): clear recovered owner markers

* fix(diagnostics): clear recovered embedded work keys

* fix(diagnostics): ignore stale same-key recovery owners

* fix(diagnostics): preserve same-session recovery rearm

* fix(diagnostics): ignore stale queued activity starts

* fix(diagnostics): record recovery cutoffs for empty activity

* fix(diagnostics): preserve fresh recovery markers

* fix(diagnostics): prune stale activity before fresh recovery block

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-01 01:07:35 -04:00

480 lines
16 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
OpenKeyedStoreOptions,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { beforeEach, describe, expect, it, vi } from "vitest";
import registerPhoneControl from "./index.js";
import type {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
OpenClawPluginService,
PluginCommandContext,
} from "./runtime-api.js";
const PHONE_CONTROL_STATE_PREFIX = "openclaw-phone-control-test-";
const WRITE_COMMANDS = ["calendar.add", "contacts.add", "reminders.add", "sms.send"] as const;
function createApi(params: {
stateDir: string;
getConfig: () => Record<string, unknown>;
writeConfig: (next: Record<string, unknown>) => Promise<void>;
registerCommand: (command: OpenClawPluginCommandDefinition) => void;
registerService?: (service: OpenClawPluginService) => void;
openKeyedStore?: OpenClawPluginApi["runtime"]["state"]["openKeyedStore"];
}): OpenClawPluginApi {
return createTestPluginApi({
id: "phone-control",
name: "phone-control",
source: "test",
config: {},
pluginConfig: {},
runtime: {
state: {
resolveStateDir: () => params.stateDir,
openKeyedStore:
params.openKeyedStore ??
((options: OpenKeyedStoreOptions) =>
createPluginStateKeyedStoreForTests("phone-control", {
...options,
env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir },
})),
},
config: {
current: () => params.getConfig(),
mutateConfigFile: async ({
mutate,
}: {
mutate: (draft: Record<string, unknown>) => void;
}) => {
const nextConfig = structuredClone(params.getConfig());
mutate(nextConfig);
await params.writeConfig(nextConfig);
return {
path: "/tmp/openclaw.json",
previousHash: null,
persistedHash: null,
snapshot: {},
nextConfig,
afterWrite: { mode: "auto" },
followUp: { mode: "auto", requiresRestart: false },
result: undefined,
};
},
replaceConfigFile: ({ nextConfig }: { nextConfig: unknown }) =>
params.writeConfig(nextConfig as Record<string, unknown>),
},
} as unknown as OpenClawPluginApi["runtime"],
registerCommand: params.registerCommand,
...(params.registerService ? { registerService: params.registerService } : {}),
});
}
function createCommandContext(args: string): PluginCommandContext {
return {
channel: "test",
isAuthorizedSender: true,
commandBody: `/phone ${args}`,
args,
config: {},
requestConversationBinding: async () => ({
status: "error",
message: "unsupported",
}),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
};
}
function createPhoneControlConfig(): Record<string, unknown> {
return {
gateway: {
nodes: {
allowCommands: [],
denyCommands: [...WRITE_COMMANDS],
},
},
};
}
function createMockOpenKeyedStore(params: {
lookup: ReturnType<typeof vi.fn>;
delete?: ReturnType<typeof vi.fn>;
}): OpenClawPluginApi["runtime"]["state"]["openKeyedStore"] {
return <T>() => {
const store: PluginStateKeyedStore<T> = {
register: vi.fn(async () => {}),
registerIfAbsent: vi.fn(async () => true),
update: vi.fn(async () => true),
lookup: params.lookup as (key: string) => Promise<T | undefined>,
consume: vi.fn(async () => undefined),
delete: (params.delete ?? vi.fn(async () => true)) as (key: string) => Promise<boolean>,
entries: vi.fn(async () => []),
clear: vi.fn(async () => {}),
};
return store;
};
}
async function withRegisteredPhoneControl(
run: (params: {
command: OpenClawPluginCommandDefinition;
writeConfigFile: ReturnType<typeof vi.fn>;
getConfig: () => Record<string, unknown>;
}) => Promise<void>,
) {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), PHONE_CONTROL_STATE_PREFIX));
try {
let config = createPhoneControlConfig();
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
config = next;
});
let command: OpenClawPluginCommandDefinition | undefined;
registerPhoneControl.register(
createApi({
stateDir,
getConfig: () => config,
writeConfig: writeConfigFile,
registerCommand: (nextCommand) => {
command = nextCommand;
},
}),
);
if (!command) {
throw new Error("phone-control plugin did not register its command");
}
await run({
command,
writeConfigFile,
getConfig: () => config,
});
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
}
describe("phone-control plugin", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
});
it("arms sms.send as part of the writes group", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => {
expect(command.name).toBe("phone");
expect(command.requiredScopes).toBeUndefined();
expect(command.exposeSenderIsOwner).toBe(true);
const res = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
});
const text = res?.text ?? "";
const nodes = (
getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } }
).nodes;
if (!nodes) {
throw new Error("phone-control command did not persist gateway node config");
}
expect(writeConfigFile).toHaveBeenCalledTimes(1);
expect(nodes.allowCommands).toEqual([...WRITE_COMMANDS]);
expect(nodes.denyCommands).toStrictEqual([]);
expect(text).toContain("sms.send");
});
});
it("blocks internal operator.write callers from mutating phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "webchat",
gatewayClientScopes: ["operator.write"],
});
expect(res?.text ?? "").toContain("requires operator.admin");
expect(writeConfigFile).not.toHaveBeenCalled();
});
});
it("blocks external non-owner callers without operator.admin from mutating phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "telegram",
senderIsOwner: false,
});
expect(res?.text ?? "").toContain("requires operator.admin");
expect(writeConfigFile).not.toHaveBeenCalled();
});
});
it("blocks external non-owner callers without operator.admin from disarming phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("disarm"),
channel: "telegram",
senderIsOwner: false,
});
expect(res?.text ?? "").toContain("requires operator.admin");
expect(writeConfigFile).not.toHaveBeenCalled();
});
});
it("allows external non-owner callers without operator.admin to read phone control status", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("status"),
channel: "telegram",
senderIsOwner: false,
});
expect(res?.text ?? "").toContain("Phone control: disarmed.");
expect(writeConfigFile).not.toHaveBeenCalled();
});
});
it("allows external non-owner callers without operator.admin to read phone control help", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("help"),
channel: "telegram",
senderIsOwner: false,
});
expect(res?.text ?? "").toContain("/phone status");
expect(writeConfigFile).not.toHaveBeenCalled();
});
});
it("regression: blocks non-webchat gateway callers with operator.write from arm/disarm", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const armRes = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "telegram",
gatewayClientScopes: ["operator.write"],
});
expect(armRes?.text ?? "").toContain("requires operator.admin");
expect(writeConfigFile).not.toHaveBeenCalled();
const disarmRes = await command.handler({
...createCommandContext("disarm"),
channel: "telegram",
gatewayClientScopes: ["operator.write"],
});
expect(disarmRes?.text ?? "").toContain("requires operator.admin");
expect(writeConfigFile).not.toHaveBeenCalled();
});
});
it("allows internal operator.admin callers to mutate phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
});
expect(res?.text ?? "").toContain("sms.send");
expect(writeConfigFile).toHaveBeenCalledTimes(1);
});
});
it("rejects invalid arm durations without mutating phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const typoRes = await command.handler({
...createCommandContext("arm writes forever"),
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
});
const overflowRes = await command.handler({
...createCommandContext("arm writes 9007199254740993d"),
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
});
expect(typoRes?.text ?? "").toContain("Invalid duration");
expect(overflowRes?.text ?? "").toContain("Invalid duration");
expect(writeConfigFile).not.toHaveBeenCalled();
});
});
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({
...createCommandContext("arm writes 30s"),
channel: "telegram",
senderIsOwner: true,
});
expect(res?.text ?? "").toContain("Phone control: armed");
expect(writeConfigFile).toHaveBeenCalledTimes(1);
});
});
it("allows external channel callers with operator.admin to disarm phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
await command.handler({
...createCommandContext("arm writes 30s"),
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
});
const res = await command.handler({
...createCommandContext("disarm"),
channel: "telegram",
gatewayClientScopes: ["operator.admin"],
});
expect(res?.text ?? "").toContain("disarmed");
expect(writeConfigFile).toHaveBeenCalledTimes(2);
});
});
it("does not block service startup on the initial expiry check", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), PHONE_CONTROL_STATE_PREFIX));
try {
const lookup = vi.fn(async () => undefined);
let service: OpenClawPluginService | undefined;
registerPhoneControl.register(
createApi({
stateDir,
getConfig: createPhoneControlConfig,
writeConfig: async () => {},
registerCommand: () => {},
registerService: (registeredService) => {
service = registeredService;
},
openKeyedStore: createMockOpenKeyedStore({ lookup }),
}),
);
if (!service) {
throw new Error("phone-control plugin did not register its service");
}
await service.start({
config: createPhoneControlConfig(),
stateDir,
logger: { info() {}, warn() {}, error() {}, debug() {} },
});
expect(lookup).not.toHaveBeenCalled();
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(lookup).toHaveBeenCalledWith("current");
await service.stop?.({
config: createPhoneControlConfig(),
stateDir,
logger: { info() {}, warn() {}, error() {}, debug() {} },
});
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("clears expired active allows before service startup completes", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), PHONE_CONTROL_STATE_PREFIX));
try {
let config: Record<string, unknown> = {
gateway: {
nodes: {
allowCommands: [...WRITE_COMMANDS],
denyCommands: [],
},
},
};
const writeConfigFile = vi.fn(async (next: Record<string, unknown>) => {
config = next;
});
const lookup = vi.fn(async () => ({
version: 2,
armedAtMs: Date.now() - 120_000,
expiresAtMs: Date.now() - 60_000,
group: "writes",
armedCommands: [...WRITE_COMMANDS],
addedToAllow: [...WRITE_COMMANDS],
removedFromDeny: [...WRITE_COMMANDS],
}));
const removeState = vi.fn(async () => true);
let service: OpenClawPluginService | undefined;
registerPhoneControl.register(
createApi({
stateDir,
getConfig: () => config,
writeConfig: writeConfigFile,
registerCommand: () => {},
registerService: (registeredService) => {
service = registeredService;
},
openKeyedStore: createMockOpenKeyedStore({ lookup, delete: removeState }),
}),
);
if (!service) {
throw new Error("phone-control plugin did not register its service");
}
await service.start({
config,
stateDir,
logger: { info() {}, warn() {}, error() {}, debug() {} },
});
expect(writeConfigFile).toHaveBeenCalledTimes(1);
expect(removeState).toHaveBeenCalledWith("current");
expect(
(config.gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } }).nodes,
).toEqual({
allowCommands: [],
denyCommands: [...WRITE_COMMANDS],
});
await service.stop?.({
config,
stateDir,
logger: { info() {}, warn() {}, error() {}, debug() {} },
});
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
});