mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(auth): preserve Codex login profile identity
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user