mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
58b68e92f2
commit
6f2b3830f1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 +1 @@
|
||||
export declare function copyA2uiAssets(params: {
|
||||
srcDir: string;
|
||||
outDir: string;
|
||||
}): Promise<void>;
|
||||
export declare function copyA2uiAssets(params: { srcDir: string; outDir: string }): Promise<void>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
|
||||
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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: ["*"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user