mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(agents): scope external CLI auth discovery
This commit is contained in:
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07.
|
||||
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
|
||||
- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07.
|
||||
- Agents/auth: scope external CLI credential discovery to configured providers during model auth status and startup prewarm, so opencode-only and other single-provider gateways do not block on unrelated Claude CLI Keychain probes. Fixes #73908. Thanks @Ailuras.
|
||||
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.
|
||||
- Agents/Claude CLI: reuse already-cached macOS Keychain credentials for no-prompt Claude credential reads, so doctor/runtime checks do not miss fresh interactive Claude auth. Fixes #73682. Thanks @RyanSandoval.
|
||||
- Agents/Claude CLI doctor: scope workspace and project-dir checks to agents that actually use the Claude CLI runtime, so non-default Claude agents no longer make the default agent look Claude-backed. Fixes #73903. Thanks @bobfreeman1989.
|
||||
|
||||
@@ -48,6 +48,9 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**:
|
||||
`openai-codex:default` profile, but once OpenClaw has a local OAuth profile,
|
||||
the local refresh token is canonical; other integrations can remain
|
||||
externally managed and re-read their CLI auth store
|
||||
- status and startup paths that already know the configured provider set scope
|
||||
external CLI discovery to that set, so an unrelated CLI login store is not
|
||||
probed for a single-provider setup
|
||||
|
||||
## Storage (where tokens live)
|
||||
|
||||
|
||||
85
src/agents/auth-profiles.external-cli-scope.test.ts
Normal file
85
src/agents/auth-profiles.external-cli-scope.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveExternalCliAuthScopeFromConfig } from "./auth-profiles/external-cli-scope.js";
|
||||
|
||||
describe("external CLI auth scope", () => {
|
||||
it("returns undefined when config has no provider signal", () => {
|
||||
expect(resolveExternalCliAuthScopeFromConfig({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("scopes opencode-only config without adding unrelated CLI providers", () => {
|
||||
const scope = resolveExternalCliAuthScopeFromConfig({
|
||||
auth: {
|
||||
profiles: {
|
||||
"opencode-go:default": { provider: "opencode-go", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "opencode-go/kimi-k2.6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"opencode-go": {
|
||||
baseUrl: "https://example.test/v1",
|
||||
auth: "api-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(scope?.providerIds).toContain("opencode-go");
|
||||
expect(scope?.profileIds).toEqual(["opencode-go:default"]);
|
||||
expect(scope?.providerIds).not.toContain("claude-cli");
|
||||
expect(scope?.providerIds).not.toContain("openai-codex");
|
||||
expect(scope?.providerIds).not.toContain("minimax-portal");
|
||||
});
|
||||
|
||||
it("collects model, auth order, media model, and runtime signals", () => {
|
||||
const cfg = {
|
||||
auth: {
|
||||
order: {
|
||||
"openai-codex": ["openai-codex:default"],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["openai/gpt-5.5"],
|
||||
},
|
||||
imageGenerationModel: "minimax-portal/image-01",
|
||||
cliBackends: {
|
||||
"claude-cli": { command: "claude" },
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "worker",
|
||||
model: "opencode-go/kimi-k2.6",
|
||||
agentRuntime: { id: "codex" },
|
||||
subagents: { model: { primary: "z.ai/glm-4.7" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const scope = resolveExternalCliAuthScopeFromConfig(cfg);
|
||||
|
||||
expect(scope?.providerIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
"anthropic",
|
||||
"openai",
|
||||
"openai-codex",
|
||||
"minimax-portal",
|
||||
"claude-cli",
|
||||
"codex",
|
||||
"opencode-go",
|
||||
"z.ai",
|
||||
"zai",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,11 @@ import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js
|
||||
import type { ClaudeCliCredential } from "./cli-credentials.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readClaudeCliCredentialsCached: vi.fn<() => ClaudeCliCredential | null>(() => null),
|
||||
readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
readClaudeCliCredentialsCached: vi.fn<(options?: unknown) => ClaudeCliCredential | null>(
|
||||
() => null,
|
||||
),
|
||||
readCodexCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
|
||||
readMiniMaxCliCredentialsCached: vi.fn<(options?: unknown) => OAuthCredential | null>(() => null),
|
||||
}));
|
||||
|
||||
let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential;
|
||||
@@ -331,6 +333,47 @@ describe("external cli oauth resolution", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips external cli readers outside the scoped provider set", () => {
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["opencode-go"],
|
||||
});
|
||||
|
||||
expect(profiles).toEqual([]);
|
||||
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(mocks.readClaudeCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes non-prompting keychain policy to scoped Claude CLI credential reads", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "claude-cli-access",
|
||||
refresh: "claude-cli-refresh",
|
||||
expires: Date.now() + 5 * 24 * 60 * 60_000,
|
||||
});
|
||||
|
||||
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
|
||||
providerIds: ["claude-cli"],
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
|
||||
expect(profiles).toEqual([
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
credential: expect.objectContaining({
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(mocks.readClaudeCliCredentialsCached).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ allowKeychainPrompt: false }),
|
||||
);
|
||||
expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled();
|
||||
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores Claude CLI token credentials", () => {
|
||||
mocks.readClaudeCliCredentialsCached.mockReturnValue({
|
||||
type: "token",
|
||||
|
||||
@@ -12,7 +12,9 @@ import type { OAuthCredential } from "./auth-profiles/types.js";
|
||||
type RuntimeOnlyOverlay = { profileId: string; credential: OAuthCredential };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveExternalCliAuthProfiles: vi.fn<() => RuntimeOnlyOverlay[]>(() => []),
|
||||
resolveExternalCliAuthProfiles: vi.fn<
|
||||
(store?: unknown, options?: unknown) => RuntimeOnlyOverlay[]
|
||||
>(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/external-cli-sync.js", () => ({
|
||||
|
||||
@@ -10,6 +10,11 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
|
||||
type ExternalAuthProfileMap = Map<string, ProviderExternalAuthProfile>;
|
||||
type ResolveExternalAuthProfiles = typeof resolveExternalAuthProfilesWithPlugins;
|
||||
type ExternalCliOverlayOptions = {
|
||||
allowKeychainPrompt?: boolean;
|
||||
externalCliProviderIds?: Iterable<string>;
|
||||
externalCliProfileIds?: Iterable<string>;
|
||||
};
|
||||
|
||||
let resolveExternalAuthProfilesForRuntime: ResolveExternalAuthProfiles | undefined;
|
||||
|
||||
@@ -38,6 +43,7 @@ function resolveExternalAuthProfileMap(params: {
|
||||
store: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
externalCli?: ExternalCliOverlayOptions;
|
||||
}): ExternalAuthProfileMap {
|
||||
const env = params.env ?? process.env;
|
||||
const resolveProfiles =
|
||||
@@ -54,7 +60,12 @@ function resolveExternalAuthProfileMap(params: {
|
||||
});
|
||||
|
||||
const resolved: ExternalAuthProfileMap = new Map();
|
||||
const cliProfiles = externalCliSync.resolveExternalCliAuthProfiles?.(params.store) ?? [];
|
||||
const cliProfiles =
|
||||
externalCliSync.resolveExternalCliAuthProfiles?.(params.store, {
|
||||
allowKeychainPrompt: params.externalCli?.allowKeychainPrompt,
|
||||
providerIds: params.externalCli?.externalCliProviderIds,
|
||||
profileIds: params.externalCli?.externalCliProfileIds,
|
||||
}) ?? [];
|
||||
for (const profile of cliProfiles) {
|
||||
resolved.set(profile.profileId, {
|
||||
profileId: profile.profileId,
|
||||
@@ -76,24 +87,27 @@ function listRuntimeExternalAuthProfiles(params: {
|
||||
store: AuthProfileStore;
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
externalCli?: ExternalCliOverlayOptions;
|
||||
}): RuntimeExternalOAuthProfile[] {
|
||||
return Array.from(
|
||||
resolveExternalAuthProfileMap({
|
||||
store: params.store,
|
||||
agentDir: params.agentDir,
|
||||
env: params.env,
|
||||
externalCli: params.externalCli,
|
||||
}).values(),
|
||||
);
|
||||
}
|
||||
|
||||
export function overlayExternalAuthProfiles(
|
||||
store: AuthProfileStore,
|
||||
params?: { agentDir?: string; env?: NodeJS.ProcessEnv },
|
||||
params?: { agentDir?: string; env?: NodeJS.ProcessEnv } & ExternalCliOverlayOptions,
|
||||
): AuthProfileStore {
|
||||
const profiles = listRuntimeExternalAuthProfiles({
|
||||
store,
|
||||
agentDir: params?.agentDir,
|
||||
env: params?.env,
|
||||
externalCli: params,
|
||||
});
|
||||
return overlayRuntimeExternalOAuthProfiles(store, profiles);
|
||||
}
|
||||
@@ -104,11 +118,17 @@ export function shouldPersistExternalAuthProfile(params: {
|
||||
credential: OAuthCredential;
|
||||
agentDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
externalCliProviderIds?: Iterable<string>;
|
||||
externalCliProfileIds?: Iterable<string>;
|
||||
}): boolean {
|
||||
const profiles = listRuntimeExternalAuthProfiles({
|
||||
store: params.store,
|
||||
agentDir: params.agentDir,
|
||||
env: params.env,
|
||||
externalCli: {
|
||||
externalCliProviderIds: params.externalCliProviderIds,
|
||||
externalCliProfileIds: params.externalCliProfileIds,
|
||||
},
|
||||
});
|
||||
return shouldPersistRuntimeExternalOAuthProfile({
|
||||
profileId: params.profileId,
|
||||
|
||||
110
src/agents/auth-profiles/external-cli-scope.ts
Normal file
110
src/agents/auth-profiles/external-cli-scope.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
resolveAgentModelFallbackValues,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "../../config/model-input.js";
|
||||
import type { AgentModelConfig } from "../../config/types.agents-shared.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeProviderId } from "../provider-id.js";
|
||||
|
||||
export type ExternalCliAuthScope = {
|
||||
providerIds: string[];
|
||||
profileIds: string[];
|
||||
};
|
||||
|
||||
function addProviderScopeId(out: Set<string>, value: string | undefined): void {
|
||||
const raw = value?.trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
out.add(raw);
|
||||
const normalized = normalizeProviderId(raw);
|
||||
if (normalized) {
|
||||
out.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function addProviderScopeFromModelRef(out: Set<string>, value: string | undefined): void {
|
||||
const raw = value?.trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const slash = raw.indexOf("/");
|
||||
if (slash <= 0) {
|
||||
return;
|
||||
}
|
||||
addProviderScopeId(out, raw.slice(0, slash));
|
||||
}
|
||||
|
||||
function addProviderScopeFromModelConfig(out: Set<string>, model: AgentModelConfig | undefined) {
|
||||
addProviderScopeFromModelRef(out, resolveAgentModelPrimaryValue(model));
|
||||
for (const fallback of resolveAgentModelFallbackValues(model)) {
|
||||
addProviderScopeFromModelRef(out, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function addExternalCliRuntimeScope(out: Set<string>, value: string | undefined): void {
|
||||
const normalized = normalizeProviderId(value?.trim() ?? "");
|
||||
if (
|
||||
normalized === "claude-cli" ||
|
||||
normalized === "codex" ||
|
||||
normalized === "codex-cli" ||
|
||||
normalized === "openai-codex" ||
|
||||
normalized === "minimax" ||
|
||||
normalized === "minimax-cli" ||
|
||||
normalized === "minimax-portal"
|
||||
) {
|
||||
addProviderScopeId(out, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveExternalCliAuthScopeFromConfig(
|
||||
cfg: OpenClawConfig,
|
||||
): ExternalCliAuthScope | undefined {
|
||||
const providerIds = new Set<string>();
|
||||
const profileIds = new Set<string>();
|
||||
|
||||
for (const id of Object.keys(cfg.models?.providers ?? {})) {
|
||||
addProviderScopeId(providerIds, id);
|
||||
}
|
||||
for (const [profileId, profile] of Object.entries(cfg.auth?.profiles ?? {})) {
|
||||
const normalizedProfileId = profileId.trim();
|
||||
if (normalizedProfileId) {
|
||||
profileIds.add(normalizedProfileId);
|
||||
}
|
||||
addProviderScopeId(providerIds, profile?.provider);
|
||||
}
|
||||
for (const provider of Object.keys(cfg.auth?.order ?? {})) {
|
||||
addProviderScopeId(providerIds, provider);
|
||||
}
|
||||
|
||||
const defaults = cfg.agents?.defaults;
|
||||
addProviderScopeFromModelConfig(providerIds, defaults?.model);
|
||||
addProviderScopeFromModelConfig(providerIds, defaults?.imageModel);
|
||||
addProviderScopeFromModelConfig(providerIds, defaults?.imageGenerationModel);
|
||||
addProviderScopeFromModelConfig(providerIds, defaults?.videoGenerationModel);
|
||||
addProviderScopeFromModelConfig(providerIds, defaults?.musicGenerationModel);
|
||||
addProviderScopeFromModelConfig(providerIds, defaults?.pdfModel);
|
||||
for (const modelRef of Object.keys(defaults?.models ?? {})) {
|
||||
addProviderScopeFromModelRef(providerIds, modelRef);
|
||||
}
|
||||
addExternalCliRuntimeScope(providerIds, defaults?.agentRuntime?.id);
|
||||
addExternalCliRuntimeScope(providerIds, defaults?.embeddedHarness?.runtime);
|
||||
for (const backendId of Object.keys(defaults?.cliBackends ?? {})) {
|
||||
addExternalCliRuntimeScope(providerIds, backendId);
|
||||
}
|
||||
|
||||
for (const agent of cfg.agents?.list ?? []) {
|
||||
addProviderScopeFromModelConfig(providerIds, agent.model);
|
||||
addProviderScopeFromModelConfig(providerIds, agent.subagents?.model);
|
||||
addExternalCliRuntimeScope(providerIds, agent.agentRuntime?.id);
|
||||
addExternalCliRuntimeScope(providerIds, agent.embeddedHarness?.runtime);
|
||||
}
|
||||
|
||||
if (providerIds.size === 0 && profileIds.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
providerIds: [...providerIds].toSorted((left, right) => left.localeCompare(right)),
|
||||
profileIds: [...profileIds].toSorted((left, right) => left.localeCompare(right)),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
readCodexCliCredentialsCached,
|
||||
readMiniMaxCliCredentialsCached,
|
||||
} from "../cli-credentials.js";
|
||||
import { normalizeProviderId } from "../provider-id.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
@@ -34,10 +35,19 @@ export type ExternalCliResolvedProfile = {
|
||||
credential: OAuthCredential;
|
||||
};
|
||||
|
||||
export type ExternalCliAuthProfileOptions = {
|
||||
allowKeychainPrompt?: boolean;
|
||||
providerIds?: Iterable<string>;
|
||||
profileIds?: Iterable<string>;
|
||||
};
|
||||
|
||||
type ExternalCliSyncProvider = {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
readCredentials: () => OAuthCredential | null;
|
||||
aliases?: readonly string[];
|
||||
readCredentials: (
|
||||
options?: Pick<ExternalCliAuthProfileOptions, "allowKeychainPrompt">,
|
||||
) => OAuthCredential | null;
|
||||
// bootstrapOnly providers adopt the external CLI credential only to
|
||||
// seed an empty slot; once a local OAuth credential exists for the
|
||||
// profile, the local refresh token is treated as canonical and the
|
||||
@@ -90,14 +100,18 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
|
||||
{
|
||||
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
provider: "openai-codex",
|
||||
aliases: ["codex", "codex-cli"],
|
||||
readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
bootstrapOnly: true,
|
||||
},
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
provider: "claude-cli",
|
||||
readCredentials: () => {
|
||||
const credential = readClaudeCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS });
|
||||
readCredentials: (options) => {
|
||||
const credential = readClaudeCliCredentialsCached({
|
||||
ttlMs: EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
});
|
||||
if (credential?.type !== "oauth") {
|
||||
return null;
|
||||
}
|
||||
@@ -107,6 +121,7 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
|
||||
{
|
||||
profileId: MINIMAX_CLI_PROFILE_ID,
|
||||
provider: "minimax-portal",
|
||||
aliases: ["minimax", "minimax-cli"],
|
||||
readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
},
|
||||
];
|
||||
@@ -147,13 +162,75 @@ export function readExternalCliBootstrapCredential(params: {
|
||||
|
||||
export const readManagedExternalCliCredential = readExternalCliBootstrapCredential;
|
||||
|
||||
function normalizeProviderScope(values: Iterable<string> | undefined): Set<string> | undefined {
|
||||
if (values === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const value of values) {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
out.add(raw.toLowerCase());
|
||||
const normalized = normalizeProviderId(raw);
|
||||
if (normalized) {
|
||||
out.add(normalized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeProfileScope(values: Iterable<string> | undefined): Set<string> | undefined {
|
||||
if (values === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const value of values) {
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (raw) {
|
||||
out.add(raw);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isExternalCliProviderInScope(
|
||||
providerConfig: ExternalCliSyncProvider,
|
||||
options?: ExternalCliAuthProfileOptions,
|
||||
): boolean {
|
||||
const providerScope = normalizeProviderScope(options?.providerIds);
|
||||
const profileScope = normalizeProfileScope(options?.profileIds);
|
||||
if (providerScope === undefined && profileScope === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (profileScope?.has(providerConfig.profileId.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (!providerScope || providerScope.size === 0) {
|
||||
return false;
|
||||
}
|
||||
const aliases = [providerConfig.provider, ...(providerConfig.aliases ?? [])];
|
||||
return aliases.some((alias) => {
|
||||
const raw = alias.trim().toLowerCase();
|
||||
const normalized = normalizeProviderId(alias);
|
||||
return providerScope.has(raw) || (normalized ? providerScope.has(normalized) : false);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveExternalCliAuthProfiles(
|
||||
store: AuthProfileStore,
|
||||
options?: ExternalCliAuthProfileOptions,
|
||||
): ExternalCliResolvedProfile[] {
|
||||
const profiles: ExternalCliResolvedProfile[] = [];
|
||||
const now = Date.now();
|
||||
for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) {
|
||||
const creds = providerConfig.readCredentials();
|
||||
if (!isExternalCliProviderInScope(providerConfig, options)) {
|
||||
continue;
|
||||
}
|
||||
const creds = providerConfig.readCredentials({
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
});
|
||||
if (!creds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ type LoadAuthProfileStoreOptions = {
|
||||
allowKeychainPrompt?: boolean;
|
||||
readOnly?: boolean;
|
||||
syncExternalCli?: boolean;
|
||||
externalCliProviderIds?: Iterable<string>;
|
||||
externalCliProfileIds?: Iterable<string>;
|
||||
};
|
||||
|
||||
type SaveAuthProfileStoreOptions = {
|
||||
@@ -269,12 +271,20 @@ export function loadAuthProfileStoreForRuntime(
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const mainAuthPath = resolveAuthStorePath();
|
||||
if (!agentDir || authPath === mainAuthPath) {
|
||||
return overlayExternalAuthProfiles(store, { agentDir });
|
||||
return overlayExternalAuthProfiles(store, {
|
||||
agentDir,
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
externalCliProviderIds: options?.externalCliProviderIds,
|
||||
externalCliProfileIds: options?.externalCliProfileIds,
|
||||
});
|
||||
}
|
||||
|
||||
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
|
||||
return overlayExternalAuthProfiles(mergeAuthProfileStores(mainStore, store), {
|
||||
agentDir,
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
externalCliProviderIds: options?.externalCliProviderIds,
|
||||
externalCliProfileIds: options?.externalCliProfileIds,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,11 +307,20 @@ export function loadAuthProfileStoreWithoutExternalProfiles(agentDir?: string):
|
||||
|
||||
export function ensureAuthProfileStore(
|
||||
agentDir?: string,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
options?: {
|
||||
allowKeychainPrompt?: boolean;
|
||||
externalCliProviderIds?: Iterable<string>;
|
||||
externalCliProfileIds?: Iterable<string>;
|
||||
},
|
||||
): AuthProfileStore {
|
||||
return overlayExternalAuthProfiles(
|
||||
ensureAuthProfileStoreWithoutExternalProfiles(agentDir, options),
|
||||
{ agentDir },
|
||||
{
|
||||
agentDir,
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
externalCliProviderIds: options?.externalCliProviderIds,
|
||||
externalCliProfileIds: options?.externalCliProfileIds,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -456,6 +456,7 @@ export async function resolveImplicitProviders(
|
||||
const getAuthStore = () =>
|
||||
(authStore ??= ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
externalCliProviderIds: params.providerDiscoveryProviderIds,
|
||||
}));
|
||||
const context: ImplicitProviderContext = {
|
||||
...params,
|
||||
|
||||
@@ -5,7 +5,11 @@ import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getRuntimeConfig: vi.fn(() => ({})),
|
||||
resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })),
|
||||
ensureAuthProfileStore: vi.fn((agentDir?: string, options?: unknown) => {
|
||||
void agentDir;
|
||||
void options;
|
||||
return { profiles: {} };
|
||||
}),
|
||||
buildAuthHealthSummary: vi.fn(
|
||||
(): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }),
|
||||
),
|
||||
@@ -187,6 +191,50 @@ describe("models.authStatus", () => {
|
||||
expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes external CLI auth overlays to configured providers", async () => {
|
||||
mocks.getRuntimeConfig.mockReturnValue({
|
||||
auth: {
|
||||
profiles: {
|
||||
"opencode-go:default": { provider: "opencode-go", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "opencode-go/kimi-k2.6" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"opencode-go": {
|
||||
baseUrl: "https://example.test/v1",
|
||||
auth: "api-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await handler(createOptions());
|
||||
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith(
|
||||
"/tmp/agent",
|
||||
expect.objectContaining({
|
||||
externalCliProviderIds: expect.arrayContaining(["opencode-go"]),
|
||||
externalCliProfileIds: ["opencode-go:default"],
|
||||
}),
|
||||
);
|
||||
const [, options] = mocks.ensureAuthProfileStore.mock.calls[0] ?? [];
|
||||
expect((options as { externalCliProviderIds?: string[] }).externalCliProviderIds).not.toContain(
|
||||
"claude-cli",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the auth store overlay unscoped when config has no provider signal", async () => {
|
||||
await handler(createOptions());
|
||||
|
||||
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledWith("/tmp/agent");
|
||||
});
|
||||
|
||||
it("still returns providers when usage fetch fails", async () => {
|
||||
mocks.buildAuthHealthSummary.mockReturnValue(createOpenAiCodexOauthHealthSummary());
|
||||
mocks.loadProviderUsageSummary.mockRejectedValue(new Error("timeout"));
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
formatRemainingShort,
|
||||
} from "../../agents/auth-health.js";
|
||||
import { ensureAuthProfileStore } from "../../agents/auth-profiles.js";
|
||||
import { resolveExternalCliAuthScopeFromConfig } from "../../agents/auth-profiles/external-cli-scope.js";
|
||||
import { normalizeProviderId } from "../../agents/provider-id.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { isSecretRef } from "../../config/types.secrets.js";
|
||||
@@ -292,7 +293,13 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
|
||||
try {
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
const externalCliAuthScope = resolveExternalCliAuthScopeFromConfig(cfg);
|
||||
const store = externalCliAuthScope
|
||||
? ensureAuthProfileStore(agentDir, {
|
||||
externalCliProviderIds: externalCliAuthScope.providerIds,
|
||||
externalCliProfileIds: externalCliAuthScope.profileIds,
|
||||
})
|
||||
: ensureAuthProfileStore(agentDir);
|
||||
const configured = resolveConfiguredProviders(cfg);
|
||||
const authHealth: AuthHealthSummary = buildAuthHealthSummary({
|
||||
store,
|
||||
|
||||
Reference in New Issue
Block a user