fix(qqbot): migrate group tool policy config (#91128)

* fix(qqbot): migrate group tool policy config

* test: stabilize changed check lanes

* style: format changed main files

* test: align CI matrix expectations
This commit is contained in:
Peter Steinberger
2026-06-07 02:33:06 -07:00
committed by GitHub
parent 58b68e92f2
commit 6f2b3830f1
48 changed files with 793 additions and 281 deletions
-14
View File
@@ -2,19 +2,14 @@
What problem does this PR solve?
Why does this matter now?
What is the intended outcome?
What is intentionally out of scope?
What does success look like?
What should reviewers focus on?
<details>
@@ -75,13 +70,10 @@ Be mindful of private information like IP addresses, API keys, phone numbers, no
Which commands did you run?
What regression coverage was added or updated?
What failed before this fix, if known?
If no test was added, why not?
<details>
@@ -95,16 +87,12 @@ List focused commands, not every incidental check. CI is useful support, but ext
Did user-visible behavior change? (`Yes/No`)
Did config, environment, or migration behavior change? (`Yes/No`)
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
What is the highest-risk area?
How is that risk mitigated?
<details>
@@ -118,10 +106,8 @@ Use this for author judgment that is not obvious from the diff. ClawSweeper can
What is the next action?
What is still waiting on author, maintainer, CI, or external proof?
Which bot or reviewer comments were addressed?
<details>
+10 -1
View File
@@ -1139,7 +1139,16 @@ jobs:
summary:
name: Verify full validation
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram, performance]
needs:
[
resolve_target,
docker_runtime_assets_preflight,
normal_ci,
plugin_prerelease,
release_checks,
npm_telegram,
performance,
]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
+2 -2
View File
@@ -1,5 +1,5 @@
---
summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft Teams/Signal/Slack/Telegram/WhatsApp/Zalo)"
summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft Teams/QQBot/Signal/Slack/Telegram/WhatsApp/Zalo)"
read_when:
- Changing group chat behavior or mention gating
- Scoping mentionPatterns to specific group conversations
@@ -7,7 +7,7 @@ title: "Groups"
sidebarTitle: "Groups"
---
OpenClaw treats group chats consistently across surfaces: Discord, iMessage, Matrix, Microsoft Teams, Signal, Slack, Telegram, WhatsApp, Zalo.
OpenClaw treats group chats consistently across surfaces: Discord, iMessage, Matrix, Microsoft Teams, QQBot, Signal, Slack, Telegram, WhatsApp, Zalo.
For always-on rooms that should provide quiet context unless the agent explicitly sends a visible message, see [Ambient room events](/channels/ambient-room-events).
+5 -2
View File
@@ -152,7 +152,7 @@ to a group, then mention it or configure the group to run without a mention.
"*": {
requireMention: true,
historyLimit: 50,
toolPolicy: "restricted",
tools: { deny: ["exec", "read", "write"] },
},
GROUP_OPENID: {
name: "Release room",
@@ -174,10 +174,13 @@ settings include:
- `requireMention`: require an @mention before the bot replies. Default: `true`.
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
- `toolPolicy`: `full`, `restricted`, or `none` for group-scoped tools.
- `tools`: allow/deny tools for the whole group.
- `toolsBySender`: per-sender group tool overrides; see [Groups](/channels/groups#groupchannel-tool-restrictions-optional).
- `name`: friendly label used in logs and group context.
- `prompt`: per-group behavior prompt appended to the agent context.
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
Activation modes are `mention` and `always`. `requireMention: true` maps to
`mention`; `requireMention: false` maps to `always`. A session-level activation
override, when present, wins over config.
+1 -4
View File
@@ -1,4 +1 @@
export declare function copyA2uiAssets(params: {
srcDir: string;
outDir: string;
}): Promise<void>;
export declare function copyA2uiAssets(params: { srcDir: string; outDir: string }): Promise<void>;
+3 -4
View File
@@ -99,10 +99,9 @@ describe("createToolbarButton icon safety", () => {
it("SVG strings in toolbarIconSvg contain no XSS patterns", () => {
for (const pattern of XSS_PATTERNS) {
expect(
VIEWER_CLIENT_SRC.includes(pattern),
`source must not contain "${pattern}"`,
).toBe(false);
expect(VIEWER_CLIENT_SRC.includes(pattern), `source must not contain "${pattern}"`).toBe(
false,
);
}
});
+48 -16
View File
@@ -26,11 +26,17 @@ export function setMdOcPath(ast: MdAst, path: OcPath, newValue: string): MdEditR
guardSentinel(newValue, formatOcPath(path));
if (path.section === "[frontmatter]") {
const key = path.item ?? path.field;
if (key === undefined) {return { ok: false, reason: "unresolved" };}
if (key === undefined) {
return { ok: false, reason: "unresolved" };
}
const idx = ast.frontmatter.findIndex((e) => e.key === key);
if (idx === -1) {return { ok: false, reason: "unresolved" };}
if (idx === -1) {
return { ok: false, reason: "unresolved" };
}
const existing = ast.frontmatter[idx];
if (existing === undefined) {return { ok: false, reason: "unresolved" };}
if (existing === undefined) {
return { ok: false, reason: "unresolved" };
}
const newEntry: FrontmatterEntry = { ...existing, value: newValue };
const newFm = ast.frontmatter.slice();
newFm[idx] = newEntry;
@@ -43,16 +49,26 @@ export function setMdOcPath(ast: MdAst, path: OcPath, newValue: string): MdEditR
const sectionSlug = path.section.toLowerCase();
const blockIdx = ast.blocks.findIndex((b) => b.slug === sectionSlug);
if (blockIdx === -1) {return { ok: false, reason: "unresolved" };}
if (blockIdx === -1) {
return { ok: false, reason: "unresolved" };
}
const block = ast.blocks[blockIdx];
if (block === undefined) {return { ok: false, reason: "unresolved" };}
if (block === undefined) {
return { ok: false, reason: "unresolved" };
}
const itemSlug = path.item.toLowerCase();
const itemIdx = block.items.findIndex((i) => i.slug === itemSlug);
if (itemIdx === -1) {return { ok: false, reason: "unresolved" };}
if (itemIdx === -1) {
return { ok: false, reason: "unresolved" };
}
const item = block.items[itemIdx];
if (item === undefined) {return { ok: false, reason: "unresolved" };}
if (item.kv === undefined) {return { ok: false, reason: "no-item-kv" };}
if (item === undefined) {
return { ok: false, reason: "unresolved" };
}
if (item.kv === undefined) {
return { ok: false, reason: "no-item-kv" };
}
if (item.kv.key.toLowerCase() !== path.field.toLowerCase()) {
return { ok: false, reason: "unresolved" };
}
@@ -78,9 +94,15 @@ function rebuildBlockBody(block: AstBlock, newItems: readonly AstItem[]): string
for (let i = 0; i < newItems.length; i++) {
const newItem = newItems[i];
const oldItem = block.items[i];
if (newItem === undefined || oldItem === undefined) {continue;}
if (newItem.kv === undefined || oldItem.kv === undefined) {continue;}
if (newItem.kv.value === oldItem.kv.value) {continue;}
if (newItem === undefined || oldItem === undefined) {
continue;
}
if (newItem.kv === undefined || oldItem.kv === undefined) {
continue;
}
if (newItem.kv.value === oldItem.kv.value) {
continue;
}
const re = new RegExp(`^(\\s*-\\s*${escapeRegex(oldItem.kv.key)}\\s*:\\s*).*$`, "m");
body = body.replace(re, `$1${newItem.kv.value}`);
}
@@ -101,19 +123,29 @@ function finalize(ast: MdAst): MdEditResult {
parts.push("---");
}
if (ast.preamble.length > 0) {
if (parts.length > 0) {parts.push("");}
if (parts.length > 0) {
parts.push("");
}
parts.push(ast.preamble);
}
for (const block of ast.blocks) {
if (parts.length > 0) {parts.push("");}
if (parts.length > 0) {
parts.push("");
}
parts.push(`## ${block.heading}`);
if (block.bodyText.length > 0) {parts.push(block.bodyText);}
if (block.bodyText.length > 0) {
parts.push(block.bodyText);
}
}
return { ok: true, ast: { ...ast, raw: parts.join("\n") } };
}
function formatFrontmatterValue(value: string): string {
if (value.length === 0) {return '""';}
if (/[:#&*?|<>=!%@`,[\]{}\r\n]/.test(value)) {return JSON.stringify(value);}
if (value.length === 0) {
return '""';
}
if (/[:#&*?|<>=!%@`,[\]{}\r\n]/.test(value)) {
return JSON.stringify(value);
}
return value;
}
+3 -1
View File
@@ -33,7 +33,9 @@ export function emitJsonc(ast: JsoncAst, opts: JsoncEmitOptions = {}): string {
}
// Render mode loses comments; walks leaves for caller-injected sentinel.
if (ast.root === null) {return "";}
if (ast.root === null) {
return "";
}
return renderValue(ast.root, guardPath, []);
}
@@ -21,11 +21,15 @@ export type JsoncOcPathMatch =
};
export function resolveJsoncOcPath(ast: JsoncAst, path: OcPath): JsoncOcPathMatch | null {
if (ast.root === null) {return null;}
if (ast.root === null) {
return null;
}
const segments: string[] = [];
const collect = (slot: string | undefined): void => {
if (slot === undefined) {return;}
if (slot === undefined) {
return;
}
for (const s of splitRespectingBrackets(slot, ".")) {
segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s);
}
@@ -34,7 +38,9 @@ export function resolveJsoncOcPath(ast: JsoncAst, path: OcPath): JsoncOcPathMatc
collect(path.item);
collect(path.field);
if (segments.length === 0) {return { kind: "root", node: ast };}
if (segments.length === 0) {
return { kind: "root", node: ast };
}
return resolveJsoncValueOcPath(ast.root, segments);
}
+5 -12
View File
@@ -12,14 +12,7 @@
*/
import MarkdownIt from "markdown-it";
import type {
AstBlock,
AstItem,
Diagnostic,
FrontmatterEntry,
ParseResult,
} from "./ast.js";
import type { AstBlock, AstItem, Diagnostic, FrontmatterEntry, ParseResult } from "./ast.js";
import { slugify } from "./slug.js";
type Token = ReturnType<MarkdownIt["parse"]>[number];
@@ -153,7 +146,9 @@ function extractItems(tokens: readonly Token[], bodyFileLine: number): AstItem[]
const items: AstItem[] = [];
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
if (t.type !== "list_item_open" || t.map === null) {continue;}
if (t.type !== "list_item_open" || t.map === null) {
continue;
}
// First inline at the item's own depth is the item text.
let nestedDepth = 0;
let text = "";
@@ -175,9 +170,7 @@ function extractItems(tokens: readonly Token[], bodyFileLine: number): AstItem[]
text,
slug: kvMatch ? slugify(kvMatch[1]) : slugify(text),
line: bodyFileLine + t.map[0],
...(kvMatch !== null
? { kv: { key: kvMatch[1].trim(), value: kvMatch[2].trim() } }
: {}),
...(kvMatch !== null ? { kv: { key: kvMatch[1].trim(), value: kvMatch[2].trim() } } : {}),
});
}
return items;
+33 -11
View File
@@ -35,39 +35,61 @@ export type OcPathMatch =
export function resolveMdOcPath(ast: MdAst, path: OcPath): OcPathMatch | null {
if (path.section === "[frontmatter]") {
const key = path.item ?? path.field;
if (key === undefined) {return null;}
if (key === undefined) {
return null;
}
const entry = ast.frontmatter.find((e) => e.key === key);
if (entry === undefined) {return null;}
if (entry === undefined) {
return null;
}
return { kind: "frontmatter", node: entry };
}
if (path.section === undefined) {return { kind: "root", node: ast };}
if (path.section === undefined) {
return { kind: "root", node: ast };
}
const block = ast.blocks.find((b) => b.slug === path.section!.toLowerCase());
if (block === undefined) {return null;}
if (path.item === undefined) {return { kind: "block", node: block };}
if (block === undefined) {
return null;
}
if (path.item === undefined) {
return { kind: "block", node: block };
}
// Item dispatch: ordinal (#N) > positional ($last) > slug.
// Ordinal uses document order so duplicate-slug items stay distinct.
let item: AstItem | undefined;
if (isOrdinalSeg(path.item)) {
const n = parseOrdinalSeg(path.item);
if (n === null || n < 0 || n >= block.items.length) {return null;}
if (n === null || n < 0 || n >= block.items.length) {
return null;
}
item = block.items[n];
} else if (isPositionalSeg(path.item)) {
const concrete = resolvePositionalSeg(path.item, {
indexable: true,
size: block.items.length,
});
if (concrete === null) {return null;}
if (concrete === null) {
return null;
}
item = block.items[Number(concrete)];
} else {
item = block.items.find((i) => i.slug === path.item!.toLowerCase());
}
if (item === undefined) {return null;}
if (path.field === undefined) {return { kind: "item", node: item, block };}
if (item === undefined) {
return null;
}
if (path.field === undefined) {
return { kind: "item", node: item, block };
}
if (item.kv === undefined) {return null;}
if (item.kv.key.toLowerCase() !== path.field.toLowerCase()) {return null;}
if (item.kv === undefined) {
return null;
}
if (item.kv.key.toLowerCase() !== path.field.toLowerCase()) {
return null;
}
return { kind: "item-field", node: item, block, value: item.kv.value };
}
+1
View File
@@ -0,0 +1 @@
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
+6
View File
@@ -23,12 +23,14 @@ import { toGatewayAccount, writeOpenClawConfigThroughRuntime } from "./bridge/na
import { getQQBotRuntime } from "./bridge/runtime.js";
import { qqbotSetupWizard } from "./bridge/setup/surface.js";
import { qqbotChannelConfigSchema } from "./config-schema.js";
import { qqbotDoctor } from "./doctor.js";
import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js";
import { clearAccountCredentials } from "./engine/config/credentials.js";
import {
normalizeTarget as coreNormalizeTarget,
looksLikeQQBotTarget,
} from "./engine/messaging/target-parser.js";
import { resolveQQBotGroupToolPolicy } from "./group-policy.js";
import type { ResolvedQQBotAccount } from "./types.js";
// Shared promise so concurrent multi-account startups serialize the dynamic
@@ -216,6 +218,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
},
reload: { configPrefixes: ["channels.qqbot"] },
configSchema: qqbotChannelConfigSchema,
doctor: qqbotDoctor,
config: {
...qqbotConfigAdapter,
/**
@@ -239,6 +242,9 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
...qqbotSetupAdapterShared,
},
approvalCapability: getQQBotApprovalCapability(),
groups: {
resolveToolPolicy: resolveQQBotGroupToolPolicy,
},
message: qqbotMessageAdapter,
messaging: {
targetPrefixes: ["qqbot"],
+16
View File
@@ -1,6 +1,7 @@
// Qqbot helper module supports config schema behavior.
import {
AllowFromListSchema,
ToolPolicySchema,
buildChannelConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
@@ -54,6 +55,20 @@ const QQBotExecApprovalsSchema = z
const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotGroupPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotGroupSchema = z
.object({
requireMention: z.boolean().optional(),
ignoreOtherMentions: z.boolean().optional(),
historyLimit: z.number().optional(),
name: z.string().optional(),
prompt: z.string().optional(),
tools: ToolPolicySchema,
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
})
.strict();
const QQBotGroupsSchema = z.record(z.string(), QQBotGroupSchema).optional();
const QQBotAccountSchema = z
.object({
enabled: z.boolean().optional(),
@@ -74,6 +89,7 @@ const QQBotAccountSchema = z
upgradeMode: z.enum(["doc", "hot-reload"]).optional(),
streaming: QQBotStreamingSchema,
execApprovals: QQBotExecApprovalsSchema,
groups: QQBotGroupsSchema,
})
.passthrough();
+35
View File
@@ -132,6 +132,41 @@ describe("qqbot config", () => {
expect(parsed.success).toBe(true);
});
it("accepts canonical group tools config", () => {
const parsed = QQBotConfigSchema.safeParse({
groups: {
G1: {
requireMention: true,
tools: { deny: ["*"] },
toolsBySender: {
"id:alice": { allow: ["read"] },
},
},
},
accounts: {
bot2: {
groups: {
G1: { tools: { allow: [] } },
},
},
},
});
expect(parsed.success).toBe(true);
});
it("rejects retired group toolPolicy config", () => {
const parsed = QQBotConfigSchema.safeParse({
groups: {
G1: {
toolPolicy: "none",
},
},
});
expect(parsed.success).toBe(false);
});
it("preserves top-level media and upgrade config on the default account", () => {
const cfg = {
channels: {
@@ -0,0 +1,98 @@
// Qqbot tests cover doctor migration behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
describe("qqbot doctor contract", () => {
it("detects legacy root and account group toolPolicy config", () => {
expect(
legacyConfigRules[0]?.match?.(
{
G1: { toolPolicy: "none" },
},
{},
),
).toBe(true);
expect(
legacyConfigRules[1]?.match?.(
{
bot2: {
groups: {
G1: { toolPolicy: "none" },
},
},
},
{},
),
).toBe(true);
});
it("migrates root legacy toolPolicy values to canonical tools", () => {
const cfg = {
channels: {
qqbot: {
groups: {
G1: { toolPolicy: "none", requireMention: true },
G2: { toolPolicy: "full" },
G3: { toolPolicy: "restricted" },
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg });
expect(result.changes).toHaveLength(3);
expect(result.config.channels?.qqbot?.groups).toStrictEqual({
G1: { requireMention: true, tools: { deny: ["*"] } },
G2: { tools: { allow: [] } },
G3: { tools: { deny: ["exec", "read", "write"] } },
});
});
it("migrates named-account group toolPolicy values", () => {
const cfg = {
channels: {
qqbot: {
accounts: {
bot2: {
groups: {
G1: { toolPolicy: "none" },
},
},
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg });
expect(result.changes).toContain(
"Moved channels.qqbot.accounts.bot2.groups.G1.toolPolicy=none to channels.qqbot.accounts.bot2.groups.G1.tools.",
);
expect(result.config.channels?.qqbot?.accounts?.bot2?.groups).toStrictEqual({
G1: { tools: { deny: ["*"] } },
});
});
it("preserves existing canonical tools while deleting legacy toolPolicy", () => {
const cfg = {
channels: {
qqbot: {
groups: {
G1: { toolPolicy: "none", tools: { allow: ["read"] } },
},
},
},
} as OpenClawConfig;
const result = normalizeCompatibilityConfig({ cfg });
expect(result.changes).toContain(
"Removed channels.qqbot.groups.G1.toolPolicy (channels.qqbot.groups.G1.tools already exists).",
);
expect(result.config.channels?.qqbot?.groups).toStrictEqual({
G1: { tools: { allow: ["read"] } },
});
});
});
+164
View File
@@ -0,0 +1,164 @@
// Qqbot plugin module implements doctor contract behavior.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor";
const RESTRICTED_GROUP_TOOLS: GroupToolPolicyConfig = {
deny: ["exec", "read", "write"],
};
function hasLegacyGroupToolPolicy(value: unknown): boolean {
const groups = asObjectRecord(value);
if (!groups) {
return false;
}
return Object.values(groups).some((group) => asObjectRecord(group)?.toolPolicy !== undefined);
}
function hasLegacyAccountGroupToolPolicy(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) =>
hasLegacyGroupToolPolicy(asObjectRecord(account)?.groups),
);
}
function migrateToolPolicy(value: unknown): GroupToolPolicyConfig | undefined {
if (value === "none") {
return { deny: ["*"] };
}
if (value === "full") {
return { allow: [] };
}
if (value === "restricted") {
return { ...RESTRICTED_GROUP_TOOLS };
}
return undefined;
}
function describeToolPolicy(value: unknown): string {
return typeof value === "string" ? value : String(value);
}
function migrateGroups(params: {
groups: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { groups: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextGroups = { ...params.groups };
for (const [groupId, rawGroup] of Object.entries(params.groups)) {
const group = asObjectRecord(rawGroup);
if (!group || group.toolPolicy === undefined) {
continue;
}
const { toolPolicy, ...rest } = group;
const nextGroup = { ...rest };
const policy = migrateToolPolicy(toolPolicy);
const path = `${params.pathPrefix}.${groupId}`;
if (nextGroup.tools !== undefined) {
params.changes.push(`Removed ${path}.toolPolicy (${path}.tools already exists).`);
} else if (policy) {
nextGroup.tools = policy;
params.changes.push(
`Moved ${path}.toolPolicy=${describeToolPolicy(toolPolicy)} to ${path}.tools.`,
);
} else {
params.changes.push(
`Removed unsupported ${path}.toolPolicy=${describeToolPolicy(toolPolicy)}.`,
);
}
nextGroups[groupId] = nextGroup;
changed = true;
}
return { groups: nextGroups, changed };
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "qqbot", "groups"],
message:
'channels.qqbot.groups.<id>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.groups.<id>.tools instead. Run "openclaw doctor --fix".',
match: hasLegacyGroupToolPolicy,
},
{
path: ["channels", "qqbot", "accounts"],
message:
'channels.qqbot.accounts.<id>.groups.<groupId>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.accounts.<id>.groups.<groupId>.tools instead. Run "openclaw doctor --fix".',
match: hasLegacyAccountGroupToolPolicy,
},
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.qqbot);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const groups = asObjectRecord(updated.groups);
if (groups) {
const migrated = migrateGroups({
groups,
pathPrefix: "channels.qqbot.groups",
changes,
});
if (migrated.changed) {
updated = { ...updated, groups: migrated.groups };
changed = true;
}
}
const accounts = asObjectRecord(updated.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts = { ...accounts };
for (const [accountId, rawAccount] of Object.entries(accounts)) {
const account = asObjectRecord(rawAccount);
const accountGroups = asObjectRecord(account?.groups);
if (!account || !accountGroups) {
continue;
}
const migrated = migrateGroups({
groups: accountGroups,
pathPrefix: `channels.qqbot.accounts.${accountId}.groups`,
changes,
});
if (migrated.changed) {
nextAccounts[accountId] = { ...account, groups: migrated.groups };
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
qqbot: updated as unknown as NonNullable<OpenClawConfig["channels"]>["qqbot"],
} as OpenClawConfig["channels"],
},
changes,
};
}
+8
View File
@@ -0,0 +1,8 @@
// Qqbot plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
export const qqbotDoctor: ChannelDoctorAdapter = {
legacyConfigRules,
normalizeCompatibilityConfig,
};
@@ -6,7 +6,6 @@ import {
resolveGroupName,
resolveGroupPrompt,
resolveGroupSettings,
resolveGroupToolPolicy,
resolveHistoryLimit,
resolveIgnoreOtherMentions,
resolveMentionPatterns,
@@ -20,7 +19,6 @@ describe("engine/config/group", () => {
expect(cfg).toStrictEqual({
requireMention: true,
ignoreOtherMentions: false,
toolPolicy: "restricted",
name: "",
prompt: undefined,
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
@@ -35,7 +33,6 @@ describe("engine/config/group", () => {
groups: {
"*": {
requireMention: false,
toolPolicy: "full",
historyLimit: 20,
name: "wild",
},
@@ -45,7 +42,6 @@ describe("engine/config/group", () => {
};
const resolved = resolveGroupConfig(cfg, "G1");
expect(resolved.requireMention).toBe(false);
expect(resolved.toolPolicy).toBe("full");
expect(resolved.historyLimit).toBe(20);
expect(resolved.name).toBe("wild");
});
@@ -56,15 +52,14 @@ describe("engine/config/group", () => {
qqbot: {
appId: "1",
groups: {
"*": { requireMention: true, toolPolicy: "restricted", historyLimit: 20 },
GROUPA: { requireMention: false, toolPolicy: "none", historyLimit: 5, name: "A" },
"*": { requireMention: true, historyLimit: 20 },
GROUPA: { requireMention: false, historyLimit: 5, name: "A" },
},
},
},
};
const resolved = resolveGroupConfig(cfg, "GROUPA");
expect(resolved.requireMention).toBe(false);
expect(resolved.toolPolicy).toBe("none");
expect(resolved.historyLimit).toBe(5);
expect(resolved.name).toBe("A");
});
@@ -86,15 +81,6 @@ describe("engine/config/group", () => {
};
expect(resolveHistoryLimit(cfg, "G")).toBe(DEFAULT_GROUP_HISTORY_LIMIT);
});
it("invalid toolPolicy values are ignored", () => {
const cfg = {
channels: {
qqbot: { appId: "1", groups: { "*": { toolPolicy: "invalid" } } },
},
};
expect(resolveGroupToolPolicy(cfg, "G")).toBe("restricted");
});
});
describe("named accounts", () => {
@@ -3,12 +3,9 @@ import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime";
import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js";
import { resolveAccountBase } from "./resolve.js";
type GroupToolPolicy = "full" | "restricted" | "none";
interface GroupConfig {
requireMention: boolean;
ignoreOtherMentions: boolean;
toolPolicy: GroupToolPolicy;
name: string;
prompt?: string;
historyLimit: number;
@@ -22,7 +19,6 @@ export const DEFAULT_GROUP_PROMPT =
const DEFAULT_GROUP_CONFIG: Readonly<Omit<GroupConfig, "prompt">> = {
requireMention: true,
ignoreOtherMentions: false,
toolPolicy: "restricted",
name: "",
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
};
@@ -55,11 +51,6 @@ function readString(obj: Record<string, unknown>, key: string): string | undefin
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readToolPolicy(obj: Record<string, unknown>, key: string): GroupToolPolicy | undefined {
const v = obj[key];
return v === "full" || v === "restricted" || v === "none" ? v : undefined;
}
function readHistoryLimit(obj: Record<string, unknown>, key: string): number | undefined {
const v = obj[key];
if (typeof v !== "number" || !Number.isFinite(v)) {
@@ -86,10 +77,6 @@ export function resolveGroupConfig(
readBoolean(specific, "ignoreOtherMentions") ??
readBoolean(wildcard, "ignoreOtherMentions") ??
DEFAULT_GROUP_CONFIG.ignoreOtherMentions,
toolPolicy:
readToolPolicy(specific, "toolPolicy") ??
readToolPolicy(wildcard, "toolPolicy") ??
DEFAULT_GROUP_CONFIG.toolPolicy,
name: readString(specific, "name") ?? readString(wildcard, "name") ?? DEFAULT_GROUP_CONFIG.name,
prompt: readString(specific, "prompt") ?? readString(wildcard, "prompt"),
historyLimit:
@@ -123,15 +110,6 @@ export function resolveIgnoreOtherMentions(
return resolveGroupConfig(cfg, groupOpenid, accountId).ignoreOtherMentions;
}
/** Resolve tool policy for a given group. */
export function resolveGroupToolPolicy(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): GroupToolPolicy {
return resolveGroupConfig(cfg, groupOpenid, accountId).toolPolicy;
}
/**
* Resolve the behaviour prompt (PE) for a group. Falls back to the built-in
* default when neither specific nor wildcard configuration provides one.
+89
View File
@@ -0,0 +1,89 @@
// Qqbot tests cover shared group tool policy behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { qqbotPlugin } from "./channel.js";
import { resolveQQBotGroupToolPolicy } from "./group-policy.js";
describe("qqbot group tool policy", () => {
it("resolves canonical per-group tools config", () => {
const cfg = {
channels: {
qqbot: {
groups: {
G1: { tools: { deny: ["*"] } },
},
},
},
} as OpenClawConfig;
expect(resolveQQBotGroupToolPolicy({ cfg, groupId: "G1" })).toStrictEqual({
deny: ["*"],
});
});
it("resolves toolsBySender before group tools", () => {
const cfg = {
channels: {
qqbot: {
groups: {
G1: {
tools: { allow: ["read"] },
toolsBySender: {
"id:alice": { deny: ["*"] },
},
},
},
},
},
} as OpenClawConfig;
expect(
resolveQQBotGroupToolPolicy({
cfg,
groupId: "G1",
senderId: "alice",
}),
).toStrictEqual({ deny: ["*"] });
});
it("matches mixed-case group ids after session-key normalization", () => {
const cfg = {
channels: {
qqbot: {
groups: {
Group_OPENID: {
tools: { allow: ["read"] },
toolsBySender: {
"id:alice": { deny: ["*"] },
},
},
},
},
},
} as OpenClawConfig;
expect(
resolveQQBotGroupToolPolicy({
cfg,
groupId: "group_openid",
senderId: "alice",
}),
).toStrictEqual({ deny: ["*"] });
});
it("registers the resolver on the channel plugin", () => {
const cfg = {
channels: {
qqbot: {
groups: {
G1: { tools: { deny: ["*"] } },
},
},
},
} as OpenClawConfig;
expect(qqbotPlugin.groups?.resolveToolPolicy?.({ cfg, groupId: "G1" })).toStrictEqual({
deny: ["*"],
});
});
});
+22
View File
@@ -0,0 +1,22 @@
// Qqbot plugin module implements group tool policy behavior.
import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract";
import {
resolveChannelGroupToolsPolicy,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
export function resolveQQBotGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveChannelGroupToolsPolicy({
cfg: params.cfg,
channel: "qqbot",
groupId: params.groupId,
groupIdCaseInsensitive: true,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
}
@@ -80,7 +80,10 @@ function stringifyToolResult(value) {
return value;
}
if (Array.isArray(value)) {
return value.map((entry) => stringifyToolResult(entry)).filter(Boolean).join("\n");
return value
.map((entry) => stringifyToolResult(entry))
.filter(Boolean)
.join("\n");
}
if (!isRecord(value)) {
return value == null ? "" : String(value);
@@ -94,7 +97,10 @@ function extractTranscriptText(value) {
return value;
}
if (Array.isArray(value)) {
return value.map((entry) => extractTranscriptText(entry)).filter(Boolean).join("\n");
return value
.map((entry) => extractTranscriptText(entry))
.filter(Boolean)
.join("\n");
}
if (!isRecord(value)) {
return value == null ? "" : String(value);
@@ -179,7 +185,11 @@ function extractTranscriptToolResults(message) {
normalizeToolCallId(message.id),
...(tool ? { tool } : {}),
text,
failure: isFailureLikeToolResult({ text, isError: message.isError, is_error: message.is_error }),
failure: isFailureLikeToolResult({
text,
isError: message.isError,
is_error: message.is_error,
}),
});
}
+1 -2
View File
@@ -6,8 +6,7 @@ function numericCount(value) {
if (typeof value !== "number") {
return undefined;
}
const count = value;
return Number.isFinite(count) ? count : undefined;
return Number.isFinite(value) ? value : undefined;
}
const rssMetricIds = ["peakRssMb", "resourcePeakGatewayRssMb"];
+1 -4
View File
@@ -1,7 +1,4 @@
export {
BUILD_STAMP_FILE,
RUNTIME_POSTBUILD_STAMP_FILE,
} from "./local-build-metadata-paths.mjs";
export { BUILD_STAMP_FILE, RUNTIME_POSTBUILD_STAMP_FILE } from "./local-build-metadata-paths.mjs";
export function resolveGitHead(params?: {
cwd?: string;
+17 -7
View File
@@ -11,20 +11,29 @@ import { resetProviderAuthAliasMapCacheForTest } from "../provider-auth-aliases.
import { saveAuthProfileStore } from "./store.js";
import type { AuthProfileStore } from "./types.js";
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({
const pluginMetadataMocks = vi.hoisted(() => {
const snapshot = {
plugins: [
{
id: "fixture-provider",
origin: "bundled",
providerAuthAliases: { "fixture-provider-plan": "fixture-provider" },
},
],
diagnostics: [],
})),
);
};
return {
getCurrentPluginMetadataSnapshot: vi.fn(() => snapshot),
loadPluginMetadataSnapshot: vi.fn(() => snapshot),
};
});
vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: pluginMetadataMocks.getCurrentPluginMetadataSnapshot,
}));
vi.mock("../../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: pluginMetadataMocks.loadPluginMetadataSnapshot,
}));
vi.mock("./external-auth.js", () => ({
@@ -39,7 +48,8 @@ import { markAuthProfileSuccess } from "./profiles.js";
describe("resolveAuthProfileOrder", () => {
beforeEach(() => {
resetProviderAuthAliasMapCacheForTest();
loadPluginManifestRegistry.mockClear();
pluginMetadataMocks.getCurrentPluginMetadataSnapshot.mockClear();
pluginMetadataMocks.loadPluginMetadataSnapshot.mockClear();
});
it("accepts aliased provider credentials from manifest metadata", async () => {
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { saveAuthProfileStore } from "../auth-profiles/store.js";
@@ -719,6 +720,7 @@ describe("CLI attempt execution", () => {
),
"utf-8",
);
clearSessionStoreCacheForTest();
const nowCalls: number[] = [];
let nextNow = 10_000;
@@ -749,7 +751,7 @@ describe("CLI attempt execution", () => {
if (!updatedSessionFile) {
throw new Error("expected CLI transcript persistence to create a session file");
}
expect(updatedSessionFile).toBe(sessionFile);
expect(await fs.realpath(updatedSessionFile)).toBe(await fs.realpath(sessionFile));
const entries = await readSessionFileEntries(sessionFile);
expectRecordFields(requireRecord(entries[0], "session entry"), {
type: "session",
@@ -782,12 +784,11 @@ describe("CLI attempt execution", () => {
string,
SessionEntry
>;
expect(persisted[sessionKey]?.sessionFile).toBe(sessionFile);
expect(await fs.realpath(persisted[sessionKey]?.sessionFile ?? "")).toBe(
await fs.realpath(sessionFile),
);
expect(persisted[sessionKey]?.updatedAt).toBeGreaterThan(sessionEntry.updatedAt);
expect(persisted[sessionKey]?.updatedAt).toBeLessThan(nowCalls.at(-1) ?? 0);
expect(persisted[sessionKey]?.status).toBe("done");
expect(persisted[sessionKey]?.endedAt).toBe(4);
expect(persisted[sessionKey]?.startedAt).toBe(2);
expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt);
});
+6 -1
View File
@@ -1,5 +1,5 @@
// Exercises core model selection, aliases, thinking defaults, and visibility policy.
import { describe, it, expect, vi } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.js";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js";
@@ -114,6 +114,11 @@ vi.mock("./model-selection-cli.js", () => ({
isCliProvider: () => false,
}));
afterEach(() => {
setLoggerOverride(null);
resetLogger();
});
const EXPLICIT_ALLOWLIST_CONFIG = {
agents: {
defaults: {
+6 -6
View File
@@ -12,20 +12,20 @@ const {
SANDBOX_CONTAINERS_DIR,
SANDBOX_BROWSERS_DIR,
} = vi.hoisted(() => {
const path = require("node:path");
const nodePath = require("node:path");
const { mkdtempSync } = require("node:fs");
const { tmpdir } = require("node:os");
const baseDir = mkdtempSync(path.join(tmpdir(), "openclaw-sandbox-registry-"));
const baseDir = mkdtempSync(nodePath.join(tmpdir(), "openclaw-sandbox-registry-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = baseDir;
return {
TEST_STATE_DIR: baseDir,
PREVIOUS_OPENCLAW_STATE_DIR: previousStateDir,
SANDBOX_REGISTRY_PATH: path.join(baseDir, "containers.json"),
SANDBOX_BROWSER_REGISTRY_PATH: path.join(baseDir, "browsers.json"),
SANDBOX_CONTAINERS_DIR: path.join(baseDir, "containers"),
SANDBOX_BROWSERS_DIR: path.join(baseDir, "browsers"),
SANDBOX_REGISTRY_PATH: nodePath.join(baseDir, "containers.json"),
SANDBOX_BROWSER_REGISTRY_PATH: nodePath.join(baseDir, "browsers.json"),
SANDBOX_CONTAINERS_DIR: nodePath.join(baseDir, "containers"),
SANDBOX_BROWSERS_DIR: nodePath.join(baseDir, "browsers"),
};
});
+8 -4
View File
@@ -122,6 +122,10 @@ function parseRegistryEntryJson(row: SandboxRegistryRow): RegistryEntryPayload |
}
}
function optionalPayloadString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function rowToContainerEntry(row: SandboxRegistryRow): SandboxRegistryEntry | null {
if (row.registry_kind !== "container") {
return null;
@@ -133,10 +137,10 @@ function rowToContainerEntry(row: SandboxRegistryRow): SandboxRegistryEntry | nu
return normalizeSandboxRegistryEntry({
...payload,
containerName: row.container_name,
sessionKey: row.session_key ?? String(payload.sessionKey ?? ""),
sessionKey: row.session_key ?? optionalPayloadString(payload.sessionKey),
createdAtMs: row.created_at_ms ?? Number(payload.createdAtMs ?? 0),
lastUsedAtMs: row.last_used_at_ms ?? Number(payload.lastUsedAtMs ?? 0),
image: row.image ?? String(payload.image ?? ""),
image: row.image ?? optionalPayloadString(payload.image),
...(row.backend_id != null ? { backendId: row.backend_id } : {}),
...(row.runtime_label != null ? { runtimeLabel: row.runtime_label } : {}),
...(row.config_label_kind != null ? { configLabelKind: row.config_label_kind } : {}),
@@ -155,10 +159,10 @@ function rowToBrowserEntry(row: SandboxRegistryRow): SandboxBrowserRegistryEntry
return {
...payload,
containerName: row.container_name,
sessionKey: row.session_key ?? String(payload.sessionKey ?? ""),
sessionKey: row.session_key ?? optionalPayloadString(payload.sessionKey),
createdAtMs: row.created_at_ms ?? Number(payload.createdAtMs ?? 0),
lastUsedAtMs: row.last_used_at_ms ?? Number(payload.lastUsedAtMs ?? 0),
image: row.image ?? String(payload.image ?? ""),
image: row.image ?? optionalPayloadString(payload.image),
cdpPort: row.cdp_port ?? Number(payload.cdpPort ?? 0),
...(row.no_vnc_port != null ? { noVncPort: row.no_vnc_port } : {}),
...(row.config_hash != null ? { configHash: row.config_hash } : {}),
@@ -276,9 +276,7 @@ export async function recordChannelMessageReplyDispatch(
dispatchReplyWithBufferedBlockDispatcher: params.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: (payload) =>
payload && typeof payload === "object"
? normalizeOutboundReplyPayload(payload)
: {},
payload && typeof payload === "object" ? normalizeOutboundReplyPayload(payload) : {},
deliver: async (payload, info) => {
if (params.durable) {
const durable = await deliverInboundReplyWithMessageSendContext({
+6 -2
View File
@@ -313,13 +313,17 @@ describe("agent session resolution", () => {
storePath: resolution.storePath,
agentId: "main",
});
expect(resolvedTranscript.sessionFile).toBe(sessionFile);
expect(fs.realpathSync.native(resolvedTranscript.sessionFile)).toBe(
fs.realpathSync.native(sessionFile),
);
const persisted = loadSessionStore(resolution.storePath, { skipCache: true })[
resolution.sessionKey
];
expect(persisted?.sessionId).toBe(sessionId);
expect(persisted?.sessionFile).toBe(sessionFile);
expect(fs.realpathSync.native(persisted?.sessionFile ?? "")).toBe(
fs.realpathSync.native(sessionFile),
);
expect(persisted?.status).toBe("done");
expect(persisted?.startedAt).toBe(registryUpdatedAt - 1_000);
expect(persisted?.endedAt).toBe(registryUpdatedAt - 100);
@@ -1,5 +1,6 @@
// Status scan fast-json tests cover scan defaults, memory config, and JSON-safe status payloads.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "../config/bundled-channel-config-metadata.generated.js";
import {
applyStatusScanDefaults,
createStatusMemorySearchConfig,
@@ -21,6 +22,26 @@ let originalForceStderr: boolean;
let loggingStateRef: typeof import("../logging/state.js").loggingState;
let scanStatusJsonFast: typeof import("./status.scan.fast-json.js").scanStatusJsonFast;
const STATUS_JSON_TEST_CHANNEL_ENV_PREFIXES = GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter(
(entry) => entry.configurable !== false,
).map((entry) => `${entry.channelId.replace(/[^a-z0-9]+/gi, "_").toUpperCase()}_`);
const STATUS_JSON_TEST_CHANNEL_ENV_VARS = GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter(
(entry) => entry.configurable !== false,
).flatMap((entry) => entry.channelEnvVars ?? []);
function clearStatusJsonChannelEnv(): Record<string, string | undefined> {
const env: Record<string, string | undefined> = {};
for (const key of STATUS_JSON_TEST_CHANNEL_ENV_VARS) {
env[key] = undefined;
}
for (const key of Object.keys(process.env)) {
if (STATUS_JSON_TEST_CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) {
env[key] = undefined;
}
}
return env;
}
function configureFastJsonStatus() {
applyStatusScanDefaults(mocks, {
sourceConfig: createStatusMemorySearchConfig(),
@@ -237,6 +258,7 @@ describe("scanStatusJsonFast", () => {
it("skips gateway and update probes on cold-start status --json", async () => {
await withTemporaryEnv(
{
...clearStatusJsonChannelEnv(),
OPENCLAW_TWITCH_ACCESS_TOKEN: undefined,
TELEGRAM_BOT_TOKEN: undefined,
VITEST: undefined,
@@ -255,6 +277,7 @@ describe("scanStatusJsonFast", () => {
it("keeps cold-start gateway probes with local-only updates when a channel is configured from manifest env vars", async () => {
await withTemporaryEnv(
{
...clearStatusJsonChannelEnv(),
OPENCLAW_TWITCH_ACCESS_TOKEN: "token",
VITEST: undefined,
VITEST_POOL_ID: undefined,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -20,7 +20,7 @@ import {
shouldDeferShellEnvFallback,
shouldEnableShellEnvFallback,
} from "../infra/shell-env.js";
import { createConfigValidationMetadataPluginIdScope } from "../plugins/channel-plugin-ids.js";
import { createConfigValidationMetadataPluginIdScope } from "../plugins/gateway-startup-plugin-ids.js";
import {
loadInstalledPluginIndexInstallRecordsSync,
writePersistedInstalledPluginIndexInstallRecordsSync,
+1 -3
View File
@@ -334,9 +334,7 @@ describe("scripts/docker/setup.sh", () => {
expect(extraCompose).toContain(`"${homeVolumeDir}:/home/node"`);
expect(extraCompose).toContain(`"${configDir}:/home/node/.openclaw"`);
expect(extraCompose).toContain(`"${workspaceDir}:/home/node/.openclaw/workspace"`);
expect(extraCompose).toContain(
`"${authProfileSecretDir}:/home/node/.config/openclaw"`,
);
expect(extraCompose).toContain(`"${authProfileSecretDir}:/home/node/.config/openclaw"`);
expect(extraCompose).toContain(`"${extraMountSource}:/mnt/extra data:ro"`);
});
+78 -62
View File
@@ -3,12 +3,24 @@
import { describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { createDeferred } from "../test-helpers.deferred.js";
import { expectGatewayErrorResponse } from "./gateway-response.test-helpers.js";
import { modelsHandlers } from "./models.js";
import type { RespondFn } from "./types.js";
const withoutOpenAIEnvAuth = async <T>(run: () => Promise<T>): Promise<T> =>
await withEnvAsync(
{
CODEX_API_KEY: undefined,
OPENAI_API_KEY: undefined,
OPENAI_OAUTH_TOKEN: undefined,
CHATGPT_OAUTH_TOKEN: undefined,
},
run,
);
function requestModelsList(params: {
view: "configured" | "all";
respond?: ReturnType<typeof vi.fn>;
@@ -41,50 +53,52 @@ function requestModelsList(params: {
describe("models.list", () => {
it("does not block the configured view on slow model catalog discovery", async () => {
const catalog = createDeferred<never>();
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
const runtimeConfig = {
models: {
providers: {
openai: {
baseUrl: "https://openai.example.com",
models: [{ id: "gpt-test", name: "GPT Test" }],
await withoutOpenAIEnvAuth(async () => {
const catalog = createDeferred<never>();
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
const runtimeConfig = {
models: {
providers: {
openai: {
baseUrl: "https://openai.example.com",
models: [{ id: "gpt-test", name: "GPT Test" }],
},
},
},
},
} as unknown as OpenClawConfig;
} as unknown as OpenClawConfig;
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const { request, respond } = requestModelsList({
view: "configured",
runtimeConfig,
loadGatewayModelCatalog,
reqId: "req-models-list-slow-catalog",
});
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const { request, respond } = requestModelsList({
view: "configured",
runtimeConfig,
loadGatewayModelCatalog,
reqId: "req-models-list-slow-catalog",
});
await vi.advanceTimersByTimeAsync(800);
await vi.runOnlyPendingTimersAsync();
await request;
await vi.advanceTimersByTimeAsync(800);
await vi.runOnlyPendingTimersAsync();
await request;
expect(respond).toHaveBeenCalledWith(
true,
{
models: [
{
id: "gpt-test",
name: "GPT Test",
provider: "openai",
available: false,
},
],
},
undefined,
);
expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: true });
} finally {
vi.useRealTimers();
}
expect(respond).toHaveBeenCalledWith(
true,
{
models: [
{
id: "gpt-test",
name: "GPT Test",
provider: "openai",
available: false,
},
],
},
undefined,
);
expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: true });
} finally {
vi.useRealTimers();
}
});
});
it("keeps SecretRef configured fallback rows unknown when catalog discovery times out", async () => {
@@ -138,33 +152,35 @@ describe("models.list", () => {
});
it("keeps the all view exact instead of timing out to a partial catalog", async () => {
const catalog = createDeferred<[{ id: string; name: string; provider: string }]>();
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
await withoutOpenAIEnvAuth(async () => {
const catalog = createDeferred<[{ id: string; name: string; provider: string }]>();
const loadGatewayModelCatalog = vi.fn(() => catalog.promise);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const { request, respond } = requestModelsList({
view: "all",
loadGatewayModelCatalog,
reqId: "req-models-list-all-slow-catalog",
});
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
const { request, respond } = requestModelsList({
view: "all",
loadGatewayModelCatalog,
reqId: "req-models-list-all-slow-catalog",
});
await vi.advanceTimersByTimeAsync(800);
expect(respond).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(800);
expect(respond).not.toHaveBeenCalled();
catalog.resolve([{ id: "gpt-test", name: "GPT Test", provider: "openai" }]);
await vi.runAllTimersAsync();
await request;
catalog.resolve([{ id: "gpt-test", name: "GPT Test", provider: "openai" }]);
await vi.runAllTimersAsync();
await request;
expect(respond).toHaveBeenCalledWith(
true,
{ models: [{ id: "gpt-test", name: "GPT Test", provider: "openai", available: false }] },
undefined,
);
expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: false });
} finally {
vi.useRealTimers();
}
expect(respond).toHaveBeenCalledWith(
true,
{ models: [{ id: "gpt-test", name: "GPT Test", provider: "openai", available: false }] },
undefined,
);
expect(loadGatewayModelCatalog).toHaveBeenCalledWith({ readOnly: false });
} finally {
vi.useRealTimers();
}
});
});
it("does not expose runtime params from catalog rows", async () => {
@@ -212,10 +212,18 @@ const expectedConfiguredProviderModel = (params: ConfiguredProviderModelFixture)
describe("gateway server models + voicewake", () => {
const listModels = async (params?: { view?: "default" | "configured" | "all" }) =>
withEnvAsync({ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, async () =>
params
? await rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list", params)
: await rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list"),
withEnvAsync(
{
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
CODEX_API_KEY: undefined,
OPENAI_API_KEY: undefined,
OPENAI_OAUTH_TOKEN: undefined,
CHATGPT_OAUTH_TOKEN: undefined,
},
async () =>
params
? await rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list", params)
: await rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list"),
);
const setAgentCatalog = async (entries: AgentCatalogFixtureEntry[]) => {
+1 -2
View File
@@ -5,8 +5,7 @@ const EXACT_SEMVER_VERSION_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
const OPENCLAW_STABLE_CORRECTION_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-(?<correction>[1-9]\d*)$/;
const OPENCLAW_STABLE_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)$/;
const OPENCLAW_STABLE_VERSION_RE = /^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)$/;
const OPENCLAW_ALPHA_VERSION_RE =
/^(?<year>\d{4})\.(?<month>[1-9]\d?)\.(?<patch>[1-9]\d*)-alpha\.(?<alpha>[1-9]\d*)$/;
const OPENCLAW_BETA_VERSION_RE =
@@ -20,9 +20,8 @@ const { spawnMock } = vi.hoisted(() => ({
}));
vi.mock("node:child_process", async () => {
const { mockNodeChildProcessModule } = await import(
"../gateway/server-methods/node-child-process.test-support.js"
);
const { mockNodeChildProcessModule } =
await import("../gateway/server-methods/node-child-process.test-support.js");
return mockNodeChildProcessModule({
spawn: spawnMock as unknown as typeof import("node:child_process").spawn,
});
+1 -4
View File
@@ -5,10 +5,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveRestartSentinelPath } from "./restart-sentinel.js";
import {
SUPERVISOR_HINT_ENV_VARS,
type RespawnSupervisor,
} from "./supervisor-markers.js";
import { SUPERVISOR_HINT_ENV_VARS, type RespawnSupervisor } from "./supervisor-markers.js";
import {
CONTROL_PLANE_UPDATE_SENTINEL_META_ENV,
type ControlPlaneUpdateSentinelMetaFile,
+24 -41
View File
@@ -3,16 +3,7 @@ import crypto from "node:crypto";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveOAuthDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
@@ -29,6 +20,7 @@ vi.mock("../channels/plugins/pairing.js", () => ({
getPairingAdapter: pairingMocks.getPairingAdapter,
}));
import { drainFileLockStateForTest, resetFileLockStateForTest } from "../infra/file-lock.js";
import {
addChannelAllowFromStoreEntry,
clearPairingAllowFromReadCacheForTest,
@@ -50,9 +42,6 @@ type FileReadSpy = {
mockRestore: () => void;
};
let randomIntSpy: MockInstance<RandomIntSync>;
let nextRandomInt = 0;
beforeAll(() => {
fixtureRoot = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-pairing-"));
});
@@ -64,26 +53,20 @@ afterAll(() => {
});
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
resetFileLockStateForTest();
clearPairingAllowFromReadCacheForTest();
pairingMocks.getPairingAdapter.mockReset();
nextRandomInt = 0;
randomIntSpy ??= vi.spyOn(crypto, "randomInt") as unknown as MockInstance<RandomIntSync>;
setDefaultRandomIntMock();
});
afterAll(() => {
randomIntSpy?.mockRestore();
afterEach(async () => {
await drainFileLockStateForTest();
resetFileLockStateForTest();
vi.useRealTimers();
vi.restoreAllMocks();
});
function setDefaultRandomIntMock() {
randomIntSpy.mockImplementation((minOrMax: number, max?: number) => {
const min = max === undefined ? 0 : minOrMax;
const upper = max === undefined ? minOrMax : max;
const span = Math.max(upper - min, 1);
return min + (nextRandomInt++ % span);
});
}
function requireFirstPairingRequest(
requests: Awaited<ReturnType<typeof listChannelPairingRequests>>,
) {
@@ -249,6 +232,11 @@ async function withMockRandomInt(params: {
fallbackValue?: number;
run: () => Promise<void>;
}) {
const randomIntSpy = vi.spyOn(crypto, "randomInt") as unknown as {
mockImplementation: (impl: RandomIntSync) => void;
mockRestore: () => void;
mockReturnValue: (value: number) => void;
};
try {
if (params.initialValue !== undefined) {
randomIntSpy.mockReturnValue(params.initialValue);
@@ -261,7 +249,7 @@ async function withMockRandomInt(params: {
await params.run();
} finally {
setDefaultRandomIntMock();
randomIntSpy.mockRestore();
}
}
@@ -496,7 +484,8 @@ describe("pairing store", () => {
it("regenerates when a generated code collides", async () => {
await withTempStateDir(async (_stateDir, env) => {
await withMockRandomInt({
initialValue: 0,
sequence: Array(16).fill(0).concat(Array(8).fill(1)),
fallbackValue: 1,
run: async () => {
const first = await upsertChannelPairingRequest({
channel: "telegram",
@@ -506,19 +495,13 @@ describe("pairing store", () => {
});
expect(first.code).toBe("AAAAAAAA");
await withMockRandomInt({
sequence: Array(8).fill(0).concat(Array(8).fill(1)),
fallbackValue: 1,
run: async () => {
const second = await upsertChannelPairingRequest({
channel: "telegram",
id: "456",
accountId: DEFAULT_ACCOUNT_ID,
env,
});
expect(second.code).toBe("BBBBBBBB");
},
const second = await upsertChannelPairingRequest({
channel: "telegram",
id: "456",
accountId: DEFAULT_ACCOUNT_ID,
env,
});
expect(second.code).toBe("BBBBBBBB");
},
});
});
+1
View File
@@ -60,6 +60,7 @@ describe("plugin npm runtime build planning", () => {
api: path.join(repoRoot, "extensions", "qqbot", "api.ts"),
"channel-entry-api": path.join(repoRoot, "extensions", "qqbot", "channel-entry-api.ts"),
"channel-plugin-api": path.join(repoRoot, "extensions", "qqbot", "channel-plugin-api.ts"),
"doctor-contract-api": path.join(repoRoot, "extensions", "qqbot", "doctor-contract-api.ts"),
index: path.join(repoRoot, "extensions", "qqbot", "index.ts"),
"runtime-api": path.join(repoRoot, "extensions", "qqbot", "runtime-api.ts"),
"secret-contract-api": path.join(repoRoot, "extensions", "qqbot", "secret-contract-api.ts"),
@@ -651,7 +651,7 @@ describe("bundled plugin install/uninstall probe", () => {
let commandPid: number | undefined;
try {
const commandResult = runtimeSmoke
.runCommand(process.execPath, [commandPath], { detached: false, timeoutMs: 100 })
.runCommand(process.execPath, [commandPath], { detached: false, timeoutMs: 500 })
.catch((error: unknown) => error);
await waitForFile(commandPidPath, 1000);
commandPid = Number(fs.readFileSync(commandPidPath, "utf8"));
@@ -666,7 +666,7 @@ describe("bundled plugin install/uninstall probe", () => {
if (!(error instanceof Error)) {
throw new Error("expected non-detached runtime command to time out");
}
expect(error.message).toMatch(/timed out after 100ms/u);
expect(error.message).toMatch(/timed out after 500ms/u);
await waitForDead(commandPid, 1000);
} finally {
+3
View File
@@ -352,6 +352,7 @@ printf '%s\\n' "$count" >"$TMPDIR/docker-count"
printf '%s\\n' "$$" >"$TMPDIR/docker.pid"
printf 'rpc error: code = Unavailable\\n'
trap 'printf "term\\n" >"$TMPDIR/docker.term"; exit 0' TERM
printf 'ready\\n' >"$TMPDIR/docker.ready"
while true; do
/bin/sleep 1
done
@@ -401,6 +402,7 @@ docker_build_run e2e-build -t demo-image .
const runInterruptedBuild = async (signal: NodeJS.Signals, expectedCode: number) => {
rmSync(join(workDir, "docker.pid"), { force: true });
rmSync(join(workDir, "docker.term"), { force: true });
rmSync(join(workDir, "docker.ready"), { force: true });
rmSync(join(workDir, "docker-count"), { force: true });
const runner = spawn(join(workDir, "runner.sh"), {
env: { ...process.env, TMPDIR: workDir },
@@ -409,6 +411,7 @@ docker_build_run e2e-build -t demo-image .
try {
const pidPath = join(workDir, "docker.pid");
await waitForFile(pidPath);
await waitForFile(join(workDir, "docker.ready"));
const buildPid = Number.parseInt(readFileSync(pidPath, "utf8"), 10);
runner.kill(signal);
+6 -2
View File
@@ -38,7 +38,9 @@ describe("resolveIosVersion", () => {
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
});
expect(() => resolveIosVersion(rootDir)).toThrow("Expected pinned release version like 2026.6.5");
expect(() => resolveIosVersion(rootDir)).toThrow(
"Expected pinned release version like 2026.6.5",
);
});
it("rejects prerelease suffixes in the pinned iOS version file", () => {
@@ -47,7 +49,9 @@ describe("resolveIosVersion", () => {
changelog: "# OpenClaw iOS Changelog\n\n## Unreleased\n\nNotes.\n",
});
expect(() => resolveIosVersion(rootDir)).toThrow("Expected pinned release version like 2026.6.5");
expect(() => resolveIosVersion(rootDir)).toThrow(
"Expected pinned release version like 2026.6.5",
);
});
});
@@ -208,14 +208,15 @@ setInterval(() => {}, 1000);
const runPromise = runCommand(process.execPath, ["-e", parentScript], dir, {
timeoutKillGraceMs: 25,
timeoutMs: 100,
timeoutMs: 500,
});
const runError = runPromise.catch((error: unknown) => error);
await waitForFile(childPidPath, 2_000);
childPid = Number.parseInt(readFileSync(childPidPath, "utf8"), 10);
await expect(runPromise).rejects.toMatchObject({
await expect(runError).resolves.toMatchObject({
code: "ETIMEDOUT",
message: expect.stringContaining("timed out after 100ms"),
message: expect.stringContaining("timed out after 500ms"),
});
await waitForDead(childPid, 2_000);
} finally {
+1 -1
View File
@@ -137,7 +137,6 @@ export const forcedUnitFastTestFiles = [
"src/node-host/invoke-system-run-plan.test.ts",
"src/node-host/invoke-system-run.test.ts",
"src/pairing/pairing-challenge.test.ts",
"src/pairing/pairing-store.test.ts",
"src/pairing/setup-code.test.ts",
"src/plugin-activation-boundary.test.ts",
"src/plugin-sdk/memory-host-events.test.ts",
@@ -242,6 +241,7 @@ const broadUnitFastCandidateSkipGlobs = [
"src/proxy-capture/runtime.test.ts",
"src/plugins/install.npm-spec.test.ts",
"src/plugins/contracts/**/*.test.ts",
"src/pairing/pairing-store.test.ts",
"src/plugin-sdk/browser-subpaths.test.ts",
"src/security/**/*.test.ts",
"src/secrets/**/*.test.ts",