mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
test: speed legacy state migration discovery
Keep bundled legacy migration discovery on narrow setup-entry surfaces so state-migration tests and doctor cold paths avoid unrelated channel runtime loads. Add targeted setup feature metadata, narrow Telegram/WhatsApp legacy contracts, and a path-only pairing SDK helper.
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
|
||||
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl
|
||||
052943a9f1eb82a49452b6715f4c08faeb650d16a36c150a3c726ff392ecad0d plugin-sdk-api-baseline.json
|
||||
a5077395f009f5064331dc1c38bb2d6d2864299d3c1fbd9e40956c1700fa253c plugin-sdk-api-baseline.jsonl
|
||||
|
||||
1
extensions/telegram/legacy-state-migrations-api.ts
Normal file
1
extensions/telegram/legacy-state-migrations-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js";
|
||||
@@ -17,6 +17,9 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"legacyStateMigrations": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "telegram",
|
||||
"label": "Telegram",
|
||||
|
||||
@@ -9,6 +9,10 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "telegramSetupPlugin",
|
||||
},
|
||||
legacyStateMigrations: {
|
||||
specifier: "./legacy-state-migrations-api.js",
|
||||
exportName: "detectTelegramLegacyStateMigrations",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
|
||||
151
extensions/telegram/src/account-selection.ts
Normal file
151
extensions/telegram/src/account-selection.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
const DEFAULT_AGENT_ID = "main";
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
const normalized = (value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/g, "")
|
||||
.replace(/-+$/g, "");
|
||||
return normalized || DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
function normalizeChannelId(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function resolveDefaultAgentId(cfg: OpenClawConfig): string {
|
||||
const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id;
|
||||
return normalizeAgentId(chosen);
|
||||
}
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
|
||||
if (key) {
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function resolveBindingAccount(params: {
|
||||
binding: unknown;
|
||||
channelId: string;
|
||||
}): { agentId: string; accountId: string } | null {
|
||||
if (!params.binding || typeof params.binding !== "object") {
|
||||
return null;
|
||||
}
|
||||
const binding = params.binding as {
|
||||
agentId?: unknown;
|
||||
match?: { channel?: unknown; accountId?: unknown };
|
||||
};
|
||||
if (normalizeChannelId(binding.match?.channel) !== params.channelId) {
|
||||
return null;
|
||||
}
|
||||
const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : "";
|
||||
if (!accountId.trim() || accountId.trim() === "*") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined),
|
||||
accountId: normalizeAccountId(accountId),
|
||||
};
|
||||
}
|
||||
|
||||
function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const resolved = resolveBindingAccount({ binding, channelId });
|
||||
if (resolved) {
|
||||
ids.add(resolved.accountId);
|
||||
}
|
||||
}
|
||||
return [...ids].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null {
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
for (const binding of cfg.bindings ?? []) {
|
||||
const resolved = resolveBindingAccount({ binding, channelId });
|
||||
if (resolved?.agentId === defaultAgentId) {
|
||||
return resolved.accountId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function combineAccountIds(params: {
|
||||
configuredAccountIds: readonly string[];
|
||||
additionalAccountIds: readonly string[];
|
||||
}): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) {
|
||||
ids.add(normalizeAccountId(id));
|
||||
}
|
||||
if (ids.size === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return [...ids].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveListedDefaultAccountId(params: {
|
||||
accountIds: readonly string[];
|
||||
configuredDefaultAccountId: string | null | undefined;
|
||||
}): string {
|
||||
const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId);
|
||||
if (configured && params.accountIds.includes(configured)) {
|
||||
return configured;
|
||||
}
|
||||
if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return combineAccountIds({
|
||||
configuredAccountIds: listConfiguredAccountIds(cfg),
|
||||
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): {
|
||||
accountId: string;
|
||||
accountIds: string[];
|
||||
shouldWarnMissingDefault: boolean;
|
||||
} {
|
||||
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||
if (boundDefault) {
|
||||
return {
|
||||
accountId: boundDefault,
|
||||
accountIds: listTelegramAccountIds(cfg),
|
||||
shouldWarnMissingDefault: false,
|
||||
};
|
||||
}
|
||||
const accountIds = listTelegramAccountIds(cfg);
|
||||
const resolved = resolveListedDefaultAccountId({
|
||||
accountIds,
|
||||
configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount,
|
||||
});
|
||||
return {
|
||||
accountId: resolved,
|
||||
accountIds,
|
||||
shouldWarnMissingDefault:
|
||||
resolved === accountIds[0] &&
|
||||
!accountIds.includes(DEFAULT_ACCOUNT_ID) &&
|
||||
accountIds.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveDefaultTelegramAccountSelection(cfg).accountId;
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import util from "node:util";
|
||||
import {
|
||||
createAccountActionGate,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
listCombinedAccountIds,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
resolveAccountEntry,
|
||||
resolveListedDefaultAccountId,
|
||||
resolveAccountWithDefaultFallback,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
@@ -14,13 +11,13 @@ import type {
|
||||
TelegramAccountConfig,
|
||||
TelegramActionConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
listBoundAccountIds,
|
||||
resolveDefaultAgentBoundAccountId,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing";
|
||||
import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
listTelegramAccountIds as listSelectedTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountSelection,
|
||||
} from "./account-selection.js";
|
||||
import type { TelegramTransport } from "./fetch.js";
|
||||
import { resolveTelegramToken } from "./token.js";
|
||||
|
||||
@@ -67,22 +64,8 @@ export type TelegramMediaRuntimeOptions = {
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
|
||||
if (key) {
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = listCombinedAccountIds({
|
||||
configuredAccountIds: listConfiguredAccountIds(cfg),
|
||||
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
|
||||
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const ids = listSelectedTelegramAccountIds(cfg);
|
||||
debugAccounts("listTelegramAccountIds", ids);
|
||||
return ids;
|
||||
}
|
||||
@@ -95,26 +78,15 @@ export function resetMissingDefaultWarnFlag(): void {
|
||||
}
|
||||
|
||||
export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
|
||||
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||
if (boundDefault) {
|
||||
return boundDefault;
|
||||
}
|
||||
const ids = listTelegramAccountIds(cfg);
|
||||
const resolved = resolveListedDefaultAccountId({
|
||||
accountIds: ids,
|
||||
configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount),
|
||||
});
|
||||
if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) {
|
||||
return resolved;
|
||||
}
|
||||
if (ids.length > 1 && !emittedMissingDefaultWarn) {
|
||||
const selection = resolveDefaultTelegramAccountSelection(cfg);
|
||||
if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) {
|
||||
emittedMissingDefaultWarn = true;
|
||||
getLog().warn(
|
||||
`channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` +
|
||||
`channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` +
|
||||
`${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
return selection.accountId;
|
||||
}
|
||||
|
||||
export function resolveTelegramAccountConfig(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing-paths";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDefaultTelegramAccountId } from "./accounts.js";
|
||||
import { resolveDefaultTelegramAccountId } from "./account-selection.js";
|
||||
|
||||
function fileExists(pathValue: string): boolean {
|
||||
try {
|
||||
|
||||
6
extensions/whatsapp/legacy-session-surface-api.ts
Normal file
6
extensions/whatsapp/legacy-session-surface-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js";
|
||||
|
||||
export const whatsappLegacySessionSurface = {
|
||||
isLegacyGroupSessionKey,
|
||||
canonicalizeLegacySessionKey,
|
||||
};
|
||||
1
extensions/whatsapp/legacy-state-migrations-api.ts
Normal file
1
extensions/whatsapp/legacy-state-migrations-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js";
|
||||
@@ -25,6 +25,10 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"setupFeatures": {
|
||||
"legacyStateMigrations": true,
|
||||
"legacySessionSurfaces": true
|
||||
},
|
||||
"channel": {
|
||||
"id": "whatsapp",
|
||||
"label": "WhatsApp",
|
||||
|
||||
@@ -10,4 +10,12 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "whatsappSetupPlugin",
|
||||
},
|
||||
legacyStateMigrations: {
|
||||
specifier: "./legacy-state-migrations-api.js",
|
||||
exportName: "detectWhatsAppLegacyStateMigrations",
|
||||
},
|
||||
legacySessionSurface: {
|
||||
specifier: "./legacy-session-surface-api.js",
|
||||
exportName: "whatsappLegacySessionSurface",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
function extractLegacyWhatsAppGroupId(key: string): string | null {
|
||||
const trimmed = key.trim();
|
||||
|
||||
@@ -640,6 +640,10 @@
|
||||
"types": "./dist/plugin-sdk/channel-pairing.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-pairing.js"
|
||||
},
|
||||
"./plugin-sdk/channel-pairing-paths": {
|
||||
"types": "./dist/plugin-sdk/channel-pairing-paths.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-pairing-paths.js"
|
||||
},
|
||||
"./plugin-sdk/channel-policy": {
|
||||
"types": "./dist/plugin-sdk/channel-policy.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-policy.js"
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"channel-mention-gating",
|
||||
"channel-lifecycle",
|
||||
"channel-pairing",
|
||||
"channel-pairing-paths",
|
||||
"channel-policy",
|
||||
"channel-send-result",
|
||||
"channel-targets",
|
||||
|
||||
@@ -385,6 +385,121 @@ describe("bundled channel entry shape guards", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("loads setup-entry feature plugins without loading the main channel entry", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-only-"));
|
||||
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
const pluginDir = path.join(root, "dist", "extensions", "alpha");
|
||||
const testGlobal = globalThis as typeof globalThis & {
|
||||
__bundledSetupOnlyMainLoaded?: boolean;
|
||||
__bundledSetupOnlySetupLoaded?: number;
|
||||
__bundledSetupOnlyPluginLoaded?: boolean;
|
||||
};
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.js"),
|
||||
[
|
||||
"globalThis.__bundledSetupOnlyMainLoaded = true;",
|
||||
"throw new Error('main entry loaded');",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "setup-entry.js"),
|
||||
[
|
||||
"globalThis.__bundledSetupOnlySetupLoaded = (globalThis.__bundledSetupOnlySetupLoaded ?? 0) + 1;",
|
||||
"export default {",
|
||||
" kind: 'bundled-channel-setup-entry',",
|
||||
" features: { legacyStateMigrations: true },",
|
||||
" loadSetupPlugin() {",
|
||||
" globalThis.__bundledSetupOnlyPluginLoaded = true;",
|
||||
" throw new Error('setup plugin loaded');",
|
||||
" },",
|
||||
" loadLegacyStateMigrationDetector() {",
|
||||
" return ({ oauthDir }) => [{",
|
||||
" kind: 'copy',",
|
||||
" label: 'Alpha state',",
|
||||
" sourcePath: oauthDir + '/legacy.json',",
|
||||
" targetPath: oauthDir + '/alpha/legacy.json',",
|
||||
" }];",
|
||||
" },",
|
||||
"};",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({
|
||||
listBundledChannelPluginMetadata: () => [
|
||||
{
|
||||
dirName: "alpha",
|
||||
manifest: {
|
||||
id: "alpha",
|
||||
channels: ["alpha"],
|
||||
},
|
||||
source: {
|
||||
source: "./index.js",
|
||||
built: "./index.js",
|
||||
},
|
||||
setupSource: {
|
||||
source: "./setup-entry.js",
|
||||
built: "./setup-entry.js",
|
||||
},
|
||||
},
|
||||
],
|
||||
resolveBundledChannelGeneratedPath: (
|
||||
rootDir: string,
|
||||
entry: { built?: string; source?: string },
|
||||
pluginDirName?: string,
|
||||
) =>
|
||||
path.join(
|
||||
rootDir,
|
||||
"dist",
|
||||
"extensions",
|
||||
pluginDirName ?? "alpha",
|
||||
(entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""),
|
||||
),
|
||||
}));
|
||||
|
||||
try {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions");
|
||||
|
||||
const bundled = await importFreshModule<typeof import("./bundled.js")>(
|
||||
import.meta.url,
|
||||
"./bundled.js?scope=bundled-setup-only-feature",
|
||||
);
|
||||
|
||||
const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors();
|
||||
expect(
|
||||
detectors.map((detector) =>
|
||||
detector({ cfg: {}, env: {}, stateDir: "/state", oauthDir: "/oauth" } as never),
|
||||
),
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
kind: "copy",
|
||||
label: "Alpha state",
|
||||
sourcePath: "/oauth/legacy.json",
|
||||
targetPath: "/oauth/alpha/legacy.json",
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(testGlobal.__bundledSetupOnlySetupLoaded).toBe(1);
|
||||
expect(testGlobal.__bundledSetupOnlyMainLoaded).toBeUndefined();
|
||||
expect(testGlobal.__bundledSetupOnlyPluginLoaded).toBeUndefined();
|
||||
} finally {
|
||||
if (previousBundledPluginsDir === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
delete testGlobal.__bundledSetupOnlyMainLoaded;
|
||||
delete testGlobal.__bundledSetupOnlySetupLoaded;
|
||||
delete testGlobal.__bundledSetupOnlyPluginLoaded;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
@@ -414,6 +529,33 @@ describe("bundled channel entry shape guards", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps setup-entry legacy feature hints mirrored in package metadata", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const extensionDir of bundledPluginRoots) {
|
||||
const setupEntryPath = path.join(extensionDir, "setup-entry.ts");
|
||||
const packageJsonPath = path.join(extensionDir, "package.json");
|
||||
if (!fs.existsSync(setupEntryPath) || !fs.existsSync(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
const setupEntrySource = fs.readFileSync(setupEntryPath, "utf8");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
openclaw?: {
|
||||
setupFeatures?: Record<string, boolean>;
|
||||
};
|
||||
};
|
||||
for (const feature of ["legacyStateMigrations", "legacySessionSurfaces"]) {
|
||||
const usesFeature = setupEntrySource.includes(`${feature}: true`);
|
||||
const hasHint = packageJson.openclaw?.setupFeatures?.[feature] === true;
|
||||
if (usesFeature !== hasHint) {
|
||||
offenders.push(`${path.relative(process.cwd(), extensionDir)}:${feature}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bundled channel entrypoints free of static src imports", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type {
|
||||
BundledChannelLegacySessionSurface,
|
||||
BundledChannelLegacyStateMigrationDetector,
|
||||
} from "../../plugin-sdk/channel-entry-contract.js";
|
||||
import {
|
||||
listBundledChannelPluginMetadata,
|
||||
resolveBundledChannelGeneratedPath,
|
||||
@@ -32,6 +36,8 @@ type BundledChannelSetupEntryRuntimeContract = {
|
||||
kind: "bundled-channel-setup-entry";
|
||||
loadSetupPlugin: () => ChannelPlugin;
|
||||
loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined;
|
||||
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
|
||||
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
|
||||
features?: {
|
||||
legacyStateMigrations?: boolean;
|
||||
legacySessionSurfaces?: boolean;
|
||||
@@ -41,14 +47,15 @@ type BundledChannelSetupEntryRuntimeContract = {
|
||||
type GeneratedBundledChannelEntry = {
|
||||
id: string;
|
||||
entry: BundledChannelEntryRuntimeContract;
|
||||
setupEntry?: BundledChannelSetupEntryRuntimeContract;
|
||||
};
|
||||
|
||||
type BundledChannelCacheContext = {
|
||||
pluginLoadInProgressIds: Set<ChannelId>;
|
||||
setupPluginLoadInProgressIds: Set<ChannelId>;
|
||||
entryLoadInProgressIds: Set<ChannelId>;
|
||||
setupEntryLoadInProgressIds: Set<ChannelId>;
|
||||
lazyEntriesById: Map<ChannelId, GeneratedBundledChannelEntry | null>;
|
||||
lazySetupEntriesById: Map<ChannelId, BundledChannelSetupEntryRuntimeContract | null>;
|
||||
lazyPluginsById: Map<ChannelId, ChannelPlugin>;
|
||||
lazySetupPluginsById: Map<ChannelId, ChannelPlugin>;
|
||||
lazySecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
|
||||
@@ -102,7 +109,7 @@ function resolveChannelSetupModuleEntry(
|
||||
}
|
||||
|
||||
function hasSetupEntryFeature(
|
||||
entry: BundledChannelSetupEntryRuntimeContract | undefined,
|
||||
entry: BundledChannelSetupEntryRuntimeContract | null | undefined,
|
||||
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
|
||||
): boolean {
|
||||
return entry?.features?.[feature] === true;
|
||||
@@ -186,7 +193,6 @@ function loadGeneratedBundledChannelModule(params: {
|
||||
function loadGeneratedBundledChannelEntry(params: {
|
||||
rootScope: BundledChannelRootScope;
|
||||
metadata: BundledChannelPluginMetadata;
|
||||
includeSetup: boolean;
|
||||
}): GeneratedBundledChannelEntry | null {
|
||||
try {
|
||||
const entry = resolveChannelPluginModuleEntry(
|
||||
@@ -202,20 +208,9 @@ function loadGeneratedBundledChannelEntry(params: {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const setupEntry =
|
||||
params.includeSetup && params.metadata.setupSource
|
||||
? resolveChannelSetupModuleEntry(
|
||||
loadGeneratedBundledChannelModule({
|
||||
rootScope: params.rootScope,
|
||||
metadata: params.metadata,
|
||||
entry: params.metadata.setupSource,
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
id: params.metadata.manifest.id,
|
||||
entry,
|
||||
...(setupEntry ? { setupEntry } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = formatErrorMessage(error);
|
||||
@@ -224,6 +219,37 @@ function loadGeneratedBundledChannelEntry(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function loadGeneratedBundledChannelSetupEntry(params: {
|
||||
rootScope: BundledChannelRootScope;
|
||||
metadata: BundledChannelPluginMetadata;
|
||||
}): BundledChannelSetupEntryRuntimeContract | null {
|
||||
if (!params.metadata.setupSource) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const setupEntry = resolveChannelSetupModuleEntry(
|
||||
loadGeneratedBundledChannelModule({
|
||||
rootScope: params.rootScope,
|
||||
metadata: params.metadata,
|
||||
entry: params.metadata.setupSource,
|
||||
}),
|
||||
);
|
||||
if (!setupEntry) {
|
||||
log.warn(
|
||||
`[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return setupEntry;
|
||||
} catch (error) {
|
||||
const detail = formatErrorMessage(error);
|
||||
log.warn(
|
||||
`[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const cachedBundledChannelMetadata = new Map<string, readonly BundledChannelPluginMetadata[]>();
|
||||
const bundledChannelCacheContexts = new Map<string, BundledChannelCacheContext>();
|
||||
|
||||
@@ -232,7 +258,9 @@ function createBundledChannelCacheContext(): BundledChannelCacheContext {
|
||||
pluginLoadInProgressIds: new Set(),
|
||||
setupPluginLoadInProgressIds: new Set(),
|
||||
entryLoadInProgressIds: new Set(),
|
||||
setupEntryLoadInProgressIds: new Set(),
|
||||
lazyEntriesById: new Map(),
|
||||
lazySetupEntriesById: new Map(),
|
||||
lazyPluginsById: new Map(),
|
||||
lazySetupPluginsById: new Map(),
|
||||
lazySecretsById: new Map(),
|
||||
@@ -288,6 +316,17 @@ function listBundledChannelPluginIdsForRoot(
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function listBundledChannelPluginIdsForSetupFeature(
|
||||
rootScope: BundledChannelRootScope,
|
||||
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
|
||||
): readonly ChannelId[] {
|
||||
const hinted = listBundledChannelMetadata(rootScope)
|
||||
.filter((metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true)
|
||||
.map((metadata) => metadata.manifest.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
return hinted.length > 0 ? hinted : listBundledChannelPluginIdsForRoot(rootScope);
|
||||
}
|
||||
|
||||
export function listBundledChannelPluginIds(): readonly ChannelId[] {
|
||||
return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope());
|
||||
}
|
||||
@@ -305,13 +344,12 @@ function getLazyGeneratedBundledChannelEntryForRoot(
|
||||
id: ChannelId,
|
||||
rootScope: BundledChannelRootScope,
|
||||
cacheContext: BundledChannelCacheContext,
|
||||
params?: { includeSetup?: boolean },
|
||||
): GeneratedBundledChannelEntry | null {
|
||||
const cached = cacheContext.lazyEntriesById.get(id);
|
||||
if (cached && (!params?.includeSetup || cached.setupEntry)) {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (cached === null && !params?.includeSetup) {
|
||||
if (cached === null) {
|
||||
return null;
|
||||
}
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope);
|
||||
@@ -327,7 +365,6 @@ function getLazyGeneratedBundledChannelEntryForRoot(
|
||||
const entry = loadGeneratedBundledChannelEntry({
|
||||
rootScope,
|
||||
metadata,
|
||||
includeSetup: params?.includeSetup === true,
|
||||
});
|
||||
cacheContext.lazyEntriesById.set(id, entry);
|
||||
if (entry?.entry.id && entry.entry.id !== id) {
|
||||
@@ -339,6 +376,51 @@ function getLazyGeneratedBundledChannelEntryForRoot(
|
||||
}
|
||||
}
|
||||
|
||||
function cacheBundledChannelSetupEntry(
|
||||
metadata: BundledChannelPluginMetadata,
|
||||
cacheContext: BundledChannelCacheContext,
|
||||
entry: BundledChannelSetupEntryRuntimeContract | null,
|
||||
requestedId?: ChannelId,
|
||||
) {
|
||||
const ids = new Set<ChannelId>([
|
||||
metadata.manifest.id,
|
||||
...(metadata.manifest.channels ?? []),
|
||||
...(requestedId ? [requestedId] : []),
|
||||
]);
|
||||
for (const id of ids) {
|
||||
cacheContext.lazySetupEntriesById.set(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
function getLazyGeneratedBundledChannelSetupEntryForRoot(
|
||||
id: ChannelId,
|
||||
rootScope: BundledChannelRootScope,
|
||||
cacheContext: BundledChannelCacheContext,
|
||||
): BundledChannelSetupEntryRuntimeContract | null {
|
||||
if (cacheContext.lazySetupEntriesById.has(id)) {
|
||||
return cacheContext.lazySetupEntriesById.get(id) ?? null;
|
||||
}
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope);
|
||||
if (!metadata) {
|
||||
cacheContext.lazySetupEntriesById.set(id, null);
|
||||
return null;
|
||||
}
|
||||
if (cacheContext.setupEntryLoadInProgressIds.has(id)) {
|
||||
return null;
|
||||
}
|
||||
cacheContext.setupEntryLoadInProgressIds.add(id);
|
||||
try {
|
||||
const setupEntry = loadGeneratedBundledChannelSetupEntry({
|
||||
rootScope,
|
||||
metadata,
|
||||
});
|
||||
cacheBundledChannelSetupEntry(metadata, cacheContext, setupEntry, id);
|
||||
return setupEntry;
|
||||
} finally {
|
||||
cacheContext.setupEntryLoadInProgressIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function getBundledChannelPluginForRoot(
|
||||
id: ChannelId,
|
||||
rootScope: BundledChannelRootScope,
|
||||
@@ -414,9 +496,7 @@ function getBundledChannelSetupPluginForRoot(
|
||||
if (cacheContext.setupPluginLoadInProgressIds.has(id)) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, {
|
||||
includeSetup: true,
|
||||
})?.setupEntry;
|
||||
const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -438,9 +518,7 @@ function getBundledChannelSetupSecretsForRoot(
|
||||
if (cacheContext.lazySetupSecretsById.has(id)) {
|
||||
return cacheContext.lazySetupSecretsById.get(id) ?? undefined;
|
||||
}
|
||||
const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, {
|
||||
includeSetup: true,
|
||||
})?.setupEntry;
|
||||
const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -471,10 +549,8 @@ export function listBundledChannelSetupPluginsByFeature(
|
||||
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
|
||||
): readonly ChannelPlugin[] {
|
||||
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
|
||||
return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => {
|
||||
const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, {
|
||||
includeSetup: true,
|
||||
})?.setupEntry;
|
||||
return listBundledChannelPluginIdsForSetupFeature(rootScope, feature).flatMap((id) => {
|
||||
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext);
|
||||
if (!hasSetupEntryFeature(setupEntry, feature)) {
|
||||
return [];
|
||||
}
|
||||
@@ -483,6 +559,52 @@ export function listBundledChannelSetupPluginsByFeature(
|
||||
});
|
||||
}
|
||||
|
||||
export function listBundledChannelLegacySessionSurfaces(): readonly BundledChannelLegacySessionSurface[] {
|
||||
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
|
||||
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces").flatMap(
|
||||
(id) => {
|
||||
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(
|
||||
id,
|
||||
rootScope,
|
||||
cacheContext,
|
||||
);
|
||||
const surface = setupEntry?.loadLegacySessionSurface?.();
|
||||
if (surface) {
|
||||
return [surface];
|
||||
}
|
||||
if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) {
|
||||
return [];
|
||||
}
|
||||
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
|
||||
return plugin?.messaging ? [plugin.messaging] : [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listBundledChannelLegacyStateMigrationDetectors(): readonly BundledChannelLegacyStateMigrationDetector[] {
|
||||
const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope();
|
||||
return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations").flatMap(
|
||||
(id) => {
|
||||
const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(
|
||||
id,
|
||||
rootScope,
|
||||
cacheContext,
|
||||
);
|
||||
const detector = setupEntry?.loadLegacyStateMigrationDetector?.();
|
||||
if (detector) {
|
||||
return [detector];
|
||||
}
|
||||
if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) {
|
||||
return [];
|
||||
}
|
||||
const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext);
|
||||
return plugin?.lifecycle?.detectLegacyStateMigrations
|
||||
? [plugin.lifecycle.detectLegacyStateMigrations]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBundledChannelEntryFeature(
|
||||
id: ChannelId,
|
||||
feature: keyof NonNullable<BundledChannelEntryRuntimeContract["features"]>,
|
||||
|
||||
@@ -2,7 +2,10 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { listBundledChannelSetupPluginsByFeature } from "../channels/plugins/bundled.js";
|
||||
import {
|
||||
listBundledChannelLegacySessionSurfaces,
|
||||
listBundledChannelLegacyStateMigrationDetectors,
|
||||
} from "../channels/plugins/bundled.js";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
|
||||
import {
|
||||
resolveLegacyStateDirs,
|
||||
@@ -86,12 +89,7 @@ function getLegacySessionSurfaces(): LegacySessionSurface[] {
|
||||
// Legacy migrations run on cold doctor/startup paths. Prefer the narrower
|
||||
// setup plugin surface here so session-key cleanup does not materialize full
|
||||
// bundled channel runtimes.
|
||||
cachedLegacySessionSurfaces ??= listBundledChannelSetupPluginsByFeature(
|
||||
"legacySessionSurfaces",
|
||||
).flatMap((plugin) => {
|
||||
const surface = plugin.messaging;
|
||||
return surface && typeof surface === "object" ? [surface] : [];
|
||||
});
|
||||
cachedLegacySessionSurfaces ??= [...listBundledChannelLegacySessionSurfaces()];
|
||||
return cachedLegacySessionSurfaces;
|
||||
}
|
||||
|
||||
@@ -670,10 +668,11 @@ async function collectChannelLegacyStateMigrationPlans(params: {
|
||||
oauthDir: string;
|
||||
}): Promise<ChannelLegacyStateMigrationPlan[]> {
|
||||
const plans: ChannelLegacyStateMigrationPlan[] = [];
|
||||
// Legacy state detection belongs on the lightweight setup surface so doctor
|
||||
// Legacy state detection belongs on a narrow setup-entry surface so doctor
|
||||
// does not cold-load unrelated runtime channel code.
|
||||
for (const plugin of listBundledChannelSetupPluginsByFeature("legacyStateMigrations")) {
|
||||
const detected = await plugin.lifecycle?.detectLegacyStateMigrations?.({
|
||||
const detectors = listBundledChannelLegacyStateMigrationDetectors();
|
||||
for (const detectLegacyStateMigrations of detectors) {
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
stateDir: params.stateDir,
|
||||
|
||||
@@ -4,7 +4,9 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
import type { ChannelConfigSchema } from "../channels/plugins/types.config.js";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import {
|
||||
getCachedPluginJitiLoader,
|
||||
@@ -47,6 +49,8 @@ type DefineBundledChannelSetupEntryOptions = {
|
||||
plugin: BundledEntryModuleRef;
|
||||
secrets?: BundledEntryModuleRef;
|
||||
runtime?: BundledEntryModuleRef;
|
||||
legacyStateMigrations?: BundledEntryModuleRef;
|
||||
legacySessionSurface?: BundledEntryModuleRef;
|
||||
features?: BundledChannelSetupEntryFeatures;
|
||||
};
|
||||
|
||||
@@ -59,6 +63,25 @@ export type BundledChannelEntryFeatures = {
|
||||
accountInspect?: boolean;
|
||||
};
|
||||
|
||||
export type BundledChannelLegacySessionSurface = {
|
||||
isLegacyGroupSessionKey?: (key: string) => boolean;
|
||||
canonicalizeLegacySessionKey?: (params: {
|
||||
key: string;
|
||||
agentId: string;
|
||||
}) => string | null | undefined;
|
||||
};
|
||||
|
||||
export type BundledChannelLegacyStateMigrationDetector = (params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir: string;
|
||||
oauthDir: string;
|
||||
}) =>
|
||||
| ChannelLegacyStateMigrationPlan[]
|
||||
| Promise<ChannelLegacyStateMigrationPlan[] | null | undefined>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type BundledChannelEntryContract<TPlugin = ChannelPlugin> = {
|
||||
kind: "bundled-channel-entry";
|
||||
id: string;
|
||||
@@ -77,6 +100,8 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
|
||||
kind: "bundled-channel-setup-entry";
|
||||
loadSetupPlugin: () => TPlugin;
|
||||
loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined;
|
||||
loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector;
|
||||
loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface;
|
||||
setChannelRuntime?: (runtime: PluginRuntime) => void;
|
||||
features?: BundledChannelSetupEntryFeatures;
|
||||
};
|
||||
@@ -404,6 +429,8 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
|
||||
plugin,
|
||||
secrets,
|
||||
runtime,
|
||||
legacyStateMigrations,
|
||||
legacySessionSurface,
|
||||
features,
|
||||
}: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract<TPlugin> {
|
||||
// Bundled setup entries stay on a light path during setup-only/setup-runtime loads.
|
||||
@@ -418,6 +445,20 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
|
||||
setter(pluginRuntime);
|
||||
}
|
||||
: undefined;
|
||||
const loadLegacyStateMigrationDetector = legacyStateMigrations
|
||||
? () =>
|
||||
loadBundledEntryExportSync<BundledChannelLegacyStateMigrationDetector>(
|
||||
importMetaUrl,
|
||||
legacyStateMigrations,
|
||||
)
|
||||
: undefined;
|
||||
const loadLegacySessionSurface = legacySessionSurface
|
||||
? () =>
|
||||
loadBundledEntryExportSync<BundledChannelLegacySessionSurface>(
|
||||
importMetaUrl,
|
||||
legacySessionSurface,
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
kind: "bundled-channel-setup-entry",
|
||||
loadSetupPlugin: () => loadBundledEntryExportSync<TPlugin>(importMetaUrl, plugin),
|
||||
@@ -430,6 +471,8 @@ export function defineBundledChannelSetupEntry<TPlugin = ChannelPlugin>({
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(loadLegacyStateMigrationDetector ? { loadLegacyStateMigrationDetector } : {}),
|
||||
...(loadLegacySessionSurface ? { loadLegacySessionSurface } : {}),
|
||||
...(setChannelRuntime ? { setChannelRuntime } : {}),
|
||||
...(features ? { features } : {}),
|
||||
};
|
||||
|
||||
1
src/plugin-sdk/channel-pairing-paths.ts
Normal file
1
src/plugin-sdk/channel-pairing-paths.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { resolveChannelAllowFromPath } from "../pairing/allow-from-store-read.js";
|
||||
@@ -809,9 +809,15 @@ export type OpenClawPackageStartup = {
|
||||
deferConfiguredChannelFullLoadUntilAfterListen?: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawPackageSetupFeatures = {
|
||||
legacyStateMigrations?: boolean;
|
||||
legacySessionSurfaces?: boolean;
|
||||
};
|
||||
|
||||
export type OpenClawPackageManifest = {
|
||||
extensions?: string[];
|
||||
setupEntry?: string;
|
||||
setupFeatures?: OpenClawPackageSetupFeatures;
|
||||
channel?: PluginPackageChannel;
|
||||
install?: PluginPackageInstall;
|
||||
startup?: OpenClawPackageStartup;
|
||||
|
||||
Reference in New Issue
Block a user