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

@@ -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