diff --git a/docs/cli/openclaw.md b/docs/cli/openclaw.md index 57892e129294..3773c5d69829 100644 --- a/docs/cli/openclaw.md +++ b/docs/cli/openclaw.md @@ -180,7 +180,7 @@ setup workspace ~/Projects/work `setup` preserves the verified effective model. It does not configure or replace inference. -If inference is missing or its live check fails, leave OpenClaw and run `openclaw onboard`. Guided onboarding detects configured models, API keys, and authenticated local CLIs, asks each candidate for a real reply, and persists only a passing route. OpenClaw starts immediately after that boundary and can then configure the workspace, Gateway, channels, agents, plugins, and other optional features. +If inference is missing or its live check fails, leave OpenClaw and run `openclaw onboard`. Guided onboarding tries the configured model first, then authenticated subscription CLIs, API keys, and remaining supported CLIs; it asks each candidate for a real reply and persists only a passing route. OpenClaw starts immediately after that boundary and can then configure the workspace, Gateway, channels, agents, plugins, and other optional features. The macOS app skips this ladder entirely when it reaches a configured Gateway whose default agent already has a configured model; it opens the normal agent diff --git a/src/agents/embedded-agent-runner/run-loop.ts b/src/agents/embedded-agent-runner/run-loop.ts index 9d9657e6a9f8..3ab7ce261261 100644 --- a/src/agents/embedded-agent-runner/run-loop.ts +++ b/src/agents/embedded-agent-runner/run-loop.ts @@ -603,6 +603,8 @@ export async function runPreparedEmbeddedLoop( startedAtMs: started, provider, modelId, + modelTransportId: effectiveModel.id ?? modelId, + modelTransportApi: effectiveModel.api ?? model.api, authProfileId: lastProfileId, profileFailureStore, attemptAuthProfileStore, diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 58310a0d07a4..66b2cf417a29 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -1636,6 +1636,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expect.objectContaining({ authProfileId: "openai:work", agentHarnessId: "codex", + modelId: "gpt-5.4", + modelApi: "openai-responses", authFingerprint: "resolved-secretref-fingerprint", runtimeOwnerKind: "plugin-harness", runtimeOwnerId: "codex", @@ -1719,6 +1721,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { expect(onSuccessfulAuthBinding).toHaveBeenCalledWith({ authProfileId: "openai:work", agentHarnessId: "codex", + modelId: "gpt-5.4", + modelApi: "openai-responses", runtimeOwnerFingerprint: expect.any(String), runtimeOwnerKind: "plugin-harness", runtimeOwnerId: "codex", diff --git a/src/agents/embedded-agent-runner/run/auth-profile-success.ts b/src/agents/embedded-agent-runner/run/auth-profile-success.ts index b9e6e34ddf04..8fa72822be4b 100644 --- a/src/agents/embedded-agent-runner/run/auth-profile-success.ts +++ b/src/agents/embedded-agent-runner/run/auth-profile-success.ts @@ -75,6 +75,8 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: { apiKeyInfo: ResolvedProviderAuth | null; attempt: EmbeddedRunAttemptResult; provider: string; + modelId: string; + modelApi: string; agentHarnessId: string; pluginHarnessOwnsTransport: boolean; pluginHarnessOwnsAuthBootstrap: boolean; @@ -141,6 +143,8 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: { input.onSuccessfulAuthBinding?.({ ...(input.profileId ? { authProfileId: input.profileId } : {}), agentHarnessId: input.agentHarnessId, + modelId: input.modelId, + modelApi: input.modelApi, ...(authFingerprint ? { authFingerprint } : {}), ...(runtimeOwnerFingerprint ? { runtimeOwnerFingerprint } : {}), ...(runtimeOwnerKind ? { runtimeOwnerKind } : {}), diff --git a/src/agents/embedded-agent-runner/run/terminal-resolution.ts b/src/agents/embedded-agent-runner/run/terminal-resolution.ts index 20c2b17792ce..ec990cf0d620 100644 --- a/src/agents/embedded-agent-runner/run/terminal-resolution.ts +++ b/src/agents/embedded-agent-runner/run/terminal-resolution.ts @@ -98,6 +98,8 @@ export async function resolveEmbeddedRunTerminal(input: { startedAtMs: number; provider: string; modelId: string; + modelTransportId: string; + modelTransportApi: string; authProfileId?: string; profileFailureStore: AuthProfileStore; attemptAuthProfileStore: AuthProfileStore; @@ -447,6 +449,8 @@ function completeEmbeddedRun( apiKeyInfo: input.apiKeyInfo, attempt: input.attempt, provider: input.provider, + modelId: input.modelTransportId, + modelApi: input.modelTransportApi, agentHarnessId: input.agentHarnessId, pluginHarnessOwnsTransport: input.pluginHarnessOwnsTransport, pluginHarnessOwnsAuthBootstrap: input.pluginHarnessOwnsAuthBootstrap, diff --git a/src/agents/execution-auth-binding.ts b/src/agents/execution-auth-binding.ts index ef31d6fb9b90..c72afd5ca94c 100644 --- a/src/agents/execution-auth-binding.ts +++ b/src/agents/execution-auth-binding.ts @@ -7,6 +7,10 @@ export type AgentExecutionAuthBinding = { authProfileId?: string; /** Exact embedded harness that completed the successful turn, including openclaw. */ agentHarnessId?: string; + /** Exact model selected by the successful embedded run. */ + modelId?: string; + /** Exact transport used to select that run's credential. */ + modelApi?: string; /** Non-reversible identity hash; credential material never leaves the runner. */ authFingerprint?: string; /** Runtime-owned principal/session shape used when credentials are intentionally opaque. */ diff --git a/src/commands/auth-choice-options.static.ts b/src/commands/auth-choice-options.static.ts index 8da7f5bb77cd..83134e087df9 100644 --- a/src/commands/auth-choice-options.static.ts +++ b/src/commands/auth-choice-options.static.ts @@ -19,6 +19,7 @@ export type AuthChoiceGroup = { value: AuthChoiceGroupId; label: string; hint?: string; + methodMessage?: string; providerIds?: string[]; options: AuthChoiceOption[]; }; diff --git a/src/commands/auth-choice-prompt.test.ts b/src/commands/auth-choice-prompt.test.ts index bf35a4dee123..5e349da7355a 100644 --- a/src/commands/auth-choice-prompt.test.ts +++ b/src/commands/auth-choice-prompt.test.ts @@ -315,4 +315,80 @@ describe("promptAuthChoiceGrouped", () => { ]); expect(result).toBe("minimax-cn-api"); }); + + it("features caller-supplied groups first and excludes them from More", async () => { + buildAuthChoiceGroups.mockReturnValue({ + groups: [ + openAIGroup(), + authChoiceGroup("anthropic", "Anthropic", [["apiKey", "Anthropic API key"]], true), + authChoiceGroup("minimax", "MiniMax", [["minimax-api", "MiniMax API key"]]), + ], + skipOption: { value: "skip", label: "Skip for now" }, + }); + const providerPrompts: Array> = []; + const prompter = createPromptHarness(async (params) => { + if (params.message !== "Model/auth provider") { + throw new Error(`unexpected prompt ${params.message}`); + } + providerPrompts.push(params.options); + return providerPrompts.length === 1 ? "__more" : "minimax"; + }); + + const result = await promptAuthChoiceGrouped({ + prompter, + store: EMPTY_STORE, + includeSkip: true, + additionalGroups: [ + { + ...authChoiceGroup("detected-ai", "Detected AI", [["candidate:codex-cli", "Codex CLI"]]), + hint: "Codex CLI", + }, + authChoiceGroup("recommended-ai", "Recommended AI", [ + ["candidate:openai-api-key", "OpenAI API key"], + ]), + ], + }); + + expect(providerPrompts[0]?.map((option) => option.value)).toEqual([ + "detected-ai", + "recommended-ai", + "anthropic", + "openai", + "__more", + "skip", + ]); + expect(providerPrompts[0]?.[0]).toMatchObject({ + value: "detected-ai", + hint: "Codex CLI", + }); + expect(providerPrompts[1]?.map((option) => option.value)).toEqual(["minimax", "__back"]); + expect(result).toBe("minimax-api"); + }); + + it("uses a caller-supplied method prompt when provided", async () => { + buildAuthChoiceGroups.mockReturnValue({ groups: [], skipOption: undefined }); + const messages: string[] = []; + const prompter = createPromptHarness(async (params) => { + messages.push(params.message); + return params.message === "Model/auth provider" ? "detected-ai" : "candidate:codex-cli"; + }); + + const result = await promptAuthChoiceGrouped({ + prompter, + store: EMPTY_STORE, + includeSkip: false, + additionalGroups: [ + { + ...authChoiceGroup("detected-ai", "Detected on this machine", [ + ["candidate:codex-cli", "Codex CLI"], + ["candidate:claude-cli", "Claude Code"], + ]), + methodMessage: "Use which detected AI?", + }, + ], + }); + + expect(messages).toEqual(["Model/auth provider", "Use which detected AI?"]); + expect(result).toBe("candidate:codex-cli"); + }); }); diff --git a/src/commands/auth-choice-prompt.ts b/src/commands/auth-choice-prompt.ts index 55a4cacfed23..74cf5203c8d1 100644 --- a/src/commands/auth-choice-prompt.ts +++ b/src/commands/auth-choice-prompt.ts @@ -82,14 +82,19 @@ export async function promptAuthChoiceGrouped( options: group.options.filter((option) => params.allowedChoices?.has(option.value)), })) : groups; - const availableGroups = [...filteredGroups, ...(params.additionalGroups ?? [])].filter( + const availableBuiltInGroups = filteredGroups.filter((group) => group.options.length > 0); + const additionalGroups = (params.additionalGroups ?? []).filter( (group) => group.options.length > 0, ); + const availableGroups = [...availableBuiltInGroups, ...additionalGroups]; const groupById = new Map(availableGroups.map((group) => [group.value, group] as const)); - const featuredGroups = availableGroups - .filter(isFeaturedAuthChoiceGroup) - .toSorted(compareAuthChoiceGroups); - const moreGroups = availableGroups + // Caller-supplied groups carry pre-vetted context such as detected onboarding routes. + // Keep them ahead of the generic catalog instead of demoting them under More. + const featuredGroups = [ + ...additionalGroups, + ...availableBuiltInGroups.filter(isFeaturedAuthChoiceGroup).toSorted(compareAuthChoiceGroups), + ]; + const moreGroups = availableBuiltInGroups .filter((group) => !isFeaturedAuthChoiceGroup(group)) .toSorted(compareAuthChoiceGroups); const configuredModelRef = resolveConfiguredModelRef(params.config); @@ -109,7 +114,7 @@ export async function promptAuthChoiceGrouped( return expectDefined(group.options[0], "options entry at 0").value; } return (await params.prompter.select({ - message: `${group.label} auth method`, + message: group.methodMessage ?? `${group.label} auth method`, options: [ ...(keepCurrentOption ? [keepCurrentOption] : []), ...group.options, diff --git a/src/commands/onboard-guided-manual.ts b/src/commands/onboard-guided-manual.ts index d68019a6e7d1..7d7578b5426b 100644 --- a/src/commands/onboard-guided-manual.ts +++ b/src/commands/onboard-guided-manual.ts @@ -127,7 +127,9 @@ export async function runManualStage(params: { ? [ { value: "detected-ai", - label: t("wizard.guided.detectedTitle"), + label: t("wizard.guided.detectedGroupLabel"), + hint: params.detection.candidates.map((candidate) => candidate.label).join(", "), + methodMessage: t("wizard.guided.detectedGroupPrompt"), options: detectedOptions, }, ] diff --git a/src/commands/onboard-guided.custodian.test.ts b/src/commands/onboard-guided.custodian.test.ts index 8c02dd653744..a03981e51b1e 100644 --- a/src/commands/onboard-guided.custodian.test.ts +++ b/src/commands/onboard-guided.custodian.test.ts @@ -69,7 +69,7 @@ function existingModelCandidate() { return { kind: "existing-model", label: "Current model", - detail: "already configured", + detail: "acme/workspace-model — already configured", modelRef: "acme/workspace-model", recommended: false, credentials: true, @@ -282,17 +282,64 @@ describe("runGuidedOnboarding custodian flow", () => { it("keeps the working route when other options are explored and skipped", async () => { promptAuthChoiceGrouped.mockResolvedValueOnce("skip"); const prompter = createWizardPrompter(undefined, { selectValues: ["full", "other"] }); - const deps = setupDeps({ prompter }); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => + detection({ + candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")], + }), + ), + }); await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps); expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce(); + expect(promptAuthChoiceGrouped).toHaveBeenCalledWith( + expect.objectContaining({ + additionalGroups: [ + expect.objectContaining({ + label: "Detected on this machine", + hint: "Claude Code, Codex", + methodMessage: "Use which detected AI?", + }), + ], + }), + ); const notes = JSON.stringify((prompter.note as ReturnType).mock.calls); expect(notes).toContain("Keeping the working AI you already have."); expect(notes).not.toContain("Add AI later"); expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work"); }); + it("names the configured model in the recommended route confirmation", async () => { + const prompter = createWizardPrompter(undefined, { selectValues: ["full", "use"] }); + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => detection({ candidates: [existingModelCandidate()] })), + activate: vi.fn(async () => ({ + ok: true as const, + modelRef: "acme/workspace-model", + latencyMs: 125, + lines: ["Default model: acme/workspace-model"], + })), + }); + + await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps); + + expect(prompter.select).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: "Use Current model (acme/workspace-model)?", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "use", + label: "Continue with Current model (acme/workspace-model) — recommended", + }), + ]), + }), + ); + }); + it("quips about detected coding agents", async () => { const prompter = createWizardPrompter(); const deps = setupDeps({ diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts index 95a171f24e72..57c4df62ecc8 100644 --- a/src/commands/onboard-guided.test.ts +++ b/src/commands/onboard-guided.test.ts @@ -78,7 +78,7 @@ function existingModelCandidate() { return { kind: "existing-model", label: "Current model", - detail: "already configured", + detail: "acme/workspace-model — already configured", modelRef: "acme/workspace-model", recommended: false, credentials: true, diff --git a/src/commands/onboard-guided.ts b/src/commands/onboard-guided.ts index cc7609588e27..c44d0a95a865 100644 --- a/src/commands/onboard-guided.ts +++ b/src/commands/onboard-guided.ts @@ -286,7 +286,10 @@ async function runGuidedOnboardingFlow( }); if (attempt.kind === "success") { resultLines = activationLines(attempt.result); - successLabel = candidate.label; + successLabel = + candidate.kind === "existing-model" + ? `${candidate.label} (${candidate.modelRef})` + : candidate.label; break; } // The verification probe runs outside the configured workspace (setup never diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index e1dad014d30c..386e94c1e826 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -56,37 +56,130 @@ describe("detectInferenceBackends", () => { expect(candidates).toEqual([]); }); - it("orders the ladder: existing model, env keys, then CLI logins", async () => { + it("orders the ladder: existing model, logged-in subscriptions, env keys, then fallback CLIs", async () => { const candidates = await detectInferenceBackends({ config: { agents: { defaults: { model: "zai/glm-5.2" } } }, env: { OPENAI_API_KEY: "sk-x", ANTHROPIC_API_KEY: "sk-y" }, platform: "linux", deps: { - probeLocalCommand: probeDeps({ claude: true, codex: true }), + probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }), readClaudeCliCredentials: () => ({ type: "oauth" }), readCodexCliCredentials: () => ({ type: "oauth" }), + readGeminiCliCredentials: () => ({ type: "oauth" }), + randomInt: () => 0, + }, + }); + expect(candidates.map((candidate) => candidate.kind)).toEqual([ + "existing-model", + "claude-cli", + "codex-cli", + "openai-api-key", + "anthropic-api-key", + "gemini-cli", + ]); + expect(candidates[0]?.modelRef).toBe("zai/glm-5.2"); + expect(candidates[0]?.detail).toBe("zai/glm-5.2 — already configured"); + expect(candidates[1]?.modelRef).toBe(CLAUDE_CLI_DEFAULT_MODEL_REF); + expect(candidates[2]?.modelRef).toBe(CODEX_APP_SERVER_DEFAULT_MODEL_REF); + expect(candidates[3]?.modelRef).toBe(OPENAI_API_DEFAULT_MODEL_REF); + expect(candidates[4]?.modelRef).toBe(ANTHROPIC_API_DEFAULT_MODEL_REF); + }); + + it("ranks a logged-in Codex subscription before an OpenAI environment key", async () => { + const candidates = await detectInferenceBackends({ + env: { OPENAI_API_KEY: "sk-x" }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ codex: true }), + readCodexCliCredentials: () => ({ type: "oauth" }), }, }); - expect(candidates.slice(0, 3).map((candidate) => candidate.kind)).toEqual([ - "existing-model", - "openai-api-key", + + expect(candidates.map((candidate) => candidate.kind)).toEqual(["codex-cli", "openai-api-key"]); + }); + + it("keeps status-only Codex login after env keys without verifiable OAuth tokens", async () => { + const candidates = await detectInferenceBackends({ + env: { OPENAI_API_KEY: "sk-x" }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ codex: true }), + readCodexCliCredentials: () => null, + detectCodexLoginState: async () => true, + }, + }); + + expect(candidates.map((candidate) => candidate.kind)).toEqual(["openai-api-key", "codex-cli"]); + expect(candidates[1]).toMatchObject({ credentials: true, detail: "logged in" }); + }); + + it("keeps API-key-helper-backed Claude after environment keys", async () => { + const candidates = await detectInferenceBackends({ + env: { ANTHROPIC_API_KEY: "sk-y" }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ claude: true }), + readClaudeCliCredentials: () => ({ type: "api_key_helper" }), + }, + }); + + expect(candidates.map((candidate) => candidate.kind)).toEqual([ "anthropic-api-key", + "claude-cli", + ]); + expect(candidates[1]).toMatchObject({ credentials: true, detail: "logged in" }); + }); + + it("keeps an Anthropic environment key ahead of unknown Claude credentials", async () => { + const candidates = await detectInferenceBackends({ + env: { ANTHROPIC_API_KEY: "sk-y" }, + platform: "darwin", + deps: { + probeLocalCommand: probeDeps({ claude: true }), + readClaudeCliCredentials: () => null, + }, + }); + + expect(candidates.map((candidate) => candidate.kind)).toEqual([ + "anthropic-api-key", + "claude-cli", + ]); + expect(candidates[1]?.credentials).toBeUndefined(); + }); + + it("keeps a logged-in Gemini CLI after environment keys", async () => { + const candidates = await detectInferenceBackends({ + env: { OPENAI_API_KEY: "sk-x" }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ gemini: true }), + readGeminiCliCredentials: () => ({ type: "oauth" }), + }, + }); + + expect(candidates.map((candidate) => candidate.kind)).toEqual(["openai-api-key", "gemini-cli"]); + }); + + it("keeps the existing model first and definitively logged-out CLIs last", async () => { + const candidates = await detectInferenceBackends({ + config: { agents: { defaults: { model: "zai/glm-5.2" } } }, + env: { OPENAI_API_KEY: "sk-x" }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ claude: true, codex: true, gemini: true }), + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => ({ type: "oauth" }), + readGeminiCliCredentials: () => null, + }, + }); + + expect(candidates.map((candidate) => candidate.kind)).toEqual([ + "existing-model", + "codex-cli", + "openai-api-key", + "claude-cli", + "gemini-cli", ]); - expect( - candidates - .slice(3) - .map((candidate) => candidate.kind) - .toSorted(), - ).toEqual(["claude-cli", "codex-cli"]); - expect(candidates[0]?.modelRef).toBe("zai/glm-5.2"); - expect(candidates[1]?.modelRef).toBe(OPENAI_API_DEFAULT_MODEL_REF); - expect(candidates[2]?.modelRef).toBe(ANTHROPIC_API_DEFAULT_MODEL_REF); - expect( - candidates - .slice(3) - .map((candidate) => candidate.modelRef) - .toSorted(), - ).toEqual([CLAUDE_CLI_DEFAULT_MODEL_REF, CODEX_APP_SERVER_DEFAULT_MODEL_REF].toSorted()); }); it("prefers the configured default agent model over the global default", async () => { diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index 38539daa4ff4..a74be33a4232 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -6,6 +6,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { readClaudeCliCredentialsCached, + readCodexCliCredentialsCached, readGeminiCliCredentialsCached, } from "../agents/cli-credentials.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; @@ -18,6 +19,7 @@ import { GEMINI_CLI_DEFAULT_MODEL_REF, detectAmbientInferenceBackends, type InferenceBackendCandidate, + type InferenceBackendKind, } from "./onboard-inference-ambient.js"; export { @@ -41,6 +43,7 @@ type DetectInferenceBackendsDeps = { readClaudeCliCredentials?: () => { type: string } | null; readCodexCliCredentials?: () => { type: string } | null; readGeminiCliCredentials?: () => { type: string } | null; + detectCodexLoginState?: typeof detectCodexLoginState; randomInt?: (maxExclusive: number) => number; }; @@ -174,6 +177,9 @@ export async function detectInferenceBackends( const readClaude = options.deps?.readClaudeCliCredentials ?? (() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 })); + const readCodex = + options.deps?.readCodexCliCredentials ?? + (() => readCodexCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 })); const readGemini = options.deps?.readGeminiCliCredentials ?? (() => readGeminiCliCredentialsCached({ ttlMs: 60_000 })); @@ -191,17 +197,18 @@ export async function detectInferenceBackends( cfg: options.config ?? {}, ...(defaultAgentId ? { agentId: defaultAgentId } : {}), }); + const modelRef = `${resolved.provider}/${resolved.model}`; candidates.push({ kind: "existing-model", // Approval and activation bind to the executable target, not a mutable // alias spelling. The authored config itself remains untouched. - modelRef: `${resolved.provider}/${resolved.model}`, + modelRef, label: "Current model", - detail: "already configured", + detail: `${modelRef} — already configured`, credentials: true, }); } - candidates.push(...detectAmbientInferenceBackends(env)); + const envCandidates = detectAmbientInferenceBackends(env); const [claudeProbe, codexProbe, geminiProbe] = await Promise.all([ probe("claude"), @@ -209,12 +216,17 @@ export async function detectInferenceBackends( probe("gemini"), ]); const cliCandidates: InferenceBackendCandidate[] = []; + const subscriptionPromotionEligibleCliKinds = new Set(); if (claudeProbe.found && !claudeProbe.timedOut) { + const claudeCredential = readClaude(); const credentials = detectCliCredentialState({ probe: claudeProbe, - hasStoredCredentials: readClaude() !== null, + hasStoredCredentials: claudeCredential !== null, platform, }); + if (credentials === true && claudeCredential?.type === "oauth") { + subscriptionPromotionEligibleCliKinds.add("claude-cli"); + } cliCandidates.push({ kind: "claude-cli", modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF, @@ -224,13 +236,21 @@ export async function detectInferenceBackends( }); } if (codexProbe.found && !codexProbe.timedOut) { - const credentials = options.deps?.readCodexCliCredentials - ? detectCliCredentialState({ - probe: codexProbe, - hasStoredCredentials: options.deps.readCodexCliCredentials() !== null, - platform, - }) - : await detectCodexLoginState(probe, codexProbe.command); + const codexCredential = readCodex(); + const credentials = options.deps?.detectCodexLoginState + ? await options.deps.detectCodexLoginState(probe, codexProbe.command) + : options.deps?.readCodexCliCredentials + ? detectCliCredentialState({ + probe: codexProbe, + hasStoredCredentials: codexCredential !== null, + platform, + }) + : await detectCodexLoginState(probe, codexProbe.command); + // Promote only prompt-free ChatGPT OAuth tokens. Status-only logins may be metered; + // keychain-only ChatGPT users conservatively stay usable in the fallback tier. + if (credentials === true && codexCredential?.type === "oauth") { + subscriptionPromotionEligibleCliKinds.add("codex-cli"); + } cliCandidates.push({ kind: "codex-cli", modelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF, @@ -251,14 +271,24 @@ export async function detectInferenceBackends( credentials, }); } - // Claude Code and Codex are equivalent subscription-backed choices. When both - // may be usable, randomize their first-test order instead of encoding a preference. + // Claude Code and Codex share rank within a credential tier. Randomize before + // partitioning so logged-in and unknown ties keep no provider preference. randomizeClaudeCodexTie(cliCandidates, options.deps?.randomInt ?? randomInt); - // Stable partition: definitively logged-out installs still sink below usable or - // keychain-unknown candidates; Gemini retains its documented fallback position. + const loggedInSubscriptionCliCandidates = cliCandidates.filter( + (candidate) => + candidate.credentials === true && subscriptionPromotionEligibleCliKinds.has(candidate.kind), + ); + const remainingCliCandidates = cliCandidates.filter( + (candidate) => !loggedInSubscriptionCliCandidates.includes(candidate), + ); + // Verified flat-rate subscription logins outrank metered environment keys. + // Existing models stay first so guided setup never silently replaces one. candidates.push( - ...cliCandidates.filter((candidate) => candidate.credentials !== false), - ...cliCandidates.filter((candidate) => candidate.credentials === false), + ...loggedInSubscriptionCliCandidates, + ...envCandidates, + // Unknown login states and Gemini remain fallbacks; definitive logouts sink last. + ...remainingCliCandidates.filter((candidate) => candidate.credentials !== false), + ...remainingCliCandidates.filter((candidate) => candidate.credentials === false), ); return candidates; } diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index a29aa46f6245..28fbac6804f2 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -202,6 +202,8 @@ describe("runSystemAgentTurn", () => { authProfileId: "openai:p2", authFingerprint, agentHarnessId: "openclaw", + modelId: executionRoute.model, + modelApi: "openai-responses", }, deps: authDeps, }); diff --git a/src/system-agent/assistant.configured.test.ts b/src/system-agent/assistant.configured.test.ts index 0d36c9eb260a..5565fceff387 100644 --- a/src/system-agent/assistant.configured.test.ts +++ b/src/system-agent/assistant.configured.test.ts @@ -124,7 +124,12 @@ describe("OpenClaw configured-model planner", () => { const binding = await createSystemAgentVerifiedInferenceBinding({ configuredRoute, executionRoute: { ...configuredRoute, authProfileId: "openai:p2" }, - auth: { authProfileId: "openai:p2", authFingerprint }, + auth: { + authProfileId: "openai:p2", + authFingerprint, + modelId: configuredRoute.model, + modelApi: "openai-responses", + }, deps: authDeps, }); const runEmbeddedAgent = vi.fn(async () => ({ diff --git a/src/system-agent/chat-engine.test.ts b/src/system-agent/chat-engine.test.ts index 072d141eb8c2..721050bc0600 100644 --- a/src/system-agent/chat-engine.test.ts +++ b/src/system-agent/chat-engine.test.ts @@ -162,7 +162,12 @@ async function createAmbientVerifiedBinding(config: OpenClawConfig) { return await createSystemAgentVerifiedInferenceBinding({ configuredRoute: route, executionRoute: route, - auth: { authFingerprint, ...harnessBinding.auth }, + auth: { + authFingerprint, + modelId: route.model, + modelApi: route.provider === "anthropic" ? "anthropic-messages" : "openai-responses", + ...harnessBinding.auth, + }, deps: harnessBinding.deps, }); } diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 0a35f3e18bb4..ca74ccb8a04f 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -312,6 +312,13 @@ function successfulRun(provider: string, model: string, params?: SuccessfulRunPa : { ...successfulAgentHarnessBinding(params), authFingerprint: "test-credential-owner", + modelId: model, + modelApi: + provider === "anthropic" + ? "anthropic-messages" + : provider === "groq" + ? "openai-completions" + : "openai-responses", ...(params?.authProfileId ? { authProfileId: params.authProfileId } : {}), }, ); @@ -733,7 +740,7 @@ describe("detectSetupInference", () => { kind: "existing-model", modelRef: "openai/gpt-5.5", label: "Current model", - detail: "already configured", + detail: "openai/gpt-5.5 — already configured", credentials: true, }, ]); @@ -1681,6 +1688,35 @@ describe("activateSetupInference", () => { }); }); + it("returns an auth failure when the verified owner drifts during persistence", async () => { + const configHarness = createConfigTransformHarness(); + const result = await activateSetupInference({ + kind: "openai-api-key", + surface: "gateway", + runtime, + deps: { + runEmbeddedAgent: vi.fn(successfulRunner("openai", "gpt-5.6")) as never, + transformConfigWithPendingPluginInstalls: configHarness.transform as never, + // The real revalidation throws when the current route owner no longer + // matches the probe credential (e.g. a Codex-imported OAuth profile + // outranking the probed env key). The ladder needs a failure result. + createSystemAgentVerifiedInferenceBinding: vi.fn(async () => { + throw new Error( + "The successful inference credential is no longer the active route owner.", + ); + }) as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ + ok: false, + status: "auth", + error: expect.stringContaining("verified inference owner changed"), + }); + expect(configHarness.current()).toEqual({}); + }); + it("revalidates a stable CLI runtime owner at the config commit boundary", async () => { const configHarness = createConfigTransformHarness(); const resolveCliRuntimeOwnerFingerprint = vi.fn(async () => "test-runtime-owner"); @@ -1754,28 +1790,36 @@ describe("activateSetupInference", () => { params.onSuccessfulAuthBinding?.({ ...successfulAgentHarnessBinding(params), authFingerprint: initialAuthFingerprint, + modelId: "claude-opus-4-8", + modelApi: "anthropic-messages", }); return successfulRun("anthropic", "claude-opus-4-8"); }); - await expect( - activateSetupInference({ - kind: "anthropic-api-key", - surface: "gateway", - runtime, - deps: { - runEmbeddedAgent: runEmbeddedAgent as never, - resolveApiKeyForProvider: vi.fn(async () => ({ - apiKey: "rotated-env-key", - source: "env:ANTHROPIC_API_KEY", - mode: "api-key", - })) as never, - createSystemAgentVerifiedInferenceBinding, - transformConfigWithPendingPluginInstalls: configHarness.transform as never, - createTempDir: makeTempDir, - }, - }), - ).rejects.toThrow("active route owner"); + const result = await activateSetupInference({ + kind: "anthropic-api-key", + surface: "gateway", + runtime, + deps: { + runEmbeddedAgent: runEmbeddedAgent as never, + resolveApiKeyForProvider: vi.fn(async () => ({ + apiKey: "rotated-env-key", + source: "env:ANTHROPIC_API_KEY", + mode: "api-key", + })) as never, + createSystemAgentVerifiedInferenceBinding, + transformConfigWithPendingPluginInstalls: configHarness.transform as never, + createTempDir: makeTempDir, + }, + }); + + // Owner drift is a failure result, not a throw: the guided-onboarding + // ladder must be able to continue to its next candidate. + expect(result).toMatchObject({ + ok: false, + status: "auth", + error: expect.stringContaining("active route owner"), + }); expect(configHarness.transform).toHaveBeenCalledOnce(); expect(configHarness.current()).toEqual({}); }); @@ -2427,6 +2471,8 @@ describe("activateSetupInference", () => { authProfileId: "groq:fallback", ...successfulAgentHarnessBinding(params), authFingerprint: "fallback-owner", + modelId: "llama-3.3-70b-versatile", + modelApi: "openai-completions", }); return successfulRun("groq", "llama-3.3-70b-versatile"); }, @@ -2685,42 +2731,49 @@ describe("activateSetupInference", () => { authProfileId: profileId, ...successfulAgentHarnessBinding(params), authFingerprint, + modelId: "llama-3.3-70b-versatile", + modelApi: "openai-completions", }); return successfulRun("groq", "llama-3.3-70b-versatile"); }, ); try { - await expect( - activateSetupInference({ - kind: "api-key", - authChoice: "groq-api-key", - apiKey: "submitted-key", - surface: "gateway", - runtime, - deps: { - readConfigFileSnapshot: vi.fn(async () => ({ - exists: true, - valid: true, - config: initialConfig, - runtimeConfig: initialConfig, - })) as never, - resolvePluginProviders: () => [createGroqSetupProvider()], - resolveManifestProviderAuthChoice: groqSetupChoice, - runEmbeddedAgent: runEmbeddedAgent as never, - resolveApiKeyForProvider: vi.fn(async (params: { profileId?: string }) => ({ - apiKey: "different-real-store-key", - profileId: params.profileId, - source: `profile:${params.profileId}`, - mode: "api-key", - })) as never, - createSystemAgentVerifiedInferenceBinding, - transformConfigWithPendingPluginInstalls: configHarness.transform as never, - createTempDir: makeTempDir, - }, - }), - ).rejects.toThrow("active route owner"); + const result = await activateSetupInference({ + kind: "api-key", + authChoice: "groq-api-key", + apiKey: "submitted-key", + surface: "gateway", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + config: initialConfig, + runtimeConfig: initialConfig, + })) as never, + resolvePluginProviders: () => [createGroqSetupProvider()], + resolveManifestProviderAuthChoice: groqSetupChoice, + runEmbeddedAgent: runEmbeddedAgent as never, + resolveApiKeyForProvider: vi.fn(async (params: { profileId?: string }) => ({ + apiKey: "different-real-store-key", + profileId: params.profileId, + source: `profile:${params.profileId}`, + mode: "api-key", + })) as never, + createSystemAgentVerifiedInferenceBinding, + transformConfigWithPendingPluginInstalls: configHarness.transform as never, + createTempDir: makeTempDir, + }, + }); + // Owner drift resolves to an auth failure after rolling back the staged + // credential; a throw here used to crash the onboarding wizard. + expect(result).toMatchObject({ + ok: false, + status: "auth", + error: expect.stringContaining("active route owner"), + }); expect(configHarness.current()).toEqual(canonicalizeAgentEntriesForTest(initialConfig)); expect( Object.keys(readAuthProfileStoreForTest(agentDir).profiles).filter((id) => @@ -5135,12 +5188,16 @@ describe("verifySetupInference", () => { authProfileId?: string; agentHarnessId?: string; authFingerprint?: string; + modelId?: string; + modelApi?: string; }) => void; }) => { params.onSuccessfulAuthBinding?.({ authProfileId: "openai:p2", agentHarnessId: "openclaw", authFingerprint: verifiedAuthFingerprint, + modelId: "gpt-5.5", + modelApi: "openai-responses", }); return successfulRun("openai", "gpt-5.5"); }, @@ -5202,6 +5259,8 @@ describe("verifySetupInference", () => { authProfileId: profileId, ...successfulAgentHarnessBinding(params), authFingerprint, + modelId: "gpt-5.5", + modelApi: "openai-responses", }); return successfulRun("openai", "gpt-5.5"); }); @@ -5287,6 +5346,8 @@ describe("verifySetupInference", () => { authProfileId: profileId, ...successfulAgentHarnessBinding(params), authFingerprint, + modelId: "gpt-5.6-sol", + modelApi: "openai-responses", }); return successfulRun("openai", "gpt-5.6-sol"); }); diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index 5a1103683c19..250dc857ae42 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -197,6 +197,15 @@ class SetupInferenceActivationUnavailableError extends Error { override name = "SetupInferenceActivationUnavailableError"; } +/** + * The live-tested owner no longer matches current config. Activation maps this + * to `{ ok: false, status: "auth" }` so the guided-onboarding ladder can move + * to its next candidate instead of crashing the CLI. + */ +class SetupInferenceOwnerDriftError extends Error { + override name = "SetupInferenceOwnerDriftError"; +} + export type VerifySetupInferenceResult = | { ok: true; @@ -1612,6 +1621,9 @@ export async function activateSetupInference( if (error instanceof SetupInferenceActivationUnavailableError) { return { ok: false, status: "unavailable", error: redacted }; } + if (error instanceof SetupInferenceOwnerDriftError) { + return { ok: false, status: "auth", error: redacted }; + } if (error instanceof SetupInferenceActivationIndeterminateError) { throw new SetupInferenceActivationIndeterminateError(redacted); } @@ -2018,23 +2030,12 @@ async function activateSetupInferenceUnredacted( "The default-agent inference route could not be resolved after its live test. Review the current model/auth/runtime settings and retry.", }; } - try { - const binding = await revalidateSetupInferenceOwner({ - route: latestResolvedRoute, - auth: test.auth, - deps, - }); - if (!hasSameOwnerPluginArtifacts(binding, stagedOwnerPluginArtifacts)) { - throw new Error("inference owner plugin runtime changed during its live test"); - } - } catch { - return { - ok: false, - status: "auth", - error: - "The verified inference owner changed before activation completed. Retry the inference check.", - }; - } + await revalidateStableSetupInferenceOwner({ + route: latestResolvedRoute, + auth: test.auth, + stagedOwnerPluginArtifacts, + deps, + }); } if (needsPersistence) { const { stripPendingPluginInstallRecords } = @@ -2232,14 +2233,12 @@ async function activateSetupInferenceUnredacted( "The source config no longer matches the verified candidate, so it was not saved. Review the current config and retry.", ); } - const binding = await revalidateSetupInferenceOwner({ + await revalidateStableSetupInferenceOwner({ route: nextResolvedRoute, auth: test.auth, + stagedOwnerPluginArtifacts, deps, }); - if (!hasSameOwnerPluginArtifacts(binding, stagedOwnerPluginArtifacts)) { - throw new Error("inference owner plugin runtime changed during its live test"); - } // Once this callback returns, the config writer owns the candidate. // Any later throw may be post-commit and needs reconciliation. throwIfSetupInferenceCancelled(params); @@ -2411,6 +2410,41 @@ function hasSameOwnerPluginArtifacts( ); } +/** + * Revalidate the successful probe's owner against current config. Any drift + * throws SetupInferenceOwnerDriftError, which activation returns as an auth + * failure result — a throw that escapes here would crash the onboarding ladder. + */ +async function revalidateStableSetupInferenceOwner(params: { + route: SystemAgentConfiguredRoute; + auth: AgentExecutionAuthBinding; + stagedOwnerPluginArtifacts: SystemAgentOwnerPluginArtifactSnapshot | undefined; + deps: ActivateSetupInferenceDeps; +}): Promise { + let binding: SystemAgentVerifiedInferenceBinding; + try { + binding = await revalidateSetupInferenceOwner({ + route: params.route, + auth: params.auth, + deps: params.deps, + }); + } catch (error) { + throw new SetupInferenceOwnerDriftError( + `The verified inference owner changed before activation completed. Retry the inference check. (${formatErrorMessage(error)})`, + { cause: error }, + ); + } + if ( + !params.stagedOwnerPluginArtifacts || + !hasSameOwnerPluginArtifacts(binding, params.stagedOwnerPluginArtifacts) + ) { + throw new SetupInferenceOwnerDriftError( + "The verified inference owner changed before activation completed. Retry the inference check. (The owner plugin runtime changed during its live test.)", + ); + } + return binding; +} + type VerifySetupInferenceParams = { kind?: "existing-model"; agentId?: string; @@ -2787,17 +2821,12 @@ export async function verifySetupInferenceConfig(params: { } if (params.requireExecutionOwner || params.onVerifiedExecution) { try { - const binding = await revalidateSetupInferenceOwner({ + const binding = await revalidateStableSetupInferenceOwner({ route: configuredRoute!, auth: test.auth, + stagedOwnerPluginArtifacts, deps, }); - if ( - !stagedOwnerPluginArtifacts || - !hasSameOwnerPluginArtifacts(binding, stagedOwnerPluginArtifacts) - ) { - throw new Error("inference owner plugin runtime changed during its live test"); - } params.onVerifiedExecution?.(test.auth, binding); } catch { return { diff --git a/src/system-agent/system-agent.test-helpers.ts b/src/system-agent/system-agent.test-helpers.ts index 051623ff6408..b0a70582385f 100644 --- a/src/system-agent/system-agent.test-helpers.ts +++ b/src/system-agent/system-agent.test-helpers.ts @@ -154,6 +154,9 @@ export async function createSystemAgentVerifiedInferenceTestFixture( ...(profileId ? { authProfileId: profileId } : {}), authFingerprint, agentHarnessId, + modelId: configuredRoute.model, + modelApi: + configuredRoute.provider === "anthropic" ? "anthropic-messages" : "openai-responses", ...(agentHarnessId === "openclaw" ? {} : { diff --git a/src/system-agent/verified-inference.test.ts b/src/system-agent/verified-inference.test.ts index a0a7d43a535f..05ab9978215f 100644 --- a/src/system-agent/verified-inference.test.ts +++ b/src/system-agent/verified-inference.test.ts @@ -216,6 +216,8 @@ async function bindingFor( auth: { authProfileId: "openai:verified", authFingerprint, + modelId: route.model, + modelApi: route.provider === "anthropic" ? "anthropic-messages" : "openai-responses", ...(agentHarnessId ? { agentHarnessId, @@ -333,6 +335,91 @@ describe("verified OpenClaw inference binding", () => { ).rejects.toThrow("active secret unavailable"); }); + it("reuses the successful model transport facts for owner re-resolution", async () => { + // The successful run already resolved the model under its selected auth + // plan. Revalidation must carry that exact tuple forward instead of + // repeating catalog and provider discovery in the authority hot path. + const envConfig = { + agents: { + defaults: { + model: "openai/gpt-5.6", + models: { "openai/gpt-5.6": { agentRuntime: { id: "openclaw" } } }, + }, + }, + } satisfies OpenClawConfig; + const route = await resolveSystemAgentConfiguredRouteFromConfig(envConfig); + if (!route) { + throw new Error("missing test route"); + } + const envAuth = { + apiKey: "env-key", + source: "env: OPENAI_API_KEY", + mode: "api-key" as const, + }; + const authFingerprint = fingerprintResolvedProviderAuth(envAuth); + if (!authFingerprint) { + throw new Error("missing test env fingerprint"); + } + const resolveAuth = vi.fn(async () => envAuth); + const binding = await createSystemAgentVerifiedInferenceBinding({ + configuredRoute: route, + executionRoute: route, + auth: { + authFingerprint, + agentHarnessId: "openclaw", + modelId: "gpt-5.6", + modelApi: "openai-responses", + }, + deps: { + ...pluginArtifactDeps(), + resolveApiKeyForProvider: resolveAuth as never, + }, + }); + + expect(binding.auth.authFingerprint).toBe(authFingerprint); + expect(resolveAuth).toHaveBeenCalledWith( + expect.objectContaining({ modelId: "gpt-5.6", modelApi: "openai-responses" }), + ); + }); + + it("fails closed when a credential-backed run omits its model transport facts", async () => { + const envConfig = { + agents: { + defaults: { + model: "openai/gpt-5.6", + models: { "openai/gpt-5.6": { agentRuntime: { id: "openclaw" } } }, + }, + }, + } satisfies OpenClawConfig; + const route = await resolveSystemAgentConfiguredRouteFromConfig(envConfig); + if (!route) { + throw new Error("missing test route"); + } + const envAuth = { + apiKey: "env-key", + source: "env: OPENAI_API_KEY", + mode: "api-key" as const, + }; + const authFingerprint = fingerprintResolvedProviderAuth(envAuth); + if (!authFingerprint) { + throw new Error("missing test env fingerprint"); + } + const resolveAuth = vi.fn(async () => envAuth); + + await expect( + createSystemAgentVerifiedInferenceBinding({ + configuredRoute: route, + executionRoute: route, + auth: { authFingerprint, agentHarnessId: "openclaw" }, + deps: { + ...pluginArtifactDeps(), + resolveApiKeyForProvider: resolveAuth as never, + }, + }), + ).rejects.toThrow("no longer the active route owner"); + expect(resolveAuth).not.toHaveBeenCalled(); + }); + it("accepts and revalidates an opaque CLI owner emitted after a successful turn", async () => { const cliConfig = { agents: { defaults: { model: "claude-cli/claude-opus-4-8" } }, @@ -838,6 +925,8 @@ describe("verified OpenClaw inference binding", () => { authProfileId: "openai:verified", authFingerprint, agentHarnessId: "openclaw", + modelId: configuredRoute.model, + modelApi: "openai-responses", }, deps: { ...authDeps(), ...pluginArtifactDeps() }, }); @@ -1003,6 +1092,8 @@ describe("verified OpenClaw inference binding", () => { authProfileId: "openai:verified", authFingerprint, agentHarnessId: "codex", + modelId: route.model, + modelApi: "openai-responses", runtimeOwnerKind: "plugin-harness", runtimeOwnerId: "codex", ...codexRuntimeArtifactAuth, diff --git a/src/system-agent/verified-inference.ts b/src/system-agent/verified-inference.ts index bfe87932f959..4ab640589d9f 100644 --- a/src/system-agent/verified-inference.ts +++ b/src/system-agent/verified-inference.ts @@ -113,6 +113,8 @@ export type SystemAgentVerifiedInferenceBinding = Readonly<{ auth: Readonly<{ authProfileId?: string; agentHarnessId?: string; + modelId?: string; + modelApi?: string; authFingerprint: string; proofKind?: "runtime-owner"; runtimeOwnerKind?: OpaqueRuntimeOwnerKind; @@ -487,6 +489,8 @@ export function captureSystemAgentOwnerPluginArtifacts(params: { async function resolveCurrentAuthFingerprint(params: { route: SystemAgentConfiguredRoute; authProfileId?: string; + modelId?: string; + modelApi?: string; skipLocalCredential?: boolean; deps: SystemAgentVerifiedInferenceDeps; }): Promise { @@ -576,6 +580,9 @@ async function resolveCurrentAuthFingerprint(params: { deps: params.deps, }); } + if (!params.modelId || !params.modelApi) { + return undefined; + } const resolveAuth = params.deps.resolveApiKeyForProvider ?? resolveApiKeyForProvider; const auth = await resolveAuth({ provider: params.route.provider, @@ -588,6 +595,8 @@ async function resolveCurrentAuthFingerprint(params: { ), profileId: params.authProfileId, lockedProfile: true, + modelId: params.modelId, + modelApi: params.modelApi, secretSentinels: false, }); if (auth.profileId !== params.authProfileId || !auth.apiKey) { @@ -600,6 +609,11 @@ async function resolveCurrentAuthFingerprint(params: { }); } } + // Credential selection is transport-sensitive. Reuse the facts from the + // successful run so this authority hot path cannot pick a different owner. + if (!params.modelId || !params.modelApi) { + return undefined; + } const resolveAuth = params.deps.resolveApiKeyForProvider ?? resolveApiKeyForProvider; const auth = await resolveAuth({ provider: params.route.provider, @@ -613,6 +627,8 @@ async function resolveCurrentAuthFingerprint(params: { ...(params.authProfileId ? { profileId: params.authProfileId, lockedProfile: true as const } : {}), + modelId: params.modelId, + modelApi: params.modelApi, secretSentinels: true, }); if (params.authProfileId && auth.profileId !== params.authProfileId) { @@ -631,6 +647,8 @@ export async function createSystemAgentVerifiedInferenceBinding(params: { const runConfig = structuredClone(params.executionRoute.runConfig); const execution = { ...params.executionRoute, runConfig } as SystemAgentConfiguredRoute; const authProfileId = params.auth.authProfileId ?? execution.authProfileId; + const modelId = params.auth.modelId?.trim(); + const modelApi = params.auth.modelApi?.trim(); if (authProfileId) { execution.authProfileId = authProfileId; } @@ -725,6 +743,8 @@ export async function createSystemAgentVerifiedInferenceBinding(params: { : resolveCurrentAuthFingerprint({ route: execution, ...(authProfileId ? { authProfileId } : {}), + ...(modelId ? { modelId } : {}), + ...(modelApi ? { modelApi } : {}), ...(params.auth.skipLocalCredential ? { skipLocalCredential: true } : {}), deps, })); @@ -763,6 +783,8 @@ export async function createSystemAgentVerifiedInferenceBinding(params: { auth: { ...(authProfileId ? { authProfileId } : {}), ...(successfulHarnessId ? { agentHarnessId: successfulHarnessId } : {}), + ...(modelId ? { modelId } : {}), + ...(modelApi ? { modelApi } : {}), authFingerprint, ...(proofKind === "runtime-owner" ? { proofKind } : {}), ...(params.auth.runtimeOwnerKind ? { runtimeOwnerKind: params.auth.runtimeOwnerKind } : {}), @@ -917,6 +939,8 @@ export async function resolveSystemAgentVerifiedInferenceRoute( : resolveCurrentAuthFingerprint({ route: currentExecution, ...(binding.auth.authProfileId ? { authProfileId: binding.auth.authProfileId } : {}), + ...(binding.auth.modelId ? { modelId: binding.auth.modelId } : {}), + ...(binding.auth.modelApi ? { modelApi: binding.auth.modelApi } : {}), ...(binding.auth.skipLocalCredential ? { skipLocalCredential: true } : {}), deps, }) diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index 662c2b555530..000928eff21f 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -309,6 +309,8 @@ export const en = { completeWithoutAi: "OpenClaw setup is saved. Connect AI before opening chat.", detected: "AI detection complete.", detectedCandidate: "{label} — {detail}{recommended}", + detectedGroupLabel: "Detected on this machine", + detectedGroupPrompt: "Use which detected AI?", detectedTitle: "AI found", detecting: "Looking for AI you already use…", enterApiKey: "Enter API key — {label}", diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index c70ad6a68a09..d471ce6d5d86 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -302,6 +302,8 @@ export const zh_CN = { completeWithoutAi: "OpenClaw 设置已保存。连接 AI 后再打开聊天。", detected: "AI 检测完成。", detectedCandidate: "{label} — {detail}{recommended}", + detectedGroupLabel: "在这台机器上检测到的 AI", + detectedGroupPrompt: "要使用哪个检测到的 AI?", detectedTitle: "找到的 AI", detecting: "正在查找你已使用的 AI…", enterApiKey: "输入 API key — {label}", diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index a2ef51fa880e..9fb6b3bc77ff 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -302,6 +302,8 @@ export const zh_TW = { completeWithoutAi: "OpenClaw 設定已儲存。連接 AI 後再開啟聊天。", detected: "AI 偵測完成。", detectedCandidate: "{label} — {detail}{recommended}", + detectedGroupLabel: "在這台機器上偵測到的 AI", + detectedGroupPrompt: "要使用哪個偵測到的 AI?", detectedTitle: "找到的 AI", detecting: "正在尋找你已使用的 AI…", enterApiKey: "輸入 API key — {label}",