feat(models): list auth profiles

This commit is contained in:
Vincent Koc
2026-05-04 03:31:55 -07:00
parent e0430e2e15
commit 23eb44b045
6 changed files with 323 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Models/auth: add `openclaw models auth list [--provider <id>] [--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.

View File

@@ -162,6 +162,7 @@ openclaw models fallbacks list
```bash
openclaw models auth add
openclaw models auth list [--provider <id>] [--json]
openclaw models auth login --provider <id>
openclaw models auth setup-token --provider <id>
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 <id>` to
filter to one provider, such as `openai-codex`, and `--json` for scripting.
`models auth login` runs a provider plugins auth flow (OAuth/API key). Use
`openclaw plugins list` to see which providers are installed.
Use `openclaw models auth --agent <id> <subcommand>` 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:

View File

@@ -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",

View File

@@ -301,6 +301,28 @@ export function registerModelsCli(program: Command) {
auth.help();
});
auth
.command("list")
.description("List saved auth profiles")
.option("--provider <id>", "Filter by provider id")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Output JSON", false)
.action(async (opts, command) => {
const agent =
resolveOptionFromCommand<string>(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)")

View File

@@ -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)",
]);
});
});

View File

@@ -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<ReturnType<typeof loadModelsConfig>>,
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<ReturnType<typeof loadModelsConfig>>;
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));
}
}