fix(auth): preserve Codex login profile identity

This commit is contained in:
Vincent Koc
2026-07-12 07:40:35 +08:00
committed by Vincent Koc
parent 030fa62b79
commit a7764d4366
5 changed files with 670 additions and 76 deletions
@@ -55,9 +55,9 @@ const persistentBindingMocks = vi.hoisted(() => ({
const sessionMocks = vi.hoisted(() => ({
getSessionEntry: vi.fn(),
loadSessionStore: vi.fn(),
patchSessionEntry: vi.fn(),
recordSessionMetaFromInbound: vi.fn(),
resolveStorePath: vi.fn(),
updateSessionStoreEntry: vi.fn(),
}));
const commandAuthMocks = vi.hoisted(() => ({
resolveCommandArgMenu: vi.fn<ResolveCommandArgMenuFn>(),
@@ -176,8 +176,8 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
...actual,
getSessionEntry: sessionMocks.getSessionEntry,
loadSessionStore: sessionMocks.loadSessionStore,
patchSessionEntry: sessionMocks.patchSessionEntry,
resolveStorePath: sessionMocks.resolveStorePath,
updateSessionStoreEntry: sessionMocks.updateSessionStoreEntry,
};
});
vi.mock("openclaw/plugin-sdk/command-auth-native", async () => {
@@ -642,7 +642,14 @@ function resetSessionMetaMocks() {
({ storePath, sessionKey }: { storePath: string; sessionKey: string }) =>
sessionMocks.loadSessionStore(storePath)[sessionKey],
);
sessionMocks.patchSessionEntry.mockClear().mockResolvedValue(null);
sessionMocks.updateSessionStoreEntry.mockClear().mockImplementation(async (params) => {
const current = sessionMocks.loadSessionStore(params.storePath)[params.sessionKey];
if (!current) {
return null;
}
const patch = await params.update({ ...current });
return patch ? { ...current, ...patch } : current;
});
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" });
@@ -1663,23 +1670,22 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(
(runModelsAuthLoginFlow.mock.calls[0]?.[0] as { profileId?: string } | undefined)?.profileId,
).toBeUndefined();
expect(sessionMocks.patchSessionEntry).toHaveBeenCalledWith({
agentId: "main",
expect(sessionMocks.updateSessionStoreEntry).toHaveBeenCalledWith({
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
fallbackEntry: {
authProfileOverride: "openai:owner@example.com",
sessionId: "sess-main",
updatedAt: 1,
},
preserveActivity: true,
requireWriteSuccess: true,
skipMaintenance: true,
update: expect.any(Function),
});
const patchUpdate = (
sessionMocks.patchSessionEntry.mock.calls[0]?.[0] as {
update?: () => Record<string, unknown>;
sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as {
update?: (entry: Record<string, unknown>) => Record<string, unknown>;
}
)?.update?.();
)?.update?.({
authProfileOverride: "openai:owner@example.com",
sessionId: "sess-main",
updatedAt: 1,
});
expect(patchUpdate).toEqual({
authProfileOverride: "openai:new-owner@example.com",
authProfileOverrideSource: "user",
@@ -1687,6 +1693,174 @@ describe("registerTelegramNativeCommands — session metadata", () => {
});
});
it("marks a same-profile Telegram login as user-selected", async () => {
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": {
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "auto",
authProfileOverrideCompactionCount: 2,
sessionId: "sess-main",
updatedAt: 1,
},
});
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
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 }));
const update = (
sessionMocks.updateSessionStoreEntry.mock.calls[0]?.[0] as {
update?: (entry: Record<string, unknown>) => Record<string, unknown>;
}
)?.update;
expect(update).toBeTypeOf("function");
expect(
update?.({
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "auto",
authProfileOverrideCompactionCount: 2,
sessionId: "sess-main",
updatedAt: 1,
}),
).toEqual({
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
});
expect(
update?.({
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user",
sessionId: "sess-main",
updatedAt: 2,
}),
).toBeNull();
});
it("reports partial success when Telegram cannot persist the returned profile", async () => {
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": {
authProfileOverride: "openai:old-owner@example.com",
sessionId: "sess-main",
updatedAt: 1,
},
});
sessionMocks.updateSessionStoreEntry.mockRejectedValueOnce(new Error("write failed"));
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: "openai:new-owner@example.com", provider: "openai", mode: "oauth" }],
}));
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "login",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
runModelsAuthLoginFlow,
});
await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 }));
expect(sendMessage).toHaveBeenCalledWith(
123,
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
expect.any(Object),
);
expect(sendMessage).not.toHaveBeenCalledWith(
123,
"Codex login complete. Try your request again now.",
expect.any(Object),
);
});
it("reports partial success when Telegram login returns no OpenAI profile", async () => {
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
providerId: "openai",
methodId: "device-code",
profiles: [],
}));
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "login",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
runModelsAuthLoginFlow,
});
await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 }));
expect(sendMessage).toHaveBeenCalledWith(
123,
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
expect.any(Object),
);
expect(sendMessage).not.toHaveBeenCalledWith(
123,
"Codex login complete. Try your request again now.",
expect.any(Object),
);
});
it("revalidates an unchanged Telegram profile after device login", async () => {
const previousEntry = {
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user",
sessionId: "sess-main",
updatedAt: 1,
};
sessionMocks.loadSessionStore.mockReturnValue({
"agent:main:main": previousEntry,
});
sessionMocks.updateSessionStoreEntry.mockImplementationOnce(async (params) => {
const concurrentEntry = {
...previousEntry,
authProfileOverride: "openai:concurrent-owner@example.com",
updatedAt: 2,
};
const patch = await params.update({ ...concurrentEntry });
return patch ? { ...concurrentEntry, ...patch } : concurrentEntry;
});
const runModelsAuthLoginFlow = vi.fn<TelegramLoginFlow>(async () => ({
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: "openai:owner@example.com", provider: "openai", mode: "oauth" }],
}));
const { handler, sendMessage } = registerAndResolveCommandHandler({
commandName: "login",
cfg: {
commands: { native: true, ownerAllowFrom: ["200"] },
} as OpenClawConfig,
allowFrom: ["200"],
runModelsAuthLoginFlow,
});
await handler(createTelegramPrivateCommandContext({ match: "codex", userId: 200 }));
expect(sendMessage).toHaveBeenCalledWith(
123,
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
expect.any(Object),
);
expect(sendMessage).not.toHaveBeenCalledWith(
123,
"Codex login complete. Try your request again now.",
expect.any(Object),
);
});
it("passes session identity to plugin commands when the entry has no file", async () => {
sessionMocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions/sessions.json");
sessionMocks.getSessionEntry.mockReturnValue({
+53 -15
View File
@@ -43,9 +43,9 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
formatSqliteSessionFileMarker,
getSessionEntry,
patchSessionEntry,
resolveStorePath,
type SessionEntry,
updateSessionStoreEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import {
normalizeLowercaseStringOrEmpty,
@@ -1339,10 +1339,6 @@ export const registerTelegramNativeCommands = ({
agentId: route.agentId,
sessionKey: targetSessionKey,
});
const previousProfileId = codexChannelLoginRuntime.resolveProviderScopedProfileId(
targetSessionEntry?.authProfileOverride,
loginProvider,
);
const loginResult = await codexChannelLoginRuntime.runDeviceLoginFlow({
runLoginFlow: loginFlow,
provider: loginProvider,
@@ -1356,23 +1352,61 @@ export const registerTelegramNativeCommands = ({
const nextProfileId = loginResult.profiles.find(
(profile) => profile.provider === loginProvider,
)?.profileId;
if (targetSessionEntry && nextProfileId && nextProfileId !== previousProfileId) {
if (!nextProfileId) {
await sendLoginMessage(
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
);
return;
}
const needsSessionUpdate =
targetSessionEntry &&
(targetSessionEntry.authProfileOverride !== nextProfileId ||
targetSessionEntry.authProfileOverrideSource !== "user" ||
targetSessionEntry.authProfileOverrideCompactionCount !== undefined);
if (targetSessionEntry) {
try {
const storePath = resolveStorePath(runtimeCfg.session?.store, {
agentId: route.agentId,
});
await patchSessionEntry({
agentId: route.agentId,
let snapshotMatched = false;
const persisted = await updateSessionStoreEntry({
sessionKey: targetSessionKey,
storePath,
fallbackEntry: targetSessionEntry,
preserveActivity: true,
update: () => ({
authProfileOverride: nextProfileId,
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
}),
requireWriteSuccess: true,
skipMaintenance: true,
update: (entry) => {
if (
entry.sessionId !== targetSessionEntry.sessionId ||
entry.authProfileOverride !== targetSessionEntry.authProfileOverride ||
entry.authProfileOverrideSource !==
targetSessionEntry.authProfileOverrideSource ||
entry.authProfileOverrideCompactionCount !==
targetSessionEntry.authProfileOverrideCompactionCount
) {
return null;
}
snapshotMatched = true;
return needsSessionUpdate
? {
authProfileOverride: nextProfileId,
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
}
: null;
},
});
if (
!snapshotMatched ||
!persisted ||
persisted.authProfileOverride !== nextProfileId ||
persisted.authProfileOverrideSource !== "user" ||
persisted.authProfileOverrideCompactionCount !== undefined
) {
await sendLoginMessage(
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
);
return;
}
} catch (error) {
runtime.error?.(
danger(
@@ -1381,6 +1415,10 @@ export const registerTelegramNativeCommands = ({
)}`,
),
);
await sendLoginMessage(
"Codex login completed, but this Telegram session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
);
return;
}
}
await sendLoginMessage("Codex login complete. Try your request again now.");
+279 -35
View File
@@ -1,15 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelsAuthLoginFlowOptions } from "../../commands/models/auth.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { buildBuiltinChatCommands } from "../commands-registry.shared.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
const runModelsAuthLoginFlowMock = vi.hoisted(() => vi.fn());
const updateSessionStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../commands/models/auth.js", () => ({
runModelsAuthLoginFlow: (opts: unknown) => runModelsAuthLoginFlowMock(opts),
}));
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
updateSessionStoreEntry: (params: unknown) => updateSessionStoreEntryMock(params),
};
});
const { handleLoginCommand, testing } = await import("./commands-login.js");
const { loadCommandHandlers } = await import("./commands-handlers.runtime.js");
@@ -23,6 +34,8 @@ function buildLoginParams(
opts?: HandleCommandsParams["opts"];
sessionKey?: string;
sessionEntry?: HandleCommandsParams["sessionEntry"];
sessionStore?: HandleCommandsParams["sessionStore"];
storePath?: string;
agentId?: string;
} = {},
): HandleCommandsParams {
@@ -62,11 +75,15 @@ function buildLoginParams(
params.opts = overrides.opts;
if (overrides.sessionEntry !== undefined) {
params.sessionEntry = overrides.sessionEntry;
params.sessionStore = overrides.sessionStore ?? {
[params.sessionKey]: overrides.sessionEntry,
};
}
params.storePath = overrides.storePath;
return params;
}
function mockSuccessfulLoginFlow(): void {
function mockSuccessfulLoginFlow(profileId = "openai:owner"): void {
runModelsAuthLoginFlowMock.mockImplementation(async (opts: ModelsAuthLoginFlowOptions) => {
await opts.prompter.note?.(
"Open https://auth.openai.com/device and enter code ABCD-EFGH. Never share this code.",
@@ -75,7 +92,7 @@ function mockSuccessfulLoginFlow(): void {
return {
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: "openai:owner", provider: "openai", mode: "oauth" }],
profiles: [{ profileId, provider: "openai", mode: "oauth" }],
};
});
}
@@ -140,25 +157,41 @@ describe("handleLoginCommand", () => {
async (surface) => {
const onBlockReply = vi.fn(async () => {});
mockSuccessfulLoginFlow();
const targetSessionKey = `agent:main:${surface}:direct:owner`;
const targetSessionEntry = {
authProfileOverride: "openai:old-owner",
sessionId: `sess-${surface}`,
updatedAt: 1,
};
const otherSessionEntry = {
authProfileOverride: "openai:other-owner",
sessionId: "sess-other",
updatedAt: 2,
};
const sessionStore = {
[targetSessionKey]: targetSessionEntry,
"agent:main:other-session": otherSessionEntry,
};
const result = await handleLoginCommand(
buildLoginParams("/login codex", {
ctx: {
Provider: surface,
Surface: surface,
OriginatingChannel: surface,
OriginatingTo: "direct:conversation-1",
ChatType: "direct",
},
command: {
channel: surface,
channelId: surface,
to: "direct:conversation-1",
},
opts: { onBlockReply },
}),
true,
);
const params = buildLoginParams("/login codex", {
ctx: {
Provider: surface,
Surface: surface,
OriginatingChannel: surface,
OriginatingTo: "direct:conversation-1",
ChatType: "direct",
},
command: {
channel: surface,
channelId: surface,
to: "direct:conversation-1",
},
opts: { onBlockReply },
sessionKey: targetSessionKey,
sessionEntry: targetSessionEntry,
sessionStore,
});
const result = await handleLoginCommand(params, true);
expect(result?.reply?.text).toBe("Codex login complete. Try your request again now.");
expect(onBlockReply).toHaveBeenCalledWith(
@@ -166,6 +199,14 @@ describe("handleLoginCommand", () => {
text: expect.stringContaining("https://auth.openai.com/device"),
}),
);
expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith(
expect.not.objectContaining({ profileId: expect.any(String) }),
);
expect(params.sessionEntry).toMatchObject({
authProfileOverride: "openai:owner",
authProfileOverrideSource: "user",
});
expect(sessionStore["agent:main:other-session"]).toEqual(otherSessionEntry);
},
);
@@ -211,29 +252,125 @@ describe("handleLoginCommand", () => {
expect(runModelsAuthLoginFlowMock).not.toHaveBeenCalled();
});
it("reauths the active OpenAI profile when the session is pinned", async () => {
mockSuccessfulLoginFlow();
it("moves a pinned session to the canonical profile returned by login", async () => {
mockSuccessfulLoginFlow("openai:new-owner@example.com");
const previousEntry = {
authProfileOverride: "openai:owner@example.com",
sessionId: "sess-owner",
updatedAt: 1,
};
updateSessionStoreEntryMock.mockImplementationOnce(
async (params: {
update: (
entry: SessionEntry,
) => Partial<SessionEntry> | null | Promise<Partial<SessionEntry> | null>;
}) => {
const patch = await params.update({ ...previousEntry });
return patch ? { ...previousEntry, ...patch } : previousEntry;
},
);
const params = buildLoginParams("/login codex", {
opts: blockReplyOpts(),
sessionEntry: previousEntry,
storePath: "/tmp/openclaw-login-sessions.json",
});
await handleLoginCommand(
buildLoginParams("/login codex", {
opts: blockReplyOpts(),
sessionEntry: {
authProfileOverride: "openai:owner@example.com",
sessionId: "sess-owner",
updatedAt: 1,
},
await handleLoginCommand(params, true);
expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith(
expect.not.objectContaining({ profileId: expect.any(String) }),
);
expect(params.sessionEntry).toMatchObject({
authProfileOverride: "openai:new-owner@example.com",
authProfileOverrideSource: "user",
});
expect(updateSessionStoreEntryMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:slack:channel:C123",
storePath: "/tmp/openclaw-login-sessions.json",
requireWriteSuccess: true,
}),
);
});
it("reports partial success when login returns no requested-provider profile", async () => {
runModelsAuthLoginFlowMock.mockResolvedValue({
providerId: "openai",
methodId: "device-code",
profiles: [],
});
const result = await handleLoginCommand(
buildLoginParams("/login codex", { opts: blockReplyOpts() }),
true,
);
expect(runModelsAuthLoginFlowMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
profileId: "openai:owner@example.com",
}),
expect(result?.reply?.text).toBe(
"Codex login completed, but this session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
);
});
it("rejects empty profile identifiers returned by login", async () => {
runModelsAuthLoginFlowMock.mockResolvedValue({
providerId: "openai",
methodId: "device-code",
profiles: [{ profileId: " ", provider: "openai", mode: "oauth" }],
});
const result = await handleLoginCommand(
buildLoginParams("/login codex", { opts: blockReplyOpts() }),
true,
);
expect(result?.reply?.text).toBe(
"Codex login did not complete. Send `/login codex` to request a new code.",
);
});
it("normalizes returned login identifiers before switching profiles", async () => {
runModelsAuthLoginFlowMock.mockResolvedValue({
providerId: " openai ",
methodId: " device-code ",
defaultModel: " openai/gpt-5.4 ",
profiles: [{ profileId: " openai:owner@example.com ", provider: " openai ", mode: "oauth" }],
});
const params = buildLoginParams("/login codex", {
opts: blockReplyOpts(),
sessionEntry: {
authProfileOverride: "openai:old-owner@example.com",
sessionId: "sess-owner",
updatedAt: 1,
},
});
const result = await handleLoginCommand(params, true);
expect(result?.reply?.text).toBe("Codex login complete. Try your request again now.");
expect(params.sessionEntry?.authProfileOverride).toBe("openai:owner@example.com");
});
it("marks a same-profile explicit login as user-selected", async () => {
mockSuccessfulLoginFlow("openai:owner@example.com");
const params = buildLoginParams("/login codex", {
opts: blockReplyOpts(),
sessionEntry: {
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "auto",
authProfileOverrideCompactionCount: 3,
sessionId: "sess-owner",
updatedAt: 1,
},
});
await handleLoginCommand(params, true);
expect(params.sessionEntry).toMatchObject({
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user",
});
expect(params.sessionEntry?.authProfileOverrideCompactionCount).toBeUndefined();
});
it("does not pass unrelated pinned profiles into OpenAI login", async () => {
mockSuccessfulLoginFlow();
@@ -256,6 +393,113 @@ describe("handleLoginCommand", () => {
);
});
it("reports partial success and restores the session when profile persistence fails", async () => {
mockSuccessfulLoginFlow("openai:new-owner@example.com");
updateSessionStoreEntryMock.mockRejectedValueOnce(new Error("write failed"));
const previousEntry = {
authProfileOverride: "openai:old-owner@example.com",
authProfileOverrideSource: "user" as const,
sessionId: "sess-owner",
updatedAt: 1,
};
const sessionStore = {
"agent:main:slack:channel:C123": previousEntry,
"agent:main:other-session": {
authProfileOverride: "openai:other-owner@example.com",
sessionId: "sess-other",
updatedAt: 2,
},
};
const params = buildLoginParams("/login codex", {
opts: blockReplyOpts(),
sessionEntry: previousEntry,
sessionStore,
storePath: "/tmp/openclaw-login-sessions.json",
});
const result = await handleLoginCommand(params, true);
expect(result?.reply?.text).toBe(
"Codex login completed, but this session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
);
expect(params.sessionEntry).toBe(previousEntry);
expect(sessionStore["agent:main:slack:channel:C123"]).toBe(previousEntry);
expect(sessionStore["agent:main:other-session"]?.authProfileOverride).toBe(
"openai:other-owner@example.com",
);
});
it("does not overwrite a profile selected while device login is in progress", async () => {
mockSuccessfulLoginFlow("openai:new-owner@example.com");
const previousEntry = {
authProfileOverride: "openai:old-owner@example.com",
authProfileOverrideSource: "user" as const,
sessionId: "sess-owner",
updatedAt: 1,
};
const concurrentlySelectedEntry = {
...previousEntry,
authProfileOverride: "openai:concurrent-owner@example.com",
updatedAt: 2,
};
updateSessionStoreEntryMock.mockImplementationOnce(
async (params: { update: (entry: SessionEntry) => Partial<SessionEntry> | null }) => {
const patch = await params.update({ ...concurrentlySelectedEntry });
return patch ? { ...concurrentlySelectedEntry, ...patch } : concurrentlySelectedEntry;
},
);
const sessionStore = {
"agent:main:slack:channel:C123": previousEntry,
};
const params = buildLoginParams("/login codex", {
opts: blockReplyOpts(),
sessionEntry: previousEntry,
sessionStore,
storePath: "/tmp/openclaw-login-sessions.json",
});
const result = await handleLoginCommand(params, true);
expect(result?.reply?.text).toBe(
"Codex login completed, but this session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
);
expect(params.sessionEntry).toBe(previousEntry);
expect(sessionStore["agent:main:slack:channel:C123"]).toBe(previousEntry);
});
it("revalidates an unchanged profile after device login", async () => {
mockSuccessfulLoginFlow("openai:owner@example.com");
const previousEntry = {
authProfileOverride: "openai:owner@example.com",
authProfileOverrideSource: "user" as const,
sessionId: "sess-owner",
updatedAt: 1,
};
const concurrentlySelectedEntry = {
...previousEntry,
authProfileOverride: "openai:concurrent-owner@example.com",
updatedAt: 2,
};
updateSessionStoreEntryMock.mockImplementationOnce(
async (params: { update: (entry: SessionEntry) => Partial<SessionEntry> | null }) => {
const patch = await params.update({ ...concurrentlySelectedEntry });
return patch ? { ...concurrentlySelectedEntry, ...patch } : concurrentlySelectedEntry;
},
);
const params = buildLoginParams("/login codex", {
opts: blockReplyOpts(),
sessionEntry: previousEntry,
storePath: "/tmp/openclaw-login-sessions.json",
});
const result = await handleLoginCommand(params, true);
expect(result?.reply?.text).toBe(
"Codex login completed, but this session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.",
);
expect(params.sessionEntry).toBe(previousEntry);
});
it("dedupes an active flow for the same channel thread and provider", async () => {
let resolveLogin!: () => void;
runModelsAuthLoginFlowMock.mockImplementation(
+102 -8
View File
@@ -3,12 +3,14 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { updateSessionStoreEntry, type SessionEntry } from "../../config/sessions.js";
import {
codexChannelLoginRuntime,
type ModelsAuthLoginFlowOptions,
} from "../../plugin-sdk/provider-auth-login-flow-runtime.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import type { ReplyPayload } from "../types.js";
import { markCommandSessionMetadataChanged } from "./command-session-metadata.js";
import type { CommandHandler, HandleCommandsParams } from "./commands-types.js";
const PRIVATE_CHAT_TYPES = new Set(["direct", "dm", "im", "private"]);
@@ -19,6 +21,10 @@ const activeCodexLoginFlows = new Map<string, { expiresAt: number }>();
type RunLoginFlow = (opts: ModelsAuthLoginFlowOptions) => Promise<unknown>;
const LOGIN_COMPLETE_MESSAGE = "Codex login complete. Try your request again now.";
const LOGIN_SESSION_SWITCH_FAILED_MESSAGE =
"Codex login completed, but this session could not switch to the newly authenticated profile. Retry `/login codex`, or select the profile manually.";
function parseLoginCommand(commandBodyNormalized: string): { providerInput: string } | null {
const match = commandBodyNormalized.trim().match(/^\/login(?:\s+(.+))?$/u);
if (!match) {
@@ -140,11 +146,91 @@ async function emitLoginMessage(params: HandleCommandsParams, text: string): Pro
throw new Error("Channel /login requires immediate block delivery for device codes.");
}
async function switchLoginSessionProfile(params: {
commandParams: HandleCommandsParams;
nextProfileId: string | undefined;
}): Promise<"unchanged" | "updated" | "failed"> {
const { commandParams, nextProfileId } = params;
const currentEntry = commandParams.sessionEntry;
if (!currentEntry || !nextProfileId) {
return "unchanged";
}
const needsUpdate =
currentEntry.authProfileOverride !== nextProfileId ||
currentEntry.authProfileOverrideSource !== "user" ||
currentEntry.authProfileOverrideCompactionCount !== undefined;
const sessionStore = commandParams.sessionStore;
if (!sessionStore) {
return "failed";
}
const liveEntry = sessionStore[commandParams.sessionKey];
const matchesLoginSnapshot = (entry: SessionEntry): boolean =>
entry.sessionId === currentEntry.sessionId &&
entry.authProfileOverride === currentEntry.authProfileOverride &&
entry.authProfileOverrideSource === currentEntry.authProfileOverrideSource &&
entry.authProfileOverrideCompactionCount === currentEntry.authProfileOverrideCompactionCount;
if (!liveEntry || !matchesLoginSnapshot(liveEntry)) {
return "failed";
}
const nextEntry = {
...liveEntry,
authProfileOverride: nextProfileId,
authProfileOverrideSource: "user" as const,
};
delete nextEntry.authProfileOverrideCompactionCount;
try {
let persistedEntry: SessionEntry = nextEntry;
if (commandParams.storePath) {
let snapshotMatched = false;
const persisted = await updateSessionStoreEntry({
storePath: commandParams.storePath,
sessionKey: commandParams.sessionKey,
requireWriteSuccess: true,
skipMaintenance: true,
update: (entry) => {
if (!matchesLoginSnapshot(entry)) {
return null;
}
snapshotMatched = true;
return needsUpdate
? {
authProfileOverride: nextProfileId,
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: undefined,
}
: null;
},
});
if (
!snapshotMatched ||
!persisted ||
persisted.authProfileOverride !== nextProfileId ||
persisted.authProfileOverrideSource !== "user" ||
persisted.authProfileOverrideCompactionCount !== undefined
) {
return "failed";
}
persistedEntry = persisted;
}
commandParams.sessionEntry = persistedEntry;
sessionStore[commandParams.sessionKey] = persistedEntry;
if (needsUpdate) {
markCommandSessionMetadataChanged(commandParams);
return "updated";
}
return "unchanged";
} catch {
// Credential persistence already succeeded, so report partial success.
}
return "failed";
}
async function runChannelCodexLogin(params: {
commandParams: HandleCommandsParams;
provider: string;
agentId: string;
profileId?: string;
runLoginFlow?: RunLoginFlow;
runtime?: RuntimeEnv;
}): Promise<ReplyPayload> {
@@ -166,17 +252,29 @@ async function runChannelCodexLogin(params: {
}
try {
await codexChannelLoginRuntime.runDeviceLoginFlow({
const loginResult = await codexChannelLoginRuntime.runDeviceLoginFlow({
provider: params.provider,
agentId: params.agentId,
...(params.profileId ? { profileId: params.profileId } : {}),
config: params.commandParams.cfg,
runtime: params.runtime ?? defaultRuntime,
sendMessage: async (text) => await emitLoginMessage(params.commandParams, text),
unsupportedPromptMessage: "Channel /login supports only fixed Codex device-code auth.",
runLoginFlow: params.runLoginFlow,
});
return { text: "Codex login complete. Try your request again now." };
const nextProfileId = loginResult.profiles.find(
(profile) => profile.provider === params.provider,
)?.profileId;
if (!nextProfileId) {
return { text: LOGIN_SESSION_SWITCH_FAILED_MESSAGE };
}
const switchResult = await switchLoginSessionProfile({
commandParams: params.commandParams,
nextProfileId,
});
return {
text:
switchResult === "failed" ? LOGIN_SESSION_SWITCH_FAILED_MESSAGE : LOGIN_COMPLETE_MESSAGE,
};
} catch {
return { text: "Codex login did not complete. Send `/login codex` to request a new code." };
} finally {
@@ -236,10 +334,6 @@ export const handleLoginCommand: CommandHandler = async (params, allowTextComman
commandParams: params,
provider,
agentId,
profileId: codexChannelLoginRuntime.resolveProviderScopedProfileId(
params.sessionEntry?.authProfileOverride,
provider,
),
});
return { shouldContinue: false, reply };
};
@@ -16,9 +16,7 @@ import type {
} from "../commands/models/auth.js";
type ProviderAuthLoginFlowRuntime = typeof import("../commands/models/auth.js");
type RunModelsAuthLoginFlow = (
opts: ModelsAuthLoginFlowOptions,
) => Promise<ModelsAuthLoginFlowResult>;
type RunModelsAuthLoginFlow = (opts: ModelsAuthLoginFlowOptions) => Promise<unknown>;
const CODEX_LOGIN_PROVIDER = "openai";
const CODEX_LOGIN_METHOD = "device-code";
@@ -130,6 +128,51 @@ function buildCodexDeviceLoginPrompter(params: {
};
}
function parseModelsAuthLoginFlowResult(value: unknown): ModelsAuthLoginFlowResult {
if (!value || typeof value !== "object") {
throw new Error("Provider login returned an invalid result.");
}
const result = value as Record<string, unknown>;
if (!Array.isArray(result.profiles)) {
throw new Error("Provider login returned an invalid result.");
}
const parseRequiredString = (input: unknown, label: string): string => {
if (typeof input !== "string" || !input.trim()) {
throw new Error(`Provider login returned an invalid ${label}.`);
}
return input.trim();
};
const providerId = parseRequiredString(result.providerId, "provider id");
const methodId = parseRequiredString(result.methodId, "method id");
const profiles = result.profiles.map((profile): ModelsAuthLoginFlowResult["profiles"][number] => {
if (!profile || typeof profile !== "object") {
throw new Error("Provider login returned an invalid profile.");
}
const record = profile as Record<string, unknown>;
const profileId = parseRequiredString(record.profileId, "profile id");
const provider = parseRequiredString(record.provider, "profile provider");
const mode = parseRequiredString(record.mode, "profile mode");
if (mode !== "api_key" && mode !== "oauth" && mode !== "token") {
throw new Error("Provider login returned an invalid profile.");
}
return {
profileId,
provider,
mode,
};
});
const defaultModel =
result.defaultModel === undefined
? undefined
: parseRequiredString(result.defaultModel, "default model");
return {
providerId,
methodId,
...(defaultModel ? { defaultModel } : {}),
profiles,
};
}
async function runCodexDeviceLoginFlow(params: {
provider: string;
agentId: string;
@@ -140,7 +183,7 @@ async function runCodexDeviceLoginFlow(params: {
unsupportedPromptMessage: string;
runLoginFlow?: RunModelsAuthLoginFlow;
}): Promise<ModelsAuthLoginFlowResult> {
return await (params.runLoginFlow ?? runModelsAuthLoginFlow)({
const result = await (params.runLoginFlow ?? runModelsAuthLoginFlow)({
provider: params.provider,
method: CODEX_LOGIN_METHOD,
agent: params.agentId,
@@ -154,6 +197,7 @@ async function runCodexDeviceLoginFlow(params: {
isRemote: true,
openUrl: async () => {},
});
return parseModelsAuthLoginFlowResult(result);
}
export const codexChannelLoginRuntime = {