Compare commits

...

4 Commits

Author SHA1 Message Date
Ayaan Zaidi
c9f9661960 fix(provider-setup): harden telegram setup flow 2026-05-19 10:03:27 +05:30
Ayaan Zaidi
7154ad89c5 feat(telegram): add providers setup command 2026-05-19 08:28:19 +05:30
Ayaan Zaidi
a8d2b54fd3 feat(provider-setup): expose setup runtime sdk 2026-05-19 08:28:19 +05:30
Ayaan Zaidi
b89c57a1ba feat(provider-setup): add provider setup runtime 2026-05-19 08:27:56 +05:30
15 changed files with 1692 additions and 79 deletions

View File

@@ -31,6 +31,7 @@ import {
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime";
import { submitProviderSetupTextInput } from "openclaw/plugin-sdk/provider-setup-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
@@ -1028,6 +1029,50 @@ export const registerTelegramHandlers = ({
return { replyMedia, replyChain };
};
const handleProviderSetupTextReply = async (msg: Message, isGroup: boolean): Promise<boolean> => {
const text = typeof msg.text === "string" ? msg.text.trim() : "";
const senderId = msg.from?.id != null ? String(msg.from.id) : undefined;
if (!text || text.startsWith("/") || isGroup) {
return false;
}
const providerSetup = await submitProviderSetupTextInput({
channel: "telegram",
accountId,
conversationId: `telegram:${msg.chat.id}`,
...(senderId ? { senderId } : {}),
text,
});
if (!providerSetup.handled) {
return false;
}
if (providerSetup.deleteInputMessage === true) {
await withTelegramApiErrorLogging({
operation: "deleteMessage",
runtime,
fn: () => bot.api.deleteMessage(msg.chat.id, msg.message_id),
}).catch(() => {});
}
const reply = providerSetup.reply;
if (reply?.text) {
const telegramData = reply.channelData?.telegram as
| { buttons?: Parameters<typeof buildInlineKeyboard>[0] }
| undefined;
const buttons = telegramData?.buttons;
const keyboard = Array.isArray(buttons) ? buildInlineKeyboard(buttons) : undefined;
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(
msg.chat.id,
reply.text ?? "",
keyboard ? { reply_markup: keyboard } : undefined,
),
}).catch(() => {});
}
return true;
};
const processMessageWithReplyChain = async (
ctx: TelegramContext,
msg: Message,
@@ -2703,6 +2748,9 @@ export const registerTelegramHandlers = ({
}).sessionEntry?.sessionStartedAt,
);
if (await handleProviderSetupTextReply(event.msg, event.isGroup)) {
return;
}
recordMessageForReplyChain(event.msg, resolvedThreadId ?? dmThreadId);
await processInboundMessage({
ctx: event.ctx,

View File

@@ -262,6 +262,7 @@
"provider-auth-api-key",
"provider-auth-result",
"provider-auth-login",
"provider-setup-runtime",
"provider-selection-runtime",
"plugin-entry",
"provider-catalog-runtime",

View File

@@ -3,5 +3,6 @@
"qa-channel-protocol",
"qa-lab",
"qa-runtime",
"provider-setup-runtime",
"test-utils"
]

View File

@@ -904,6 +904,16 @@ export function buildBuiltinChatCommands(
acceptsArgs: true,
category: "options",
}),
defineChatCommand({
key: "providers",
nativeName: "providers",
description: "Manage model provider authentication.",
textAlias: "/providers",
tier: "standard",
argsParsing: "none",
acceptsArgs: true,
category: "management",
}),
defineChatCommand({
key: "queue",
nativeName: "queue",

View File

@@ -21,6 +21,7 @@ import { handleMcpCommand } from "./commands-mcp.js";
import { handleModelsCommand } from "./commands-models.js";
import { handlePluginCommand } from "./commands-plugin.js";
import { handlePluginsCommand } from "./commands-plugins.js";
import { handleProvidersCommand } from "./commands-providers.js";
import {
handleAbortTrigger,
handleActivationCommand,
@@ -69,6 +70,7 @@ export function loadCommandHandlers(): CommandHandler[] {
handleAcpCommand,
handleMcpCommand,
handlePluginsCommand,
handleProvidersCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,

View File

@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { handleProvidersCommand } from "./commands-providers.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
const handleProviderSetupCommandMock = vi.hoisted(() =>
vi.fn(async () => ({ text: "provider reply" })),
);
vi.mock("../../provider-setup/runtime.js", () => ({
handleProviderSetupCommand: handleProviderSetupCommandMock,
}));
const cfg = {
channels: {
telegram: {
allowFrom: ["owner"],
configWrites: true,
},
},
} satisfies OpenClawConfig;
function buildTelegramCommandParams(commandBody: string) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "telegram",
Surface: "telegram",
AccountId: "primary",
});
params.command = {
...params.command,
channel: "telegram",
commandBodyNormalized: commandBody,
rawBodyNormalized: commandBody,
senderId: "owner",
senderIsOwner: true,
isAuthorizedSender: true,
to: "telegram:owner-dm",
};
params.isGroup = false;
return params;
}
describe("handleProvidersCommand", () => {
beforeEach(() => {
handleProviderSetupCommandMock.mockClear();
});
it("allows Telegram callback payloads when text commands are disabled", async () => {
const result = await handleProvidersCommand(
buildTelegramCommandParams("/providers c missing-callback"),
false,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("provider reply");
});
it("does not allow typed provider subcommands when text commands are disabled", async () => {
const result = await handleProvidersCommand(
buildTelegramCommandParams("/providers start"),
false,
);
expect(result).toBeNull();
});
it("does not allow typed provider dashboard when text commands are disabled", async () => {
const result = await handleProvidersCommand(buildTelegramCommandParams("/providers"), false);
expect(result).toBeNull();
});
it("uses the originating Telegram chat instead of native slash targets", async () => {
const params = buildTelegramCommandParams("/providers start");
params.ctx.OriginatingTo = "telegram:owner-dm";
params.command.to = "slash:owner";
await handleProvidersCommand(params, true);
expect(handleProviderSetupCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: "telegram:owner-dm",
}),
);
});
});

View File

@@ -0,0 +1,30 @@
import { handleProviderSetupCommand } from "../../provider-setup/runtime.js";
import type { CommandHandler } from "./commands-types.js";
export const handleProvidersCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands && !params.command.commandBodyNormalized.startsWith("/providers c ")) {
return null;
}
if (
params.command.commandBodyNormalized !== "/providers" &&
!params.command.commandBodyNormalized.startsWith("/providers ")
) {
return null;
}
const reply = await handleProviderSetupCommand({
cfg: params.cfg,
commandBody: params.command.commandBodyNormalized,
channel: params.command.channel,
...(params.ctx.AccountId ? { accountId: params.ctx.AccountId } : {}),
...((params.ctx.OriginatingTo ?? params.command.to ?? params.command.from)
? { conversationId: params.ctx.OriginatingTo ?? params.command.to ?? params.command.from }
: {}),
...(params.command.senderId ? { senderId: params.command.senderId } : {}),
senderIsOwner: params.command.senderIsOwner,
isAuthorizedSender: params.command.isAuthorizedSender,
isGroup: params.isGroup,
...(params.agentId ? { agentId: params.agentId } : {}),
workspaceDir: params.workspaceDir,
});
return reply ? { reply, shouldContinue: false } : null;
};

