Skip to content

Commit 48974b5

Browse files
Evaobviyus
authored andcommitted
Add shared Codex login command surfaces
1 parent 77edfe7 commit 48974b5

5 files changed

Lines changed: 465 additions & 5 deletions

File tree

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7102,7 +7102,7 @@ describe("runAgentTurnWithFallback", () => {
71027102
expect(result.kind).toBe("final");
71037103
if (result.kind === "final") {
71047104
expect(result.payload.text).toBe(
7105-
"⚠️ Model login expired on the gateway for openai. Send `/login codex` in Telegram to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.",
7105+
"⚠️ 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.",
71067106
);
71077107
}
71087108
});
@@ -7121,7 +7121,7 @@ describe("runAgentTurnWithFallback", () => {
71217121
expect(result.kind).toBe("final");
71227122
if (result.kind === "final") {
71237123
expect(result.payload.text).toBe(
7124-
"⚠️ Model login expired on the gateway for openai. Send `/login codex` in Telegram to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.",
7124+
"⚠️ 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.",
71257125
);
71267126
}
71277127
});
@@ -7315,7 +7315,7 @@ describe("runAgentTurnWithFallback", () => {
73157315
expect(result.kind).toBe("final");
73167316
if (result.kind === "final") {
73177317
expect(result.payload.text).toBe(
7318-
"⚠️ Model login expired on the gateway. Send `/login codex` in Telegram to pair a new Codex login, or re-auth with `openclaw models auth login` in a terminal, then try again.",
7318+
"⚠️ 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.",
73197319
);
73207320
}
73217321
});

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -980,12 +980,12 @@ function buildExternalRunFailureReply(
980980
const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : "";
981981
if (oauthRefreshFailure.reason) {
982982
return {
983-
text: `⚠️ Model login expired on the gateway${providerText}. Send \`/login codex\` in Telegram to pair a new Codex login, or re-auth with \`${loginCommand}\` in a terminal, then try again.`,
983+
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.`,
984984
isGenericRunnerFailure: false,
985985
};
986986
}
987987
return {
988-
text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, send \`/login codex\` in Telegram or re-auth with \`${loginCommand}\` in a terminal.`,
988+
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.`,
989989
isGenericRunnerFailure: false,
990990
};
991991
}

src/auto-reply/reply/commands-handlers.runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
handleStatusCommand,
2121
handleToolsCommand,
2222
} from "./commands-info.js";
23+
import { handleLoginCommand } from "./commands-login.js";
2324
import { handleMcpCommand } from "./commands-mcp.js";
2425
import { handleModelsCommand } from "./commands-models.js";
2526
import { handleNameCommand } from "./commands-name.js";
@@ -44,6 +45,7 @@ import { handleWhoamiCommand } from "./commands-whoami.js";
4445

4546
export function loadCommandHandlers(): CommandHandler[] {
4647
return [
48+
handleLoginCommand,
4749
handlePluginCommand,
4850
handleDockCommand,
4951
handleBtwCommand,
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Tests channel-native Codex login command routing and pairing-code delivery.
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { ModelsAuthLoginFlowOptions } from "../../commands/models/auth.js";
4+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
5+
import { buildBuiltinChatCommands } from "../commands-registry.shared.js";
6+
import type { HandleCommandsParams } from "./commands-types.js";
7+
import { buildCommandTestParams } from "./commands.test-harness.js";
8+
9+
const runModelsAuthLoginFlowMock = vi.hoisted(() => vi.fn());
10+
11+
vi.mock("../../commands/models/auth.js", () => ({
12+
runModelsAuthLoginFlow: (opts: unknown) => runModelsAuthLoginFlowMock(opts),
13+
}));
14+
15+
const { handleLoginCommand, testing } = await import("./commands-login.js");
16+
const { loadCommandHandlers } = await import("./commands-handlers.runtime.js");
17+
18+
function buildLoginParams(
19+
commandBody: string,
20+
overrides: {
21+
command?: Partial<HandleCommandsParams["command"]>;
22+
ctx?: Partial<HandleCommandsParams["ctx"]>;
23+
opts?: HandleCommandsParams["opts"];
24+
sessionKey?: string;
25+
agentId?: string;
26+
} = {},
27+
): HandleCommandsParams {
28+
const params = buildCommandTestParams(
29+
commandBody,
30+
{
31+
commands: { text: true },
32+
channels: { slack: { allowFrom: ["owner"] } },
33+
session: { mainKey: "main" },
34+
} as OpenClawConfig,
35+
{
36+
Provider: "slack",
37+
Surface: "slack",
38+
OriginatingChannel: "slack",
39+
OriginatingTo: "channel:C123",
40+
AccountId: "workspace-a",
41+
MessageThreadId: "thread-1",
42+
...overrides.ctx,
43+
},
44+
{ workspaceDir: "/tmp/openclaw-login-test" },
45+
);
46+
params.sessionKey = overrides.sessionKey ?? "agent:main:slack:channel:C123";
47+
params.agentId = overrides.agentId;
48+
params.command = {
49+
...params.command,
50+
channel: "slack",
51+
channelId: "slack",
52+
accountId: "workspace-a",
53+
senderId: "owner",
54+
senderIsOwner: true,
55+
isAuthorizedSender: true,
56+
from: "slack:owner",
57+
to: "channel:C123",
58+
...overrides.command,
59+
};
60+
params.opts = overrides.opts;
61+
return params;
62+
}
63+
64+
function mockSuccessfulLoginFlow(): void {
65+
runModelsAuthLoginFlowMock.mockImplementation(async (opts: ModelsAuthLoginFlowOptions) => {
66+
await opts.prompter.note?.(
67+
"Open https://auth.openai.com/device and enter code ABCD-EFGH. Never share this code.",
68+
"Codex login",
69+
);
70+
return {
71+
providerId: "openai",
72+
methodId: "device-code",
73+
profiles: [{ profileId: "openai:owner", provider: "openai", mode: "oauth" }],
74+
};
75+
});
76+
}
77+
78+
describe("handleLoginCommand", () => {
79+
beforeEach(() => {
80+
vi.clearAllMocks();
81+
testing.clearActiveFlows();
82+
});
83+
84+
it("registers /login as a built-in command handler", () => {
85+
expect(buildBuiltinChatCommands().find((entry) => entry.key === "login")).toMatchObject({
86+
nativeName: "login",
87+
textAliases: ["/login"],
88+
scope: "both",
89+
});
90+
expect(loadCommandHandlers()).toContain(handleLoginCommand);
91+
});
92+
93+
it("starts Codex device-code login and emits the pairing code through block delivery", async () => {
94+
const onBlockReply = vi.fn(async () => {});
95+
mockSuccessfulLoginFlow();
96+
97+
const result = await handleLoginCommand(
98+
buildLoginParams("/login codex", { opts: { onBlockReply } }),
99+
true,
100+
);
101+
102+
expect(result).toEqual({
103+
shouldContinue: false,
104+
reply: { text: "Codex login complete. Try your request again now." },
105+
});
106+
expect(onBlockReply).toHaveBeenCalledWith(
107+
expect.objectContaining({
108+
text: expect.stringContaining("ABCD-EFGH"),
109+
}),
110+
);
111+
expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith(
112+
expect.objectContaining({
113+
provider: "openai",
114+
method: "device-code",
115+
agent: "main",
116+
isRemote: true,
117+
}),
118+
);
119+
});
120+
121+
it.each(["web", "discord", "slack"] as const)(
122+
"supports /login codex on the %s command surface",
123+
async (surface) => {
124+
const onBlockReply = vi.fn(async () => {});
125+
mockSuccessfulLoginFlow();
126+
127+
const result = await handleLoginCommand(
128+
buildLoginParams("/login codex", {
129+
ctx: {
130+
Provider: surface,
131+
Surface: surface,
132+
OriginatingChannel: surface,
133+
OriginatingTo: `${surface}:conversation-1`,
134+
},
135+
command: {
136+
channel: surface,
137+
channelId: surface,
138+
to: `${surface}:conversation-1`,
139+
},
140+
opts: { onBlockReply },
141+
}),
142+
true,
143+
);
144+
145+
expect(result?.reply?.text).toBe("Codex login complete. Try your request again now.");
146+
expect(onBlockReply).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
text: expect.stringContaining("https://auth.openai.com/device"),
149+
}),
150+
);
151+
},
152+
);
153+
154+
it("falls back to returning the pairing message when no block dispatcher is available", async () => {
155+
mockSuccessfulLoginFlow();
156+
157+
const result = await handleLoginCommand(buildLoginParams("/login openai"), true);
158+
159+
expect(result?.reply?.text).toContain("ABCD-EFGH");
160+
expect(result?.reply?.text).toContain("Codex login complete");
161+
});
162+
163+
it("dedupes an active flow for the same channel thread and provider", async () => {
164+
let resolveLogin!: () => void;
165+
runModelsAuthLoginFlowMock.mockImplementation(
166+
() =>
167+
new Promise((resolve) => {
168+
resolveLogin = () =>
169+
resolve({
170+
providerId: "openai",
171+
methodId: "device-code",
172+
profiles: [],
173+
});
174+
}),
175+
);
176+
177+
const first = handleLoginCommand(buildLoginParams("/login codex"), true);
178+
const second = await handleLoginCommand(buildLoginParams("/login codex"), true);
179+
180+
expect(second).toEqual({
181+
shouldContinue: false,
182+
reply: {
183+
text: "A Codex login code is already active for this chat or channel. Complete it, or wait for it to expire before requesting a new one.",
184+
},
185+
});
186+
resolveLogin();
187+
await first;
188+
});
189+
190+
it("rejects non-owner senders before starting login", async () => {
191+
const result = await handleLoginCommand(
192+
buildLoginParams("/login codex", {
193+
command: { senderIsOwner: false },
194+
}),
195+
true,
196+
);
197+
198+
expect(result).toEqual({
199+
shouldContinue: false,
200+
reply: { text: "Only an OpenClaw owner/admin can start Codex login from this channel." },
201+
});
202+
expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled();
203+
});
204+
205+
it("normalizes Codex login aliases to the OpenAI provider", async () => {
206+
mockSuccessfulLoginFlow();
207+
208+
await handleLoginCommand(buildLoginParams("/login openai-codex"), true);
209+
210+
expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith(
211+
expect.objectContaining({ provider: "openai" }),
212+
);
213+
});
214+
215+
it("returns a friendly error for unsupported providers", async () => {
216+
const result = await handleLoginCommand(buildLoginParams("/login anthropic"), true);
217+
218+
expect(result).toEqual({
219+
shouldContinue: false,
220+
reply: { text: "Unsupported login provider. Use `/login codex`." },
221+
});
222+
expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled();
223+
});
224+
});

0 commit comments

Comments
 (0)