diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 20299589ac8..a1874a086bf 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -202,7 +202,7 @@ plugins. | `/reasoning [on\|off\|stream]` | Toggle reasoning visibility. Alias: `/reason` | | `/elevated [on\|off\|ask\|full]` | Toggle elevated mode. Alias: `/elev` | | `/exec host= security= ask= node=` | Show or set exec defaults | - | `/login [codex\|openai\|openai-codex]` | Pair Codex/OpenAI login in the current chat or channel. Owner/admin only | + | `/login [codex\|openai\|openai-codex]` | Pair Codex/OpenAI login from a private chat or Web UI session. Owner/admin only | | `/model [name\|#\|status]` | Show or set the model | | `/models [provider] [page] [limit=\|all]` | List configured/auth-available providers or models | | `/queue ` | Manage active-run queue behavior. See [Queue](/concepts/queue) and [Queue steering](/concepts/queue-steering) | @@ -474,6 +474,7 @@ See [BTW side questions](/tools/btw) for the full behavior. - **Native Discord commands:** `agent::discord:slash:` - **Native Slack commands:** `agent::slack:slash:` (prefix configurable via `channels.slack.slashCommand.sessionPrefix`) - **Native Telegram commands:** `telegram:slash:` (targets the chat session via `CommandTargetSessionKey`) + - **`/login codex`** sends device pairing codes only through private chat or Web UI response paths. Telegram group/topic invocations ask the owner to DM the bot instead. - **`/stop`** targets the active chat session to abort the current run. diff --git a/extensions/telegram/src/bot-native-command-deps.runtime.ts b/extensions/telegram/src/bot-native-command-deps.runtime.ts index f529dfde321..112f94bb6c8 100644 --- a/extensions/telegram/src/bot-native-command-deps.runtime.ts +++ b/extensions/telegram/src/bot-native-command-deps.runtime.ts @@ -4,7 +4,7 @@ import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime"; import type { ModelsAuthLoginFlowOptions, ModelsAuthLoginFlowResult, -} from "openclaw/plugin-sdk/provider-auth-runtime"; +} from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime"; @@ -45,7 +45,8 @@ export const defaultTelegramNativeCommandDeps: TelegramNativeCommandDeps = { return getPluginCommandSpecs; }, async runModelsAuthLoginFlow(opts) { - const { runModelsAuthLoginFlow } = await import("openclaw/plugin-sdk/provider-auth-runtime"); + const { runModelsAuthLoginFlow } = + await import("openclaw/plugin-sdk/provider-auth-login-flow-runtime"); return await runModelsAuthLoginFlow(opts); }, async editMessageTelegram(...args) { diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index fcedfd1db57..887c392aca0 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -233,6 +233,7 @@ type TelegramCommandHandler = (ctx: unknown) => Promise; type TelegramPluginCommandSpecs = ReturnType< NonNullable >; +type TelegramLoginFlow = NonNullable; function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; @@ -275,6 +276,7 @@ function registerAndResolveCommandHandlerBase(params: { telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; pluginCommandSpecs?: TelegramPluginCommandSpecs; + runModelsAuthLoginFlow?: TelegramLoginFlow; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; @@ -289,6 +291,7 @@ function registerAndResolveCommandHandlerBase(params: { telegramCfg, resolveTelegramGroupConfig, pluginCommandSpecs, + runModelsAuthLoginFlow, } = params; const commandHandlers = new Map(); const sendMessage = vi.fn().mockResolvedValue(undefined); @@ -299,6 +302,7 @@ function registerAndResolveCommandHandlerBase(params: { getPluginCommandSpecs: vi.fn(() => pluginCommandSpecs ?? []), listSkillCommandsForAgents: vi.fn(() => []), syncTelegramMenuCommands: vi.fn(), + ...(runModelsAuthLoginFlow ? { runModelsAuthLoginFlow } : {}), }; registerTelegramNativeCommands({ ...createNativeCommandTestParams({ @@ -338,6 +342,7 @@ function registerAndResolveCommandHandler(params: { telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; pluginCommandSpecs?: TelegramPluginCommandSpecs; + runModelsAuthLoginFlow?: TelegramLoginFlow; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; @@ -352,6 +357,7 @@ function registerAndResolveCommandHandler(params: { telegramCfg, resolveTelegramGroupConfig, pluginCommandSpecs, + runModelsAuthLoginFlow, } = params; return registerAndResolveCommandHandlerBase({ commandName, @@ -363,6 +369,7 @@ function registerAndResolveCommandHandler(params: { telegramCfg, resolveTelegramGroupConfig, pluginCommandSpecs, + runModelsAuthLoginFlow, }); } @@ -1489,6 +1496,47 @@ describe("registerTelegramNativeCommands — session metadata", () => { ); }); + it("passes the target session auth profile to Telegram /login codex", async () => { + sessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:main": { + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-main", + updatedAt: 1, + }, + }); + const runModelsAuthLoginFlow = vi.fn(async (opts) => { + await opts.prompter.note?.( + "URL: https://auth.openai.com/codex/device\nCode: ABCD-EFGH", + "OpenAI Codex device code", + ); + return { + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }], + }; + }); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "login", + cfg: { + commands: { native: true, ownerAllowFrom: ["200"] }, + } as OpenClawConfig, + allowFrom: ["200"], + runModelsAuthLoginFlow, + }); + + await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 })); + + expect(runModelsAuthLoginFlow).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + method: "device-code", + agent: "main", + profileId: "openai:owner@example.com", + }), + ); + }); + it("passes a resolved transcript file to plugin commands when the entry has no file", async () => { sessionMocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions/sessions.json"); sessionMocks.getSessionEntry.mockReturnValue({ diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 226fb8753b4..0af98ca90bf 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTelegramGroupCommandContext } from "./bot-native-commands.fixture-test-support.js"; import { createCommandBot, createNativeCommandTestParams, @@ -510,7 +511,7 @@ describe("registerTelegramNativeCommands", () => { const registeredCommands = await waitForRegisteredCommands(setMyCommands); expect(registeredCommands).toContainEqual({ command: "login", - description: "Pair Codex login in this chat.", + description: "Pair Codex login.", }); await handler(createPrivateCommandContext({ match: "codex", userId: 200 })); @@ -522,6 +523,42 @@ describe("registerTelegramNativeCommands", () => { expect(texts.at(-1)).toContain("Codex login complete. Try your request again now."); }); + it("rejects group /login codex without sending the device code publicly", async () => { + const loginFlow = vi.fn( + async (params: { + prompter: { note: (message: string, title?: string) => Promise }; + }) => { + await params.prompter.note("URL: https://auth.openai.com/codex/device\nCode: SECRET"); + return { + providerId: "openai", + methodId: "device-code", + profiles: [{ profileId: "openai:codex", provider: "openai", mode: "oauth" }], + }; + }, + ); + const { handler, sendMessage } = registerLoginCommand({ + cfg: { + commands: { + native: true, + ownerAllowFrom: ["200"], + }, + agents: { list: [{ id: "main", default: true }] }, + } as OpenClawConfig, + loginFlow, + allowFrom: ["200"], + }); + + await handler(createTelegramGroupCommandContext({ match: "codex", userId: 200 })); + + expect(loginFlow).not.toHaveBeenCalled(); + const texts = sendMessage.mock.calls.map((call) => String(call[1])); + expect(texts).toContain( + "For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.", + ); + expect(texts.join("\n")).not.toContain("SECRET"); + expect(texts.join("\n")).not.toContain("https://auth.openai.com/codex/device"); + }); + it("rejects /login for authorized senders who are not owners", async () => { const loginFlow = vi.fn(async () => ({ providerId: "openai", @@ -543,7 +580,7 @@ describe("registerTelegramNativeCommands", () => { expect(loginFlow).not.toHaveBeenCalled(); expect(sendMessage.mock.calls.map((call) => String(call[1]))).toContain( - "Only the OpenClaw owner can start Codex login from Telegram.", + "Only a configured OpenClaw owner can start Codex login from Telegram.", ); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 45ea3ab498e..d06e59c6ab2 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -32,7 +32,7 @@ import type { TelegramTopicConfig, } from "openclaw/plugin-sdk/config-contracts"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; -import type { ModelsAuthLoginFlowOptions } from "openclaw/plugin-sdk/provider-auth-runtime"; +import type { ModelsAuthLoginFlowOptions } from "openclaw/plugin-sdk/provider-auth-login-flow-runtime"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; @@ -170,6 +170,25 @@ function resolveTelegramCodexLoginProvider(rawProvider: string | undefined): str : null; } +function hasConfiguredTelegramLoginOwner(cfg: OpenClawConfig): boolean { + const owners = cfg.commands?.ownerAllowFrom; + return Array.isArray(owners) && owners.some((owner) => normalizeOptionalString(String(owner))); +} + +function resolveProviderScopedProfileId( + authProfileOverride: string | undefined, + provider: string, +): string | undefined { + const profileId = normalizeOptionalString(authProfileOverride); + if (!profileId) { + return undefined; + } + const providerPrefix = `${normalizeLowercaseStringOrEmpty(provider)}:`; + return normalizeLowercaseStringOrEmpty(profileId).startsWith(providerPrefix) + ? profileId + : undefined; +} + function resolveTelegramCodexLoginProviderInput(commandArgs: CommandArgs | undefined): string { const providerValue = commandArgs?.values?.provider; return typeof providerValue === "string" && providerValue.trim() @@ -1317,8 +1336,16 @@ export const registerTelegramNativeCommands = ({ fn: () => bot.api.sendMessage(chatId, text, threadParams), }); }; - if (!senderIsOwner) { - await sendLoginMessage("Only the OpenClaw owner can start Codex login from Telegram."); + if (!senderIsOwner || !hasConfiguredTelegramLoginOwner(runtimeCfg)) { + await sendLoginMessage( + "Only a configured OpenClaw owner can start Codex login from Telegram.", + ); + return; + } + if (isGroup) { + await sendLoginMessage( + "For safety, Codex login codes are only sent in a private chat with this bot. DM this bot `/login codex` to pair Codex.", + ); return; } const loginProvider = resolveTelegramCodexLoginProvider( @@ -1355,10 +1382,30 @@ export const registerTelegramNativeCommands = ({ if (!loginFlow) { throw new Error("Codex login flow is unavailable."); } + const nativeCommandRuntime = await loadTelegramNativeCommandRuntime(); + const targetSessionKey = resolveCommandTargetSessionKey({ + runtimeCfg, + route, + chatId, + isGroup, + senderId, + threadSpec, + botHasTopicsEnabled: resolveTelegramBotHasTopicsEnabled(ctx.me), + resolveThreadSessionKeys: nativeCommandRuntime.resolveThreadSessionKeys, + }); + const targetSessionEntry = nativeCommandRuntime.getSessionEntry({ + agentId: route.agentId, + sessionKey: targetSessionKey, + }); + const profileId = resolveProviderScopedProfileId( + targetSessionEntry?.authProfileOverride, + loginProvider, + ); await loginFlow({ provider: loginProvider, method: TELEGRAM_CODEX_LOGIN_METHOD, agent: route.agentId, + ...(profileId ? { profileId } : {}), config: runtimeCfg, runtime, prompter: createTelegramLoginPrompter({ diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 8194a1d07fb..d99f2551120 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -144,6 +144,10 @@ "types": "./dist/src/plugin-sdk/provider-auth-runtime.d.ts", "default": "./src/provider-auth-runtime.ts" }, + "./provider-auth-login-flow-runtime": { + "types": "./dist/src/plugin-sdk/provider-auth-login-flow-runtime.d.ts", + "default": "./src/provider-auth-login-flow-runtime.ts" + }, "./provider-env-vars": { "types": "./dist/src/plugin-sdk/provider-env-vars.d.ts", "default": "./src/provider-env-vars.ts" diff --git a/packages/plugin-sdk/src/provider-auth-login-flow-runtime.ts b/packages/plugin-sdk/src/provider-auth-login-flow-runtime.ts new file mode 100644 index 00000000000..eefc082a8d7 --- /dev/null +++ b/packages/plugin-sdk/src/provider-auth-login-flow-runtime.ts @@ -0,0 +1,3 @@ +// Public package facade for lazy provider auth login flow runtime helpers. + +export * from "../../../src/plugin-sdk/provider-auth-login-flow-runtime.js"; diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 8758855b740..26c78bd2687 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -29,6 +29,7 @@ type DefineChatCommandInput = { key: string; nativeName?: string; nativeAliases?: string[]; + nativeProviders?: string[]; description: string; args?: ChatCommandDefinition["args"]; argsParsing?: ChatCommandDefinition["argsParsing"]; @@ -58,6 +59,9 @@ export function defineChatCommand(command: DefineChatCommandInput): ChatCommandD nativeAliases: command.nativeAliases ? normalizeStringEntries(command.nativeAliases) : undefined, + nativeProviders: command.nativeProviders + ? normalizeStringEntries(command.nativeProviders) + : undefined, description: command.description, acceptsArgs, args: command.args, @@ -269,7 +273,8 @@ export function buildBuiltinChatCommands( defineChatCommand({ key: "login", nativeName: "login", - description: "Pair Codex login in this chat.", + nativeProviders: ["telegram"], + description: "Pair Codex login.", textAlias: "/login", category: "management", tier: "standard", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 3badf3713e3..c9f51df6b10 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -223,6 +223,30 @@ describe("commands registry", () => { ]); }); + it("keeps /login text-enabled while limiting native registration to Telegram", () => { + const command = requireChatCommand("login"); + expect(command.textAliases).toEqual(["/login"]); + expect(command.nativeName).toBe("login"); + expect(command.nativeProviders).toEqual(["telegram"]); + + expect(nativeNameSet(listNativeCommandSpecs()).has("login")).toBe(false); + expect( + findCommandByNativeName("login", "telegram", { + includeBundledChannelFallback: false, + })?.key, + ).toBe("login"); + expect( + findCommandByNativeName("login", "discord", { + includeBundledChannelFallback: false, + }), + ).toBeUndefined(); + expect( + findCommandByNativeName("login", "slack", { + includeBundledChannelFallback: false, + }), + ).toBeUndefined(); + }); + it("exposes /side as a BTW text and native alias", () => { const btw = requireChatCommand("btw"); expect(btw.nativeName).toBe("btw"); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 30a327831f4..785e1c9f9e8 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -98,12 +98,28 @@ function resolveNativeNames(command: ChatCommandDefinition, provider?: string): ); } +function supportsNativeProvider(command: ChatCommandDefinition, provider?: string): boolean { + if (!command.nativeProviders?.length) { + return true; + } + const normalizedProvider = normalizeOptionalLowercaseString(provider); + if (!normalizedProvider) { + return false; + } + return command.nativeProviders.some( + (candidate) => normalizeOptionalLowercaseString(candidate) === normalizedProvider, + ); +} + function listNativeSpecsFromCommands( commands: ChatCommandDefinition[], provider?: string, ): NativeCommandSpec[] { return commands - .filter((command) => command.scope !== "text" && command.nativeName) + .filter( + (command) => + command.scope !== "text" && command.nativeName && supportsNativeProvider(command, provider), + ) .flatMap((command) => { const spec = toNativeCommandSpec(command, provider); return resolveNativeNames(command, provider).map((name, index) => { @@ -159,6 +175,7 @@ export function findCommandByNativeName( return getChatCommands().find( (command) => command.scope !== "text" && + supportsNativeProvider(command, provider) && [resolveNativeName(command, provider, options), ...(command.nativeAliases ?? [])].some( (nameLocal) => normalizeOptionalLowercaseString(nameLocal) === normalized, ), diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 46692143253..3fc8f9eb1f3 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -66,6 +66,7 @@ export type ChatCommandDefinition = { key: string; nativeName?: string; nativeAliases?: string[]; + nativeProviders?: string[]; description: string; /** Localized descriptions for native command surfaces that support them. */ descriptionLocalizations?: Record; diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index ad998772cc8..077cb2b16ee 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -7102,7 +7102,7 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ Model login expired on the gateway for openai. Send `/login codex` in this chat or channel to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.", + "⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.", ); } }); @@ -7121,11 +7121,31 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ Model login expired on the gateway for openai. Send `/login codex` in this chat or channel to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.", + "⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.", ); } }); + it("keeps non-OpenAI OAuth refresh failures on provider-specific terminal guidance", async () => { + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new OAuthRefreshFailureError({ + provider: "anthropic", + message: "invalid_grant", + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe( + "⚠️ Model login expired on the gateway for anthropic. Re-auth with `openclaw models auth login --provider anthropic` in a terminal, then try again.", + ); + expect(result.payload.text).not.toContain("/login codex"); + } + }); + it("surfaces direct provider auth guidance for missing API keys", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( @@ -7315,7 +7335,7 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ Model login expired on the gateway. Send `/login codex` in this chat or channel to pair a new Codex login, or re-auth with `openclaw models auth login` in a terminal, then try again.", + "⚠️ Model login expired on the gateway. Re-auth with `openclaw models auth login` in a terminal, then try again.", ); } }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index dfeca314e2e..1a023dd0255 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -948,6 +948,18 @@ function formatForwardedExternalRunFailureText(message: string): string { return `⚠️ Agent failed before reply: ${detail}${suffix} Please try again, or use /new to start a fresh session.`; } +function supportsChannelCodexLogin(provider: string | null | undefined): boolean { + if (!provider) { + return false; + } + const normalizedProvider = provider.trim().toLowerCase().replace(/_/gu, "-"); + return ( + normalizedProvider === "openai" || + normalizedProvider === "codex" || + normalizedProvider === "openai-codex" + ); +} + function buildExternalRunFailureReply( input: ExternalRunFailureInput, options?: { includeDetails?: boolean; isHeartbeat?: boolean }, @@ -978,14 +990,21 @@ function buildExternalRunFailureReply( if (oauthRefreshFailure) { const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider); const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""; + const supportsCodexLogin = supportsChannelCodexLogin(oauthRefreshFailure.provider); + const channelLoginHint = supportsCodexLogin + ? "Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth" + : "Re-auth"; + const retryLoginHint = supportsCodexLogin + ? "send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth" + : "re-auth"; if (oauthRefreshFailure.reason) { return { - text: `⚠️ Model login expired on the gateway${providerText}. Send \`/login codex\` in this chat or channel to pair a new Codex login, or re-auth with \`${loginCommand}\` in a terminal, then try again.`, + text: `⚠️ Model login expired on the gateway${providerText}. ${channelLoginHint} with \`${loginCommand}\` in a terminal, then try again.`, isGenericRunnerFailure: false, }; } return { - text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, send \`/login codex\` in this chat or channel, or re-auth with \`${loginCommand}\` in a terminal.`, + text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, ${retryLoginHint} with \`${loginCommand}\` in a terminal.`, isGenericRunnerFailure: false, }; } diff --git a/src/auto-reply/reply/commands-login.test.ts b/src/auto-reply/reply/commands-login.test.ts index b5e59a16761..62ebe28e054 100644 --- a/src/auto-reply/reply/commands-login.test.ts +++ b/src/auto-reply/reply/commands-login.test.ts @@ -22,13 +22,14 @@ function buildLoginParams( ctx?: Partial; opts?: HandleCommandsParams["opts"]; sessionKey?: string; + sessionEntry?: HandleCommandsParams["sessionEntry"]; agentId?: string; } = {}, ): HandleCommandsParams { const params = buildCommandTestParams( commandBody, { - commands: { text: true }, + commands: { text: true, ownerAllowFrom: ["owner"] }, channels: { slack: { allowFrom: ["owner"] } }, session: { mainKey: "main" }, } as OpenClawConfig, @@ -36,8 +37,9 @@ function buildLoginParams( Provider: "slack", Surface: "slack", OriginatingChannel: "slack", - OriginatingTo: "channel:C123", + OriginatingTo: "direct:owner", AccountId: "workspace-a", + ChatType: "direct", MessageThreadId: "thread-1", ...overrides.ctx, }, @@ -54,10 +56,13 @@ function buildLoginParams( senderIsOwner: true, isAuthorizedSender: true, from: "slack:owner", - to: "channel:C123", + to: "direct:owner", ...overrides.command, }; params.opts = overrides.opts; + if (overrides.sessionEntry !== undefined) { + params.sessionEntry = overrides.sessionEntry; + } return params; } @@ -75,6 +80,10 @@ function mockSuccessfulLoginFlow(): void { }); } +function blockReplyOpts(): NonNullable { + return { onBlockReply: vi.fn(async () => {}) }; +} + describe("handleLoginCommand", () => { beforeEach(() => { vi.clearAllMocks(); @@ -84,6 +93,7 @@ describe("handleLoginCommand", () => { it("registers /login as a built-in command handler", () => { expect(buildBuiltinChatCommands().find((entry) => entry.key === "login")).toMatchObject({ nativeName: "login", + nativeProviders: ["telegram"], textAliases: ["/login"], scope: "both", }); @@ -130,12 +140,13 @@ describe("handleLoginCommand", () => { Provider: surface, Surface: surface, OriginatingChannel: surface, - OriginatingTo: `${surface}:conversation-1`, + OriginatingTo: "direct:conversation-1", + ChatType: "direct", }, command: { channel: surface, channelId: surface, - to: `${surface}:conversation-1`, + to: "direct:conversation-1", }, opts: { onBlockReply }, }), @@ -151,13 +162,91 @@ describe("handleLoginCommand", () => { }, ); - it("falls back to returning the pairing message when no block dispatcher is available", async () => { + it("rejects dispatcher-less contexts before starting device-code polling", async () => { mockSuccessfulLoginFlow(); const result = await handleLoginCommand(buildLoginParams("/login openai"), true); - expect(result?.reply?.text).toContain("ABCD-EFGH"); - expect(result?.reply?.text).toContain("Codex login complete"); + expect(result?.reply?.text).toBe( + "Codex login needs a live private response path so the code can be shown before it expires. Use the Web UI or a private chat and send `/login codex` again.", + ); + expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled(); + }); + + it("rejects grouped shared-channel login before emitting a device code", async () => { + const onBlockReply = vi.fn(async () => {}); + mockSuccessfulLoginFlow(); + const params = buildLoginParams("/login codex", { + ctx: { + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "channel:C123", + ChatType: "channel", + }, + command: { + channel: "slack", + to: "channel:C123", + }, + opts: { onBlockReply }, + }); + params.isGroup = true; + + const result = await handleLoginCommand(params, true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "Codex login codes are only sent in a private chat or Web UI session. Open a private chat with OpenClaw and send `/login codex` there.", + }, + }); + expect(onBlockReply).not.toHaveBeenCalled(); + expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled(); + }); + + it("reauths the active OpenAI profile when the session is pinned", async () => { + mockSuccessfulLoginFlow(); + + await handleLoginCommand( + buildLoginParams("/login codex", { + opts: blockReplyOpts(), + sessionEntry: { + authProfileOverride: "openai:owner@example.com", + sessionId: "sess-owner", + updatedAt: 1, + }, + }), + true, + ); + + expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + profileId: "openai:owner@example.com", + }), + ); + }); + + it("does not pass unrelated pinned profiles into OpenAI login", async () => { + mockSuccessfulLoginFlow(); + + await handleLoginCommand( + buildLoginParams("/login codex", { + opts: blockReplyOpts(), + sessionEntry: { + authProfileOverride: "anthropic:owner@example.com", + sessionId: "sess-owner", + updatedAt: 1, + }, + }), + true, + ); + + expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + profileId: expect.any(String), + }), + ); }); it("dedupes an active flow for the same channel thread and provider", async () => { @@ -174,8 +263,14 @@ describe("handleLoginCommand", () => { }), ); - const first = handleLoginCommand(buildLoginParams("/login codex"), true); - const second = await handleLoginCommand(buildLoginParams("/login codex"), true); + const first = handleLoginCommand( + buildLoginParams("/login codex", { opts: blockReplyOpts() }), + true, + ); + const second = await handleLoginCommand( + buildLoginParams("/login codex", { opts: blockReplyOpts() }), + true, + ); expect(second).toEqual({ shouldContinue: false, @@ -197,7 +292,32 @@ describe("handleLoginCommand", () => { expect(result).toEqual({ shouldContinue: false, - reply: { text: "Only an OpenClaw owner/admin can start Codex login from this channel." }, + reply: { + text: "Only a configured OpenClaw owner/admin can start Codex login from this channel.", + }, + }); + expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled(); + }); + + it("rejects allowlisted senders when no command owner is configured", async () => { + const params = buildLoginParams("/login codex", { + command: { + senderIsOwner: true, + isAuthorizedSender: true, + }, + }); + params.cfg = { + ...params.cfg, + commands: { text: true }, + } as OpenClawConfig; + + const result = await handleLoginCommand(params, true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "Only a configured OpenClaw owner/admin can start Codex login from this channel.", + }, }); expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled(); }); @@ -205,7 +325,10 @@ describe("handleLoginCommand", () => { it("normalizes Codex login aliases to the OpenAI provider", async () => { mockSuccessfulLoginFlow(); - await handleLoginCommand(buildLoginParams("/login openai-codex"), true); + await handleLoginCommand( + buildLoginParams("/login openai-codex", { opts: blockReplyOpts() }), + true, + ); expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith( expect.objectContaining({ provider: "openai" }), diff --git a/src/auto-reply/reply/commands-login.ts b/src/auto-reply/reply/commands-login.ts index 22285c60e6c..8ee634e4002 100644 --- a/src/auto-reply/reply/commands-login.ts +++ b/src/auto-reply/reply/commands-login.ts @@ -16,6 +16,9 @@ const CODEX_LOGIN_PROVIDER = "openai"; const CODEX_LOGIN_METHOD = "device-code"; const CODEX_LOGIN_FLOW_TTL_MS = 15 * 60_000; const CODEX_LOGIN_PROVIDER_ALIASES = new Set(["codex", "openai", "openai-codex"]); +const PRIVATE_CHAT_TYPES = new Set(["direct", "dm", "im", "private"]); +const PUBLIC_CHAT_TYPES = new Set(["channel", "forum", "group", "public", "supergroup", "topic"]); +const WEB_LOGIN_SURFACES = new Set(["control", "control-ui", "dashboard", "internal", "web"]); type CodexLoginFlowRecord = { expiresAt: number; @@ -42,6 +45,87 @@ function resolveCodexLoginProvider(rawProvider: string | undefined): string | nu return CODEX_LOGIN_PROVIDER_ALIASES.has(normalized) ? CODEX_LOGIN_PROVIDER : null; } +function hasConfiguredCommandOwners(params: HandleCommandsParams): boolean { + const owners = params.cfg.commands?.ownerAllowFrom; + return Array.isArray(owners) && owners.some((owner) => normalizeOptionalString(String(owner))); +} + +function hasInternalAdminScope(params: HandleCommandsParams): boolean { + return ( + Array.isArray(params.ctx.GatewayClientScopes) && + params.ctx.GatewayClientScopes.includes("operator.admin") + ); +} + +function canStartCodexLogin(params: HandleCommandsParams): boolean { + return ( + params.command.isAuthorizedSender && + params.command.senderIsOwner && + (hasConfiguredCommandOwners(params) || hasInternalAdminScope(params)) + ); +} + +function normalizeSurface(value: unknown): string { + return normalizeLowercaseStringOrEmpty(String(value ?? "")).replace(/_/gu, "-"); +} + +function hasPrivateTarget(value: unknown): boolean { + const normalized = normalizeSurface(value); + return /^(?:direct|dm|im|private|user):/u.test(normalized); +} + +function hasPublicTarget(value: unknown): boolean { + const normalized = normalizeSurface(value); + return /^(?:channel|forum|group|guild|public|room|topic):/u.test(normalized); +} + +function isPrivateLoginContext(params: HandleCommandsParams): boolean { + const surface = normalizeSurface( + params.command.channel || params.command.surface || params.ctx.Surface, + ); + if (WEB_LOGIN_SURFACES.has(surface)) { + return true; + } + if (params.isGroup) { + return false; + } + const chatType = normalizeSurface(params.ctx.ChatType); + if (PRIVATE_CHAT_TYPES.has(chatType)) { + return true; + } + if (PUBLIC_CHAT_TYPES.has(chatType)) { + return false; + } + const targets = [ + params.ctx.OriginatingTo, + params.ctx.To, + params.command.to, + params.command.from, + params.ctx.From, + ]; + if (targets.some(hasPrivateTarget)) { + return true; + } + if (targets.some(hasPublicTarget)) { + return false; + } + return false; +} + +function resolveProviderScopedProfileId( + authProfileOverride: string | undefined, + provider: string, +): string | undefined { + const profileId = normalizeOptionalString(authProfileOverride); + if (!profileId) { + return undefined; + } + const providerPrefix = `${normalizeLowercaseStringOrEmpty(provider)}:`; + return normalizeLowercaseStringOrEmpty(profileId).startsWith(providerPrefix) + ? profileId + : undefined; +} + function keyPart(value: unknown, fallback: string): string { if (typeof value === "string") { return value.trim() || fallback; @@ -109,18 +193,11 @@ function buildLoginPrompter(params: { }; } -function buildFinalReply(params: { emittedMessages: string[]; status: string }): ReplyPayload { - const prefix = params.emittedMessages.map((message) => message.trim()).filter(Boolean); - return { - text: [...prefix, params.status].join("\n\n"), - }; +function buildFinalReply(status: string): ReplyPayload { + return { text: status }; } -async function emitLoginMessage( - params: HandleCommandsParams, - emittedMessages: string[], - text: string, -): Promise { +async function emitLoginMessage(params: HandleCommandsParams, text: string): Promise { const trimmed = text.trim(); if (!trimmed) { return; @@ -129,13 +206,14 @@ async function emitLoginMessage( await params.opts.onBlockReply({ text: trimmed }); return; } - emittedMessages.push(trimmed); + throw new Error("Channel /login requires immediate block delivery for device codes."); } async function runChannelCodexLogin(params: { commandParams: HandleCommandsParams; provider: string; agentId: string; + profileId?: string; runLoginFlow?: RunLoginFlow; runtime?: RuntimeEnv; }): Promise { @@ -150,8 +228,12 @@ async function runChannelCodexLogin(params: { if (activeFlow) { activeCodexLoginFlows.delete(flowKey); } + if (!params.commandParams.opts?.onBlockReply) { + return { + text: "Codex login needs a live private response path so the code can be shown before it expires. Use the Web UI or a private chat and send `/login codex` again.", + }; + } - const emittedMessages: string[] = []; const flowRecord = { expiresAt: now + CODEX_LOGIN_FLOW_TTL_MS }; activeCodexLoginFlows.set(flowKey, flowRecord); try { @@ -159,24 +241,20 @@ async function runChannelCodexLogin(params: { provider: params.provider, method: CODEX_LOGIN_METHOD, agent: params.agentId, + ...(params.profileId ? { profileId: params.profileId } : {}), config: params.commandParams.cfg, runtime: params.runtime ?? defaultRuntime, prompter: buildLoginPrompter({ - sendMessage: async (text) => - await emitLoginMessage(params.commandParams, emittedMessages, text), + sendMessage: async (text) => await emitLoginMessage(params.commandParams, text), }), isRemote: true, openUrl: async () => {}, }); - return buildFinalReply({ - emittedMessages, - status: "Codex login complete. Try your request again now.", - }); + return buildFinalReply("Codex login complete. Try your request again now."); } catch { - return buildFinalReply({ - emittedMessages, - status: "Codex login did not complete. Send `/login codex` to request a new code.", - }); + return buildFinalReply( + "Codex login did not complete. Send `/login codex` to request a new code.", + ); } finally { if (activeCodexLoginFlows.get(flowKey) === flowRecord) { activeCodexLoginFlows.delete(flowKey); @@ -193,10 +271,12 @@ export const handleLoginCommand: CommandHandler = async (params, allowTextComman return null; } - if (!params.command.isAuthorizedSender || !params.command.senderIsOwner) { + if (!canStartCodexLogin(params)) { return { shouldContinue: false, - reply: { text: "Only an OpenClaw owner/admin can start Codex login from this channel." }, + reply: { + text: "Only a configured OpenClaw owner/admin can start Codex login from this channel.", + }, }; } @@ -217,11 +297,20 @@ export const handleLoginCommand: CommandHandler = async (params, allowTextComman }, }; } + if (!isPrivateLoginContext(params)) { + return { + shouldContinue: false, + reply: { + text: "Codex login codes are only sent in a private chat or Web UI session. Open a private chat with OpenClaw and send `/login codex` there.", + }, + }; + } const reply = await runChannelCodexLogin({ commandParams: params, provider, agentId, + profileId: resolveProviderScopedProfileId(params.sessionEntry?.authProfileOverride, provider), }); return { shouldContinue: false, reply }; }; diff --git a/src/gateway/server-methods/commands-list-result.ts b/src/gateway/server-methods/commands-list-result.ts index 6e7cc041a66..3e39f159633 100644 --- a/src/gateway/server-methods/commands-list-result.ts +++ b/src/gateway/server-methods/commands-list-result.ts @@ -64,6 +64,18 @@ function resolveNativeName(cmd: ChatCommandDefinition, provider?: string): strin ); } +function supportsNativeProvider(cmd: ChatCommandDefinition, provider?: string): boolean { + if (!cmd.nativeProviders?.length) { + return true; + } + if (!provider) { + return true; + } + return cmd.nativeProviders.some( + (candidate) => normalizeOptionalLowercaseString(candidate) === provider, + ); +} + function stripLeadingSlash(value: string): string { return value.startsWith("/") ? value.slice(1) : value; } @@ -214,6 +226,13 @@ export function buildCommandsListResult(params: { if (scopeFilter !== "both" && cmd.scope !== "both" && cmd.scope !== scopeFilter) { continue; } + if ( + nameSurface === "native" && + cmd.scope !== "text" && + !supportsNativeProvider(cmd, provider) + ) { + continue; + } commands.push( mapCommand( cmd, diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 2b7a82414d4..6c394d3bf5d 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -39,6 +39,15 @@ const mockChatCommands: ChatCommandDefinition[] = [ scope: "both", category: "session", }, + { + key: "login", + nativeName: "login", + nativeProviders: ["telegram"], + description: "Pair Codex login", + textAliases: ["/login"], + scope: "both", + category: "management", + }, { key: "commands", description: "List commands", @@ -390,6 +399,22 @@ describe("commands.list handler", () => { expect(commands.find((c) => c.name === "model")).toBeUndefined(); }); + it("limits provider-specific native commands while keeping login text-visible", () => { + expect(listCommands({ provider: "discord" }).find((c) => c.name === "login")).toBeUndefined(); + expect( + listCommands({ provider: "slack", scope: "native" }).find((c) => c.name === "login"), + ).toBeUndefined(); + expect(requireCommand(listCommands({ provider: "telegram" }), "login").nativeName).toBe( + "login", + ); + expect(requireCommand(listCommands({ provider: "discord", scope: "text" }), "login")).toEqual( + expect.objectContaining({ + name: "login", + textAliases: ["/login"], + }), + ); + }); + it("normalizes mixed-case provider", () => { const commands = listCommands({ provider: "Discord" }); expect(requireCommand(commands, "set_model").name).toBe("set_model"); diff --git a/src/plugin-sdk/provider-auth-login-flow-runtime.ts b/src/plugin-sdk/provider-auth-login-flow-runtime.ts new file mode 100644 index 00000000000..234cb7426ac --- /dev/null +++ b/src/plugin-sdk/provider-auth-login-flow-runtime.ts @@ -0,0 +1,20 @@ +// Lazy runtime facade for channel-triggered provider auth login flows. +import { createLazyRuntimeMethodBinder, createLazyRuntimeModule } from "../shared/lazy-runtime.js"; + +export type { + ModelsAuthLoginFlowOptions, + ModelsAuthLoginFlowResult, +} from "../commands/models/auth.js"; + +type ProviderAuthLoginFlowRuntime = typeof import("../commands/models/auth.js"); + +const loadProviderAuthLoginFlowRuntime = createLazyRuntimeModule( + () => import("../commands/models/auth.js"), +); +const bindProviderAuthLoginFlowRuntime = createLazyRuntimeMethodBinder( + loadProviderAuthLoginFlowRuntime, +); + +/** Runs provider login and persists returned auth profiles. Loaded lazily for channel /login. */ +export const runModelsAuthLoginFlow: ProviderAuthLoginFlowRuntime["runModelsAuthLoginFlow"] = + bindProviderAuthLoginFlowRuntime((runtime) => runtime.runModelsAuthLoginFlow); diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index 9f55b5726da..84f15cd98ef 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -15,11 +15,6 @@ export { collectProviderApiKeysForExecution, executeWithApiKeyRotation, } from "../agents/api-key-rotation.js"; -export { - runModelsAuthLoginFlow, - type ModelsAuthLoginFlowOptions, - type ModelsAuthLoginFlowResult, -} from "../commands/models/auth.js"; export { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; export { requireApiKey, diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index bef8ff6214e..1c0c837386c 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -40,7 +40,6 @@ function getReservedCommands(): Set { "status", "diagnostics", "codex", - "login", "whoami", "context", "btw", diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index f324375da68..30ef66f4bd4 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -659,6 +659,16 @@ describe("registerPluginCommand", () => { }); }); + it("does not reserve login globally for external plugins", () => { + const result = registerPluginCommand("demo-plugin", { + name: "login", + description: "Plugin-owned login command", + handler: async () => ({ text: "ok" }), + }); + + expect(result).toEqual({ ok: true }); + }); + it("rejects reserved ownership on non-reserved direct command registrations", () => { const result = registerPluginCommand( "demo-plugin",