diff --git a/docs/auth-credential-semantics.md b/docs/auth-credential-semantics.md index 649c79d004fd..6e30b1055b16 100644 --- a/docs/auth-credential-semantics.md +++ b/docs/auth-credential-semantics.md @@ -63,8 +63,10 @@ restart, without caching a verification result. This adds no database schema or migration; older runtimes do not enforce the inactive state. Before downgrading, remove saved inactive replacements or restore the state from before setup. -Noninteractive setup saves replacement credentials for later activation in Model -Setup and leaves the current connection unchanged. Reusing an existing credential +Noninteractive setup saves replacement credentials and prints a +`openclaw models auth activate --agent ` command to test and activate +the saved sign-in. Model Setup offers the same operation. Interactive setup defaults +to activation after a successful test. Reusing an existing credential and first-run noninteractive setup retain their existing behavior. ## Agent copy portability diff --git a/docs/cli/models.md b/docs/cli/models.md index d48ba5fb6c75..b14940c622e6 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -314,6 +314,7 @@ openclaw models auth list [--provider ] [--json] openclaw models auth login --provider [--agent ] openclaw models auth login --provider openai --profile-id openai:work openclaw models auth login-github-copilot +openclaw models auth activate [--agent ] openclaw models auth logout [--yes] openclaw models auth paste-api-key --provider openclaw models auth setup-token --provider @@ -341,11 +342,13 @@ fallback applies to auth changes, not to `models list`. For the shared-main agent, `--force` clears the provider's shared credentials and main-agent local overrides, including their order and health state. For another agent it clears only that agent's local profiles, leaving shared credentials unchanged. A busy auth store stops the command before login starts; close other OpenClaw commands using the same state directory and retry. SQLite lock diagnostics can name either the shared state database or an agent database, so checking only the legacy auth file for open handles does not rule out contention. +`models auth activate ` tests a saved sign-in and selects its verified model and account for the chosen agent. Use the exact command printed after unattended replacement setup, or find the saved id with `models auth list --json`. This command confirms activation without another prompt; a failed test leaves the current connection unchanged. + `models auth logout ` removes one saved auth profile from the selected agent auth store. Use the profile id shown by `models auth list`. It also drops that profile from `auth.profiles` and from every `auth.order` list in your config, so no stale reference is left behind, and it deletes an `auth.order.` entry that would otherwise be emptied (an authored empty order means "select no profiles" and would disable the provider). It prompts for confirmation on a TTY; pass `--yes` for scripts and agents. Provider key references are cleared before the credential is removed. Model defaults and connection settings stay unchanged. Logout refuses when the profile is not in the store. `models auth login-github-copilot` is a shortcut for `models auth login --provider github-copilot --method device` (GitHub device flow); it accepts `--yes` to overwrite an existing profile without prompting. -Use either `openclaw models auth --agent ` or `openclaw models auth --agent ` to target a specific configured agent store. Both forms are supported by `add`, `list`, `login`, `logout`, `paste-api-key`, `setup-token`, `paste-token`, `login-github-copilot`, and `order get`/`set`/`clear`. +Use either `openclaw models auth --agent ` or `openclaw models auth --agent ` to target a specific configured agent store. Both forms are supported by `add`, `list`, `login`, `activate`, `logout`, `paste-api-key`, `setup-token`, `paste-token`, `login-github-copilot`, and `order get`/`set`/`clear`. For OpenAI models, `--provider openai` defaults to ChatGPT/Codex account login. Use `--method api-key` only when you want to add an OpenAI API-key profile, usually as a backup for Codex subscription limits. Run `openclaw doctor --fix` to migrate older legacy OpenAI Codex prefix auth/profile state to `openai`. diff --git a/src/agents/auth-profiles/session-override.selection.test.ts b/src/agents/auth-profiles/session-override.selection.test.ts index bbd6a9d94bcf..5220a6e16642 100644 --- a/src/agents/auth-profiles/session-override.selection.test.ts +++ b/src/agents/auth-profiles/session-override.selection.test.ts @@ -39,9 +39,12 @@ async function select(params: { sessionEntry: SessionEntry; configuredProfileId?: string; modelId?: string; + cfg?: OpenClawConfig; + agentId?: string; }) { return await resolveSessionAuthSelection({ - cfg: {} as OpenClawConfig, + cfg: params.cfg ?? {}, + agentId: params.agentId, provider: "openai", modelId: params.modelId ?? "gpt-5.6-sol", ...(params.configuredProfileId ? { configuredProfileId: params.configuredProfileId } : {}), @@ -54,6 +57,43 @@ async function select(params: { } describe("session auth selection prepared facts", () => { + it.each([ + { source: "auto", selectedModel: "gpt-4.1", expected: TEST_SECONDARY_PROFILE_ID }, + { source: "user", selectedModel: "gpt-4.1", expected: TEST_PRIMARY_PROFILE_ID }, + { source: "auto", selectedModel: "gpt-4.1-mini", expected: TEST_PRIMARY_PROFILE_ID }, + ] as const)( + "selects $expected for $source sessions using $selectedModel after activation", + async ({ source, selectedModel, expected }) => { + await withAuthState(async (state) => { + configureProfiles(); + const sessionEntry: SessionEntry = { + sessionId: "existing-session", + updatedAt: 1, + compactionCount: 0, + authProfileOverride: TEST_PRIMARY_PROFILE_ID, + authProfileOverrideSource: source, + authProfileOverrideCompactionCount: 0, + }; + await expect( + select({ + agentDir: state.agentDir(), + agentId: "main", + cfg: { + agents: { + entries: { main: { model: `openai/gpt-4.1@${TEST_SECONDARY_PROFILE_ID}` } }, + }, + }, + modelId: selectedModel, + sessionEntry, + }), + ).resolves.toMatchObject({ + profileId: expected, + source: source === "user" || expected === TEST_SECONDARY_PROFILE_ID ? "user" : "auto", + }); + }); + }, + ); + it("returns prepared facts for a user pin", async () => { await withAuthState(async (state) => { configureProfiles(); diff --git a/src/agents/auth-profiles/session-override.test-support.ts b/src/agents/auth-profiles/session-override.test-support.ts index f1b39a2517d7..63d53c5e724e 100644 --- a/src/agents/auth-profiles/session-override.test-support.ts +++ b/src/agents/auth-profiles/session-override.test-support.ts @@ -61,6 +61,7 @@ vi.mock("./usage.js", () => ({ vi.mock("../../plugins/provider-model-routes.js", () => ({ // Synthetic route IDs in this fixture are already canonical. createProviderModelCatalogIdNormalizer: () => (modelId: string) => modelId, + resolveProviderModelCatalogId: ({ modelId }: { modelId: string }) => modelId, resolveProviderModelPolicySurface: () => null, resolveProviderModelRoutes: authStoreMocks.resolveProviderModelRoutes, })); diff --git a/src/agents/auth-profiles/session-override.ts b/src/agents/auth-profiles/session-override.ts index cd1ac7107054..2e3bd33de00b 100644 --- a/src/agents/auth-profiles/session-override.ts +++ b/src/agents/auth-profiles/session-override.ts @@ -8,6 +8,7 @@ import { shouldPreserveUnavailableSessionAuthProfileOverride } from "../../sessi import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { isUserModelAuthProfileId } from "../../state/user-model-account-id.js"; import { resolveUserProfileAuthLink } from "../../state/user-model-accounts.js"; +import { resolveAgentEffectiveModelPrimary } from "../agent-scope.js"; import { isConfiguredAwsSdkAuthProfileForProvider, isStoredCredentialCompatibleWithAuthProvider, @@ -21,6 +22,8 @@ import { import { isProfileInCooldown } from "../auth-profiles/usage.js"; import { resolveModelProviderAuthConfig } from "../model-auth-provider-route.js"; import { splitTrailingAuthProfile } from "../model-ref-profile.js"; +import { resolveDefaultModelForAgent } from "../model-selection.js"; +import { resolveModelCatalogIdentityKey } from "../openai-model-routes.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../openai-routing.js"; import { resolveProviderModelRouteAuthRequirement } from "../provider-model-route-auth.js"; import { ensureAuthProfileStore } from "./store-runtime.js"; @@ -547,6 +550,7 @@ export async function resolveSessionAuthSelection(params: { cfg: OpenClawConfig; provider: string; modelId: string; + agentId?: string; configuredProfileId?: string; harnessRuntime?: string; agentDir: string; @@ -582,7 +586,20 @@ export async function resolveSessionAuthSelection(params: { // Person-linked pins carry user strength and outrank the agent's static @profile. const rotatedPinnedProfileId = rotatedSource === "user" || rotatedSource === "user-link" ? rotatedProfileId : undefined; - const configuredProfileId = params.configuredProfileId?.trim() || undefined; + const configuredProfile = params.agentId + ? splitTrailingAuthProfile(resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) ?? "") + .profile + : undefined; + const defaultModel = configuredProfile + ? resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId }) + : undefined; + const configuredProfileId = + params.configuredProfileId?.trim() || + (defaultModel && + resolveModelCatalogIdentityKey({ provider: params.provider, id: modelId }) === + resolveModelCatalogIdentityKey({ provider: defaultModel.provider, id: defaultModel.model }) + ? configuredProfile + : undefined); const profileId = rotatedPinnedProfileId ?? configuredProfileId ?? rotatedProfileId; if (!profileId) { return undefined; diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 5b6bce7e76d0..a4e9894771f4 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -1026,6 +1026,9 @@ describe("runBtwSideQuestion", () => { agentId: "work", allowGatewaySubagentBinding: true, }); + expect(resolveSessionAuthSelectionMock).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "work" }), + ); if (sideQuestion) { expect(sideQuestion).toHaveBeenCalledWith( expect.objectContaining({ agentId: "work", sessionKey: "global" }), diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 330dc563ad23..de17e3e22ef4 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -529,6 +529,7 @@ async function resolveRuntimeModel(params: { cfg, provider: runtimeProvider, modelId: runtimeModelId, + agentId: params.agentId, harnessRuntime: params.harnessId, agentDir, sessionEntry: params.sessionEntry, diff --git a/src/auto-reply/reply/get-reply-run-admission.ts b/src/auto-reply/reply/get-reply-run-admission.ts index 5ff25ddbe676..134ac420aa5a 100644 --- a/src/auto-reply/reply/get-reply-run-admission.ts +++ b/src/auto-reply/reply/get-reply-run-admission.ts @@ -441,6 +441,7 @@ export async function prepareReplyRunAdmission(context: PreparedReplyRunContext) cfg, provider, modelId: model, + agentId, ...(agentHarnessPolicy ? { harnessRuntime: agentHarnessPolicy.runtime } : {}), agentDir, sessionEntry: authSessionEntry, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index d9c40599f6dd..b7ff1af443eb 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -3144,6 +3144,9 @@ describe("runPreparedReply media-only handling", () => { const call = requireLastRunReplyAgentCall(); expect(call?.followupRun.run.authProfileId).toBe("profile-after-wait"); expect(vi.mocked(resolveSessionAuthSelection)).toHaveBeenCalledTimes(1); + expect(resolveSessionAuthSelection).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "default" }), + ); }); it("re-resolves same-session ownership after session-id rotation during async prep", async () => { diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 14258bbbaf2c..2692ec4c9741 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ modelsAuthListCommand: vi.fn().mockResolvedValue(undefined), modelsAuthLoginCommand: vi.fn().mockResolvedValue(undefined), modelsAuthLogoutCommand: vi.fn().mockResolvedValue(undefined), + modelsAuthActivateCommand: vi.fn().mockResolvedValue(undefined), modelsAuthOrderClearCommand: vi.fn().mockResolvedValue(undefined), modelsAuthOrderGetCommand: vi.fn().mockResolvedValue(undefined), modelsAuthOrderSetCommand: vi.fn().mockResolvedValue(undefined), @@ -77,6 +78,9 @@ vi.mock("../commands/models/accounts.js", () => ({ modelsAccountsUseCommand: mocks.modelsAccountsUseCommand, modelsAccountsClearDefaultCommand: mocks.modelsAccountsClearDefaultCommand, })); +vi.mock("../commands/models/auth-activate.js", () => ({ + modelsAuthActivateCommand: mocks.modelsAuthActivateCommand, +})); vi.mock("../commands/models/auth-logout.js", () => ({ modelsAuthLogoutCommand: mocks.modelsAuthLogoutCommand, })); @@ -125,6 +129,7 @@ describe("models cli", () => { modelsAuthListCommand.mockClear(); modelsAuthLoginCommand.mockClear(); modelsAuthLogoutCommand.mockClear(); + mocks.modelsAuthActivateCommand.mockClear(); modelsAuthOrderClearCommand.mockClear(); modelsAuthOrderGetCommand.mockClear(); modelsAuthOrderSetCommand.mockClear(); @@ -383,6 +388,12 @@ describe("models cli", () => { command: modelsAuthAddCommand, expected: { agent: "poe" }, }, + { + label: "activate", + args: ["models", "auth", "--agent", "poe", "activate", "openai:saved"], + command: mocks.modelsAuthActivateCommand, + expected: { agent: "poe", profileId: "openai:saved" }, + }, { label: "list", args: ["models", "auth", "--agent", "poe", "list", "--provider", "openai"], @@ -438,6 +449,12 @@ describe("models cli", () => { command: modelsAuthAddCommand, expected: { agent: "poe" }, }, + { + label: "activate", + args: ["models", "auth", "activate", "openai:saved", "--agent", "poe"], + command: mocks.modelsAuthActivateCommand, + expected: { agent: "poe", profileId: "openai:saved" }, + }, { label: "list", args: ["models", "auth", "list", "--provider", "openai", "--agent", "poe"], diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 6dbacd3e45a4..3ef2f6bfe9f7 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -361,6 +361,19 @@ export function registerModelsCli(program: Command) { }); }); + auth + .command("activate") + .description("Test a saved sign-in and use it for this agent") + .argument("", "Saved sign-in id from models auth list") + .option("--agent ", "Agent id (default: the only configured agent)") + .action(async (profileId: string, opts, command) => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command, opts); + const { modelsAuthActivateCommand } = await import("../commands/models/auth-activate.js"); + await modelsAuthActivateCommand({ profileId, agent }, defaultRuntime); + }); + }); + auth .command("logout") .description("Remove a saved auth profile (see `models auth list` for ids)") diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 56935a022cf4..fb7de16baeaa 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -175,6 +175,7 @@ const JSON_NOT_APPLICABLE = { "models image-fallbacks add", "models image-fallbacks remove", "models image-fallbacks clear", + "models auth activate", "models auth logout", "models auth order set", "models auth order clear", diff --git a/src/commands/models/auth-activate.test.ts b/src/commands/models/auth-activate.test.ts new file mode 100644 index 000000000000..c7b2c5e9bc79 --- /dev/null +++ b/src/commands/models/auth-activate.test.ts @@ -0,0 +1,55 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { modelsAuthActivateCommand } from "./auth-activate.js"; + +const mocks = vi.hoisted(() => ({ callGateway: vi.fn() })); +vi.mock("../../gateway/call.js", () => ({ callGateway: mocks.callGateway })); +vi.mock("../../system-agent/setup-inference.js", () => ({ + activateSetupInference: async () => ({ + ok: true, + modelRef: "sample/chat", + latencyMs: 1, + lines: ["Connection verified"], + }), +})); +vi.mock("./auth-refresh.js", () => ({ + refreshRunningGatewayAuthState: async () => "refreshed", +})); +afterEach(() => vi.clearAllMocks()); + +it.each([ + { name: "applied account", appliedHash: "saved", profile: "replacement", activated: true }, + { name: "reload disabled", appliedHash: "old", profile: "replacement", activated: false }, + { name: "another account selected", appliedHash: "saved", profile: "other", activated: false }, + { name: "lost acknowledgement", appliedHash: null, profile: "replacement", activated: false }, +])( + "reports the saved sign-in accurately with $name", + async ({ appliedHash, profile, activated }) => { + const state = await createOpenClawTestState({ label: "activate-saved-sign-in" }); + try { + await state.writeConfig({ agents: { entries: { main: { workspace: state.workspaceDir } } } }); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + if (appliedHash === null) { + mocks.callGateway.mockRejectedValue(new Error("connection closed")); + } else { + mocks.callGateway.mockResolvedValue({ + config: { agents: { entries: { main: { model: `sample/chat@${profile}` } } } }, + configRevisionHash: "saved", + appliedConfigHash: appliedHash, + }); + } + await modelsAuthActivateCommand({ profileId: "replacement", agent: "main" }, runtime); + const output = runtime.log.mock.calls.flat().join("\n"); + expect(output).toContain("Connection verified"); + if (activated) { + expect(output).toContain("Saved sign-in activated"); + } else { + expect(output).not.toContain("Saved sign-in activated"); + expect(output).toContain("verified and saved"); + expect(output).toContain("openclaw gateway restart"); + } + } finally { + await state.cleanup(); + } + }, +); diff --git a/src/commands/models/auth-activate.ts b/src/commands/models/auth-activate.ts new file mode 100644 index 000000000000..f6b727ecc2ed --- /dev/null +++ b/src/commands/models/auth-activate.ts @@ -0,0 +1,57 @@ +import { resolveAgentEffectiveModelPrimary } from "../../agents/agent-scope.js"; +import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { callGateway } from "../../gateway/call.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { toSavedAuthSetupKind } from "../../system-agent/setup-inference-core.js"; +import { activateSetupInference } from "../../system-agent/setup-inference.js"; +import { refreshRunningGatewayAuthState } from "./auth-refresh.js"; +import { loadValidConfigSnapshotOrThrow, resolveModelsTargetAgent } from "./shared.js"; + +export async function modelsAuthActivateCommand( + opts: { profileId: string; agent?: string }, + runtime: RuntimeEnv, +): Promise { + const { runtimeConfig } = await loadValidConfigSnapshotOrThrow(); + const { agentId } = resolveModelsTargetAgent(runtimeConfig, opts.agent, { kind: "mutation" }); + const result = await activateSetupInference({ + kind: toSavedAuthSetupKind(opts.profileId.trim()), + agentId, + surface: "cli", + activationConfirmed: true, + runtime, + }); + if (!result.ok) { + throw new Error(result.error); + } + const refreshed = await refreshRunningGatewayAuthState(agentId, "update", runtime); + for (const line of result.lines) { + runtime.log(line); + } + let applied = false; + if (refreshed === "refreshed") { + try { + const current = await callGateway<{ + config: OpenClawConfig; + configRevisionHash: string; + appliedConfigHash: string | null; + }>({ + method: "config.get", + params: {}, + timeoutMs: 3000, + requireLocalBackendSharedAuth: true, + }); + applied = + current.configRevisionHash === current.appliedConfigHash && + splitTrailingAuthProfile(resolveAgentEffectiveModelPrimary(current.config, agentId) ?? "") + .profile === opts.profileId.trim(); + } catch { + // A lost acknowledgement cannot turn the completed save into a failed save. + } + } + runtime.log( + applied + ? `Saved sign-in activated for ${agentId}: ${opts.profileId}` + : "Sign-in verified and saved. The running connection could not be confirmed. Run `openclaw gateway restart` to apply the saved settings.", + ); +} diff --git a/src/commands/models/auth-model-policy.test.ts b/src/commands/models/auth-model-policy.test.ts index 6ee9534226f4..c0c50e1ac950 100644 --- a/src/commands/models/auth-model-policy.test.ts +++ b/src/commands/models/auth-model-policy.test.ts @@ -203,8 +203,13 @@ describe("provider model access consent", () => { if (status === "applied") { await expect(result).resolves.toBe("All Sample models are now visible."); } else { - await expect(result).rejects.toThrow("did not apply"); + await expect(result).resolves.toContain("Model access saved."); + expect(await result).toContain("openclaw gateway restart"); } + expect((await readSaved()).agents?.defaults?.modelPolicy?.allow).toEqual([ + "other/current", + "sample/*", + ]); } finally { stop(); } diff --git a/src/commands/models/auth-model-policy.ts b/src/commands/models/auth-model-policy.ts index d9d239765794..51cadd97c3fe 100644 --- a/src/commands/models/auth-model-policy.ts +++ b/src/commands/models/auth-model-policy.ts @@ -162,11 +162,9 @@ export async function completeProviderModelAccess(params: { }, attachRuntimeConfigWriteApplication({}, application), ); - if (application.claimed && (await application.result) !== "applied") { - throw new Error("The running Gateway did not apply the saved model restrictions."); - } + const applied = application.claimed && (await application.result) === "applied"; logConfigUpdated(params.runtime); - const message = application.claimed + const message = applied ? `All ${prepared.providerLabel} models are now visible.` : "Model access saved. Application by the running Gateway is not confirmed. Run `openclaw gateway restart` to apply it."; params.runtime.log(`Credentials saved. ${message}`); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 9e640dbb4fca..f6ce2ab6b08e 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -9,6 +9,8 @@ import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; +import { formatCliCommand } from "../../../cli/command-format.js"; +import { quoteCliArg } from "../../../cli/quote-cli-arg.js"; import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { enablePluginWithCapabilityConsent } from "../../../plugins/enable.js"; @@ -313,6 +315,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { }; const stagingRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-credential-")); const stagingAgentDir = path.join(stagingRoot, "agents", "setup", "agent"); + let savedProfileId: string | undefined; try { await fs.mkdir(stagingAgentDir, { recursive: true }); result = await withAuthProfileStoreAgentDir(stagingAgentDir, stagingRoot, async () => { @@ -369,7 +372,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { "Provider setup did not save a replacement credential. Your connection is unchanged.", ); } - await saveSetupCredential({ + const saved = await saveSetupCredential({ profile, config: projectProviderResult(prepared.config), baseConfig: params.baseConfig, @@ -378,7 +381,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { authChoice: trustedManifestMatch?.choiceId ?? providerChoice.wizard?.choiceId, pluginId: providerChoice.provider.pluginId, }); - result = null; + savedProfileId = saved.profile.profileId; } } finally { clearRuntimeAuthProfileStoreSnapshot(stagingAgentDir); @@ -386,9 +389,9 @@ export async function applyNonInteractivePluginProviderChoice(params: { closeOpenClawAgentDatabases(stagingRoot); await fs.rm(stagingRoot, { recursive: true, force: true }); } - if (!result) { + if (savedProfileId) { return reject( - "Replacement credential saved but inactive. Your connection is unchanged. Open Model Setup to test and activate the saved sign-in.", + `Replacement credential saved but inactive. Your connection is unchanged. Test and activate it with:\n${formatCliCommand(`openclaw models auth activate ${quoteCliArg(savedProfileId)} --agent ${quoteCliArg(params.target.agentId)}`)}`, ); } } else { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.setup-replacement.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.setup-replacement.test.ts index 4b4333a1f301..2058f10d2411 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.setup-replacement.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.setup-replacement.test.ts @@ -117,6 +117,9 @@ it.each([true, false])( }), }), ]); - expect(runtime.error.mock.calls.flat().join("\n")).toContain("saved but inactive"); + const savedProfileId = Object.keys(store.profiles).find((id) => id !== "openai:default"); + expect(runtime.error.mock.calls.flat().join("\n")).toContain( + `openclaw models auth activate ${savedProfileId} --agent main`, + ); }, ); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 61b71da9958d..1884ad985eda 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -6,6 +6,7 @@ */ import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; import { formatCliCommand } from "../../../cli/command-format.js"; +import { quoteCliArg } from "../../../cli/quote-cli-arg.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { SecretInput } from "../../../config/types.secrets.js"; import { formatErrorMessage } from "../../../infra/errors.js"; @@ -278,7 +279,7 @@ export async function applyNonInteractiveAuthChoice(params: { const { prepareCustomSetupCredentials } = await import("../../../system-agent/setup-inference-custom.js"); const prepared = prepareCustomSetupCredentials(result); - await saveSetupCredential({ + const saved = await saveSetupCredential({ profile: prepared.profiles[0]!, config: prepared.config, baseConfig, @@ -288,7 +289,7 @@ export async function applyNonInteractiveAuthChoice(params: { rejectOnboardingOption( opts, runtime, - "Replacement credential saved but inactive. Your connection is unchanged. Open Model Setup to test and activate the saved sign-in.", + `Replacement credential saved but inactive. Your connection is unchanged. Test and activate it with:\n${formatCliCommand(`openclaw models auth activate ${quoteCliArg(saved.profile.profileId)} --agent ${quoteCliArg(params.target.agentId)}`)}`, ); return null; } diff --git a/src/gateway/worker-environments/inference-runtime.ts b/src/gateway/worker-environments/inference-runtime.ts index 5e69f17f1ea0..0130a5a5c7a2 100644 --- a/src/gateway/worker-environments/inference-runtime.ts +++ b/src/gateway/worker-environments/inference-runtime.ts @@ -7,11 +7,7 @@ import type { WorkerInferenceStartParams, WorkerInferenceTerminalOutcome, } from "../../../packages/gateway-protocol/src/schema/worker-inference.js"; -import { - resolveAgentDir, - resolveAgentEffectiveModelPrimary, - resolveAgentWorkspaceDir, -} from "../../agents/agent-scope.js"; +import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { resolveSessionAuthSelection } from "../../agents/auth-profiles/session-override.js"; import { applyExtraParamsToAgent } from "../../agents/embedded-agent-runner/extra-params.js"; import { resolveModelAsync } from "../../agents/embedded-agent-runner/model.js"; @@ -417,13 +413,6 @@ async function resolveApprovedModel(params: { runtimeLease.release(); return undefined; } - const configuredDefaultProfile = - resolvedKey === - resolveModelCatalogIdentityKey({ provider: defaultModel.provider, id: defaultModel.model }) - ? splitTrailingAuthProfile( - resolveAgentEffectiveModelPrimary(lifecycleConfig, target.agentId) ?? "", - ).profile - : undefined; const harnessPolicy = resolveAgentHarnessPolicy({ provider: resolved.ref.provider, modelId: resolved.ref.model, @@ -440,7 +429,7 @@ async function resolveApprovedModel(params: { cfg: lifecycleConfig, provider: resolved.ref.provider, modelId: resolved.ref.model, - ...(configuredDefaultProfile ? { configuredProfileId: configuredDefaultProfile } : {}), + agentId: target.agentId, harnessRuntime: harnessPolicy.runtime, agentDir, sessionEntry: target.sessionEntry, diff --git a/src/system-agent/setup-inference-activate.test.ts b/src/system-agent/setup-inference-activate.test.ts index 6e54f7392159..281eaddca6e3 100644 --- a/src/system-agent/setup-inference-activate.test.ts +++ b/src/system-agent/setup-inference-activate.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert/strict"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -14,6 +15,7 @@ import { resolveApiKeyForProviderCore } from "../agents/model-auth.js"; import { clearConfigCache, readConfigFileSnapshot } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { validateConfigObjectRaw } from "../config/validation-core.js"; import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choices.js"; import { persistProviderAuthProfilesAfterLogin } from "../plugins/provider-auth-persistence.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; @@ -24,6 +26,7 @@ import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js"; import { activateSetupInference } from "./setup-inference-activate.js"; import type { ActivateSetupInferenceDeps } from "./setup-inference-core.js"; +import { saveSetupCredential } from "./setup-inference-credentials.js"; import { detectSetupInference } from "./setup-inference-detect.js"; import { createSystemAgentPluginMetadataTestSnapshot } from "./system-agent.test-helpers.js"; @@ -197,7 +200,10 @@ async function fixture( }; }; } - const activate = (kind: Parameters[0]["kind"] = "provider-auth") => + const activate = ( + kind: Parameters[0]["kind"] = "provider-auth", + activationConfirmed?: true, + ) => metadata.run(() => activateSetupInference({ kind, @@ -206,7 +212,8 @@ async function fixture( nativeSessionCatalogsEnabled: false, surface: "cli", runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - prompter, + prompter: activationConfirmed ? undefined : prompter, + activationConfirmed, deps, }), ); @@ -348,12 +355,13 @@ describe("setup activation credentials and configuration", () => { }); it.each([ - { explicitProfile: true, restartRequired: false }, - { explicitProfile: false, restartRequired: false }, - { explicitProfile: true, restartRequired: true }, + { explicitProfile: true, restartRequired: false, activationConfirmed: undefined }, + { explicitProfile: false, restartRequired: false, activationConfirmed: undefined }, + { explicitProfile: true, restartRequired: true, activationConfirmed: undefined }, + { explicitProfile: true, restartRequired: false, activationConfirmed: true as const }, ])( - "keeps a working credential and rotation when a replacement is rejected (configured: $explicitProfile, restart: $restartRequired)", - async ({ explicitProfile, restartRequired }) => { + "keeps a working credential and rotation when a replacement is rejected (configured: $explicitProfile, restart: $restartRequired, confirmed: $activationConfirmed)", + async ({ explicitProfile, restartRequired, activationConfirmed }) => { const setup = await fixture({ restartRequired }); const originalProfileId = "openai:fixture"; const originalCredential = { ...credential, key: "working-original-key" }; @@ -449,7 +457,7 @@ describe("setup activation credentials and configuration", () => { expect(declined, await setup.diagnostics(declined)).toMatchObject({ ok: false }); expect(setup.prompter.confirm).toHaveBeenCalledWith({ message: "Connection verified. Activate this saved sign-in?", - initialValue: false, + initialValue: true, }); expect(await fs.readFile(setup.configPath, "utf8")).toBe(before); const inactive = loadAuthProfileStoreWithoutExternalProfiles(setup.agentDir); @@ -457,8 +465,10 @@ describe("setup activation credentials and configuration", () => { expect(inactive.lastGood).toEqual(store.lastGood); expect(inactive.usageStats).toEqual(store.usageStats); - vi.mocked(setup.prompter.confirm).mockResolvedValue(true); - const accepted = await setup.activate(retryKind); + vi.mocked(setup.prompter.confirm).mockImplementation( + async ({ initialValue }) => initialValue ?? false, + ); + const accepted = await setup.activate(retryKind, activationConfirmed); expect(accepted, await setup.diagnostics(accepted)).toMatchObject({ ok: true }); expect(setup.readProfile()).toEqual([savedId, credential]); expect(setup.login).toHaveBeenCalledOnce(); @@ -469,6 +479,60 @@ describe("setup activation credentials and configuration", () => { }, ); + it("activates saved sparse model settings without treating runtime defaults as a changed connection", async () => { + const setup = await fixture(); + const configured: OpenClawConfig = { + ...setup.config, + agents: { + ...setup.config.agents, + defaults: { ...setup.config.agents?.defaults, model: `${modelRef}@openai:original` }, + }, + }; + await persistProviderAuthProfilesAfterLogin({ + config: configured, + agentDir: setup.agentDir, + profiles: [ + { profileId: "openai:original", credential: { ...credential, key: "original-key" } }, + ], + }); + await fs.writeFile(setup.configPath, JSON.stringify(configured)); + clearConfigCache(); + const sparse = validateConfigObjectRaw({ + ...configured, + models: { + providers: { + openai: { + baseUrl: "https://provider.example/v1", + api: "openai-responses", + models: [{ id: "gpt-4.1-mini", name: "Sparse saved model" }], + }, + }, + }, + }); + assert.ok(sparse.ok); + const saved = await saveSetupCredential({ + profile: { profileId: "openai:replacement", credential }, + config: sparse.config, + baseConfig: configured, + agentDir: setup.agentDir, + modelRef, + authChoice: "fixture-login", + pluginId: "openai", + }); + + const result = await setup.activate( + `saved-auth:${encodeURIComponent(saved.profile.profileId)}`, + true, + ); + + expect(result, await setup.diagnostics(result)).toMatchObject({ ok: true }); + expect(setup.readProfile()).toEqual([saved.profile.profileId, credential]); + const snapshot = await readConfigFileSnapshot(); + expect(snapshot.sourceConfig.models?.providers?.openai?.models).toEqual([ + { id: "gpt-4.1-mini", name: "Sparse saved model" }, + ]); + }); + it("preserves an unrelated config edit when selecting the verified model", async () => { const setup = await fixture(); const changed = `${JSON.stringify({ ...setup.config, messages: { ackReaction: "seen" } })}\n`; diff --git a/src/system-agent/setup-inference-activate.ts b/src/system-agent/setup-inference-activate.ts index b2cab87b668b..55010e944e17 100644 --- a/src/system-agent/setup-inference-activate.ts +++ b/src/system-agent/setup-inference-activate.ts @@ -13,6 +13,7 @@ import { GEMINI_CLI_DEFAULT_MODEL_REF, OPENAI_API_DEFAULT_MODEL_REF, } from "../commands/onboard-inference.js"; +import { materializeRuntimeConfig } from "../config/materialize.js"; import { applyMergePatch, createMergePatch } from "../config/merge-patch.js"; import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; import { @@ -471,8 +472,14 @@ async function verifyAndActivateCandidate( loadAuthProfileStoreForRuntime: deps.loadAuthProfileStoreForRuntime, }; const requestedAgentId = params.agentId ? routeAgentId : undefined; + // Saved model rows stay sparse; compare the same runtime defaults before and after writing. const project = (config: OpenClawConfig, sourceConfig: OpenClawConfig) => - projectInferenceRoute(config, requestedAgentId, routeDeps, sourceConfig); + projectInferenceRoute( + materializeRuntimeConfig(config, { manifestRegistry: { plugins: [...metadata.plugins] } }), + requestedAgentId, + routeDeps, + sourceConfig, + ); const resolveRoute = (config: OpenClawConfig, currentSnapshot = snapshot) => resolveSystemAgentConfiguredRouteFromConfig( config, @@ -532,12 +539,12 @@ async function verifyAndActivateCandidate( const savedCredential = staged.authProfileId ? loadAuthProfileStoreWithoutExternalProfiles(ctx.agentDir).profiles[staged.authProfileId] : undefined; - if (savedCredential?.setup?.replacement) { + if (savedCredential?.setup?.replacement && !params.activationConfirmed) { if ( !params.prompter || !(await params.prompter.confirm({ message: "Connection verified. Activate this saved sign-in?", - initialValue: false, + initialValue: true, })) ) { return failure({ diff --git a/src/system-agent/setup-inference-core.ts b/src/system-agent/setup-inference-core.ts index d0c0b1799362..c35d9a64d5d2 100644 --- a/src/system-agent/setup-inference-core.ts +++ b/src/system-agent/setup-inference-core.ts @@ -203,6 +203,8 @@ export type ActivateSetupInferenceParams = { /** False when an enclosing persistent-operation boundary owns the setup audit. */ recordSetupAudit?: boolean; runtime: RuntimeEnv; + /** Explicit consent from the CLI command that tests and activates a saved sign-in. */ + activationConfirmed?: true; /** Interactive provider login transport, required for `provider-auth`. */ prompter?: WizardPrompter; /** Cancels provider-owned browser callbacks and device-code polling. */ diff --git a/src/wizard/setup.inference-verification.ts b/src/wizard/setup.inference-verification.ts index 38fefc257f24..b9eb3be36de2 100644 --- a/src/wizard/setup.inference-verification.ts +++ b/src/wizard/setup.inference-verification.ts @@ -278,7 +278,7 @@ export async function offerLiveModelVerification(params: { (params.opts.nonInteractive || !(await params.prompter.confirm({ message: "Connection verified. Activate this saved sign-in?", - initialValue: false, + initialValue: true, }))) ) { await params.prompter.note( diff --git a/ui/src/e2e/model-api-keys.real-gateway.e2e.test.ts b/ui/src/e2e/model-api-keys.real-gateway.e2e.test.ts index 1732bb499d6c..ae3e736cdbe0 100644 --- a/ui/src/e2e/model-api-keys.real-gateway.e2e.test.ts +++ b/ui/src/e2e/model-api-keys.real-gateway.e2e.test.ts @@ -77,7 +77,7 @@ suite.define(() => { const card = page.locator('[data-provider-id="fixture"]'); await card.waitFor(); await page.screenshot({ path: path.join(suite.artifactDir, "before-edit.png") }); - await card.getByRole("button", { name: "Replace key" }).click(); + await card.getByRole("button", { name: "Set API key" }).click(); await card.getByLabel("API key").fill(key); await card.getByRole("button", { name: "Save", exact: true }).click(); await expect.poll(() => card.textContent()).toContain("Secret saved."); diff --git a/ui/src/e2e/model-provider-usage-outcomes.e2e.test.ts b/ui/src/e2e/model-provider-usage-outcomes.e2e.test.ts index 371edfbc364a..02adf75e02e7 100644 --- a/ui/src/e2e/model-provider-usage-outcomes.e2e.test.ts +++ b/ui/src/e2e/model-provider-usage-outcomes.e2e.test.ts @@ -275,7 +275,7 @@ suite.define(() => { await page.goto(`${suite.server.baseUrl}settings/model-providers`); const openaiCard = page.locator('[data-provider-id="openai"]'); await expect.poll(async () => openaiCard.textContent()).toContain("Credentials for Main"); - await openaiCard.getByRole("button", { name: "Replace key" }).click(); + await openaiCard.getByRole("button", { name: "Set API key" }).click(); if (recordVisuals) { await mkdir(path.join(suite.artifactDir, "model-providers"), { recursive: true }); await page.screenshot({ diff --git a/ui/src/e2e/model-providers.e2e.test.ts b/ui/src/e2e/model-providers.e2e.test.ts index 937d5ef93aeb..f525a966a437 100644 --- a/ui/src/e2e/model-providers.e2e.test.ts +++ b/ui/src/e2e/model-providers.e2e.test.ts @@ -374,7 +374,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => { const openrouterCard = page.locator(".model-providers__row", { hasText: "OpenRouter" }); await openrouterCard.waitFor(); - await openrouterCard.getByRole("button", { name: "Replace key", exact: true }).waitFor(); + await openrouterCard.getByRole("button", { name: "Set API key", exact: true }).waitFor(); expect(await openrouterCard.locator(".model-providers__profile").textContent()).toContain( "openrouter:default", ); @@ -657,7 +657,7 @@ describeControlUiE2e("Control UI Models mocked Gateway E2E", () => { await captureProviderProof("01-configured.png", openaiCard); } - await openaiCard.getByRole("button", { name: "Replace key" }).click(); + await openaiCard.getByRole("button", { name: "Set API key" }).click(); await openaiCard.getByLabel("API key").fill(openaiInputValue); const keyWriteCount = (await gateway.getRequests("models.authSetApiKey")).length; await gateway.deferNext("models.authSetApiKey"); diff --git a/ui/src/i18n/locales/en-settings.ts b/ui/src/i18n/locales/en-settings.ts index 23f9f8c58b6a..796bcd667a2d 100644 --- a/ui/src/i18n/locales/en-settings.ts +++ b/ui/src/i18n/locales/en-settings.ts @@ -354,7 +354,6 @@ const enSettings = { placeholder: "Enter provider API key", replacePlaceholder: "Secret saved. Enter a new key to replace it.", set: "Set API key", - replace: "Replace key", remove: "Remove key", saved: "Secret saved.", removed: "Saved API keys removed.", diff --git a/ui/src/pages/model-providers/view.test.ts b/ui/src/pages/model-providers/view.test.ts index b3667465c4c1..0e2de4aaa6a8 100644 --- a/ui/src/pages/model-providers/view.test.ts +++ b/ui/src/pages/model-providers/view.test.ts @@ -480,7 +480,7 @@ describe("renderModelProviders", () => { expect( provider?.querySelector(".model-providers__inline-form input")?.disabled, ).toBe(true); - expect(button(provider!, "Replace key")?.disabled).toBe(true); + expect(button(provider!, "Set API key")?.disabled).toBe(true); expect(button(provider!, "Remove key")?.disabled).toBe(true); expect( provider?.querySelector(".model-providers__profile-logout")?.disabled, diff --git a/ui/src/pages/model-providers/view.ts b/ui/src/pages/model-providers/view.ts index 68bbdfdc6c93..5359e47b75bd 100644 --- a/ui/src/pages/model-providers/view.ts +++ b/ui/src/pages/model-providers/view.ts @@ -338,12 +338,7 @@ function renderProviderActions(card: ModelProviderCard, props: ModelProvidersVie title=${keyBlocked} @click=${() => props.onOpenKeyEditor(card.id)} > - ${ - card.hasConfigApiKey || - card.profiles.some((profile) => profile.type === "api_key") - ? t("modelProviders.apiKey.replace") - : t("modelProviders.apiKey.set") - } + ${t("modelProviders.apiKey.set")} ` }