重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,221 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
import registerPhoneControl from "./index.js";
import type {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
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;
}): OpenClawPluginApi {
return createTestPluginApi({
id: "phone-control",
name: "phone-control",
source: "test",
config: {},
pluginConfig: {},
runtime: {
state: {
resolveStateDir: () => params.stateDir,
},
config: {
loadConfig: () => params.getConfig(),
writeConfigFile: (next: Record<string, unknown>) => params.writeConfig(next),
},
} as OpenClawPluginApi["runtime"],
registerCommand: params.registerCommand,
});
}
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],
},
},
};
}
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", () => {
it("arms sms.send as part of the writes group", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => {
expect(command.name).toBe("phone");
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).toEqual([]);
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("allows external channel callers without operator.admin to mutate phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("arm writes 30s"),
channel: "telegram",
});
expect(res?.text ?? "").toContain("Phone control: armed");
expect(writeConfigFile).toHaveBeenCalledTimes(1);
});
});
it("allows external channel callers without operator.admin to disarm phone control", async () => {
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
const res = await command.handler({
...createCommandContext("disarm"),
channel: "telegram",
});
expect(res?.text ?? "").toContain("Phone control: disarmed.");
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("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);
});
});
});

View file

@ -0,0 +1,452 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/text-runtime";
import {
definePluginEntry,
type OpenClawPluginApi,
type OpenClawPluginService,
} from "./runtime-api.js";
type ArmGroup = "camera" | "screen" | "writes" | "all";
type ArmStateFileV1 = {
version: 1;
armedAtMs: number;
expiresAtMs: number | null;
removedFromDeny: string[];
};
type ArmStateFileV2 = {
version: 2;
armedAtMs: number;
expiresAtMs: number | null;
group: ArmGroup;
armedCommands: string[];
addedToAllow: string[];
removedFromDeny: string[];
};
type ArmStateFile = ArmStateFileV1 | ArmStateFileV2;
const STATE_VERSION = 2;
const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const;
const PHONE_ADMIN_SCOPE = "operator.admin";
const GROUP_COMMANDS: Record<Exclude<ArmGroup, "all">, string[]> = {
camera: ["camera.snap", "camera.clip"],
screen: ["screen.record"],
writes: ["calendar.add", "contacts.add", "reminders.add", "sms.send"],
};
function uniqSorted(values: string[]): string[] {
return [...new Set(values.map((v) => v.trim()).filter(Boolean))].toSorted();
}
function resolveCommandsForGroup(group: ArmGroup): string[] {
if (group === "all") {
return uniqSorted(Object.values(GROUP_COMMANDS).flat());
}
return uniqSorted(GROUP_COMMANDS[group]);
}
function formatGroupList(): string {
return ["camera", "screen", "writes", "all"].join(", ");
}
function parseDurationMs(input: string | undefined): number | null {
const raw = normalizeOptionalLowercaseString(input);
if (!raw) {
return null;
}
const m = raw.match(/^(\d+)(s|m|h|d)$/);
if (!m) {
return null;
}
const n = Number.parseInt(m[1] ?? "", 10);
if (!Number.isFinite(n) || n <= 0) {
return null;
}
const unit = m[2];
const mult = unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
return n * mult;
}
function formatDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) {
return `${s}s`;
}
const m = Math.floor(s / 60);
if (m < 60) {
return `${m}m`;
}
const h = Math.floor(m / 60);
if (h < 48) {
return `${h}h`;
}
const d = Math.floor(h / 24);
return `${d}d`;
}
function resolveStatePath(stateDir: string): string {
return path.join(stateDir, ...STATE_REL_PATH);
}
async function readArmState(statePath: string): Promise<ArmStateFile | null> {
try {
const raw = await fs.readFile(statePath, "utf8");
// Type as unknown record first to allow property access during validation
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (parsed.version !== 1 && parsed.version !== 2) {
return null;
}
if (typeof parsed.armedAtMs !== "number") {
return null;
}
if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) {
return null;
}
if (parsed.version === 1) {
if (
!Array.isArray(parsed.removedFromDeny) ||
!parsed.removedFromDeny.every((v: unknown) => typeof v === "string")
) {
return null;
}
return parsed as unknown as ArmStateFile;
}
const group = typeof parsed.group === "string" ? parsed.group : "";
if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") {
return null;
}
if (
!Array.isArray(parsed.armedCommands) ||
!parsed.armedCommands.every((v: unknown) => typeof v === "string")
) {
return null;
}
if (
!Array.isArray(parsed.addedToAllow) ||
!parsed.addedToAllow.every((v: unknown) => typeof v === "string")
) {
return null;
}
if (
!Array.isArray(parsed.removedFromDeny) ||
!parsed.removedFromDeny.every((v: unknown) => typeof v === "string")
) {
return null;
}
return parsed as unknown as ArmStateFile;
} catch {
return null;
}
}
async function writeArmState(statePath: string, state: ArmStateFile | null): Promise<void> {
await fs.mkdir(path.dirname(statePath), { recursive: true });
if (!state) {
try {
await fs.unlink(statePath);
} catch {
// ignore
}
return;
}
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}
function normalizeDenyList(cfg: OpenClawPluginApi["config"]): string[] {
return uniqSorted([...(cfg.gateway?.nodes?.denyCommands ?? [])]);
}
function normalizeAllowList(cfg: OpenClawPluginApi["config"]): string[] {
return uniqSorted([...(cfg.gateway?.nodes?.allowCommands ?? [])]);
}
function patchConfigNodeLists(
cfg: OpenClawPluginApi["config"],
next: { allowCommands: string[]; denyCommands: string[] },
): OpenClawPluginApi["config"] {
return {
...cfg,
gateway: {
...cfg.gateway,
nodes: {
...cfg.gateway?.nodes,
allowCommands: next.allowCommands,
denyCommands: next.denyCommands,
},
},
};
}
async function disarmNow(params: {
api: OpenClawPluginApi;
stateDir: string;
statePath: string;
reason: string;
}): Promise<{ changed: boolean; restored: string[]; removed: string[] }> {
const { api, stateDir, statePath, reason } = params;
const state = await readArmState(statePath);
if (!state) {
return { changed: false, restored: [], removed: [] };
}
const cfg = api.runtime.config.loadConfig();
const allow = new Set(normalizeAllowList(cfg));
const deny = new Set(normalizeDenyList(cfg));
const removed: string[] = [];
const restored: string[] = [];
if (state.version === 1) {
for (const cmd of state.removedFromDeny) {
if (!deny.has(cmd)) {
deny.add(cmd);
restored.push(cmd);
}
}
} else {
for (const cmd of state.addedToAllow) {
if (allow.delete(cmd)) {
removed.push(cmd);
}
}
for (const cmd of state.removedFromDeny) {
if (!deny.has(cmd)) {
deny.add(cmd);
restored.push(cmd);
}
}
}
if (removed.length > 0 || restored.length > 0) {
const next = patchConfigNodeLists(cfg, {
allowCommands: uniqSorted([...allow]),
denyCommands: uniqSorted([...deny]),
});
await api.runtime.config.writeConfigFile(next);
}
await writeArmState(statePath, null);
api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`);
return {
changed: removed.length > 0 || restored.length > 0,
removed: uniqSorted(removed),
restored: uniqSorted(restored),
};
}
function formatHelp(): string {
return [
"Phone control commands:",
"",
"/phone status",
"/phone arm <group> [duration]",
"/phone disarm",
"",
"Groups:",
`- ${formatGroupList()}`,
"",
"Duration format: 30s | 10m | 2h | 1d (default: 10m).",
"",
"Notes:",
"- This only toggles what the gateway is allowed to invoke on phone nodes.",
"- iOS will still ask for permissions (camera, photos, contacts, etc.) on first use.",
].join("\n");
}
function parseGroup(raw: string | undefined): ArmGroup | null {
const value = normalizeOptionalLowercaseString(raw) ?? "";
if (!value) {
return null;
}
if (value === "camera" || value === "screen" || value === "writes" || value === "all") {
return value;
}
return null;
}
function requiresAdminToMutatePhoneControl(
channel: string,
gatewayClientScopes?: readonly string[],
): boolean {
if (Array.isArray(gatewayClientScopes)) {
return !gatewayClientScopes.includes(PHONE_ADMIN_SCOPE);
}
return channel === "webchat";
}
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 cmds = uniqSorted(
state.version === 1
? state.removedFromDeny
: state.armedCommands.length > 0
? state.armedCommands
: [...state.addedToAllow, ...state.removedFromDeny],
);
const cmdLabel = cmds.length > 0 ? cmds.join(", ") : "none";
return `Phone control: armed (${until}).\nTemporarily allowed: ${cmdLabel}`;
}
export default definePluginEntry({
id: "phone-control",
name: "Phone Control",
description: "Temporary allowlist control for phone automation commands",
register(api: OpenClawPluginApi) {
let expiryInterval: ReturnType<typeof setInterval> | null = null;
const timerService: OpenClawPluginService = {
id: "phone-control-expiry",
start: async (ctx) => {
const statePath = resolveStatePath(ctx.stateDir);
const tick = async () => {
const state = await readArmState(statePath);
if (!state || state.expiresAtMs == null) {
return;
}
if (Date.now() < state.expiresAtMs) {
return;
}
await disarmNow({
api,
stateDir: ctx.stateDir,
statePath,
reason: "expired",
});
};
// Best effort; don't crash the gateway if state is corrupt.
await tick().catch(() => {});
expiryInterval = setInterval(() => {
tick().catch(() => {});
}, 15_000);
expiryInterval.unref?.();
return;
},
stop: async () => {
if (expiryInterval) {
clearInterval(expiryInterval);
expiryInterval = null;
}
return;
},
};
api.registerService(timerService);
api.registerCommand({
name: "phone",
description: "Arm/disarm high-risk phone node commands (camera/screen/writes).",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = normalizeLowercaseStringOrEmpty(tokens[0]);
const stateDir = api.runtime.state.resolveStateDir();
const statePath = resolveStatePath(stateDir);
if (!action || action === "help") {
const state = await readArmState(statePath);
return { text: `${formatStatus(state)}\n\n${formatHelp()}` };
}
if (action === "status") {
const state = await readArmState(statePath);
return { text: formatStatus(state) };
}
if (action === "disarm") {
if (requiresAdminToMutatePhoneControl(ctx.channel, ctx.gatewayClientScopes)) {
return {
text: "⚠️ /phone disarm requires operator.admin.",
};
}
const res = await disarmNow({
api,
stateDir,
statePath,
reason: "manual",
});
if (!res.changed) {
return { text: "Phone control: disarmed." };
}
const restoredLabel = res.restored.length > 0 ? res.restored.join(", ") : "none";
const removedLabel = res.removed.length > 0 ? res.removed.join(", ") : "none";
return {
text: `Phone control: disarmed.\nRemoved allowlist: ${removedLabel}\nRestored denylist: ${restoredLabel}`,
};
}
if (action === "arm") {
if (requiresAdminToMutatePhoneControl(ctx.channel, ctx.gatewayClientScopes)) {
return {
text: "⚠️ /phone arm requires operator.admin.",
};
}
const group = parseGroup(tokens[1]);
if (!group) {
return { text: `Usage: /phone arm <group> [duration]\nGroups: ${formatGroupList()}` };
}
const durationMs = parseDurationMs(tokens[2]) ?? 10 * 60_000;
const expiresAtMs = Date.now() + durationMs;
const commands = resolveCommandsForGroup(group);
const cfg = api.runtime.config.loadConfig();
const allowSet = new Set(normalizeAllowList(cfg));
const denySet = new Set(normalizeDenyList(cfg));
const addedToAllow: string[] = [];
const removedFromDeny: string[] = [];
for (const cmd of commands) {
if (!allowSet.has(cmd)) {
allowSet.add(cmd);
addedToAllow.push(cmd);
}
if (denySet.delete(cmd)) {
removedFromDeny.push(cmd);
}
}
const next = patchConfigNodeLists(cfg, {
allowCommands: uniqSorted([...allowSet]),
denyCommands: uniqSorted([...denySet]),
});
await api.runtime.config.writeConfigFile(next);
await writeArmState(statePath, {
version: STATE_VERSION,
armedAtMs: Date.now(),
expiresAtMs,
group,
armedCommands: uniqSorted(commands),
addedToAllow: uniqSorted(addedToAllow),
removedFromDeny: uniqSorted(removedFromDeny),
});
const allowedLabel = uniqSorted(commands).join(", ");
return {
text:
`Phone control: armed for ${formatDuration(durationMs)}.\n` +
`Temporarily allowed: ${allowedLabel}\n` +
`To disarm early: /phone disarm`,
};
}
return { text: formatHelp() };
},
});
},
});

View file

@ -0,0 +1,17 @@
{
"id": "phone-control",
"enabledByDefault": true,
"name": "Phone Control",
"description": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry.",
"commandAliases": [
{
"name": "phone",
"kind": "runtime-slash"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,7 @@
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
PluginCommandContext,
OpenClawPluginService,
} from "openclaw/plugin-sdk/plugin-entry";