fix: honor explicit auth profile selection (#62744)

* Auth: fix native model profile selection

Fix native `/model ...@profile` targeting so profile selections persist onto the intended session, and preserve explicit session auth-profile overrides even when stored auth order prefers another profile. Update the reply/session regressions to use placeholder example.test profile ids.

Regeneration-Prompt: |
  Native `/model ...@profile` commands in chat were acknowledging the requested auth profile but later runs still used another account. Fix the target-session handling so native slash commands mutate the real chat session rather than a slash-session surrogate, and keep explicit session auth-profile overrides from being cleared just because stored provider order prefers another profile. Update the tests to cover the target-session path and the override-preservation behavior, and use placeholder profile ids instead of real email addresses in test fixtures.

* Auth: honor explicit user-locked profiles in runner

Allow an explicit user-selected auth profile to run even when per-agent auth-state order excludes it. Keep auth-state order for automatic selection and failover, and add an embedded runner regression that seeds stored order with one profile while verifying a different user-locked profile still executes.

Regeneration-Prompt: |
  The remaining bug after fixing native `/model ...@profile` persistence was in the embedded runner itself. A user could explicitly select a valid auth profile for a provider, but the run still failed if per-agent auth-state order did not include that profile. Preserve the intended semantics by validating user-locked profiles directly for provider match and credential eligibility, then using them without requiring membership in resolved auto-order. Add a regression in the embedded auth-profile rotation suite where stored order only includes one OpenAI profile but a different user-locked profile is chosen and must still be used.

* Changelog: note explicit auth profile selection fix

Add the required Unreleased changelog line for the explicit auth-profile selection and runner honor fix in this PR.

Regeneration-Prompt: |
  The PR needed a mandatory CHANGELOG.md entry under Unreleased/Fixes. Add a concise user-facing line describing that native `/model ...@profile` selections now persist on the target session and explicit user-locked OpenAI Codex auth profiles are honored even when per-agent auth order excludes them, and include the PR number plus thanks attribution for the PR author.
This commit is contained in:
Josh Lehman
2026-04-07 16:12:25 -07:00
committed by GitHub
parent 1c1eb542b6
commit 6bd480ea1f
12 changed files with 351 additions and 21 deletions

View File

@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
- Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987.
- Media/base64 decode guards: enforce byte limits before decoding missed base64-backed Teams, Signal, QQ Bot, and image-tool payloads so oversized inbound media and data URLs no longer bypass pre-decode size checks. (#62007) Thanks @eleqtrizit.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Auth/OpenAI Codex OAuth: keep native `/model ...@profile` selections on the target session and honor explicit user-locked auth profiles even when per-agent auth order excludes them. (#62744) Thanks @jalehman.
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987.
- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit.

View File

@@ -24,6 +24,32 @@ async function writeAuthStore(agentDir: string) {
await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
}
async function writeAuthStoreWithProfiles(
agentDir: string,
params: {
profiles: Record<string, { type: "api_key"; provider: string; key: string }>;
order?: Record<string, string[]>;
},
) {
const authPath = path.join(agentDir, "auth-profiles.json");
await fs.writeFile(
authPath,
JSON.stringify(
{
version: 1,
profiles: params.profiles,
...(params.order ? { order: params.order } : {}),
},
null,
2,
),
"utf-8",
);
}
const TEST_PRIMARY_PROFILE_ID = "openai-codex:primary@example.test";
const TEST_SECONDARY_PROFILE_ID = "openai-codex:secondary@example.test";
describe("resolveSessionAuthProfileOverride", () => {
it("returns early when no auth sources exist", async () => {
await withStateDirEnv("openclaw-auth-", async ({ stateDir }) => {
@@ -83,4 +109,51 @@ describe("resolveSessionAuthProfileOverride", () => {
expect(sessionEntry.authProfileOverride).toBe("zai:work");
});
});
it("keeps explicit user override when stored order prefers another profile", async () => {
await withStateDirEnv("openclaw-auth-", async ({ stateDir }) => {
const agentDir = path.join(stateDir, "agent");
await fs.mkdir(agentDir, { recursive: true });
await writeAuthStoreWithProfiles(agentDir, {
profiles: {
[TEST_PRIMARY_PROFILE_ID]: {
type: "api_key",
provider: "openai-codex",
key: "sk-josh",
},
[TEST_SECONDARY_PROFILE_ID]: {
type: "api_key",
provider: "openai-codex",
key: "sk-claude",
},
},
order: {
"openai-codex": [TEST_PRIMARY_PROFILE_ID],
},
});
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: TEST_SECONDARY_PROFILE_ID,
authProfileOverrideSource: "user",
};
const sessionStore = { "agent:main:main": sessionEntry };
const resolved = await resolveSessionAuthProfileOverride({
cfg: {} as OpenClawConfig,
provider: "openai-codex",
agentDir,
sessionEntry,
sessionStore,
sessionKey: "agent:main:main",
storePath: undefined,
isNewSession: false,
});
expect(resolved).toBe(TEST_SECONDARY_PROFILE_ID);
expect(sessionEntry.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID);
expect(sessionEntry.authProfileOverrideSource).toBe("user");
});
});
});

View File

@@ -85,6 +85,13 @@ export async function resolveSessionAuthProfileOverride(params: {
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
let current = sessionEntry.authProfileOverride?.trim();
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
if (current && !store.profiles[current]) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
@@ -96,7 +103,8 @@ export async function resolveSessionAuthProfileOverride(params: {
current = undefined;
}
if (current && order.length > 0 && !order.includes(current)) {
// Explicit user picks should survive provider rotation order changes.
if (current && order.length > 0 && !order.includes(current) && source !== "user") {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
@@ -126,14 +134,6 @@ export async function resolveSessionAuthProfileOverride(params: {
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
if (source === "user" && current && !isNewSession) {
return current;
}

View File

@@ -327,6 +327,7 @@ const writeAuthStore = async (
agentDir: string,
opts?: {
includeAnthropic?: boolean;
order?: Record<string, string[]>;
usageStats?: Record<
string,
{
@@ -353,6 +354,7 @@ const writeAuthStore = async (
};
const statePayload = {
version: 1,
...(opts?.order ? { order: opts.order } : {}),
usageStats:
opts?.usageStats ??
({
@@ -1058,6 +1060,39 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
expect(usageStats["openai:p2"]?.lastUsed).toBe(2);
});
it("honors user-pinned profiles even when stored order excludes them", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir, {
order: {
openai: ["openai:p1"],
},
});
mockSingleSuccessfulAttempt();
await runEmbeddedPiAgentInline({
sessionId: "session:test",
sessionKey: "agent:test:user-order-excluded",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "openai:p2",
authProfileIdSource: "user",
timeoutMs: 5_000,
runId: "run:user-order-excluded",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
const usageStats = await readUsageStats(agentDir);
expect(usageStats["openai:p1"]?.lastUsed).toBe(1);
expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number");
expect(usageStats["openai:p2"]?.lastUsed).not.toBe(2);
});
});
it("ignores user-locked profile when provider mismatches", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir, { includeAnthropic: true });

View File

@@ -18,6 +18,7 @@ import { hasConfiguredModelFallbacks } from "../agent-scope.js";
import {
type AuthProfileFailureReason,
markAuthProfileFailure,
resolveAuthProfileEligibility,
markAuthProfileGood,
markAuthProfileUsed,
} from "../auth-profiles.js";
@@ -294,6 +295,17 @@ export async function runEmbeddedPiAgent(
lockedProfileId = undefined;
}
}
if (lockedProfileId) {
const eligibility = resolveAuthProfileEligibility({
cfg: params.config,
store: authStore,
provider,
profileId: lockedProfileId,
});
if (!eligibility.eligible) {
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
}
}
const profileOrder = shouldPreferExplicitConfigApiKeyAuth(params.config, provider)
? []
: resolveAuthProfileOrder({
@@ -302,9 +314,6 @@ export async function runEmbeddedPiAgent(
provider,
preferredProfile: preferredProfileId,
});
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
}
const profileCandidates = lockedProfileId
? [lockedProfileId]
: profileOrder.length > 0

View File

@@ -17,11 +17,15 @@ import {
runGreetingPromptForBareNewOrReset,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
import { withFullRuntimeReplyConfig } from "./reply/get-reply-fast-path.js";
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
type GetReplyFromConfig = typeof import("./reply.js").getReplyFromConfig;
const TEST_PRIMARY_PROFILE_ID = "openai-codex:primary@example.test";
const TEST_SECONDARY_PROFILE_ID = "openai-codex:secondary@example.test";
vi.mock("./reply/agent-runner.runtime.js", () => ({
runReplyAgent: async (params: {
commandBody: string;
@@ -29,6 +33,8 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
run: {
provider: string;
model: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
sessionId: string;
sessionKey?: string;
sessionFile: string;
@@ -61,6 +67,8 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
prompt: params.commandBody,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
authProfileId: params.followupRun.run.authProfileId,
authProfileIdSource: params.followupRun.run.authProfileIdSource,
sessionId: params.followupRun.run.sessionId,
sessionKey: params.followupRun.run.sessionKey,
sessionFile: params.followupRun.run.sessionFile,
@@ -528,6 +536,7 @@ describe("trigger handling", () => {
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: targetSessionKey,
},
{},
cfg,
@@ -543,6 +552,129 @@ describe("trigger handling", () => {
});
});
it("applies native model auth profile overrides to the target session", async () => {
await withTempHome(async (home) => {
const cfg = withFullRuntimeReplyConfig({
...makeCfg(home),
session: { store: join(home, "native-model-auth.sessions.json") },
});
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
runEmbeddedPiAgentMock.mockReset();
const storePath = cfg.session?.store;
if (!storePath) {
throw new Error("missing session store path");
}
const authDir = join(home, ".openclaw", "agents", "main", "agent");
await fs.mkdir(authDir, { recursive: true });
await fs.writeFile(
join(authDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
[TEST_PRIMARY_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "oauth-access-token-josh",
},
[TEST_SECONDARY_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "oauth-access-token",
},
},
},
null,
2,
),
);
await fs.writeFile(
join(authDir, "auth-state.json"),
JSON.stringify(
{
version: 1,
order: {
"openai-codex": [TEST_PRIMARY_PROFILE_ID],
},
},
null,
2,
),
);
const slashSessionKey = "telegram:slash:111";
const targetSessionKey = MAIN_SESSION_KEY;
await fs.writeFile(
storePath,
JSON.stringify({
[targetSessionKey]: {
sessionId: "session-target",
updatedAt: Date.now(),
},
}),
);
const res = await getReplyFromConfig(
{
Body: `/model openai-codex/gpt-5.4@${TEST_SECONDARY_PROFILE_ID}`,
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: slashSessionKey,
CommandSource: "native",
CommandTargetSessionKey: targetSessionKey,
CommandAuthorized: true,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain(`Auth profile set to ${TEST_SECONDARY_PROFILE_ID}`);
const store = loadSessionStore(storePath);
expect(store[targetSessionKey]?.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID);
expect(store[targetSessionKey]?.authProfileOverrideSource).toBe("user");
expect(store[slashSessionKey]).toBeUndefined();
runEmbeddedPiAgentMock.mockReset();
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await getReplyFromConfig(
{
Body: "hi",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: targetSessionKey,
},
{},
cfg,
);
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
provider: "openai-codex",
model: "gpt-5.4",
authProfileId: TEST_SECONDARY_PROFILE_ID,
authProfileIdSource: "user",
}),
);
});
});
it("handles bare session reset, inline commands, and unauthorized inline status", async () => {
await withTempHome(async (home) => {
await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig });

View File

@@ -27,6 +27,7 @@ const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mo
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
@@ -55,6 +56,8 @@ const installPiEmbeddedMock = () =>
runEmbeddedPiAgent: (...args: unknown[]) => piEmbeddedMocks.runEmbeddedPiAgent(...args),
queueEmbeddedPiMessage: (...args: unknown[]) => piEmbeddedMocks.queueEmbeddedPiMessage(...args),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
resolveActiveEmbeddedRunSessionId: (...args: unknown[]) =>
piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args),
isEmbeddedPiRunActive: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunActive(...args),
isEmbeddedPiRunStreaming: (...args: unknown[]) =>
piEmbeddedMocks.isEmbeddedPiRunStreaming(...args),

View File

@@ -30,6 +30,11 @@ function isSlowReplyTestAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
}
function resolveFastSessionKey(ctx: MsgContext): string {
const nativeCommandTarget =
ctx.CommandSource === "native" ? normalizeOptionalString(ctx.CommandTargetSessionKey) : "";
if (nativeCommandTarget) {
return nativeCommandTarget;
}
const existing = normalizeOptionalString(ctx.SessionKey);
if (existing) {
return existing;

View File

@@ -4,7 +4,11 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { markCompleteReplyConfig, withFastReplyConfig } from "./get-reply-fast-path.js";
import {
initFastReplySessionState,
markCompleteReplyConfig,
withFastReplyConfig,
} from "./get-reply-fast-path.js";
import { loadGetReplyModuleForTest } from "./get-reply.test-loader.js";
import "./get-reply.test-runtime-mocks.js";
@@ -183,4 +187,21 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled();
expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce();
});
it("uses native command target session keys during fast bootstrap", () => {
const result = initFastReplySessionState({
ctx: buildCtx({
SessionKey: "telegram:slash:123",
CommandSource: "native",
CommandTargetSessionKey: "agent:main:main",
}),
cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig,
agentId: "main",
commandAuthorized: true,
workspaceDir: "/tmp/workspace",
});
expect(result.sessionKey).toBe("agent:main:main");
expect(result.sessionCtx.SessionKey).toBe("agent:main:main");
});
});

View File

@@ -280,7 +280,6 @@ export async function getReplyFromConfig(
triggerBodyNormalized,
bodyStripped,
} = sessionState;
if (resetTriggered && normalizeOptionalString(bodyStripped)) {
const { applyResetModelOverride } = await loadSessionResetModelRuntime();
await applyResetModelOverride({

View File

@@ -79,6 +79,7 @@ async function makeStorePath(prefix: string): Promise<string> {
}
const createStorePath = makeStorePath;
const TEST_NATIVE_MODEL_PROFILE_ID = "openai-codex:secondary@example.test";
async function writeSessionStoreFast(
storePath: string,
@@ -963,6 +964,53 @@ describe("initSessionState RawBody", () => {
expect(result.sessionId).not.toBe(existingSessionId);
});
it("prefers native command target sessions over bound slash sessions", async () => {
const storePath = await createStorePath("native-command-target-session-");
const boundSlashSessionKey = "slack:slash:123";
const targetSessionKey = "agent:main:main";
const cfg = {
session: { store: storePath },
} as OpenClawConfig;
setMinimalCurrentConversationBindingRegistryForTests();
registerCurrentConversationBindingAdapterForTest({
channel: "slack",
accountId: "default",
});
await getSessionBindingService().bind({
targetSessionKey: boundSlashSessionKey,
targetKind: "session",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "channel:ops",
},
placement: "current",
});
const result = await initSessionState({
ctx: {
Body: `/model openai-codex/gpt-5.4@${TEST_NATIVE_MODEL_PROFILE_ID}`,
CommandBody: `/model openai-codex/gpt-5.4@${TEST_NATIVE_MODEL_PROFILE_ID}`,
Provider: "slack",
Surface: "slack",
AccountId: "default",
SenderId: "U123",
From: "slack:U123",
To: "channel:ops",
OriginatingTo: "channel:ops",
SessionKey: boundSlashSessionKey,
CommandSource: "native",
CommandTargetSessionKey: targetSessionKey,
},
cfg,
commandAuthorized: true,
});
expect(result.sessionKey).toBe(targetSessionKey);
expect(result.sessionCtx.SessionKey).toBe(targetSessionKey);
});
it("uses the default per-agent sessions store when config store is unset", async () => {
const root = await makeCaseDir("openclaw-session-store-default-");
const stateDir = path.join(root, ".openclaw");

View File

@@ -224,12 +224,16 @@ export async function initSessionState(params: {
ctx.CommandSource === "native"
? normalizeOptionalString(ctx.CommandTargetSessionKey)
: undefined;
// Native slash/menu commands can arrive on a transport-specific "slash session"
// while explicitly targeting an existing chat session. Honor that explicit target
// before any binding lookup so command-side mutations land on the intended session.
const targetSessionKey =
commandTargetSessionKey ??
resolveBoundConversationSessionKey({
cfg,
ctx,
bindingContext: conversationBindingContext,
}) ?? commandTargetSessionKey;
});
const sessionCtxForState =
targetSessionKey && targetSessionKey !== ctx.SessionKey
? { ...ctx, SessionKey: targetSessionKey }
@@ -695,16 +699,16 @@ export async function initSessionState(params: {
}
const sessionCtx: TemplateContext = {
...ctx,
...sessionCtxForState,
// Keep BodyStripped aligned with Body (best default for agent prompts).
// RawBody is reserved for command/directive parsing and may omit context.
BodyStripped: normalizeInboundTextNewlines(
bodyStripped ??
ctx.BodyForAgent ??
ctx.Body ??
ctx.CommandBody ??
ctx.RawBody ??
ctx.BodyForCommands ??
sessionCtxForState.BodyForAgent ??
sessionCtxForState.Body ??
sessionCtxForState.CommandBody ??
sessionCtxForState.RawBody ??
sessionCtxForState.BodyForCommands ??
"",
),
SessionId: sessionId,