mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
feat(models): list auth profiles
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 plugin’s 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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)")
|
||||
|
||||
127
src/commands/models/auth-list.test.ts
Normal file
127
src/commands/models/auth-list.test.ts
Normal 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)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
144
src/commands/models/auth-list.ts
Normal file
144
src/commands/models/auth-list.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user