fix: remove bundled channel startup reentry

This commit is contained in:
Peter Steinberger
2026-04-04 15:38:41 +01:00
parent b9201e8333
commit 4dbc66b1ed
22 changed files with 160 additions and 38 deletions

View File

@@ -1,2 +1,2 @@
f9276d60f9fed5813979adf2c91a9145a3fb4101c17ae214439cd47fc1cbd2ae plugin-sdk-api-baseline.json
ee74172a0a8685cec1847095589ada102fac7877e92afe6b19206f88805fa62d plugin-sdk-api-baseline.jsonl
8c38d3e2d0a61c02db70070e0d032b54b1de474000e1c1221332efc495e8681d plugin-sdk-api-baseline.json
d057310712f83b27f64b53dbe45ef2e92795407e56503a255de0f29d915c1ee4 plugin-sdk-api-baseline.jsonl

View File

@@ -2,7 +2,7 @@ import {
createPatchedAccountSetupAdapter,
createSetupInputPresenceValidator,
DEFAULT_ACCOUNT_ID,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
const channel = "googlechat" as const;

View File

@@ -11,12 +11,12 @@ import {
setSetupChannelEnabled,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import type {
ChannelSetupAdapter,
ChannelSetupWizard,
ChannelSetupWizardTextInput,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import {
listIMessageAccountIds,

View File

@@ -10,8 +10,7 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/core";
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
import { chunkText, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
buildBaseChannelStatusSummary,

View File

@@ -1,4 +1,4 @@
import { createActionGate } from "openclaw/plugin-sdk/agent-runtime";
import { createActionGate } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelToolSend } from "openclaw/plugin-sdk/tool-send";

View File

@@ -1,3 +1,4 @@
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import {
createAllowlistSetupWizardProxy,
createAccountScopedAllowFromSection,
@@ -6,18 +7,17 @@ import {
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
createEnvPatchedAccountSetupAdapter,
hasConfiguredSecretInput,
type OpenClawConfig,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
setSetupChannelEnabled,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import {
type ChannelSetupAdapter,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type ChannelSetupWizardAllowFromEntry,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { inspectSlackAccount } from "./account-inspect.js";
import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js";

View File

@@ -7,11 +7,11 @@ import {
parseMentionOrPrefixedId,
promptLegacyChannelAllowFromForAccount,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import type {
ChannelSetupWizard,
ChannelSetupWizardAllowFromEntry,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import {
resolveDefaultSlackAccountId,

View File

@@ -4,11 +4,9 @@ import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
formatDocsLink,
hasConfiguredSecretInput,
patchChannelConfigForAccount,
} from "openclaw/plugin-sdk/setup";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
import { patchChannelConfigForAccount } from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { inspectSlackAccount } from "./account-inspect.js";
import {
listSlackAccountIds,

View File

@@ -4,6 +4,7 @@
* Implements the ChannelPlugin interface following the LINE pattern.
*/
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
import {
createHybridChannelConfigAdapter,
@@ -19,7 +20,6 @@ import {
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/core";
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { synologyChatApprovalAuth } from "./approval-auth.js";
import { sendMessage, sendFileUrl } from "./client.js";

View File

@@ -7,8 +7,8 @@ import {
splitSetupEntries,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup-runtime";
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
import { lookupTelegramChatId } from "./api-fetch.js";

View File

@@ -1,4 +1,4 @@
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
import { chunkText } from "openclaw/plugin-sdk/reply-chunking";
import { createWhatsAppOutboundBase } from "./outbound-base.js";
import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
import { getWhatsAppRuntime } from "./runtime.js";

View File

@@ -11,7 +11,7 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
createDelegatedSetupWizardProxy,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
} from "openclaw/plugin-sdk/setup-runtime";
import {
listWhatsAppAccountIds,
resolveDefaultWhatsAppAccountId,

View File

@@ -1,4 +1,4 @@
import { createPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup";
import { createPatchedAccountSetupAdapter } from "openclaw/plugin-sdk/setup-runtime";
const channel = "zalouser" as const;

View File

@@ -10,13 +10,16 @@ const state = vi.hoisted(() => ({
loadSessionStoreMock: vi.fn(),
resolveStorePathMock: vi.fn(),
updateSessionStoreMock: vi.fn(),
piEmbeddedModuleImported: false,
}));
vi.mock("./pi-embedded.js", () => ({
abortEmbeddedPiRun: (...args: unknown[]) => state.abortEmbeddedPiRunMock(...args),
}));
vi.mock("./pi-embedded.js", () => {
state.piEmbeddedModuleImported = true;
return {};
});
vi.mock("./pi-embedded-runner/runs.js", () => ({
abortEmbeddedPiRun: (...args: unknown[]) => state.abortEmbeddedPiRunMock(...args),
requestEmbeddedRunModelSwitch: (...args: unknown[]) =>
state.requestEmbeddedRunModelSwitchMock(...args),
consumeEmbeddedRunModelSwitch: (...args: unknown[]) =>
@@ -56,6 +59,7 @@ describe("live model switch", () => {
state.abortEmbeddedPiRunMock.mockReset().mockReturnValue(false);
state.requestEmbeddedRunModelSwitchMock.mockReset();
state.consumeEmbeddedRunModelSwitchMock.mockReset();
state.piEmbeddedModuleImported = false;
state.resolveDefaultModelForAgentMock
.mockReset()
.mockReturnValue({ provider: "anthropic", model: "claude-opus-4-6" });
@@ -247,6 +251,12 @@ describe("live model switch", () => {
});
});
it("does not import the broad pi-embedded barrel on module load", async () => {
await loadModule();
expect(state.piEmbeddedModuleImported).toBe(false);
});
it("treats auth-profile-source changes as no-op when no auth profile is selected", async () => {
const { hasDifferentLiveSessionModelSelection } = await loadModule();

View File

@@ -4,11 +4,11 @@ import type { SessionEntry } from "../config/sessions/types.js";
import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
import { resolveDefaultModelForAgent, resolvePersistedModelRef } from "./model-selection.js";
import {
abortEmbeddedPiRun,
consumeEmbeddedRunModelSwitch,
requestEmbeddedRunModelSwitch,
type EmbeddedRunModelSwitchRequest,
} from "./pi-embedded-runner/runs.js";
import { abortEmbeddedPiRun } from "./pi-embedded.js";
export { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
export type LiveSessionModelSelection = EmbeddedRunModelSwitchRequest;

View File

@@ -279,7 +279,6 @@ function getBundledChannelState(): BundledChannelState {
if (cachedBundledChannelState) {
return cachedBundledChannelState;
}
const entries = loadGeneratedBundledChannelEntries();
const plugins = entries.map(({ entry }) => entry.channelPlugin);
const setupPlugins = entries.flatMap(({ setupEntry }) => {

View File

@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../../test/helpers/import-fresh.ts";
import { clearSetupPromotionRuntimeModuleCache } from "./setup-helpers.js";
afterEach(() => {
vi.doUnmock("../../plugins/discovery.js");
clearSetupPromotionRuntimeModuleCache();
});
describe("setup helper import safety", () => {
it("does not load contract-surface discovery on module import", async () => {
const state = {
discoveryLoaded: false,
};
vi.doMock("../../plugins/discovery.js", () => {
state.discoveryLoaded = true;
throw new Error("contract surface discovery should stay lazy on import");
});
const helpers = await importFreshModule<typeof import("./setup-helpers.js")>(
import.meta.url,
"./setup-helpers.js?scope=import-safety",
);
expect(state.discoveryLoaded).toBe(false);
expect(
helpers.createPatchedAccountSetupAdapter({
channelKey: "demo-setup",
buildPatch: () => ({}),
}),
).toBeDefined();
});
});

View File

@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import {
applySetupAccountConfigPatch,
clearSetupPromotionRuntimeModuleCache,
createEnvPatchedAccountSetupAdapter,
createPatchedAccountSetupAdapter,
moveSingleAccountChannelSectionToDefaultAccount,
@@ -13,6 +14,10 @@ function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
afterEach(() => {
clearSetupPromotionRuntimeModuleCache();
});
describe("applySetupAccountConfigPatch", () => {
it("patches top-level config for default account and enables channel", () => {
const next = applySetupAccountConfigPatch({

View File

@@ -1,10 +1,53 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { z, type ZodType } from "zod";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { getBundledChannelContractSurfaceEntries } from "./contract-surfaces.js";
import type { ChannelSetupAdapter } from "./types.adapters.js";
import type { ChannelSetupInput } from "./types.core.js";
const SETUP_PROMOTION_SURFACE_MODULE_BASENAMES = [
"contract-surfaces.ts",
"contract-surfaces.js",
] as const;
type SetupPromotionRuntimeModule = Pick<
typeof import("./contract-surfaces.js"),
"getBundledChannelContractSurfaceEntries"
>;
let cachedSetupPromotionRuntimeModule: SetupPromotionRuntimeModule | null = null;
export function clearSetupPromotionRuntimeModuleCache(): void {
cachedSetupPromotionRuntimeModule = null;
}
function resolveSetupPromotionRuntimeModulePath(): string {
for (const basename of SETUP_PROMOTION_SURFACE_MODULE_BASENAMES) {
const candidatePath = fileURLToPath(new URL(basename, import.meta.url));
const resolvedPath = candidatePath.replace(
`${path.sep}dist-runtime${path.sep}`,
`${path.sep}dist${path.sep}`,
);
if (fs.existsSync(resolvedPath)) {
return resolvedPath;
}
if (fs.existsSync(candidatePath)) {
return candidatePath;
}
}
throw new Error("missing setup promotion runtime module");
}
function loadSetupPromotionRuntimeModule(): SetupPromotionRuntimeModule {
cachedSetupPromotionRuntimeModule ??= createJiti(import.meta.url)(
resolveSetupPromotionRuntimeModulePath(),
) as SetupPromotionRuntimeModule;
return cachedSetupPromotionRuntimeModule;
}
type ChannelSectionBase = {
name?: string;
defaultAccount?: string;
@@ -415,9 +458,9 @@ type ChannelSetupPromotionSurface = {
};
function getChannelSetupPromotionSurface(channelKey: string): ChannelSetupPromotionSurface | null {
const entry = getBundledChannelContractSurfaceEntries().find(
(candidate) => candidate.pluginId === channelKey,
);
const entry = loadSetupPromotionRuntimeModule()
.getBundledChannelContractSurfaceEntries()
.find((candidate) => candidate.pluginId === channelKey);
if (!entry || !entry.surface || typeof entry.surface !== "object") {
return null;
}
@@ -473,14 +516,22 @@ export function resolveSingleAccountPromotionTarget(params: {
channelKey: string;
channel: ChannelSectionBase;
}): string {
const accounts = params.channel.accounts ?? {};
const resolveExistingAccountId = (targetAccountId: string): string => {
const normalizedTargetAccountId = normalizeAccountId(targetAccountId);
const matchedAccountId = Object.keys(accounts).find(
(accountId) => normalizeAccountId(accountId) === normalizedTargetAccountId,
);
return matchedAccountId ?? normalizedTargetAccountId;
};
const surface = getChannelSetupPromotionSurface(params.channelKey);
const resolved = surface?.resolveSingleAccountPromotionTarget?.({
channel: params.channel,
});
if (typeof resolved === "string" && resolved.trim()) {
return normalizeAccountId(resolved);
return resolveExistingAccountId(resolved);
}
return DEFAULT_ACCOUNT_ID;
return resolveExistingAccountId(DEFAULT_ACCOUNT_ID);
}
function cloneIfObject<T>(value: T): T {

View File

@@ -1,7 +1,22 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { importFreshModule } from "../../../test/helpers/import-fresh.js";
import { mapFailoverReasonToProbeStatus } from "./list.probe.js";
describe("mapFailoverReasonToProbeStatus", () => {
it("does not import the embedded runner on module load", async () => {
vi.doMock("../../agents/pi-embedded.js", () => {
throw new Error("pi-embedded should stay lazy for probe imports");
});
try {
await importFreshModule<typeof import("./list.probe.js")>(
import.meta.url,
`./list.probe.js?scope=${Math.random().toString(36).slice(2)}`,
);
} finally {
vi.doUnmock("../../agents/pi-embedded.js");
}
});
it("maps auth_permanent to auth", () => {
expect(mapFailoverReasonToProbeStatus("auth_permanent")).toBe("auth");
});

View File

@@ -19,7 +19,6 @@ import {
normalizeProviderId,
parseModelRef,
} from "../../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
@@ -33,6 +32,13 @@ import { DEFAULT_PROVIDER, formatMs } from "./shared.js";
const PROBE_PROMPT = "Reply with OK. Do not use tools.";
let embeddedRunnerModulePromise: Promise<typeof import("../../agents/pi-embedded.js")> | undefined;
function loadEmbeddedRunnerModule() {
embeddedRunnerModulePromise ??= import("../../agents/pi-embedded.js");
return embeddedRunnerModulePromise;
}
export type AuthProbeStatus =
| "ok"
| "auth"
@@ -450,6 +456,7 @@ async function probeTarget(params: {
latencyMs: Date.now() - start,
});
try {
const { runEmbeddedPiAgent } = await loadEmbeddedRunnerModule();
await runEmbeddedPiAgent({
sessionId,
sessionFile,

View File

@@ -23,15 +23,19 @@ export {
createLegacyCompatChannelDmPolicy,
createStandardChannelSetupStatus,
mergeAllowFromEntries,
noteChannelLookupFailure,
noteChannelLookupSummary,
parseSetupEntriesAllowingWildcard,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
promptResolvedAllowFrom,
promptLegacyChannelAllowFromForAccount,
promptParsedAllowFromForAccount,
resolveEntriesWithOptionalToken,
resolveSetupAccountId,
setAccountAllowFromForChannel,
setSetupChannelEnabled,
splitSetupEntries,
} from "../channels/plugins/setup-wizard-helpers.js";
export { createAllowlistSetupWizardProxy } from "../channels/plugins/setup-wizard-proxy.js";