From e9990b818a42b49d8ed97dc8e4c87a5dd64cf7ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 03:48:27 -0400 Subject: [PATCH] feat(cli): add models auth logout (#114407) --- docs/cli/models.md | 6 +- docs/plugins/codex-harness.md | 7 + src/agents/auth-profiles.ts | 1 + src/agents/auth-profiles/profiles.test.ts | 38 +++ src/agents/auth-profiles/profiles.ts | 34 +++ src/cli/models-cli.test.ts | 12 + src/cli/models-cli.ts | 21 ++ src/commands/models/auth-logout.test.ts | 256 ++++++++++++++++++ src/commands/models/auth-logout.ts | 122 +++++++++ src/commands/models/auth-refresh.ts | 16 ++ src/commands/models/auth.ts | 15 +- .../server-methods/models-auth-status.test.ts | 48 +--- .../server-methods/models-auth-status.ts | 32 +-- src/plugins/provider-auth-helpers.ts | 45 +++ 14 files changed, 566 insertions(+), 87 deletions(-) create mode 100644 src/commands/models/auth-logout.test.ts create mode 100644 src/commands/models/auth-logout.ts create mode 100644 src/commands/models/auth-refresh.ts diff --git a/docs/cli/models.md b/docs/cli/models.md index 5cf1c955d86e..e42285d6a01e 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -149,6 +149,7 @@ openclaw models auth list [--provider ] [--json] openclaw models auth login --provider openclaw models auth login --provider openai --profile-id openai:work openclaw models auth login-github-copilot +openclaw models auth logout [--yes] openclaw models auth paste-api-key --provider openclaw models auth setup-token --provider openclaw models auth paste-token --provider @@ -163,9 +164,11 @@ openclaw models auth order clear --provider `models auth login` runs a provider plugin's auth flow (OAuth/API key). Use `openclaw plugins list` to see which providers are installed. `login` accepts `--profile-id ` for providers that support named profiles during login (use this to keep multiple logins for the same provider separate), `--method ` to pick a specific auth method, `--device-code` as a shortcut for `--method device-code`, `--set-default` to apply the provider's recommended default model, and `--force` to remove existing profiles for that provider first (use when a cached OAuth profile is stuck or you want to switch accounts). +`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. Logout refuses when the profile is not in the store, or when a `models.providers..apiKey` entry names it — change that config value first. + `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 `openclaw models auth --agent ` to write auth results to a specific configured agent store. The parent `--agent` flag is honored by `add`, `list`, `login`, `paste-api-key`, `setup-token`, `paste-token`, `login-github-copilot`, and `order get`/`set`/`clear`. +Use `openclaw models auth --agent ` to write auth results to a specific configured agent store. The parent `--agent` flag is honored by `add`, `list`, `login`, `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`. @@ -176,6 +179,7 @@ openclaw models auth login --provider openai --set-default openclaw models auth login --provider openai --method api-key openclaw models auth paste-api-key --provider openai openclaw models auth list --provider openai +openclaw models auth logout openai:manual --yes ``` Notes: diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 99d244178b33..62fc45797aa9 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -146,6 +146,13 @@ model route has a stored OpenAI profile: a subscription route is verified against the native account instead. Log in with Codex itself if a turn reports missing subscription credentials. +Because user-home mode refuses a prepared OpenClaw auth profile outright +(`Prepared Codex auth requires an isolated app-server home.`), a stored OpenAI +profile plus `homeScope: "user"` stops the agent from starting. Check with +`openclaw models auth list --provider openai` and remove the stored profile with +`openclaw models auth logout --yes`, or switch back to +`homeScope: "agent"` if you want OpenClaw to keep managing that credential. + Owner turns gain the `codex_threads` tool: list, search, read, fork, rename, archive, and restore native threads. Fork a thread to continue it in OpenClaw; the fork attaches to the current OpenClaw session and stays diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 5cd2bd2ca0c9..4c2ff501b914 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -36,6 +36,7 @@ export { dedupeProfileIds, listProfilesForProvider, markAuthProfileSuccess, + removeAuthProfilesAcrossOwnerStores, removeAuthProfilesWithLock, removeProviderAuthProfilesWithLock, resolveSubscriptionAuthModeForProfiles, diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts index 424787c95699..aee35a28905a 100644 --- a/src/agents/auth-profiles/profiles.test.ts +++ b/src/agents/auth-profiles/profiles.test.ts @@ -20,6 +20,7 @@ import { loadPersistedAuthProfileStore } from "./persisted.js"; import { clearLastGoodProfileWithLock, promoteAuthProfileInOrder, + removeAuthProfilesAcrossOwnerStores, removeAuthProfilesWithLock, upsertAuthProfileWithLock, } from "./profiles.js"; @@ -1329,6 +1330,43 @@ describe("promoteAuthProfileInOrder", () => { }); }); + it("removes an inherited profile from the owning main store too", async () => { + await withAuthProfileTestState("openclaw-auth-remove-owner-", async ({ agentDirFor }) => { + const mainAgentDir = agentDirFor("main"); + const customAgentDir = agentDirFor("custom"); + fs.mkdirSync(mainAgentDir, { recursive: true }); + fs.mkdirSync(customAgentDir, { recursive: true }); + const credential = { + type: "oauth" as const, + provider: "openai", + access: "inherited-access", + refresh: "inherited-refresh", + expires: Date.now() + 60_000, + }; + saveAuthProfileStore( + { version: AUTH_STORE_VERSION, profiles: { "openai:shared": credential } }, + mainAgentDir, + ); + saveAuthProfileStore( + { version: AUTH_STORE_VERSION, profiles: { "openai:shared": credential } }, + customAgentDir, + ); + + const removed = await removeAuthProfilesAcrossOwnerStores({ + agentDir: customAgentDir, + profileIds: ["openai:shared"], + }); + + expect(removed).toBe(true); + expect( + loadAuthProfileStoreForRuntime(customAgentDir).profiles["openai:shared"], + ).toBeUndefined(); + expect( + loadAuthProfileStoreForRuntime(mainAgentDir).profiles["openai:shared"], + ).toBeUndefined(); + }); + }); + it("does not clear lastGood when the failed profile is not the stored profile", async () => { await withAuthProfileTestState("openclaw-auth-clear-lastgood-keep-", async ({ agentDir }) => { fs.mkdirSync(agentDir, { recursive: true }); diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index 311e1e19f1d9..efefc7ab9cdc 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -14,6 +14,7 @@ import { normalizeAuthProfileCredential } from "./credential-normalize.js"; import { dedupeProfileIds, listProfilesForProvider } from "./profile-list.js"; import { ensureAuthProfileStoreForLocalUpdate, + resolvePersistedAuthProfileOwnerAgentDir, saveAuthProfileStore, updateAuthProfileStoreWithLock, } from "./store.js"; @@ -272,6 +273,39 @@ export async function removeAuthProfilesWithLock(params: { }); } +/** + * Removes profiles from every store that owns them. Auth profiles can be + * adopted by a provider-specific owner agent dir, so removing only the caller's + * store lets the profile reappear on the next status read and auth warmup. + */ +export async function removeAuthProfilesAcrossOwnerStores(params: { + agentDir: string; + profileIds: readonly string[]; +}): Promise { + const profilesByOwner = new Map>([ + [params.agentDir, new Set(params.profileIds)], + ]); + for (const profileId of params.profileIds) { + const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({ + agentDir: params.agentDir, + profileId, + }); + const ownerProfiles = profilesByOwner.get(ownerAgentDir) ?? new Set(); + ownerProfiles.add(profileId); + profilesByOwner.set(ownerAgentDir, ownerProfiles); + } + for (const [ownerAgentDir, profileIds] of profilesByOwner) { + const updatedStore = await removeAuthProfilesWithLock({ + profileIds: [...profileIds], + agentDir: ownerAgentDir, + }); + if (!updatedStore) { + return false; + } + } + return true; +} + /** Clear the last-good profile pointer for a provider under the store lock. */ export async function clearLastGoodProfileWithLock(params: { provider: string; diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 1e74bd13c9f5..9880cd47f459 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ modelsAuthAddCommand: vi.fn().mockResolvedValue(undefined), modelsAuthListCommand: vi.fn().mockResolvedValue(undefined), modelsAuthLoginCommand: vi.fn().mockResolvedValue(undefined), + modelsAuthLogoutCommand: vi.fn().mockResolvedValue(undefined), modelsAuthPasteApiKeyCommand: vi.fn().mockResolvedValue(undefined), modelsAuthPasteTokenCommand: vi.fn().mockResolvedValue(undefined), modelsAuthSetupTokenCommand: vi.fn().mockResolvedValue(undefined), @@ -21,6 +22,7 @@ const { modelsAuthAddCommand, modelsAuthListCommand, modelsAuthLoginCommand, + modelsAuthLogoutCommand, modelsAuthPasteApiKeyCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand, @@ -45,6 +47,9 @@ vi.mock("../commands/models/auth.js", () => ({ vi.mock("../commands/models/auth-list.js", () => ({ modelsAuthListCommand: mocks.modelsAuthListCommand, })); +vi.mock("../commands/models/auth-logout.js", () => ({ + modelsAuthLogoutCommand: mocks.modelsAuthLogoutCommand, +})); vi.mock("../commands/models/auth-order.js", () => ({ modelsAuthOrderClearCommand: mocks.noopAsync, modelsAuthOrderGetCommand: mocks.noopAsync, @@ -82,6 +87,7 @@ describe("models cli", () => { modelsAuthAddCommand.mockClear(); modelsAuthListCommand.mockClear(); modelsAuthLoginCommand.mockClear(); + modelsAuthLogoutCommand.mockClear(); modelsAuthPasteApiKeyCommand.mockClear(); modelsAuthPasteTokenCommand.mockClear(); modelsAuthSetupTokenCommand.mockClear(); @@ -173,6 +179,12 @@ describe("models cli", () => { command: modelsAuthLoginCommand, expected: { agent: "poe", provider: "openai" }, }, + { + label: "logout", + args: ["models", "auth", "--agent", "poe", "logout", "openai:manual", "--yes"], + command: modelsAuthLogoutCommand, + expected: { agent: "poe", profileId: "openai:manual", yes: true }, + }, { label: "setup-token", args: ["models", "auth", "--agent", "poe", "setup-token", "--provider", "anthropic"], diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 031ec515ec84..bc61c44ac041 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -353,6 +353,27 @@ export function registerModelsCli(program: Command) { }); }); + auth + .command("logout") + .description("Remove a saved auth profile (see `models auth list` for ids)") + .argument("", "Auth profile id (e.g. openai:manual)") + .option("--agent ", "Agent id (default: configured default agent)") + .option("--yes", "Skip the confirmation prompt", false) + .action(async (profileId: string, opts, command) => { + await withModelsRuntime(async ({ defaultRuntime, resolveModelAgentOption }) => { + const agent = resolveModelAgentOption(command, opts); + const { modelsAuthLogoutCommand } = await import("../commands/models/auth-logout.js"); + await modelsAuthLogoutCommand( + { + profileId, + agent, + yes: Boolean(opts.yes), + }, + defaultRuntime, + ); + }); + }); + auth .command("login") .description("Run a provider plugin auth flow (OAuth/API key)") diff --git a/src/commands/models/auth-logout.test.ts b/src/commands/models/auth-logout.test.ts new file mode 100644 index 000000000000..446fddd85f6d --- /dev/null +++ b/src/commands/models/auth-logout.test.ts @@ -0,0 +1,256 @@ +// Covers `models auth logout`: store removal, config-reference cleanup, and refusals. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +const mocks = vi.hoisted(() => ({ + ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(), + listProfilesForProvider: vi.fn(() => [] as string[]), + removeAuthProfilesAcrossOwnerStores: vi.fn(async () => true), + loadModelsConfig: vi.fn(), + updateConfig: vi.fn(), + logConfigUpdated: vi.fn(), + refreshRunningGatewayAuthState: vi.fn(async () => undefined), + confirm: vi.fn(async () => true), +})); + +vi.mock("../../agents/auth-profiles.js", () => ({ + ensureAuthProfileStoreWithoutExternalProfiles: + mocks.ensureAuthProfileStoreWithoutExternalProfiles, + listProfilesForProvider: mocks.listProfilesForProvider, + removeAuthProfilesAcrossOwnerStores: mocks.removeAuthProfilesAcrossOwnerStores, +})); + +vi.mock("./load-config.js", () => ({ + loadModelsConfig: mocks.loadModelsConfig, +})); + +vi.mock("./shared.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModelsTargetAgent: (_cfg: OpenClawConfig, rawAgentId?: string) => ({ + agentId: rawAgentId ?? "main", + agentDir: `/tmp/agent-${rawAgentId ?? "main"}`, + }), + updateConfig: mocks.updateConfig, + }; +}); + +vi.mock("./auth-refresh.js", () => ({ + refreshRunningGatewayAuthState: mocks.refreshRunningGatewayAuthState, +})); + +vi.mock("../../config/logging.js", () => ({ + logConfigUpdated: mocks.logConfigUpdated, +})); + +vi.mock("../../wizard/clack-prompter.js", () => ({ + createClackPrompter: () => ({ confirm: mocks.confirm }), +})); + +const { modelsAuthLogoutCommand } = await import("./auth-logout.js"); + +function createRuntime(): RuntimeEnv & { logs: string[] } { + const logs: string[] = []; + return { + logs, + log: (message: string) => { + logs.push(message); + }, + error: () => {}, + } as unknown as RuntimeEnv & { logs: string[] }; +} + +function storeWith(profileIds: string[]): AuthProfileStore { + return { + version: 1, + profiles: Object.fromEntries( + profileIds.map((profileId) => [ + profileId, + { type: "oauth" as const, provider: profileId.split(":")[0] ?? "openai", access: "tok" }, + ]), + ), + } as unknown as AuthProfileStore; +} + +/** Runs the config mutator captured by the mocked updateConfig. */ +function applyCapturedConfigUpdate(cfg: OpenClawConfig): OpenClawConfig { + const mutator = mocks.updateConfig.mock.calls[0]?.[0] as + | ((current: OpenClawConfig) => OpenClawConfig) + | undefined; + if (!mutator) { + throw new Error("expected updateConfig to be called"); + } + return mutator(cfg); +} + +describe("models auth logout", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.removeAuthProfilesAcrossOwnerStores.mockResolvedValue(true); + mocks.confirm.mockResolvedValue(true); + mocks.listProfilesForProvider.mockReturnValue([]); + mocks.updateConfig.mockResolvedValue({} as OpenClawConfig); + mocks.loadModelsConfig.mockResolvedValue({} as OpenClawConfig); + mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue( + storeWith(["openai:manual"]), + ); + }); + + it("removes the profile from the selected agent store", async () => { + const runtime = createRuntime(); + await modelsAuthLogoutCommand({ profileId: "openai:manual", agent: "poe", yes: true }, runtime); + + expect(mocks.removeAuthProfilesAcrossOwnerStores).toHaveBeenCalledWith({ + agentDir: "/tmp/agent-poe", + profileIds: ["openai:manual"], + }); + expect(mocks.refreshRunningGatewayAuthState).toHaveBeenCalledTimes(1); + expect(runtime.logs).toContain("Removed auth profile: openai:manual (openai/oauth)"); + expect(runtime.logs.some((line) => line.includes("No auth profiles remain for openai"))).toBe( + true, + ); + // Nothing in config referenced the profile, so config stays untouched. + expect(mocks.updateConfig).not.toHaveBeenCalled(); + }); + + it("drops config auth.profiles and auth.order references to the removed profile", async () => { + const cfg = { + auth: { + profiles: { + "openai:manual": { provider: "openai", mode: "oauth" }, + "openai:backup": { provider: "openai", mode: "api_key" }, + "anthropic:manual": { provider: "anthropic", mode: "oauth" }, + }, + order: { + openai: ["openai:manual", "openai:backup"], + anthropic: ["anthropic:manual"], + }, + }, + } as unknown as OpenClawConfig; + mocks.loadModelsConfig.mockResolvedValue(cfg); + + await modelsAuthLogoutCommand({ profileId: "openai:manual", yes: true }, createRuntime()); + + expect(mocks.updateConfig).toHaveBeenCalledTimes(1); + expect(applyCapturedConfigUpdate(cfg).auth).toEqual({ + profiles: { + "openai:backup": { provider: "openai", mode: "api_key" }, + "anthropic:manual": { provider: "anthropic", mode: "oauth" }, + }, + order: { + openai: ["openai:backup"], + anthropic: ["anthropic:manual"], + }, + }); + expect(mocks.logConfigUpdated).toHaveBeenCalledTimes(1); + }); + + it("deletes an emptied provider order but keeps an authored empty one", async () => { + const cfg = { + auth: { + profiles: { "openai:manual": { provider: "openai", mode: "oauth" } }, + order: { openai: ["openai:manual"], anthropic: [] }, + }, + } as unknown as OpenClawConfig; + mocks.loadModelsConfig.mockResolvedValue(cfg); + + await modelsAuthLogoutCommand({ profileId: "openai:manual", yes: true }, createRuntime()); + + // `anthropic: []` is an authored "select no profiles" instruction for an + // unrelated provider; only the order this removal emptied may go. + expect(applyCapturedConfigUpdate(cfg).auth).toEqual({ + profiles: {}, + order: { anthropic: [] }, + }); + }); + + it("removes the config reference before deleting the credential", async () => { + const cfg = { + auth: { profiles: { "openai:manual": { provider: "openai", mode: "oauth" } } }, + } as unknown as OpenClawConfig; + mocks.loadModelsConfig.mockResolvedValue(cfg); + const calls: string[] = []; + mocks.updateConfig.mockImplementation(async () => { + calls.push("config"); + return cfg; + }); + mocks.removeAuthProfilesAcrossOwnerStores.mockImplementation(async () => { + calls.push("store"); + return true; + }); + + await modelsAuthLogoutCommand({ profileId: "openai:manual", yes: true }, createRuntime()); + + expect(calls).toEqual(["config", "store"]); + }); + + it.each([ + { + label: "unknown profile id", + profileId: "openai:missing", + cfg: {} as OpenClawConfig, + expected: 'Auth profile "openai:missing" not found for agent "main"', + }, + { + label: "profile bound to a provider apiKey entry", + profileId: "openai:manual", + cfg: { + models: { providers: { openai: { apiKey: "openai:manual" } } }, + } as unknown as OpenClawConfig, + expected: "referenced by models.providers.openai.apiKey", + }, + { + label: "blank profile id", + profileId: " ", + cfg: {} as OpenClawConfig, + expected: "Missing profile id", + }, + ])("refuses removal for $label", async ({ profileId, cfg, expected }) => { + mocks.loadModelsConfig.mockResolvedValue(cfg); + + await expect( + modelsAuthLogoutCommand({ profileId, yes: true }, createRuntime()), + ).rejects.toThrow(expected); + expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled(); + }); + + it("fails when the auth store update does not complete", async () => { + mocks.removeAuthProfilesAcrossOwnerStores.mockResolvedValue(false); + + await expect( + modelsAuthLogoutCommand({ profileId: "openai:manual", yes: true }, createRuntime()), + ).rejects.toThrow('Failed to remove auth profile "openai:manual"'); + }); + + it("keeps the profile when an interactive confirmation is declined", async () => { + mocks.confirm.mockResolvedValue(false); + const stdin = process.stdin as unknown as { isTTY: boolean }; + const priorIsTty = stdin.isTTY; + stdin.isTTY = true; + try { + const runtime = createRuntime(); + await modelsAuthLogoutCommand({ profileId: "openai:manual" }, runtime); + expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled(); + expect(runtime.logs).toContain("Cancelled."); + } finally { + stdin.isTTY = priorIsTty; + } + }); + + it("refuses to remove without --yes when stdin is not a TTY", async () => { + const stdin = process.stdin as unknown as { isTTY: boolean }; + const priorIsTty = stdin.isTTY; + stdin.isTTY = false; + try { + await expect( + modelsAuthLogoutCommand({ profileId: "openai:manual" }, createRuntime()), + ).rejects.toThrow("Pass --yes to remove it non-interactively."); + expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled(); + } finally { + stdin.isTTY = priorIsTty; + } + }); +}); diff --git a/src/commands/models/auth-logout.ts b/src/commands/models/auth-logout.ts new file mode 100644 index 000000000000..64c00ea72f7a --- /dev/null +++ b/src/commands/models/auth-logout.ts @@ -0,0 +1,122 @@ +/** Command for removing one saved model auth profile. */ +import { + type AuthProfileStore, + ensureAuthProfileStoreWithoutExternalProfiles, + listProfilesForProvider, + removeAuthProfilesAcrossOwnerStores, +} from "../../agents/auth-profiles.js"; +import { resolveProviderEntryApiKeyProfileReference } from "../../agents/model-auth-provider-config.js"; +import { formatCliCommand } from "../../cli/command-format.js"; +import { logConfigUpdated } from "../../config/logging.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + configReferencesAuthProfile, + removeAuthProfileConfig, +} from "../../plugins/provider-auth-helpers.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import { refreshRunningGatewayAuthState } from "./auth-refresh.js"; +import { loadModelsConfig } from "./load-config.js"; +import { resolveModelsTargetAgent, updateConfig } from "./shared.js"; + +// A provider entry can name an auth profile as its `apiKey`. Removing such a +// profile would leave that config key pointing at nothing and silently degrade +// the provider to an unresolvable literal key, so refuse instead. +function findProviderEntryBoundToProfile(params: { + cfg: OpenClawConfig; + store: AuthProfileStore; + profileId: string; +}): string | undefined { + for (const provider of Object.keys(params.cfg.models?.providers ?? {})) { + const reference = resolveProviderEntryApiKeyProfileReference({ + cfg: params.cfg, + provider, + store: params.store, + }); + if ( + (reference.kind === "profile" || reference.kind === "profile-incompatible") && + reference.profileId === params.profileId + ) { + return provider; + } + } + return undefined; +} + +/** Removes a saved auth profile from the agent auth store and from config. */ +export async function modelsAuthLogoutCommand( + opts: { profileId: string; agent?: string; yes?: boolean }, + runtime: RuntimeEnv, +) { + const profileId = opts.profileId?.trim(); + if (!profileId) { + throw new Error( + `Missing profile id. Run ${formatCliCommand("openclaw models auth list")} to see saved profile ids.`, + ); + } + + const cfg = await loadModelsConfig({ commandName: "models auth logout", runtime }); + const { agentId, agentDir } = resolveModelsTargetAgent(cfg, opts.agent); + // External CLI overlays (Claude/Codex CLI) are not ours to delete, so the + // removable set is exactly the persisted store. + const store = ensureAuthProfileStoreWithoutExternalProfiles(agentDir); + const credential = store.profiles[profileId]; + if (!credential) { + throw new Error( + `Auth profile "${profileId}" not found for agent "${agentId}". Run ${formatCliCommand(`openclaw models auth list --agent ${agentId}`)} to see saved profile ids.`, + ); + } + + const boundProvider = findProviderEntryBoundToProfile({ cfg, store, profileId }); + if (boundProvider) { + throw new Error( + `Auth profile "${profileId}" is referenced by models.providers.${boundProvider}.apiKey. Change that config value first, then rerun ${formatCliCommand(`openclaw models auth logout ${profileId}`)}.`, + ); + } + + const description = `${profileId} (${credential.provider}/${credential.type})`; + if (!opts.yes) { + if (!process.stdin.isTTY) { + throw new Error( + `Refusing to remove auth profile ${description} without confirmation. Pass --yes to remove it non-interactively.`, + ); + } + const proceed = await createClackPrompter().confirm({ + message: `Remove auth profile ${description} from agent ${agentId}?`, + initialValue: false, + }); + if (!proceed) { + runtime.log("Cancelled."); + return; + } + } + + // Config first: `auth.profiles`/`auth.order` are a separate surface from the + // store, and a failed config write after the credential is gone would leave a + // dangling reference that logout can no longer repair (the profile lookup + // above would then fail). This order makes a partial failure retryable. + if (configReferencesAuthProfile(cfg, profileId)) { + await updateConfig((current) => removeAuthProfileConfig(current, profileId)); + logConfigUpdated(runtime); + } + + const removed = await removeAuthProfilesAcrossOwnerStores({ agentDir, profileIds: [profileId] }); + if (!removed) { + throw new Error( + `Failed to remove auth profile "${profileId}"; the auth store lock may be busy. Wait a moment and retry.`, + ); + } + + await refreshRunningGatewayAuthState(); + + runtime.log(`Agent: ${agentId}`); + runtime.log(`Removed auth profile: ${description}`); + const remaining = listProfilesForProvider(store, credential.provider).filter( + (id) => id !== profileId, + ); + if (remaining.length === 0) { + runtime.log( + `No auth profiles remain for ${credential.provider}. Run ${formatCliCommand(`openclaw models auth login --provider ${credential.provider}`)} to sign in again.`, + ); + } +} diff --git a/src/commands/models/auth-refresh.ts b/src/commands/models/auth-refresh.ts new file mode 100644 index 000000000000..f925f8f20181 --- /dev/null +++ b/src/commands/models/auth-refresh.ts @@ -0,0 +1,16 @@ +/** Shared gateway refresh for CLI auth writes made outside the gateway process. */ +import { callGateway } from "../../gateway/call.js"; + +// CLI auth writes occur outside the gateway process, which may retain an older +// runtime snapshot. Best-effort: auth writes must still succeed with no gateway. +export async function refreshRunningGatewayAuthState(): Promise { + try { + await callGateway({ + method: "models.authStatus", + params: { refresh: true }, + timeoutMs: 3000, + }); + } catch { + // No local gateway, or it is unreachable — the store write already landed. + } +} diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 5732dbbbc2eb..fcbaf2b9cd68 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -41,7 +41,6 @@ import { parseDurationMs } from "../../cli/parse-duration.js"; import { logConfigUpdated } from "../../config/logging.js"; import { normalizeAgentModelRefForConfig } from "../../config/model-input.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { callGateway } from "../../gateway/call.js"; import { isRemoteEnvironment } from "../../infra/remote-env.js"; import { applyProviderAuthConfigPatch, @@ -69,23 +68,11 @@ import type { WizardPrompter } from "../../wizard/prompts.js"; import { validateAnthropicSetupToken } from "../auth-token.js"; import { repairCodexRuntimePluginInstallForModelSelection } from "../codex-runtime-plugin-install.js"; import { repairCopilotRuntimePluginInstallForModelSelection } from "../copilot-runtime-plugin-install.js"; +import { refreshRunningGatewayAuthState } from "./auth-refresh.js"; import { loadValidConfigOrThrow, resolveKnownAgentId, updateConfig } from "./shared.js"; type UpsertAuthProfileParams = Parameters[0]; -// CLI auth writes occur outside the gateway process, which may retain an older runtime snapshot. -async function refreshRunningGatewayAuthState(): Promise { - try { - await callGateway({ - method: "models.authStatus", - params: { refresh: true }, - timeoutMs: 3000, - }); - } catch { - // Auth writes must still succeed when no local gateway is running. - } -} - function resolveManualTokenExpiryMs(expiresIn: string | undefined): number | undefined { const normalizedExpiresIn = normalizeStringifiedOptionalString(expiresIn); if (!normalizedExpiresIn) { diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 6ba388bb3e68..097e6282cfc9 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -40,9 +40,7 @@ const mocks = vi.hoisted(() => ({ return { version: 1, profiles: {} }; }), listProfilesForProvider: vi.fn((): string[] => []), - removeAuthProfilesWithLock: vi.fn( - async (): Promise => ({ version: 1, profiles: {} }), - ), + removeAuthProfilesAcrossOwnerStores: vi.fn(async (): Promise => true), removeProviderAuthProfilesWithLock: vi.fn( async (): Promise => ({ version: 1, profiles: {} }), ), @@ -79,7 +77,7 @@ vi.mock("../../agents/auth-profiles.js", async () => { ensureAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStoreWithoutExternalProfiles, listProfilesForProvider: mocks.listProfilesForProvider, - removeAuthProfilesWithLock: mocks.removeAuthProfilesWithLock, + removeAuthProfilesAcrossOwnerStores: mocks.removeAuthProfilesAcrossOwnerStores, removeProviderAuthProfilesWithLock: mocks.removeProviderAuthProfilesWithLock, resolvePersistedAuthProfileOwnerAgentDir: mocks.resolvePersistedAuthProfileOwnerAgentDir, clearRuntimeAuthProfileStoreSnapshots: mocks.clearRuntimeAuthProfileStoreSnapshots, @@ -241,7 +239,7 @@ function resetAuthStatusMocks(): void { profiles: {}, }); mocks.listProfilesForProvider.mockReturnValue([]); - mocks.removeAuthProfilesWithLock.mockResolvedValue({ version: 1, profiles: {} }); + mocks.removeAuthProfilesAcrossOwnerStores.mockResolvedValue(true); mocks.removeProviderAuthProfilesWithLock.mockResolvedValue({ version: 1, profiles: {} }); mocks.resolvePersistedAuthProfileOwnerAgentDir.mockImplementation( (params: { agentDir?: string }) => params.agentDir, @@ -1278,7 +1276,7 @@ describe("models.authLogout", () => { expect(mocks.resolveAgentDir).not.toHaveBeenCalled(); expect(mocks.ensureAuthProfileStoreWithoutExternalProfiles).not.toHaveBeenCalled(); expect(mocks.removeProviderAuthProfilesWithLock).not.toHaveBeenCalled(); - expect(mocks.removeAuthProfilesWithLock).not.toHaveBeenCalled(); + expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled(); const [ok, payload, error] = firstRespondCall(opts) ?? []; expect(ok).toBe(false); expect(payload).toBeUndefined(); @@ -1338,7 +1336,7 @@ describe("models.authLogout", () => { await logoutHandler(opts); - expect(mocks.removeAuthProfilesWithLock).toHaveBeenCalledWith({ + expect(mocks.removeAuthProfilesAcrossOwnerStores).toHaveBeenCalledWith({ profileIds: ["openrouter:oauth"], agentDir: "/tmp/agent", }); @@ -1368,7 +1366,7 @@ describe("models.authLogout", () => { await logoutHandler(opts); - expect(mocks.removeAuthProfilesWithLock).not.toHaveBeenCalled(); + expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled(); expect(mocks.removeProviderAuthProfilesWithLock).not.toHaveBeenCalled(); const [ok, , error] = firstRespondCall(opts) ?? []; expect(ok).toBe(false); @@ -1398,7 +1396,7 @@ describe("models.authLogout", () => { await logoutHandler(opts); - expect(mocks.removeAuthProfilesWithLock).not.toHaveBeenCalled(); + expect(mocks.removeAuthProfilesAcrossOwnerStores).not.toHaveBeenCalled(); expect(mocks.removeProviderAuthProfilesWithLock).not.toHaveBeenCalled(); expect(activeRun.controller.signal.aborted).toBe(false); const [ok, , error] = firstRespondCall(opts) ?? []; @@ -1525,38 +1523,6 @@ describe("models.authLogout", () => { expect(ok).toBe(true); }); - it("cleans requester references when targeted auth is inherited", async () => { - const profileId = "openrouter:main"; - mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ - version: 1, - profiles: { - [profileId]: { - type: "oauth", - provider: "openrouter", - access: "access", - refresh: "refresh", - expires: 1_000_000, - }, - }, - }); - mocks.listProfilesForProvider.mockReturnValue([profileId]); - mocks.resolvePersistedAuthProfileOwnerAgentDir.mockReturnValue(undefined); - const opts = createLogoutOptions({ provider: "openrouter", profileIds: [profileId] }); - - await logoutHandler(opts); - - expect(mocks.removeAuthProfilesWithLock).toHaveBeenCalledWith({ - profileIds: [profileId], - agentDir: "/tmp/agent", - }); - expect(mocks.removeAuthProfilesWithLock).toHaveBeenCalledWith({ - profileIds: [profileId], - agentDir: undefined, - }); - const [ok] = firstRespondCall(opts) ?? []; - expect(ok).toBe(true); - }); - it("preserves active provider runs on a targeted logout", async () => { const profileId = "openrouter:saved"; mocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReturnValue({ diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index 927ddaac05ec..4bd599685e6a 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -20,7 +20,7 @@ import { ensureAuthProfileStoreWithoutExternalProfiles, externalCliDiscoveryForConfigStatus, listProfilesForProvider, - removeAuthProfilesWithLock, + removeAuthProfilesAcrossOwnerStores, removeProviderAuthProfilesWithLock, resolvePersistedAuthProfileOwnerAgentDir, } from "../../agents/auth-profiles.js"; @@ -244,36 +244,6 @@ async function removeProviderAuthProfilesAcrossOwnerStores(params: { return true; } -// Targeted UI logout preserves API-key and unrelated profiles. Ownership is -// resolved before each locked store mutation so inherited profiles stay gone. -async function removeAuthProfilesAcrossOwnerStores(params: { - agentDir: string; - profileIds: string[]; -}): Promise { - const profilesByOwner = new Map>([ - [params.agentDir, new Set(params.profileIds)], - ]); - for (const profileId of params.profileIds) { - const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({ - agentDir: params.agentDir, - profileId, - }); - const ownerProfiles = profilesByOwner.get(ownerAgentDir) ?? new Set(); - ownerProfiles.add(profileId); - profilesByOwner.set(ownerAgentDir, ownerProfiles); - } - for (const [ownerAgentDir, profileIds] of profilesByOwner) { - const updatedStore = await removeAuthProfilesWithLock({ - profileIds: [...profileIds], - agentDir: ownerAgentDir, - }); - if (!updatedStore) { - return false; - } - } - return true; -} - // UI expiry fields are emitted only when both timestamp and remaining duration // are valid, keeping profile/provider expiry shapes all-or-nothing. function buildExpiry( diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index eb353e369550..ae19a9319562 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -239,6 +239,51 @@ export function applyAuthProfileConfig( }; } +/** Returns true when config still names a removed auth profile. */ +export function configReferencesAuthProfile(cfg: OpenClawConfig, profileId: string): boolean { + return ( + Boolean(cfg.auth?.profiles?.[profileId]) || + Object.values(cfg.auth?.order ?? {}).some((order) => order.includes(profileId)) + ); +} + +/** + * Counterpart to {@link applyAuthProfileConfig}: drops a profile from + * `auth.profiles` and every `auth.order` list. An emptied provider order is + * deleted rather than left as `[]`, because an authored empty order is a hard + * "select no profiles" instruction and would disable the provider entirely. + */ +export function removeAuthProfileConfig(cfg: OpenClawConfig, profileId: string): OpenClawConfig { + if (!configReferencesAuthProfile(cfg, profileId)) { + return cfg; + } + const profiles = Object.fromEntries( + Object.entries(cfg.auth?.profiles ?? {}).filter(([id]) => id !== profileId), + ); + const order = Object.entries(cfg.auth?.order ?? {}).reduce>( + (acc, [providerId, providerOrder]) => { + const next = providerOrder.filter((id) => id !== profileId); + // Drop only an order this removal emptied. An order that was already + // empty is an authored "select no profiles" instruction for an unrelated + // provider and must survive untouched. + if (next.length > 0 || next.length === providerOrder.length) { + acc[providerId] = next; + } + return acc; + }, + {}, + ); + const { order: _droppedOrder, ...auth } = cfg.auth ?? {}; + return { + ...cfg, + auth: { + ...auth, + profiles, + ...(Object.keys(order).length > 0 ? { order } : {}), + }, + }; +} + /** Resolve real path, returning null if the target doesn't exist. */ function safeRealpathSync(dir: string): string | null { try {