mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix(qqbot): unify slash command auth, c2cOnly gating, and file delivery (#73616)
* fix(qqbot): align clear-storage command with actual downloads directory
The /bot-clear-storage command previously targeted
~/.openclaw/media/qqbot/downloads/{appId}/, but inbound attachments
and outbound fallback downloads are stored directly under
~/.openclaw/media/qqbot/downloads/ without appId subdivision.
This mismatch caused the clear command to report 'no files to clean'
while downloaded files continued to occupy disk space.
Changes:
- Replace resolveQqbotDownloadsDirForApp(appId) with
resolveQqbotDownloadsDir() that returns the downloads root
- Use getQQBotMediaPath('downloads') instead of manual path assembly
- Remove appId-based path validation (no longer needed)
- Update usage text to reflect the new scope
* refactor(qqbot): unify slash command auth and c2cOnly gating in registry
Previously, slash command authorization and group-chat rejection were
scattered across individual handlers and a hardcoded GROUP_EXCLUDED set.
This led to inconsistent behavior: commandAuthorized was hardcoded to
true in the pre-dispatch path, some handlers checked allowFrom while
others did not, and group users received no response for auth-gated
commands.
Changes:
1. Add resolveSlashCommandAuth() (new file slash-command-auth.ts)
- Requires sender to appear in an explicit non-wildcard allowFrom
list; wildcard ['*'] does not grant admin command access
- Group messages use groupAllowFrom, falling back to allowFrom
2. Fix commandAuthorized in slash-command-handler.ts
- Replace hardcoded 'true' with resolveSlashCommandAuth() call
3. Add c2cOnly field to SlashCommand interface
- Commands declare c2cOnly: true instead of checking ctx.type
inside their handler
- Registry rejects c2cOnly commands in group chat before auth
check, returning a user-friendly hint
4. Remove GROUP_EXCLUDED hardcoded set from register-basic.ts
- /bot-help now filters by cmd.c2cOnly dynamically
5. Clean up handler-level auth and scene checks
- Remove hasExplicitCommandAllowlist check from register-logs
- Remove ctx.type !== 'c2c' guards from all c2cOnly handlers
- Improve rejection message to mention the correct config field
(allowFrom for c2c, groupAllowFrom for group)
6. Mark commands: bot-upgrade, bot-streaming, bot-logs,
bot-clear-storage, bot-approve as c2cOnly: true
* fix(qqbot): pass allowQQBotDataDownloads when sending slash command file attachments
The /bot-logs command writes temporary log files to the QQBot data
downloads directory (~/.openclaw/qqbot/downloads/), but sendDocument
was called without allowQQBotDataDownloads: true. This caused
resolveOutboundMediaPath to reject the file path as outside the
allowed media roots, silently failing the file attachment while
the text reply was sent successfully.
Add { allowQQBotDataDownloads: true } to the sendDocument call in
slash-command-handler.ts so file-bearing slash command results
(currently only /bot-logs) can deliver their attachments.
* feat(qqbot): add /bot-me command to display sender user ID
Add a new /bot-me slash command that returns the sender's user ID
(openid). This helps users quickly find the value they need to add
to allowFrom or groupAllowFrom configuration for admin command
access.
Marked as c2cOnly since the user ID is sensitive information.
* feat(qqbot): update response timeout
* feat(qqbot): add engine import boundary test and bump version
- Add engine-import-boundary.test.ts to enforce that engine/ sources
only import from openclaw/plugin-sdk/* and never reach into other
openclaw internals directly. Scans all 110 source files recursively.
- Bump plugin version to 2026.4.27.
* fix(qqbot): unify slash command auth, c2cOnly gating, and file delivery (#73616) (thanks @cxyhhhhh)
---------
Co-authored-by: sliverp <870080352@qq.com>
This commit is contained in:
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/outbound: strip re-formed HTML tags during plain-text sanitization so nested tag fragments cannot leave a CodeQL-detected `<script>` sequence behind. Thanks @vincentkoc.
|
||||
- Security/secrets: compare credential bytes with padded timing-safe buffers instead of hashing candidate passwords before equality checks. Thanks @vincentkoc.
|
||||
- Security/QQBot: sanitize debug log arguments before writing to `console.*`, so gateway payload fields cannot forge extra log lines when debug logging is enabled. Thanks @vincentkoc.
|
||||
- QQBot: unify slash command auth and c2cOnly gating in the command registry, pass `allowQQBotDataDownloads` when sending slash command file attachments, align clear-storage with actual downloads directory, and add `/bot-me` to display sender user ID. (#73616) Thanks @cxyhhhhh.
|
||||
- CLI/agents/status: keep `openclaw agents`, text `agents list`, and plain text `status` on read-only metadata paths so human output no longer preloads plugin runtimes or live channel scans before printing. Fixes #74195. Thanks @NianJiuZst.
|
||||
- Agents/local models: derive context-window guard thresholds from the effective model window with 4k/8k safety floors, so small local models are no longer rejected by fixed 16k/32k preflight cutoffs. Fixes #42999. Thanks @chengjialu8888.
|
||||
- PDF extraction: resolve PDF.js standard fonts from the installed package root and pass a filesystem path to the Node fallback extractor, so built-in font PDFs render without `file://` URL lookup failures. Fixes #51455; carries forward #70936, #54447, and #62175. Thanks @anyech, @JuanRdBO, and @solomonneas.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/qqbot",
|
||||
"version": "2026.4.26",
|
||||
"version": "2026.4.27",
|
||||
"private": false,
|
||||
"description": "OpenClaw QQ Bot channel plugin",
|
||||
"type": "module",
|
||||
@@ -17,7 +17,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.26"
|
||||
"openclaw": ">=2026.4.27"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -46,10 +46,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.26"
|
||||
"pluginApi": ">=2026.4.27"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.26"
|
||||
"openclawVersion": "2026.4.27"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
@@ -7,6 +7,7 @@ export function registerApproveCommands(registry: SlashCommandRegistry): void {
|
||||
name: "bot-approve",
|
||||
description: "管理命令执行审批配置",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-approve 查看操作指引`,
|
||||
`/bot-approve on 开启审批(白名单模式,推荐)`,
|
||||
|
||||
@@ -4,9 +4,40 @@ import { getPluginVersionString, resolveRuntimeServiceVersion } from "./state.js
|
||||
const QQBOT_PLUGIN_GITHUB_URL = "https://github.com/openclaw/openclaw/tree/main/extensions/qqbot";
|
||||
const QQBOT_UPGRADE_GUIDE_URL = "https://q.qq.com/qqbot/openclaw/upgrade.html";
|
||||
|
||||
const GROUP_EXCLUDED = new Set(["bot-upgrade", "bot-clear-storage", "bot-streaming"]);
|
||||
|
||||
export function registerBasicBotCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-help",
|
||||
description: "查看所有内置命令",
|
||||
usage: [
|
||||
`/bot-help`,
|
||||
``,
|
||||
`查看所有可用的 QQBot 内置命令及其简要说明。`,
|
||||
`在命令后追加 ? 可查看详细用法。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
const isGroup = ctx.type === "group";
|
||||
const lines = [`### QQBot 内置命令`, ``];
|
||||
for (const [name, cmd] of registry.getAllCommands()) {
|
||||
if (isGroup && cmd.c2cOnly) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
||||
}
|
||||
lines.push(``, `> 插件版本 v${getPluginVersionString()}`);
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "bot-me",
|
||||
description: "查看当前发送者的账号ID",
|
||||
c2cOnly: true,
|
||||
usage: [`/bot-me`, ``, `显示当前发送者的账号ID`].join("\n"),
|
||||
handler: (ctx) => {
|
||||
return `你的账号ID:\`${ctx.senderId}\``;
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "bot-ping",
|
||||
description: "测试 OpenClaw 与 QQ 之间的网络延迟",
|
||||
@@ -39,6 +70,7 @@ export function registerBasicBotCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-version",
|
||||
description: "查看 QQBot 插件版本和 OpenClaw 框架版本",
|
||||
c2cOnly: true,
|
||||
usage: [`/bot-version`, ``, `查看当前 QQBot 插件版本和 OpenClaw 框架版本。`].join("\n"),
|
||||
handler: async () => {
|
||||
const frameworkVersion = resolveRuntimeServiceVersion();
|
||||
@@ -55,31 +87,9 @@ export function registerBasicBotCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-upgrade",
|
||||
description: "查看 QQBot 升级指引",
|
||||
c2cOnly: true,
|
||||
usage: [`/bot-upgrade`, ``, `查看 QQBot 升级说明。`].join("\n"),
|
||||
handler: () =>
|
||||
[`📘 QQBot 升级指引:`, `[点击查看升级说明](${QQBOT_UPGRADE_GUIDE_URL})`].join("\n"),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "bot-help",
|
||||
description: "查看所有内置命令",
|
||||
usage: [
|
||||
`/bot-help`,
|
||||
``,
|
||||
`查看所有可用的 QQBot 内置命令及其简要说明。`,
|
||||
`在命令后追加 ? 可查看详细用法。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
const isGroup = ctx.type === "group";
|
||||
const lines = [`### QQBot 内置命令`, ``];
|
||||
for (const [name, cmd] of registry.getAllCommands()) {
|
||||
if (isGroup && GROUP_EXCLUDED.has(name)) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
||||
}
|
||||
lines.push(``, `> 插件版本 v${getPluginVersionString()}`);
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getHomeDir } from "../../utils/platform.js";
|
||||
import { getQQBotMediaPath } from "../../utils/platform.js";
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
|
||||
function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] {
|
||||
@@ -75,31 +75,26 @@ function removeEmptyDirs(dirPath: string): void {
|
||||
const CLEAR_STORAGE_MAX_DISPLAY = 10;
|
||||
|
||||
/**
|
||||
* Resolve the canonical downloads directory for an appId under the user's home.
|
||||
* Must stay strictly under ~/.openclaw/media/qqbot/downloads/.
|
||||
* Resolve the canonical QQBot downloads directory.
|
||||
*
|
||||
* All inbound attachments and outbound fallback downloads are stored directly
|
||||
* under `~/.openclaw/media/qqbot/downloads/` without appId subdivision.
|
||||
* The clear-storage command therefore cleans the entire downloads root.
|
||||
*/
|
||||
export function resolveQqbotDownloadsDirForApp(appId: string): string {
|
||||
const id = appId.trim();
|
||||
if (!id || id.includes("\0") || /[/\\\n]|\.\./.test(id)) {
|
||||
throw new Error("invalid appId path");
|
||||
}
|
||||
const base = path.join(getHomeDir(), ".openclaw", "media", "qqbot", "downloads");
|
||||
const resolvedBase = path.resolve(base);
|
||||
const target = path.resolve(path.join(resolvedBase, id));
|
||||
if (target === resolvedBase || !target.startsWith(resolvedBase + path.sep)) {
|
||||
throw new Error("invalid appId path");
|
||||
}
|
||||
return target;
|
||||
export function resolveQqbotDownloadsDir(): string {
|
||||
return getQQBotMediaPath("downloads");
|
||||
}
|
||||
|
||||
export function registerClearStorageCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-clear-storage",
|
||||
description: "清理通过 QQBot 对话产生的下载文件,释放主机磁盘空间",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-clear-storage`,
|
||||
``,
|
||||
`扫描当前机器人产生的下载文件并列出明细。`,
|
||||
`扫描 QQBot 下载目录下的所有文件并列出明细。`,
|
||||
`确认后执行删除,释放主机磁盘空间。`,
|
||||
``,
|
||||
`/bot-clear-storage --force 确认执行清理`,
|
||||
@@ -107,20 +102,9 @@ export function registerClearStorageCommands(registry: SlashCommandRegistry): vo
|
||||
`⚠️ 仅在私聊中可用。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
const { appId, type } = ctx;
|
||||
|
||||
if (type !== "c2c") {
|
||||
return `💡 请在私聊中使用此指令`;
|
||||
}
|
||||
|
||||
const isForce = ctx.args.trim() === "--force";
|
||||
let targetDir: string;
|
||||
try {
|
||||
targetDir = resolveQqbotDownloadsDirForApp(appId);
|
||||
} catch {
|
||||
return `❌ 无效的机器人标识,无法解析清理目录。`;
|
||||
}
|
||||
const displayDir = `~/.openclaw/media/qqbot/downloads/${appId}`;
|
||||
const targetDir = resolveQqbotDownloadsDir();
|
||||
const displayDir = `~/.openclaw/media/qqbot/downloads`;
|
||||
|
||||
if (!isForce) {
|
||||
const files = scanDirectoryFiles(targetDir);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
import { buildBotLogsResult, hasExplicitCommandAllowlist } from "./log-helpers.js";
|
||||
import { buildBotLogsResult } from "./log-helpers.js";
|
||||
|
||||
export function registerLogCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-logs",
|
||||
description: "导出本地日志文件",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-logs`,
|
||||
``,
|
||||
`导出最近的 OpenClaw 日志文件(最多 4 个文件)。`,
|
||||
`每个文件只保留最后 1000 行,并作为附件返回。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
if (!hasExplicitCommandAllowlist(ctx.accountConfig)) {
|
||||
return `⛔ 权限不足:请先在 channels.qqbot.allowFrom(或对应账号 allowFrom)中配置明确的发送者列表后再使用 /bot-logs。`;
|
||||
}
|
||||
handler: () => {
|
||||
return buildBotLogsResult();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export function registerStreamingCommands(registry: SlashCommandRegistry): void
|
||||
registry.register({
|
||||
name: "bot-streaming",
|
||||
description: "一键开关流式消息",
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-streaming on 开启流式消息`,
|
||||
`/bot-streaming off 关闭流式消息`,
|
||||
@@ -39,10 +40,6 @@ export function registerStreamingCommands(registry: SlashCommandRegistry): void
|
||||
`注意:仅 C2C(私聊)支持流式消息。`,
|
||||
].join("\n"),
|
||||
handler: async (ctx) => {
|
||||
if (ctx.type !== "c2c") {
|
||||
return `❌ 流式消息仅支持私聊场景,请在私聊中使用 /bot-streaming 指令`;
|
||||
}
|
||||
|
||||
const arg = ctx.args.trim().toLowerCase();
|
||||
const currentOn = isStreamingConfigEnabled(ctx.accountConfig?.streaming);
|
||||
|
||||
|
||||
48
extensions/qqbot/src/engine/commands/slash-command-auth.ts
Normal file
48
extensions/qqbot/src/engine/commands/slash-command-auth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Pre-dispatch authorization for requireAuth slash commands.
|
||||
*
|
||||
* Unlike the access-stage's `resolveCommandAuthorized` (which permits
|
||||
* `dm_policy_open` senders — i.e. anyone), this function requires the
|
||||
* sender to appear in an **explicit non-wildcard** allowFrom list.
|
||||
*
|
||||
* Rationale: sensitive operations (log export, file deletion, approval
|
||||
* config changes) must be gated behind a deliberate operator decision.
|
||||
* A wide-open DM policy means "anyone can chat", not "anyone can run
|
||||
* admin commands".
|
||||
*/
|
||||
|
||||
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../access/index.js";
|
||||
|
||||
/**
|
||||
* Determine whether `senderId` is authorized to execute `requireAuth`
|
||||
* slash commands for the given account configuration.
|
||||
*
|
||||
* Authorization rules:
|
||||
* - `allowFrom` not configured / empty / only `["*"]` → **false**
|
||||
* (wildcard means "open to everyone", not explicit authorization)
|
||||
* - `allowFrom` contains at least one concrete entry AND sender
|
||||
* matches → **true**
|
||||
* - Group messages use `groupAllowFrom` when present, falling back
|
||||
* to `allowFrom`.
|
||||
*/
|
||||
export function resolveSlashCommandAuth(params: {
|
||||
senderId: string;
|
||||
isGroup: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
}): boolean {
|
||||
const rawList =
|
||||
params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
|
||||
? params.groupAllowFrom
|
||||
: params.allowFrom;
|
||||
|
||||
const normalized = normalizeQQBotAllowFrom(rawList);
|
||||
|
||||
// Require at least one explicit (non-wildcard) entry.
|
||||
const hasExplicitEntry = normalized.some((entry) => entry !== "*");
|
||||
if (!hasExplicitEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return createQQBotSenderMatcher(params.senderId)(normalized);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
buildDeliveryTarget,
|
||||
accountToCreds,
|
||||
} from "../messaging/sender.js";
|
||||
import { resolveSlashCommandAuth } from "./slash-command-auth.js";
|
||||
import { matchSlashCommand } from "./slash-commands-impl.js";
|
||||
import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js";
|
||||
|
||||
@@ -75,7 +76,12 @@ export async function trySlashCommand(
|
||||
accountId: account.accountId,
|
||||
appId: account.appId,
|
||||
accountConfig: account.config,
|
||||
commandAuthorized: true,
|
||||
commandAuthorized: resolveSlashCommandAuth({
|
||||
senderId: msg.senderId,
|
||||
isGroup: msg.type === "group" || msg.type === "guild",
|
||||
allowFrom: account.config?.allowFrom,
|
||||
groupAllowFrom: account.config?.groupAllowFrom,
|
||||
}),
|
||||
queueSnapshot: ctx.getQueueSnapshot(peerId),
|
||||
};
|
||||
|
||||
@@ -125,6 +131,7 @@ export async function trySlashCommand(
|
||||
replyToId: msg.messageId,
|
||||
},
|
||||
replyFile,
|
||||
{ allowQQBotDataDownloads: true },
|
||||
);
|
||||
} catch (fileErr) {
|
||||
log?.error(`Failed to send slash command file: ${String(fileErr)}`);
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface SlashCommand {
|
||||
usage?: string;
|
||||
/** When true, the command requires the sender to pass the allowFrom authorization check. */
|
||||
requireAuth?: boolean;
|
||||
/** When true, the command is only available in c2c (private) chat. Group invocations are rejected automatically. */
|
||||
c2cOnly?: boolean;
|
||||
/** Command handler. */
|
||||
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
|
||||
}
|
||||
@@ -106,10 +108,14 @@ export class SlashCommandRegistry {
|
||||
|
||||
/** Register one command. */
|
||||
register(cmd: SlashCommand): void {
|
||||
const key = lc(cmd.name);
|
||||
// Always register in the pre-dispatch map so QQ message-flow slash
|
||||
// commands can match and execute directly (with requireAuth gating).
|
||||
this.commands.set(key, cmd);
|
||||
// Auth-gated commands are additionally exposed to the framework command
|
||||
// surface (api.registerCommand) for CLI / control-plane invocation.
|
||||
if (cmd.requireAuth) {
|
||||
this.frameworkCommands.set(lc(cmd.name), cmd);
|
||||
} else {
|
||||
this.commands.set(lc(cmd.name), cmd);
|
||||
this.frameworkCommands.set(key, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,12 +170,19 @@ export class SlashCommandRegistry {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject c2cOnly commands when invoked outside private chat.
|
||||
if (cmd.c2cOnly && ctx.type !== "c2c") {
|
||||
return `💡 请在私聊中使用此指令`;
|
||||
}
|
||||
|
||||
// Gate sensitive commands behind the allowFrom authorization check.
|
||||
if (cmd.requireAuth && !ctx.commandAuthorized) {
|
||||
log?.info?.(
|
||||
`[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
|
||||
);
|
||||
return `⛔ 权限不足:/${cmd.name} 需要管理员权限。`;
|
||||
const isGroup = ctx.type === "group" || ctx.type === "guild";
|
||||
const configHint = isGroup ? "groupAllowFrom" : "allowFrom";
|
||||
return `⛔ 权限不足:请先在 channels.qqbot.${configHint} 中配置明确的发送者列表后再使用 /${cmd.name}。`;
|
||||
}
|
||||
|
||||
// `/command ?` returns usage help.
|
||||
|
||||
73
extensions/qqbot/src/engine/engine-import-boundary.test.ts
Normal file
73
extensions/qqbot/src/engine/engine-import-boundary.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Engine import boundary test.
|
||||
*
|
||||
* Ensures that engine/ sources only import from `openclaw/plugin-sdk/*`
|
||||
* and never reach into other openclaw internals directly.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ENGINE_DIR = path.resolve(import.meta.dirname);
|
||||
|
||||
/** Recursively collect all non-test .ts files under a directory. */
|
||||
function walkSourceFiles(dir: string, files: string[] = []): string[] {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||
continue;
|
||||
}
|
||||
walkSourceFiles(fullPath, files);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entry.name.endsWith(".ts") &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".spec.ts")
|
||||
) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all `openclaw/...` import specifiers from source text.
|
||||
* Matches: import ... from "openclaw/...", import("openclaw/...")
|
||||
*/
|
||||
function findOpenclawImports(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(/from\s+["'](openclaw\/[^"']+)["']/g),
|
||||
...source.matchAll(/import\(\s*["'](openclaw\/[^"']+)["']\s*\)/g),
|
||||
].map((match) => match[1]);
|
||||
}
|
||||
|
||||
/** Check if an import specifier is an allowed openclaw/plugin-sdk subpath. */
|
||||
const ALLOWED_PREFIX = ["openclaw", "plugin-sdk"].join("/");
|
||||
function isAllowedImport(specifier: string): boolean {
|
||||
return specifier.startsWith(ALLOWED_PREFIX);
|
||||
}
|
||||
|
||||
describe("engine import boundary", () => {
|
||||
it("only imports from openclaw/plugin-sdk, never from other openclaw internals", () => {
|
||||
const sourceFiles = walkSourceFiles(ENGINE_DIR);
|
||||
const offenders: Array<{ file: string; imports: string[] }> = [];
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const openclawImports = findOpenclawImports(source);
|
||||
const forbidden = openclawImports.filter((specifier) => !isAllowedImport(specifier));
|
||||
|
||||
if (forbidden.length > 0) {
|
||||
offenders.push({
|
||||
file: path.relative(ENGINE_DIR, file),
|
||||
imports: forbidden,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ import type {
|
||||
|
||||
// ============ Config ============
|
||||
|
||||
const RESPONSE_TIMEOUT = 120_000;
|
||||
const RESPONSE_TIMEOUT = 300_000;
|
||||
const TOOL_ONLY_TIMEOUT = 60_000;
|
||||
const MAX_TOOL_RENEWALS = 3;
|
||||
const TOOL_MEDIA_SEND_TIMEOUT = 45_000;
|
||||
|
||||
Reference in New Issue
Block a user