mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 12:42:51 +00:00
* 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>
480 lines
16 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|