View File

@@ -60,14 +60,14 @@ export async function loadValidConfigOrThrow(): Promise<OpenClawConfig> {
}
export async function updateConfig(
mutator: (cfg: OpenClawConfig) => OpenClawConfig,
mutator: (cfg: OpenClawConfig) => OpenClawConfig | Promise<OpenClawConfig>,
): Promise<OpenClawConfig> {
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n");
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
}
const next = mutator(structuredClone(snapshot.sourceConfig ?? snapshot.config));
const next = await mutator(structuredClone(snapshot.sourceConfig ?? snapshot.config));
await replaceConfigFile({
nextConfig: next,
baseHash: snapshot.hash,

View File

@@ -0,0 +1,7 @@
export {
handleProviderSetupCommand,
submitProviderSetupTextInput,
__testing as providerSetupRuntimeTesting,
type ProviderSetupCommandParams,
type ProviderSetupTextInputResult,
} from "../provider-setup/runtime.js";

View File

@@ -15,6 +15,13 @@ import {
} from "../shared/string-coerce.js";
import type { ProviderAuthMethod, ProviderPlugin } from "./types.js";
type ConfigPatchDiff =
| { changed: false }
| {
changed: true;
value: unknown;
};
export function resolveProviderMatch(
providers: ProviderPlugin[],
rawProvider?: string,
@@ -96,6 +103,80 @@ function mergeConfigPatch<T>(base: T, patch: unknown): T {
return next as T;
}
function diffConfigPatchValue(base: unknown, next: unknown): ConfigPatchDiff {
if (Object.is(base, next)) {
return { changed: false };
}
if (Array.isArray(base) && Array.isArray(next)) {
if (
base.length === next.length &&
next.every((value, index) => !diffConfigPatchValue(base[index], value).changed)
) {
return { changed: false };
}
return { changed: true, value: sanitizeConfigPatchValue(next) };
}
if (!isPlainRecord(base) || !isPlainRecord(next)) {
return { changed: true, value: sanitizeConfigPatchValue(next) };
}
const patch: Record<string, unknown> = {};
for (const key of Object.keys(next)) {
if (BLOCKED_MERGE_KEYS.has(key)) {
continue;
}
const diff = diffConfigPatchValue(base[key], next[key]);
if (diff.changed) {
patch[key] = diff.value;
}
}
return Object.keys(patch).length > 0 ? { changed: true, value: patch } : { changed: false };
}
function withDefaultModelsReplacementPatch(params: {
patch: Partial<OpenClawConfig> | undefined;
nextConfig: OpenClawConfig;
replaceDefaultModels?: boolean;
}): Partial<OpenClawConfig> | undefined {
if (!params.replaceDefaultModels) {
return params.patch;
}
const replacementModels = params.nextConfig.agents?.defaults?.models;
if (!replacementModels) {
return params.patch;
}
const patch = params.patch ?? {};
const patchAgents = isPlainRecord(patch.agents) ? patch.agents : {};
const patchDefaults = isPlainRecord(patchAgents.defaults) ? patchAgents.defaults : {};
return {
...patch,
agents: {
...patchAgents,
defaults: {
...patchDefaults,
models: sanitizeConfigPatchValue(replacementModels) as NonNullable<
NonNullable<OpenClawConfig["agents"]>["defaults"]
>["models"],
},
},
};
}
export function buildMinimalProviderAuthConfigPatch(params: {
baseConfig: OpenClawConfig;
nextConfig: OpenClawConfig;
replaceDefaultModels?: boolean;
}): Partial<OpenClawConfig> | undefined {
const diff = diffConfigPatchValue(params.baseConfig, params.nextConfig);
const patch =
diff.changed && isPlainRecord(diff.value) ? (diff.value as Partial<OpenClawConfig>) : undefined;
return withDefaultModelsReplacementPatch({
patch,
nextConfig: params.nextConfig,
replaceDefaultModels: params.replaceDefaultModels,
});
}
function normalizeAgentModelConfigForWrite(value: unknown): unknown {
if (typeof value === "string") {
return normalizeAgentModelRefForConfig(value);
@@ -287,6 +368,38 @@ export function applyProviderAuthConfigPatch(
});
}
export function restoreConfiguredPrimaryModel(
nextConfig: OpenClawConfig,
originalConfig: OpenClawConfig,
): OpenClawConfig {
const originalModel = originalConfig.agents?.defaults?.model;
const nextAgents = nextConfig.agents;
const nextDefaults = nextAgents?.defaults;
if (!nextDefaults) {
return nextConfig;
}
if (originalModel !== undefined) {
return {
...nextConfig,
agents: {
...nextAgents,
defaults: {
...nextDefaults,
model: originalModel,
},
},
};
}
const { model: _model, ...restDefaults } = nextDefaults;
return {
...nextConfig,
agents: {
...nextAgents,
defaults: restDefaults,
},
};
}
/**
* Restore `agents.defaults.model` after a provider auth config merge when the user did not pass
* `--set-default`, so `applyConfig` patches cannot replace the primary without an explicit opt-in.

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createNonExitingRuntime } from "../runtime.js";
import type { ProviderPlugin } from "./types.js";
import type { ProviderAuthMethod, ProviderPlugin } from "./types.js";
const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() => vi.fn());
vi.mock("../commands/codex-runtime-plugin-install.js", () => ({
@@ -15,7 +15,8 @@ vi.mock("../wizard/setup.post-install-migration.js", () => ({
offerPostInstallMigrations,
}));
const { testing, applyAuthChoicePluginProvider } = await import("./provider-auth-choice.js");
const { testing, applyAuthChoicePluginProvider, runProviderPluginAuthMethod } =
await import("./provider-auth-choice.js");
function buildProvider(): ProviderPlugin {
return {
@@ -134,4 +135,102 @@ describe("applyAuthChoicePluginProvider", () => {
},
});
});
it("applies deferred auth config patches without replaying unchanged config", async () => {
const baseConfig = {
channels: {
telegram: {
allowFrom: ["old-owner"],
},
},
} satisfies OpenClawConfig;
const method = {
id: "api-key",
label: "API key",
kind: "api_key",
run: vi.fn(async ({ config }: { config: OpenClawConfig }) => ({
profiles: [],
configPatch: {
...config,
models: {
...config.models,
providers: {
...config.models?.providers,
openai: { models: [] },
},
},
},
})),
} satisfies ProviderAuthMethod;
const result = await runProviderPluginAuthMethod({
config: baseConfig,
runtime: createNonExitingRuntime(),
prompter: createWizardPrompter(),
method,
});
const nextConfig = result.applyToConfig({
...baseConfig,
channels: {
telegram: {
allowFrom: ["new-owner"],
},
},
});
expect(nextConfig.channels?.telegram?.allowFrom).toEqual(["new-owner"]);
expect(nextConfig.models?.providers?.openai).toEqual({ models: [] });
});
it("preserves replaceDefaultModels removals in deferred auth config patches", async () => {
const method = {
id: "local",
label: "Local",
kind: "custom",
run: vi.fn(async () => ({
profiles: [],
configPatch: {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6": {},
},
},
},
},
replaceDefaultModels: true,
})),
} satisfies ProviderAuthMethod;
const result = await runProviderPluginAuthMethod({
config: {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6": {},
"claude-cli/claude-sonnet-4-6": {},
},
},
},
},
runtime: createNonExitingRuntime(),
prompter: createWizardPrompter(),
method,
});
const nextConfig = result.applyToConfig({
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6": {},
"claude-cli/claude-sonnet-4-6": {},
"openai/gpt-5.5": {},
},
},
},
});
expect(nextConfig.agents?.defaults?.models).toEqual({
"anthropic/claude-sonnet-4-6": {},
});
});
});

View File

@@ -16,8 +16,10 @@ import { enablePluginInConfig } from "./enable.js";
import {
applyProviderAuthConfigPatch,
applyDefaultModel,
buildMinimalProviderAuthConfigPatch,
pickAuthMethod,
resolveProviderMatch,
restoreConfiguredPrimaryModel,
} from "./provider-auth-choice-helpers.js";
import {
resolveManifestProviderAuthChoice,
@@ -30,6 +32,9 @@ import { isRemoteEnvironment, openUrl } from "./setup-browser.js";
import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
type ProviderAuthMethodResult = Awaited<ReturnType<ProviderAuthMethod["run"]>>;
type ProviderAuthConfigUpdate = (cfg: OpenClawConfig) => OpenClawConfig;
type ProviderAuthProfileConfig = Parameters<typeof applyAuthProfileConfig>[1];
export type ApplyProviderAuthChoiceParams = {
authChoice: string;
@@ -65,38 +70,6 @@ function formatModelRefForDisplay(modelRef: string, provider: ProviderPlugin): s
return formatLiteralProviderPrefixedModelRef(provider.id, modelRef);
}
function restoreConfiguredPrimaryModel(
nextConfig: OpenClawConfig,
originalConfig: OpenClawConfig,
): OpenClawConfig {
const originalModel = originalConfig.agents?.defaults?.model;
const nextAgents = nextConfig.agents;
const nextDefaults = nextAgents?.defaults;
if (!nextDefaults) {
return nextConfig;
}
if (originalModel !== undefined) {
return {
...nextConfig,
agents: {
...nextAgents,
defaults: {
...nextDefaults,
model: originalModel,
},
},
};
}
const { model: _model, ...restDefaults } = nextDefaults;
return {
...nextConfig,
agents: {
...nextAgents,
defaults: restDefaults,
},
};
}
function resolveConfiguredDefaultModelPrimary(cfg: OpenClawConfig): string | undefined {
const model = cfg.agents?.defaults?.model;
if (typeof model === "string") {
@@ -256,7 +229,12 @@ export async function runProviderPluginAuthMethod(params: {
secretInputMode?: ProviderAuthOptionBag["secretInputMode"];
allowSecretRefPrompt?: boolean;
opts?: Partial<ProviderAuthOptionBag>;
}): Promise<{ config: OpenClawConfig; defaultModel?: string }> {
openUrl?: (url: string) => Promise<void>;
}): Promise<{
config: OpenClawConfig;
defaultModel?: string;
applyToConfig: ProviderAuthConfigUpdate;
}> {
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
const agentDir = params.agentDir ?? resolveAgentDir(params.config, agentId);
const workspaceDir =
@@ -275,40 +253,25 @@ export async function runProviderPluginAuthMethod(params: {
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
isRemote: isRemoteEnvironment(),
openUrl: async (url) => {
await openUrl(url);
},
openUrl:
params.openUrl ??
(async (url) => {
await openUrl(url);
}),
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
});
let nextConfig = params.config;
if (result.configPatch) {
nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch, {
replaceDefaultModels: result.replaceDefaultModels,
});
}
for (const profile of result.profiles) {
await upsertAuthProfileWithLockOrThrow({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
...("displayName" in profile.credential && profile.credential.displayName
? { displayName: profile.credential.displayName }
: {}),
});
}
const applyToConfig = buildProviderAuthConfigUpdate(result, params.config);
const nextConfig = applyToConfig(params.config);
if (params.emitNotes !== false && result.notes && result.notes.length > 0) {
await params.prompter.note(result.notes.join("\n"), "Provider notes");
@@ -321,6 +284,45 @@ export async function runProviderPluginAuthMethod(params: {
return {
config: nextConfig,
...(defaultModel ? { defaultModel } : {}),
applyToConfig,
};
}
function buildProviderAuthConfigUpdate(
result: ProviderAuthMethodResult,
baseConfig: OpenClawConfig,
): ProviderAuthConfigUpdate {
const profileConfigs: ProviderAuthProfileConfig[] = result.profiles.map((profile) => ({
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
...("displayName" in profile.credential && profile.credential.displayName
? { displayName: profile.credential.displayName }
: {}),
}));
const configPatch = result.configPatch
? buildMinimalProviderAuthConfigPatch({
baseConfig,
nextConfig: applyProviderAuthConfigPatch(baseConfig, result.configPatch, {
replaceDefaultModels: result.replaceDefaultModels,
}),
replaceDefaultModels: result.replaceDefaultModels,
})
: undefined;
return (cfg) => {
let nextConfig = cfg;
if (configPatch) {
nextConfig = applyProviderAuthConfigPatch(nextConfig, configPatch, {
replaceDefaultModels: result.replaceDefaultModels,
});
}
for (const profileConfig of profileConfigs) {
nextConfig = applyAuthProfileConfig(nextConfig, profileConfig);
}
return nextConfig;
};
}

View File

@@ -0,0 +1,537 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { WizardPrompter } from "../wizard/prompts.js";
type AuthMockParams = {
config: OpenClawConfig;
prompter: WizardPrompter;
openUrl: (url: string) => Promise<void>;
};
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn());
const resolvePluginSetupRegistryMock = vi.hoisted(() => vi.fn());
const runProviderPluginAuthMethodMock = vi.hoisted(() => vi.fn());
const runProviderModelSelectedHookMock = vi.hoisted(() => vi.fn());
const updateConfigMock = vi.hoisted(() => vi.fn());
vi.mock("../plugins/providers.runtime.js", () => ({
resolvePluginProviders: resolvePluginProvidersMock,
}));
vi.mock("../plugins/setup-registry.js", () => ({
resolvePluginSetupRegistry: resolvePluginSetupRegistryMock,
}));
vi.mock("../plugins/provider-auth-choice.js", () => ({
runProviderPluginAuthMethod: runProviderPluginAuthMethodMock,
}));
vi.mock("../plugins/provider-wizard.js", () => ({
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
}));
vi.mock("../commands/models/shared.js", () => ({
updateConfig: updateConfigMock,
}));
const apiKeyMethod: ProviderAuthMethod = {
id: "api-key",
label: "API key",
kind: "api_key",
run: async () => ({ profiles: [] }),
};
const oauthMethod: ProviderAuthMethod = {
id: "oauth",
label: "OAuth",
kind: "oauth",
run: async () => ({ profiles: [] }),
};
function buildProvider(auth: ProviderAuthMethod[] = [apiKeyMethod]): ProviderPlugin {
return {
id: "test-provider",
pluginId: "test-provider",
label: "Test Provider",
auth,
};
}
function buildConfig(configWrites = true): OpenClawConfig {
return {
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
},
},
channels: {
telegram: {
allowFrom: ["owner"],
configWrites,
},
},
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com/v1",
models: [],
},
},
},
};
}
function commandParams(params: {
commandBody: string;
cfg?: OpenClawConfig;
senderIsOwner?: boolean;
isAuthorizedSender?: boolean;
isGroup?: boolean;
}) {
return {
cfg: params.cfg ?? buildConfig(),
commandBody: params.commandBody,
channel: "telegram",
accountId: "primary",
conversationId: "telegram:owner-dm",
senderId: "owner",
senderIsOwner: params.senderIsOwner ?? true,
isAuthorizedSender: params.isAuthorizedSender ?? true,
isGroup: params.isGroup ?? false,
workspaceDir: "/tmp/openclaw",
};
}
function telegramButtons(reply: ReplyPayload) {
const telegramData = reply.channelData?.telegram as
| { buttons?: Array<Array<{ text: string; callback_data?: string; url?: string }>> }
| undefined;
return telegramData?.buttons ?? [];
}
function callbackData(reply: ReplyPayload, row: number, column = 0): string {
const value = telegramButtons(reply)[row]?.[column]?.callback_data;
expect(value).toBeDefined();
expect(value?.length).toBeLessThanOrEqual(64);
return value ?? "";
}
function firstUrl(reply: ReplyPayload): string | undefined {
return telegramButtons(reply)
.flat()
.find((button) => button.url)?.url;
}
describe("Telegram provider setup runtime", () => {
let provider: ProviderPlugin;
let sourceConfig: OpenClawConfig;
let savedConfig: OpenClawConfig | undefined;
let capturedSecret: string | undefined;
beforeEach(async () => {
vi.clearAllMocks();
const runtime = await import("./runtime.js");
runtime.__testing.clearProviderSetupSessions();
provider = buildProvider();
sourceConfig = buildConfig();
savedConfig = undefined;
capturedSecret = undefined;
resolvePluginProvidersMock.mockReturnValue([provider]);
resolvePluginSetupRegistryMock.mockReturnValue({
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
});
runProviderPluginAuthMethodMock.mockImplementation(async (params: AuthMockParams) => {
capturedSecret = await params.prompter.text({
message: "Paste the API key",
sensitive: true,
validate: (value) => (value.startsWith("sk-") ? undefined : "Invalid API key"),
});
const applyToConfig = (cfg: OpenClawConfig): OpenClawConfig => ({
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
model: { primary: "test-provider/model-a" },
},
},
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
"test-provider": {
baseUrl: "https://api.test-provider.example/v1",
models: [],
},
},
},
});
return {
config: applyToConfig(params.config),
applyToConfig,
defaultModel: "test-provider/model-a",
};
});
runProviderModelSelectedHookMock.mockResolvedValue(undefined);
updateConfigMock.mockImplementation(
async (mutator: (cfg: OpenClawConfig) => OpenClawConfig | Promise<OpenClawConfig>) => {
savedConfig = await mutator(structuredClone(sourceConfig));
return savedConfig;
},
);
});
it("preserves config changes made while the setup session is open", async () => {
const { handleProviderSetupCommand, submitProviderSetupTextInput } =
await import("./runtime.js");
const sessionConfig = structuredClone(sourceConfig);
const chooseProvider = await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sessionConfig }),
);
const confirmAuth = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sessionConfig }),
);
await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sessionConfig }),
);
const afterSecret = await submitProviderSetupTextInput({
channel: "telegram",
accountId: "primary",
conversationId: "telegram:owner-dm",
senderId: "owner",
text: "sk-test",
});
sourceConfig = {
...sourceConfig,
agents: {
...sourceConfig.agents,
defaults: {
...sourceConfig.agents?.defaults,
models: {
"anthropic/claude-sonnet-4-6": {},
},
},
},
};
await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(afterSecret.reply!, 0), cfg: sessionConfig }),
);
expect(savedConfig?.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]).toEqual({});
expect(savedConfig?.agents?.defaults?.models?.["test-provider/model-a"]).toEqual({});
expect(savedConfig?.models?.providers?.["test-provider"]?.baseUrl).toBe(
"https://api.test-provider.example/v1",
);
expect(savedConfig?.plugins?.entries?.["test-provider"]?.enabled).toBe(true);
});
it("renders an owner-only private dashboard", async () => {
const { handleProviderSetupCommand } = await import("./runtime.js");
const reply = await handleProviderSetupCommand(commandParams({ commandBody: "/providers" }));
expect(reply?.text).toContain("Model providers");
expect(reply?.text).toContain("Default: anthropic/claude-sonnet-4-6");
expect(reply?.text).toContain("Configured: anthropic");
expect(callbackData(reply!, 0)).toMatch(/^\/providers c [a-f0-9]{16}$/u);
});
it("expires unused dashboard callbacks", async () => {
vi.useFakeTimers();
try {
const { __testing, handleProviderSetupCommand } = await import("./runtime.js");
await handleProviderSetupCommand(commandParams({ commandBody: "/providers" }));
expect(__testing.providerSetupCallbackCount()).toBe(2);
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
await handleProviderSetupCommand(commandParams({ commandBody: "/providers" }));
expect(__testing.providerSetupCallbackCount()).toBe(2);
} finally {
vi.useRealTimers();
}
});
it("expires abandoned text prompts before consuming later DMs", async () => {
vi.useFakeTimers();
try {
const { __testing, handleProviderSetupCommand, submitProviderSetupTextInput } =
await import("./runtime.js");
const chooseProvider = await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
);
const confirmAuth = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
);
const secretPrompt = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
);
expect(secretPrompt?.text).toContain("Paste the API key");
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
const laterDm = await submitProviderSetupTextInput({
channel: "telegram",
accountId: "primary",
conversationId: "telegram:owner-dm",
senderId: "owner",
text: "normal later dm",
});
expect(laterDm).toEqual({ handled: false });
expect(capturedSecret).toBeUndefined();
expect(__testing.providerSetupSessionCount()).toBe(0);
expect(__testing.providerSetupCallbackCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("refuses unauthorized senders, groups, and configWrites-disabled mutation", async () => {
const { handleProviderSetupCommand } = await import("./runtime.js");
const unauthorized = await handleProviderSetupCommand(
commandParams({
commandBody: "/providers",
senderIsOwner: false,
isAuthorizedSender: false,
}),
);
expect(unauthorized?.text).toBe("Provider setup is only available to the Telegram owner.");
const group = await handleProviderSetupCommand(
commandParams({
commandBody: "/providers start",
isGroup: true,
}),
);
expect(group?.text).toBe("Provider setup is only available in a private Telegram DM.");
const readOnly = await handleProviderSetupCommand(
commandParams({
commandBody: "/providers start",
cfg: buildConfig(false),
}),
);
expect(readOnly?.text).toContain("read-only");
expect(runProviderPluginAuthMethodMock).not.toHaveBeenCalled();
});
it("does not run setup auth for provider plugins blocked by config policy", async () => {
const { handleProviderSetupCommand } = await import("./runtime.js");
resolvePluginProvidersMock.mockReturnValue([]);
resolvePluginSetupRegistryMock.mockReturnValue({
providers: [{ pluginId: "test-provider", provider }],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
});
const reply = await handleProviderSetupCommand(
commandParams({
commandBody: "/providers start",
cfg: {
...sourceConfig,
plugins: { allow: ["other-provider"] },
},
}),
);
expect(reply?.text).toContain("No setup-capable model providers are available.");
expect(runProviderPluginAuthMethodMock).not.toHaveBeenCalled();
});
it("keeps text prompts open after provider validation errors", async () => {
const { __testing, handleProviderSetupCommand, submitProviderSetupTextInput } =
await import("./runtime.js");
const chooseProvider = await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
);
const confirmAuth = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
);
await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
);
const invalid = await submitProviderSetupTextInput({
channel: "telegram",
accountId: "primary",
conversationId: "telegram:owner-dm",
senderId: "owner",
text: "not-a-key",
});
expect(invalid.handled).toBe(true);
expect(invalid.deleteInputMessage).toBe(true);
expect(invalid.reply?.text).toContain("Invalid API key");
expect(invalid.reply?.text).toContain("Paste the API key");
expect(capturedSecret).toBeUndefined();
expect(__testing.providerSetupSessionCount()).toBe(1);
});
it("runs API-key setup through private text input and saves after confirmation", async () => {
const { __testing, handleProviderSetupCommand, submitProviderSetupTextInput } =
await import("./runtime.js");
runProviderModelSelectedHookMock.mockImplementationOnce(
async ({ config }: { config: OpenClawConfig }) => {
const providerConfig = config.models?.providers?.["test-provider"];
if (providerConfig) {
providerConfig.baseUrl = "https://hooked.test-provider.example/v1";
}
},
);
const chooseProvider = await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
);
expect(chooseProvider?.text).toBe("Choose provider");
const providerCallback = callbackData(chooseProvider!, 0);
expect(providerCallback).toMatch(/^\/providers c [a-f0-9]{16}$/u);
const confirmAuth = await handleProviderSetupCommand(
commandParams({ commandBody: providerCallback, cfg: sourceConfig }),
);
expect(confirmAuth?.text).toContain("Continue with Test Provider API key?");
const staleDeclineCallback = callbackData(confirmAuth!, 0, 1);
const secretPrompt = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
);
expect(secretPrompt?.text).toContain("Paste the API key");
expect(secretPrompt?.text).toContain("delete the message");
const staleDecline = await handleProviderSetupCommand(
commandParams({ commandBody: staleDeclineCallback, cfg: sourceConfig }),
);
expect(staleDecline?.text).toContain("expired");
const afterSecret = await submitProviderSetupTextInput({
channel: "telegram",
accountId: "primary",
conversationId: "telegram:owner-dm",
senderId: "owner",
text: "sk-test",
});
expect(afterSecret.handled).toBe(true);
expect(afterSecret.deleteInputMessage).toBe(true);
expect(capturedSecret).toBe("sk-test");
expect(afterSecret.reply?.text).toContain("Set test-provider/model-a as the default model?");
const saved = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(afterSecret.reply!, 0), cfg: sourceConfig }),
);
expect(saved?.text).toContain("Test Provider is ready.");
expect(savedConfig?.agents?.defaults?.model).toEqual({ primary: "test-provider/model-a" });
expect(savedConfig?.models?.providers?.["test-provider"]?.baseUrl).toBe(
"https://hooked.test-provider.example/v1",
);
expect(savedConfig?.plugins?.entries?.["test-provider"]?.enabled).toBe(true);
expect(runProviderModelSelectedHookMock).toHaveBeenCalledWith(
expect.objectContaining({
config: savedConfig,
model: "test-provider/model-a",
workspaceDir: "/tmp/openclaw",
}),
);
expect(
telegramButtons(saved!)
.flat()
.map((button) => button.text),
).not.toContain("Cancel");
const complete = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(saved!, 0), cfg: sourceConfig }),
);
expect(complete?.text).toBe("Provider setup complete.");
expect(__testing.providerSetupSessionCount()).toBe(0);
});
it("keeps the current default model when setup declines the provider default", async () => {
const { handleProviderSetupCommand, submitProviderSetupTextInput } =
await import("./runtime.js");
const chooseProvider = await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
);
const confirmAuth = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
);
const secretPrompt = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
);
expect(secretPrompt?.text).toContain("Paste the API key");
const afterSecret = await submitProviderSetupTextInput({
channel: "telegram",
accountId: "primary",
conversationId: "telegram:owner-dm",
senderId: "owner",
text: "sk-test",
});
const saved = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(afterSecret.reply!, 0, 1), cfg: sourceConfig }),
);
expect(saved?.text).toContain("Test Provider is ready.");
expect(savedConfig?.agents?.defaults?.model).toBe("anthropic/claude-sonnet-4-6");
expect(savedConfig?.plugins?.entries?.["test-provider"]?.enabled).toBe(true);
});
it("renders OAuth URLs as URL buttons before continuation", async () => {
const { handleProviderSetupCommand } = await import("./runtime.js");
provider = buildProvider([oauthMethod]);
resolvePluginProvidersMock.mockReturnValue([provider]);
runProviderPluginAuthMethodMock.mockImplementation(async (params: AuthMockParams) => {
await params.openUrl("https://login.example.test/device");
return {
config: params.config,
defaultModel: "test-provider/model-a",
};
});
const chooseProvider = await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
);
const confirmAuth = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(chooseProvider!, 0), cfg: sourceConfig }),
);
const login = await handleProviderSetupCommand(
commandParams({ commandBody: callbackData(confirmAuth!, 0), cfg: sourceConfig }),
);
expect(login?.text).toContain("https://login.example.test/device");
expect(firstUrl(login!)).toBe("https://login.example.test/device");
});
it("expires old callbacks when a concurrent setup starts", async () => {
const { __testing, handleProviderSetupCommand } = await import("./runtime.js");
const first = await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
);
const firstCallback = callbackData(first!, 0);
await handleProviderSetupCommand(
commandParams({ commandBody: "/providers start", cfg: sourceConfig }),
);
const expired = await handleProviderSetupCommand(
commandParams({ commandBody: firstCallback, cfg: sourceConfig }),
);
expect(expired?.text).toContain("expired");
expect(__testing.providerSetupSessionCount()).toBe(1);
});
});

View File

@@ -0,0 +1,674 @@
import { randomUUID } from "node:crypto";
import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
import { updateConfig } from "../commands/models/shared.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import {
applyDefaultModel,
pickAuthMethod,
restoreConfiguredPrimaryModel,
} from "../plugins/provider-auth-choice-helpers.js";
import { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js";
import { runProviderModelSelectedHook } from "../plugins/provider-wizard.js";
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
import { resolvePluginSetupRegistry } from "../plugins/setup-registry.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import { defaultRuntime } from "../runtime.js";
import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js";
import { WizardSession, type WizardStep } from "../wizard/session.js";
type ProviderSetupBinding = {
channel: string;
accountId?: string;
conversationId: string;
senderId?: string;
};
type ProviderSetupSessionRecord = {
session: WizardSession;
binding: ProviderSetupBinding;
callbackIds: Set<string>;
expiresAt: number;
lastStep?: WizardStep;
};
type ProviderSetupCallback =
| { type: "answer"; sessionId: string; value: unknown }
| { type: "cancel"; sessionId: string }
| { type: "dashboard" }
| { type: "start" };
type StoredProviderSetupCallback = {
callback: ProviderSetupCallback;
expiresAt: number;
};
export type ProviderSetupCommandParams = {
cfg: OpenClawConfig;
commandBody: string;
channel: string;
accountId?: string;
conversationId?: string;
senderId?: string;
senderIsOwner: boolean;
isAuthorizedSender: boolean;
isGroup: boolean;
agentId?: string;
workspaceDir: string;
};
export type ProviderSetupTextInputResult = {
handled: boolean;
reply?: ReplyPayload;
deleteInputMessage?: boolean;
};
const PROVIDER_SETUP_COMMAND = "/providers";
const PROVIDER_SETUP_INTERACTION_TTL_MS = 10 * 60 * 1000;
const sessions = new Map<string, ProviderSetupSessionRecord>();
const callbacks = new Map<string, StoredProviderSetupCallback>();
function normalizeCommandTail(commandBody: string): string {
const trimmed = commandBody.trim();
if (trimmed === PROVIDER_SETUP_COMMAND) {
return "";
}
return trimmed.startsWith(`${PROVIDER_SETUP_COMMAND} `)
? trimmed.slice(PROVIDER_SETUP_COMMAND.length + 1).trim()
: "";
}
function buildCommandButton(label: string, command: string) {
return { text: label, callback_data: command };
}
function buildUrlButton(label: string, url: string) {
return { text: label, url };
}
function storeCallback(
record: ProviderSetupSessionRecord,
callback: ProviderSetupCallback,
): string {
const id = randomUUID().replaceAll("-", "").slice(0, 16);
callbacks.set(id, { callback, expiresAt: record.expiresAt });
record.callbackIds.add(id);
return `${PROVIDER_SETUP_COMMAND} c ${id}`;
}
function storeLooseCallback(callback: ProviderSetupCallback): string {
pruneExpiredProviderSetupState(Date.now());
const id = randomUUID().replaceAll("-", "").slice(0, 16);
callbacks.set(id, { callback, expiresAt: Date.now() + PROVIDER_SETUP_INTERACTION_TTL_MS });
return `${PROVIDER_SETUP_COMMAND} c ${id}`;
}
function clearSessionCallbacks(record: ProviderSetupSessionRecord) {
for (const id of record.callbackIds) {
callbacks.delete(id);
}
record.callbackIds.clear();
}
function expireSession(sessionId: string, record: ProviderSetupSessionRecord) {
record.session.cancel();
sessions.delete(sessionId);
clearSessionCallbacks(record);
}
function pruneExpiredProviderSetupState(now: number) {
for (const [id, stored] of callbacks) {
if (stored.expiresAt <= now) {
callbacks.delete(id);
}
}
for (const [sessionId, record] of sessions) {
if (record.expiresAt <= now) {
expireSession(sessionId, record);
}
}
}
function refreshSessionExpiry(record: ProviderSetupSessionRecord) {
record.expiresAt = Date.now() + PROVIDER_SETUP_INTERACTION_TTL_MS;
}
function buildSessionButton(
record: ProviderSetupSessionRecord,
label: string,
callback: ProviderSetupCallback,
) {
return buildCommandButton(label, storeCallback(record, callback));
}
function providerSetupChannelData(
buttons: Array<Array<{ text: string; callback_data?: string; url?: string }>>,
): ReplyPayload["channelData"] {
return { telegram: { buttons } };
}
function sameBinding(left: ProviderSetupBinding, right: ProviderSetupBinding): boolean {
return (
left.channel === right.channel &&
left.accountId === right.accountId &&
left.conversationId === right.conversationId &&
left.senderId === right.senderId
);
}
function resolveBinding(params: ProviderSetupCommandParams): ProviderSetupBinding | null {
if (!params.conversationId) {
return null;
}
return {
channel: params.channel,
...(params.accountId ? { accountId: params.accountId } : {}),
conversationId: params.conversationId,
...(params.senderId ? { senderId: params.senderId } : {}),
};
}
function listSetupProviders(params: {
config: OpenClawConfig;
workspaceDir: string;
}): ProviderPlugin[] {
const providers = resolvePluginProviders({
config: params.config,
workspaceDir: params.workspaceDir,
mode: "setup",
includeUntrustedWorkspacePlugins: false,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
});
const setupProviders = resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
}).providers.map((entry) => ({ ...entry.provider, pluginId: entry.pluginId }));
const byId = new Map<string, ProviderPlugin>();
for (const provider of [...providers, ...setupProviders]) {
if (provider.auth.length > 0 && isProviderPluginAllowed(params.config, provider)) {
byId.set(provider.id, provider);
}
}
return [...byId.values()].toSorted((left, right) => left.label.localeCompare(right.label));
}
function resolveProvider(providers: readonly ProviderPlugin[], providerId: string): ProviderPlugin {
const provider = providers.find((entry) => entry.id === providerId);
if (!provider) {
throw new Error("Provider no longer available.");
}
return provider;
}
function resolveAuthMethod(provider: ProviderPlugin, methodId: string): ProviderAuthMethod {
const method = pickAuthMethod(provider, methodId);
if (!method) {
throw new Error("Auth method no longer available.");
}
return method;
}
function applyProviderPluginEnablement(cfg: OpenClawConfig, provider: ProviderPlugin) {
if (!provider.pluginId) {
return cfg;
}
const result = enablePluginInConfig(cfg, provider.pluginId);
if (!result.enabled) {
throw new Error(`${provider.label} plugin is ${result.reason ?? "disabled"}.`);
}
return result.config;
}
function isProviderPluginAllowed(cfg: OpenClawConfig, provider: ProviderPlugin): boolean {
if (!provider.pluginId) {
return true;
}
return enablePluginInConfig(cfg, provider.pluginId).enabled;
}
function makeProviderSetupRunner(params: {
cfg: OpenClawConfig;
agentId: string;
agentDir: string;
workspaceDir: string;
}) {
return async (prompter: WizardPrompter) => {
const providers = listSetupProviders({
config: params.cfg,
workspaceDir: params.workspaceDir,
});
if (providers.length === 0) {
await prompter.note("No setup-capable model providers are available.", "Providers");
return;
}
const providerId = await prompter.select({
message: "Choose provider",
options: providers.map((provider) => ({
value: provider.id,
label: provider.label,
hint: provider.auth.map((method) => method.label).join(", "),
})),
});
const provider = resolveProvider(providers, providerId);
const methodId =
provider.auth.length === 1
? provider.auth[0].id
: await prompter.select({
message: `Auth method for ${provider.label}`,
options: provider.auth.map((method) => ({
value: method.id,
label: method.label,
hint: method.hint,
})),
});
const method = resolveAuthMethod(provider, methodId);
const proceed = await prompter.confirm({
message: `Continue with ${provider.label} ${method.label}?`,
initialValue: true,
});
if (!proceed) {
throw new WizardCancelledError("cancelled");
}
const enabledConfig = applyProviderPluginEnablement(params.cfg, provider);
const applied = await runProviderPluginAuthMethod({
config: enabledConfig,
runtime: defaultRuntime,
prompter,
method,
agentDir: params.agentDir,
agentId: params.agentId,
workspaceDir: params.workspaceDir,
emitNotes: true,
allowSecretRefPrompt: false,
openUrl: async (url) => {
await prompter.note(url, "Open provider login URL");
},
});
const applyProviderAuthConfig = (cfg: OpenClawConfig) =>
applied.applyToConfig(applyProviderPluginEnablement(cfg, provider));
let applyToConfig = async (cfg: OpenClawConfig) =>
restoreConfiguredPrimaryModel(applyProviderAuthConfig(cfg), cfg);
let defaultModel: string | undefined;
if (applied.defaultModel) {
const setDefault = await prompter.confirm({
message: `Set ${applied.defaultModel} as the default model?`,
initialValue: true,
});
if (setDefault) {
const selectedDefaultModel = applied.defaultModel;
applyToConfig = async (cfg) => {
const nextConfig = applyDefaultModel(applyProviderAuthConfig(cfg), selectedDefaultModel);
await runProviderModelSelectedHook({
config: nextConfig,
model: selectedDefaultModel,
prompter,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
});
return nextConfig;
};
defaultModel = applied.defaultModel;
}
}
await updateConfig((cfg) => applyToConfig(cfg));
await prompter.note(
[`${provider.label} is ready.`, defaultModel ? `Default model: ${defaultModel}` : null]
.filter(Boolean)
.join("\n"),
"Provider saved",
);
};
}
function configuredProviderLabels(
cfg: OpenClawConfig,
providers: readonly ProviderPlugin[],
): string[] {
const configuredIds = Object.keys(cfg.models?.providers ?? {});
const labelById = new Map(providers.map((provider) => [provider.id, provider.label]));
return configuredIds.toSorted().map((id) => labelById.get(id) ?? id);
}
function renderDashboard(params: ProviderSetupCommandParams): ReplyPayload {
const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg);
const current = resolveDefaultModelForAgent({ cfg: params.cfg, agentId });
const providers = listSetupProviders({
config: params.cfg,
workspaceDir: params.workspaceDir,
});
const providerCount = providers.length;
const configured = configuredProviderLabels(params.cfg, providers);
return {
text: [
"Model providers",
"",
`Default: ${current.provider}/${current.model}`,
`Configured: ${configured.length > 0 ? configured.join(", ") : "none"}`,
`Setup-capable providers: ${providerCount}`,
"",
"Choose an action.",
].join("\n"),
channelData: providerSetupChannelData([
[buildCommandButton("Add or update provider", storeLooseCallback({ type: "start" }))],
[buildCommandButton("Refresh", storeLooseCallback({ type: "dashboard" }))],
]),
};
}
function isHttpUrl(value: string | undefined): value is string {
if (!value) {
return false;
}
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
function renderStep(
sessionId: string,
record: ProviderSetupSessionRecord,
step: WizardStep,
): ReplyPayload {
const title = step.title ? `${step.title}\n\n` : "";
if (step.type === "note" || step.type === "progress" || step.type === "action") {
const loginUrl =
step.title === "Open provider login URL" && isHttpUrl(step.message) ? step.message : null;
const terminalSuccess = step.title === "Provider saved";
const buttons = [
loginUrl ? [buildUrlButton("Open login URL", loginUrl)] : [],
[
buildSessionButton(record, terminalSuccess ? "Done" : "Continue", {
type: "answer",
sessionId,
value: "ok",
}),
],
terminalSuccess ? [] : [buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
].filter((row) => row.length > 0);
return {
text: `${title}${step.message ?? ""}`.trim() || "Continue.",
channelData: providerSetupChannelData(buttons),
};
}
if (step.type === "confirm") {
return {
text: `${title}${step.message ?? "Confirm?"}`,
channelData: providerSetupChannelData([
[
buildSessionButton(record, "Yes", { type: "answer", sessionId, value: true }),
buildSessionButton(record, "No", { type: "answer", sessionId, value: false }),
],
[buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
]),
};
}
if (step.type === "select") {
const rows =
step.options?.map((option) => [
buildSessionButton(record, option.label, {
type: "answer",
sessionId,
value: option.value,
}),
]) ?? [];
rows.push([buildSessionButton(record, "Cancel", { type: "cancel", sessionId })]);
return {
text: `${title}${step.message ?? "Choose one."}`,
channelData: providerSetupChannelData(rows),
};
}
if (step.type === "text") {
return {
text: [
`${title}${step.message ?? "Reply with the value."}`,
"",
step.sensitive
? "Reply in this DM. OpenClaw will delete the message after reading it when Telegram allows."
: "Reply in this DM.",
].join("\n"),
channelData: providerSetupChannelData([
[buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
]),
};
}
return {
text: "This step is not supported in Telegram yet.",
channelData: providerSetupChannelData([
[buildSessionButton(record, "Cancel", { type: "cancel", sessionId })],
]),
};
}
async function renderNext(
sessionId: string,
record: ProviderSetupSessionRecord,
): Promise<ReplyPayload> {
const result = await record.session.next();
if (result.done) {
sessions.delete(sessionId);
clearSessionCallbacks(record);
if (result.status === "done") {
return {
text: "Provider setup complete.",
channelData: providerSetupChannelData([
[buildCommandButton("Back to providers", "/providers")],
]),
};
}
return {
text: result.status === "cancelled" ? "Provider setup cancelled." : "Provider setup failed.",
channelData: providerSetupChannelData([
[buildCommandButton("Back to providers", "/providers")],
]),
};
}
if (!result.step) {
sessions.delete(sessionId);
clearSessionCallbacks(record);
return {
text: "Provider setup failed.",
channelData: providerSetupChannelData([
[buildCommandButton("Back to providers", "/providers")],
]),
};
}
record.lastStep = result.step;
refreshSessionExpiry(record);
return renderStep(sessionId, record, result.step);
}
async function answerSession(sessionId: string, value: unknown): Promise<ReplyPayload> {
pruneExpiredProviderSetupState(Date.now());
const record = sessions.get(sessionId);
if (!record) {
return {
text: "Provider setup expired. Start again with /providers.",
channelData: providerSetupChannelData([[buildCommandButton("Start", "/providers start")]]),
};
}
const stepId = record.lastStep?.id;
if (!stepId) {
return renderNext(sessionId, record);
}
clearSessionCallbacks(record);
await record.session.answer(stepId, value);
return renderNext(sessionId, record);
}
function cancelSession(sessionId: string): ReplyPayload {
pruneExpiredProviderSetupState(Date.now());
const record = sessions.get(sessionId);
if (record) {
record.session.cancel();
sessions.delete(sessionId);
clearSessionCallbacks(record);
}
return {
text: "Provider setup cancelled.",
channelData: providerSetupChannelData([
[buildCommandButton("Back to providers", "/providers")],
]),
};
}
function cancelExistingSessions(binding: ProviderSetupBinding) {
for (const [sessionId, record] of sessions) {
if (sameBinding(record.binding, binding)) {
record.session.cancel();
sessions.delete(sessionId);
clearSessionCallbacks(record);
}
}
}
async function startSession(params: ProviderSetupCommandParams, binding: ProviderSetupBinding) {
cancelExistingSessions(binding);
const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg);
const id = randomUUID().slice(0, 12);
const session = new WizardSession(
makeProviderSetupRunner({
cfg: params.cfg,
agentId,
agentDir: resolveAgentDir(params.cfg, agentId),
workspaceDir: params.workspaceDir || resolveDefaultAgentWorkspaceDir(),
}),
);
const record = {
session,
binding,
callbackIds: new Set<string>(),
expiresAt: Date.now() + PROVIDER_SETUP_INTERACTION_TTL_MS,
} satisfies ProviderSetupSessionRecord;
sessions.set(id, record);
return renderNext(id, record);
}
async function handleStoredCallback(
callbackId: string,
params: ProviderSetupCommandParams,
binding: ProviderSetupBinding,
): Promise<ReplyPayload> {
pruneExpiredProviderSetupState(Date.now());
const stored = callbacks.get(callbackId);
if (!stored) {
callbacks.delete(callbackId);
return {
text: "Provider setup expired. Start again with /providers.",
channelData: providerSetupChannelData([[buildCommandButton("Start", "/providers")]]),
};
}
callbacks.delete(callbackId);
const { callback } = stored;
if (callback.type === "dashboard") {
return renderDashboard(params);
}
if (callback.type === "start") {
return startSession(params, binding);
}
if (callback.type === "cancel") {
return cancelSession(callback.sessionId);
}
return answerSession(callback.sessionId, callback.value);
}
export async function handleProviderSetupCommand(
params: ProviderSetupCommandParams,
): Promise<ReplyPayload | null> {
if (!params.commandBody.trim().startsWith(PROVIDER_SETUP_COMMAND)) {
return null;
}
if (params.channel !== "telegram") {
return { text: "Use openclaw configure on the server for provider setup on this channel." };
}
if (!params.isAuthorizedSender || !params.senderIsOwner) {
return { text: "Provider setup is only available to the Telegram owner." };
}
if (params.isGroup) {
return { text: "Provider setup is only available in a private Telegram DM." };
}
if (
!resolveChannelConfigWrites({
cfg: params.cfg,
channelId: "telegram",
accountId: params.accountId,
})
) {
return {
text: [
"Provider setup is read-only because Telegram config writes are disabled.",
"",
"Enable config writes on the server, then retry /providers.",
].join("\n"),
};
}
const binding = resolveBinding(params);
if (!binding) {
return { text: "Provider setup needs a Telegram conversation context." };
}
const tail = normalizeCommandTail(params.commandBody);
const [action, sessionId, rawValue] = tail.split(/\s+/, 3);
if (!action) {
return renderDashboard(params);
}
if (action === "start") {
return startSession(params, binding);
}
if (action === "c" && sessionId) {
return handleStoredCallback(sessionId, params, binding);
}
if (action === "cancel" && sessionId) {
return cancelSession(sessionId);
}
if (action === "next" && sessionId) {
const value = rawValue === "true" ? true : rawValue === "false" ? false : rawValue;
return answerSession(sessionId, value);
}
return renderDashboard(params);
}
export async function submitProviderSetupTextInput(params: {
channel: string;
accountId?: string;
conversationId: string;
senderId?: string;
text: string;
}): Promise<ProviderSetupTextInputResult> {
pruneExpiredProviderSetupState(Date.now());
const binding: ProviderSetupBinding = {
channel: params.channel,
...(params.accountId ? { accountId: params.accountId } : {}),
conversationId: params.conversationId,
...(params.senderId ? { senderId: params.senderId } : {}),
};
const entry = [...sessions.entries()].find(([, record]) => {
return sameBinding(record.binding, binding) && record.lastStep?.type === "text";
});
if (!entry) {
return { handled: false };
}
const [sessionId, record] = entry;
const sensitive = record.lastStep?.sensitive === true;
const reply = await answerSession(sessionId, params.text);
return { handled: true, reply, deleteInputMessage: sensitive };
}
export const __testing = {
clearProviderSetupSessions() {
sessions.clear();
callbacks.clear();
},
providerSetupSessionCount() {
return sessions.size;
},
providerSetupCallbackCount() {
return callbacks.size;
},
};

View File

@@ -119,27 +119,30 @@ class WizardSessionPrompter implements WizardPrompter {
validate?: (value: string) => string | undefined;
sensitive?: boolean;
}): Promise<string> {
const res = await this.prompt({
type: "text",
message: params.message,
initialValue: params.initialValue,
placeholder: params.placeholder,
sensitive: params.sensitive,
executor: "client",
});
const value =
res === null || res === undefined
? ""
: typeof res === "string"
? res
: typeof res === "number" || typeof res === "boolean" || typeof res === "bigint"
? String(res)
: "";
const error = params.validate?.(value);
if (error) {
throw new Error(error);
let message = params.message;
for (;;) {
const res = await this.prompt({
type: "text",
message,
initialValue: params.initialValue,
placeholder: params.placeholder,
sensitive: params.sensitive,
executor: "client",
});
const value =
res === null || res === undefined
? ""
: typeof res === "string"
? res
: typeof res === "number" || typeof res === "boolean" || typeof res === "bigint"
? String(res)
: "";
const error = params.validate?.(value);
if (!error) {
return value;
}
message = `${error}\n\n${params.message}`;
}
return value;
}
async confirm(params: { message: string; initialValue?: boolean }): Promise<boolean> {