diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7ddf223b8..c1f1f439395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Models/auth: add `openclaw models auth list [--provider ] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc. - Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar. - Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev. - Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure. diff --git a/docs/cli/models.md b/docs/cli/models.md index ba8903d20fd..ed03c2a4e4d 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -162,6 +162,7 @@ openclaw models fallbacks list ```bash openclaw models auth add +openclaw models auth list [--provider ] [--json] openclaw models auth login --provider openclaw models auth setup-token --provider openclaw models auth paste-token @@ -171,16 +172,22 @@ openclaw models auth paste-token flow (OAuth/API key) or guide you into manual token paste, depending on the provider you choose. +`models auth list` lists saved auth profiles for the selected agent without +printing token, API-key, or OAuth secret material. Use `--provider ` to +filter to one provider, such as `openai-codex`, and `--json` for scripting. + `models auth login` runs a provider plugin’s auth flow (OAuth/API key). Use `openclaw plugins list` to see which providers are installed. Use `openclaw models auth --agent ` to write auth results to a specific configured agent store. The parent `--agent` flag is honored by -`add`, `login`, `setup-token`, `paste-token`, and `login-github-copilot`. +`add`, `list`, `login`, `setup-token`, `paste-token`, and +`login-github-copilot`. Examples: ```bash openclaw models auth login --provider openai-codex --set-default +openclaw models auth list --provider openai-codex ``` Notes: diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 7eefc382c8f..afa814d2da3 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ modelsSetImageCommand: vi.fn().mockResolvedValue(undefined), noopAsync: vi.fn(async () => undefined), modelsAuthAddCommand: vi.fn().mockResolvedValue(undefined), + modelsAuthListCommand: vi.fn().mockResolvedValue(undefined), modelsAuthLoginCommand: vi.fn().mockResolvedValue(undefined), modelsAuthPasteTokenCommand: vi.fn().mockResolvedValue(undefined), modelsAuthSetupTokenCommand: vi.fn().mockResolvedValue(undefined), @@ -16,6 +17,7 @@ const mocks = vi.hoisted(() => ({ const { modelsAuthAddCommand, + modelsAuthListCommand, modelsAuthLoginCommand, modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand, @@ -36,6 +38,9 @@ vi.mock("../commands/models/auth.js", () => ({ modelsAuthPasteTokenCommand: mocks.modelsAuthPasteTokenCommand, modelsAuthSetupTokenCommand: mocks.modelsAuthSetupTokenCommand, })); +vi.mock("../commands/models/auth-list.js", () => ({ + modelsAuthListCommand: mocks.modelsAuthListCommand, +})); vi.mock("../commands/models/auth-order.js", () => ({ modelsAuthOrderClearCommand: mocks.noopAsync, modelsAuthOrderGetCommand: mocks.noopAsync, @@ -71,6 +76,7 @@ vi.mock("../commands/models/set-image.js", () => ({ describe("models cli", () => { beforeEach(() => { modelsAuthAddCommand.mockClear(); + modelsAuthListCommand.mockClear(); modelsAuthLoginCommand.mockClear(); modelsAuthPasteTokenCommand.mockClear(); modelsAuthSetupTokenCommand.mockClear(); @@ -138,6 +144,12 @@ describe("models cli", () => { command: modelsAuthAddCommand, expected: { agent: "poe" }, }, + { + label: "list", + args: ["models", "auth", "--agent", "poe", "list", "--provider", "openai-codex"], + command: modelsAuthListCommand, + expected: { agent: "poe", provider: "openai-codex" }, + }, { label: "login", args: ["models", "auth", "--agent", "poe", "login", "--provider", "openai-codex"], @@ -168,6 +180,15 @@ describe("models cli", () => { expect(command).toHaveBeenCalledWith(expect.objectContaining(expected), expect.any(Object)); }); + it("passes list-specific --agent and --json to models auth list", async () => { + await runModelsCommand(["models", "auth", "list", "--agent", "poe", "--json"]); + + expect(modelsAuthListCommand).toHaveBeenCalledWith( + expect.objectContaining({ agent: "poe", json: true }), + expect.any(Object), + ); + }); + it.each([ { label: "set", diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 0a66770a1f7..fe0a94d562d 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -301,6 +301,28 @@ export function registerModelsCli(program: Command) { auth.help(); }); + auth + .command("list") + .description("List saved auth profiles") + .option("--provider ", "Filter by provider id") + .option("--agent ", "Agent id (default: configured default agent)") + .option("--json", "Output JSON", false) + .action(async (opts, command) => { + const agent = + resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); + await runModelsCommand(async () => { + const { modelsAuthListCommand } = await import("../commands/models/auth-list.js"); + await modelsAuthListCommand( + { + provider: opts.provider as string | undefined, + agent, + json: Boolean(opts.json), + }, + defaultRuntime, + ); + }); + }); + auth .command("add") .description("Interactive auth helper (provider auth or paste token)") diff --git a/src/commands/models/auth-list.test.ts b/src/commands/models/auth-list.test.ts new file mode 100644 index 00000000000..f5af91b8e17 --- /dev/null +++ b/src/commands/models/auth-list.test.ts @@ -0,0 +1,127 @@ +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 { OutputRuntimeEnv } from "../../runtime.js"; +import { modelsAuthListCommand } from "./auth-list.js"; + +const mocks = vi.hoisted(() => ({ + ensureAuthProfileStore: vi.fn(), + externalCliDiscoveryForProviderAuth: vi.fn(() => ({ kind: "none" })), + loadModelsConfig: vi.fn(), + resolveAuthProfileDisplayLabel: vi.fn(({ profileId }: { profileId: string }) => profileId), + resolveKnownAgentId: vi.fn(({ rawAgentId }: { rawAgentId?: string }) => rawAgentId ?? undefined), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + resolveAgentDir: (_cfg: OpenClawConfig, agentId: string) => `/tmp/openclaw/agents/${agentId}`, + resolveDefaultAgentId: () => "main", +})); + +vi.mock("../../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore: mocks.ensureAuthProfileStore, + externalCliDiscoveryForProviderAuth: mocks.externalCliDiscoveryForProviderAuth, + resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, + resolveAuthStatePathForDisplay: (agentDir: string) => `${agentDir}/auth-state.json`, +})); + +vi.mock("./load-config.js", () => ({ + loadModelsConfig: mocks.loadModelsConfig, +})); + +vi.mock("./shared.js", () => ({ + resolveKnownAgentId: mocks.resolveKnownAgentId, +})); + +function createRuntime(): OutputRuntimeEnv & { logs: string[]; jsonPayloads: unknown[] } { + const logs: string[] = []; + const jsonPayloads: unknown[] = []; + return { + logs, + jsonPayloads, + log: (...args: unknown[]) => { + logs.push(args.map((value) => String(value)).join(" ")); + }, + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit ${code}`); + }), + writeStdout: vi.fn(), + writeJson: (value: unknown) => { + jsonPayloads.push(value); + }, + }; +} + +describe("modelsAuthListCommand", () => { + beforeEach(() => { + mocks.loadModelsConfig.mockReset().mockResolvedValue({} as OpenClawConfig); + mocks.ensureAuthProfileStore.mockReset(); + mocks.externalCliDiscoveryForProviderAuth.mockClear(); + mocks.resolveAuthProfileDisplayLabel.mockClear(); + mocks.resolveKnownAgentId.mockClear(); + }); + + it("filters profiles by provider and redacts credential material in JSON output", async () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai-codex:user@example.com": { + type: "oauth", + provider: "openai-codex", + access: "access-secret", + refresh: "refresh-secret", + expires: 1_800_000_000_000, + email: "user@example.com", + }, + "anthropic:manual": { + type: "token", + provider: "anthropic", + token: "token-secret", + }, + }, + usageStats: { + "openai-codex:user@example.com": { + cooldownUntil: 1_800_000_010_000, + }, + }, + }; + mocks.ensureAuthProfileStore.mockReturnValue(store); + const runtime = createRuntime(); + + await modelsAuthListCommand({ provider: "OpenAI-Codex", agent: "coder", json: true }, runtime); + + expect(mocks.externalCliDiscoveryForProviderAuth).toHaveBeenCalledWith({ + cfg: {}, + provider: "openai-codex", + }); + expect(runtime.jsonPayloads).toHaveLength(1); + expect(JSON.stringify(runtime.jsonPayloads[0])).not.toContain("secret"); + expect(runtime.jsonPayloads[0]).toMatchObject({ + agentId: "coder", + provider: "openai-codex", + profiles: [ + { + id: "openai-codex:user@example.com", + provider: "openai-codex", + type: "oauth", + email: "user@example.com", + expiresAt: "2027-01-15T08:00:00.000Z", + cooldownUntil: "2027-01-15T08:00:10.000Z", + }, + ], + }); + }); + + it("prints an empty profile list without failing", async () => { + mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} }); + const runtime = createRuntime(); + + await modelsAuthListCommand({}, runtime); + + expect(runtime.logs).toEqual([ + "Agent: main", + "Auth state file: /tmp/openclaw/agents/main/auth-state.json", + "Profiles: (none)", + ]); + }); +}); diff --git a/src/commands/models/auth-list.ts b/src/commands/models/auth-list.ts new file mode 100644 index 00000000000..11ef187fea9 --- /dev/null +++ b/src/commands/models/auth-list.ts @@ -0,0 +1,144 @@ +import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { + ensureAuthProfileStore, + externalCliDiscoveryForProviderAuth, + resolveAuthProfileDisplayLabel, + resolveAuthStatePathForDisplay, + type AuthProfileCredential, + type AuthProfileStore, + type ProfileUsageStats, +} from "../../agents/auth-profiles.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; +import { shortenHomePath } from "../../utils.js"; +import { loadModelsConfig } from "./load-config.js"; +import { resolveKnownAgentId } from "./shared.js"; + +type AuthProfileSummary = { + id: string; + provider: string; + type: AuthProfileCredential["type"]; + label: string; + email?: string; + displayName?: string; + expiresAt?: string; + cooldownUntil?: string; + disabledUntil?: string; +}; + +function resolveTargetAgent( + cfg: Awaited>, + raw?: string, +): { + agentId: string; + agentDir: string; +} { + const agentId = resolveKnownAgentId({ cfg, rawAgentId: raw }) ?? resolveDefaultAgentId(cfg); + const agentDir = resolveAgentDir(cfg, agentId); + return { agentId, agentDir }; +} + +function formatTimestamp(value: number | undefined): string | undefined { + if (!Number.isFinite(value)) { + return undefined; + } + return new Date(value).toISOString(); +} + +function resolveProfileExpiry(profile: AuthProfileCredential): string | undefined { + return profile.type === "api_key" ? undefined : formatTimestamp(profile.expires); +} + +function summarizeProfile(params: { + cfg: Awaited>; + store: AuthProfileStore; + profileId: string; + profile: AuthProfileCredential; + usage?: ProfileUsageStats; +}): AuthProfileSummary { + return { + id: params.profileId, + provider: normalizeProviderId(params.profile.provider), + type: params.profile.type, + label: resolveAuthProfileDisplayLabel({ + cfg: params.cfg, + store: params.store, + profileId: params.profileId, + }), + ...(params.profile.email ? { email: params.profile.email } : {}), + ...(params.profile.displayName ? { displayName: params.profile.displayName } : {}), + ...(resolveProfileExpiry(params.profile) + ? { expiresAt: resolveProfileExpiry(params.profile) } + : {}), + ...(formatTimestamp(params.usage?.cooldownUntil) + ? { cooldownUntil: formatTimestamp(params.usage?.cooldownUntil) } + : {}), + ...(formatTimestamp(params.usage?.disabledUntil) + ? { disabledUntil: formatTimestamp(params.usage?.disabledUntil) } + : {}), + }; +} + +function formatProfileLine(profile: AuthProfileSummary): string { + const details = [`${profile.provider}/${profile.type}`]; + if (profile.expiresAt) { + details.push(`expires ${profile.expiresAt}`); + } + if (profile.cooldownUntil) { + details.push(`cooldown until ${profile.cooldownUntil}`); + } + if (profile.disabledUntil) { + details.push(`disabled until ${profile.disabledUntil}`); + } + return `- ${profile.label} [${details.join("; ")}]`; +} + +export async function modelsAuthListCommand( + opts: { provider?: string; agent?: string; json?: boolean }, + runtime: RuntimeEnv, +) { + const cfg = await loadModelsConfig({ commandName: "models auth list", runtime }); + const { agentId, agentDir } = resolveTargetAgent(cfg, opts.agent); + const provider = opts.provider?.trim() ? normalizeProviderId(opts.provider) : undefined; + const store = ensureAuthProfileStore( + agentDir, + provider ? { externalCli: externalCliDiscoveryForProviderAuth({ cfg, provider }) } : undefined, + ); + const profiles = Object.entries(store.profiles) + .map(([profileId, profile]) => + summarizeProfile({ + cfg, + store, + profileId, + profile, + usage: store.usageStats?.[profileId], + }), + ) + .filter((profile) => !provider || profile.provider === provider) + .toSorted((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id)); + + if (opts.json) { + writeRuntimeJson(runtime, { + agentId, + agentDir: shortenHomePath(agentDir), + authStatePath: shortenHomePath(resolveAuthStatePathForDisplay(agentDir)), + provider: provider ?? null, + profiles, + }); + return; + } + + runtime.log(`Agent: ${agentId}`); + runtime.log(`Auth state file: ${shortenHomePath(resolveAuthStatePathForDisplay(agentDir))}`); + if (provider) { + runtime.log(`Provider: ${provider}`); + } + if (profiles.length === 0) { + runtime.log("Profiles: (none)"); + return; + } + runtime.log("Profiles:"); + for (const profile of profiles) { + runtime.log(formatProfileLine(profile)); + } +}