mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 00:20:20 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user