diff --git a/src/commands/models/list.auth-index.ts b/src/commands/models/list.auth-index.ts index fbf3303355c..4eea5987a81 100644 --- a/src/commands/models/list.auth-index.ts +++ b/src/commands/models/list.auth-index.ts @@ -1,7 +1,8 @@ import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { - resolveProviderEnvApiKeyCandidates, resolveProviderEnvAuthEvidence, + resolveProviderEnvApiKeyCandidates, + resolveProviderEnvAuthLookupKeys, } from "../../agents/model-auth-env-vars.js"; import { resolveEnvApiKey } from "../../agents/model-auth-env.js"; import { resolveAwsSdkEnvVarName } from "../../agents/model-auth-runtime-shared.js"; @@ -89,10 +90,7 @@ export function createModelListAuthIndex( addProvider(credential.provider); } - for (const provider of new Set([ - ...Object.keys(envCandidateMap), - ...Object.keys(authEvidenceMap), - ])) { + for (const provider of resolveProviderEnvAuthLookupKeys(lookupParams)) { if ( resolveEnvApiKey(provider, env, { aliasMap, diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index 81385f6a116..b3aabecc796 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -22,7 +22,19 @@ vi.mock("../../agents/model-auth.js", () => ({ const raw = cfg.models?.providers?.[provider]?.apiKey; return typeof raw === "string" && raw.trim().length > 0 && raw !== "ollama-local"; }, - resolveEnvApiKey: (provider: string) => { + resolveEnvApiKey: ( + provider: string, + _env?: NodeJS.ProcessEnv, + options?: { workspaceDir?: string }, + ) => { + if (provider === "workspace-cloud") { + return options?.workspaceDir === "/tmp/workspace" + ? { + source: "workspace cloud credentials", + apiKey: "workspace-cloud-local-credentials", + } + : null; + } const keys = provider === "anthropic" ? ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] @@ -322,4 +334,48 @@ describe("buildProbeTargets reason codes", () => { ); }); }); + + it("uses workspace-scoped auth evidence when building env probe targets", async () => { + mockStore = { + version: 1, + profiles: {}, + order: {}, + }; + loadModelCatalogMock.mockResolvedValue([ + { provider: "workspace-cloud", id: "workspace-model", name: "Workspace Model" }, + ]); + + const withoutWorkspace = await buildProbeTargets({ + cfg: {} as OpenClawConfig, + providers: ["workspace-cloud"], + modelCandidates: [], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); + const withWorkspace = await buildProbeTargets({ + cfg: {} as OpenClawConfig, + workspaceDir: "/tmp/workspace", + providers: ["workspace-cloud"], + modelCandidates: [], + options: { + timeoutMs: 5_000, + concurrency: 1, + maxTokens: 16, + }, + }); + + expect(withoutWorkspace.targets).toEqual([]); + expect(withWorkspace.targets).toHaveLength(1); + expect(withWorkspace.targets[0]).toEqual( + expect.objectContaining({ + provider: "workspace-cloud", + source: "env", + label: "env", + model: { provider: "workspace-cloud", model: "workspace-model" }, + }), + ); + }); }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 8d7abc87115..83c3f5380a6 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -249,11 +249,12 @@ async function maybeResolveUnresolvedRefIssue(params: { export async function buildProbeTargets(params: { cfg: OpenClawConfig; + workspaceDir?: string; providers: string[]; modelCandidates: string[]; options: AuthProbeOptions; }): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> { - const { cfg, providers, modelCandidates, options } = params; + const { cfg, providers, modelCandidates, options, workspaceDir } = params; const store = ensureAuthProfileStore(); const providerFilter = options.provider?.trim(); const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; @@ -380,7 +381,10 @@ export async function buildProbeTargets(params: { continue; } - const envKey = resolveEnvApiKey(providerKey); + const envKey = resolveEnvApiKey(providerKey, process.env, { + config: cfg, + workspaceDir, + }); const hasUsableModelsJsonKey = hasUsableCustomProviderApiKey(cfg, providerKey); if (!envKey && !hasUsableModelsJsonKey) { continue; @@ -494,6 +498,9 @@ async function probeTarget(params: { async function runTargetsWithConcurrency(params: { cfg: OpenClawConfig; + agentId?: string; + agentDir?: string; + workspaceDir?: string; targets: AuthProbeTarget[]; timeoutMs: number; maxTokens: number; @@ -503,9 +510,12 @@ async function runTargetsWithConcurrency(params: { const { cfg, targets, timeoutMs, maxTokens, onProgress } = params; const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency)); - const agentId = resolveDefaultAgentId(cfg); - const agentDir = resolveOpenClawAgentDir(); - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const agentId = params.agentId ?? resolveDefaultAgentId(cfg); + const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); + const workspaceDir = + params.workspaceDir ?? + resolveAgentWorkspaceDir(cfg, agentId) ?? + resolveDefaultAgentWorkspaceDir(); const sessionDir = resolveSessionTranscriptsDirForAgent(agentId); await fs.mkdir(workspaceDir, { recursive: true }); @@ -550,6 +560,9 @@ async function runTargetsWithConcurrency(params: { export async function runAuthProbes(params: { cfg: OpenClawConfig; + agentId?: string; + agentDir?: string; + workspaceDir?: string; providers: string[]; modelCandidates: string[]; options: AuthProbeOptions; @@ -558,6 +571,7 @@ export async function runAuthProbes(params: { const startedAt = Date.now(); const plan = await buildProbeTargets({ cfg: params.cfg, + workspaceDir: params.workspaceDir, providers: params.providers, modelCandidates: params.modelCandidates, options: params.options, @@ -569,6 +583,9 @@ export async function runAuthProbes(params: { const results = totalTargets ? await runTargetsWithConcurrency({ cfg: params.cfg, + agentId: params.agentId, + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, targets: plan.targets, timeoutMs: params.options.timeoutMs, maxTokens: params.options.maxTokens, diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 259c1828e22..a7a298b80f5 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -16,7 +16,11 @@ import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles/paths import { ensureAuthProfileStoreWithoutExternalProfiles as ensureAuthProfileStore } from "../../agents/auth-profiles/store.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; import { resolveProfileUnusableUntilForDisplay } from "../../agents/auth-profiles/usage.js"; -import { resolveProviderEnvApiKeyCandidates } from "../../agents/model-auth-env-vars.js"; +import { + resolveProviderEnvApiKeyCandidates, + resolveProviderEnvAuthEvidence, + resolveProviderEnvAuthLookupKeys, +} from "../../agents/model-auth-env-vars.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; import { buildModelAliasIndex, @@ -247,12 +251,21 @@ export async function modelsStatusCommand( const providersFromEnv = new Set(); // Use the shared provider-env registry so `models status` stays aligned with // env-backed providers beyond the text-model defaults (for example image-gen). - const envCandidateMap = resolveProviderEnvApiKeyCandidates({ + const envLookupParams = { config: cfg, workspaceDir, - }); - for (const provider of Object.keys(envCandidateMap).toSorted()) { - if (resolveEnvApiKey(provider, process.env, { config: cfg, workspaceDir })) { + }; + const envCandidateMap = resolveProviderEnvApiKeyCandidates(envLookupParams); + const authEvidenceMap = resolveProviderEnvAuthEvidence(envLookupParams); + for (const provider of resolveProviderEnvAuthLookupKeys(envLookupParams)) { + if ( + resolveEnvApiKey(provider, process.env, { + config: cfg, + workspaceDir, + candidateMap: envCandidateMap, + authEvidenceMap, + }) + ) { providersFromEnv.add(provider); } } @@ -384,6 +397,9 @@ export async function modelsStatusCommand( async (update) => { return await runAuthProbes({ cfg, + agentId: workspaceAgentId, + agentDir, + workspaceDir, providers, modelCandidates, options: { diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index d93ea0dd864..138ac241f77 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -91,6 +91,18 @@ const mocks = vi.hoisted(() => { "openai-codex": ["OPENAI_OAUTH_TOKEN"], fal: ["FAL_KEY"], }), + resolveProviderEnvAuthEvidence: vi.fn().mockReturnValue({}), + resolveProviderEnvAuthLookupKeys: vi + .fn() + .mockImplementation(() => [ + "anthropic", + "google", + "minimax", + "minimax-portal", + "openai", + "openai-codex", + "fal", + ]), listKnownProviderEnvApiKeyNames: vi .fn() .mockReturnValue([ @@ -195,6 +207,8 @@ vi.mock("../../agents/model-auth.js", () => ({ })); vi.mock("../../agents/model-auth-env-vars.js", () => ({ resolveProviderEnvApiKeyCandidates: mocks.resolveProviderEnvApiKeyCandidates, + resolveProviderEnvAuthEvidence: mocks.resolveProviderEnvAuthEvidence, + resolveProviderEnvAuthLookupKeys: mocks.resolveProviderEnvAuthLookupKeys, listKnownProviderEnvApiKeyNames: mocks.listKnownProviderEnvApiKeyNames, })); vi.mock("../../agents/model-selection-cli.js", () => ({ @@ -527,6 +541,61 @@ describe("modelsStatusCommand auth overview", () => { } }); + it("includes auth-evidence-only providers in the auth overview", async () => { + const localRuntime = createRuntime(); + const originalKeysImpl = mocks.resolveProviderEnvAuthLookupKeys.getMockImplementation(); + const originalEvidenceImpl = mocks.resolveProviderEnvAuthEvidence.getMockImplementation(); + const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); + + mocks.resolveProviderEnvAuthLookupKeys.mockReturnValue(["workspace-cloud"]); + mocks.resolveProviderEnvAuthEvidence.mockReturnValue({ + "workspace-cloud": [ + { + type: "local-file-with-env", + credentialMarker: "workspace-cloud-local-credentials", + source: "workspace cloud credentials", + }, + ], + }); + mocks.resolveEnvApiKey.mockImplementation( + (provider: string, _env?: NodeJS.ProcessEnv, options?: { workspaceDir?: string }) => + provider === "workspace-cloud" && options?.workspaceDir === "/tmp/openclaw-agent/workspace" + ? { + apiKey: "workspace-cloud-local-credentials", + source: "workspace cloud credentials", + } + : null, + ); + + try { + await modelsStatusCommand({ json: true }, localRuntime as never); + const payload = JSON.parse(String((localRuntime.log as Mock).mock.calls[0]?.[0])); + expect(payload.auth.providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: "workspace-cloud", + effective: expect.objectContaining({ kind: "env" }), + env: expect.objectContaining({ source: "workspace cloud credentials" }), + }), + ]), + ); + } finally { + if (originalKeysImpl) { + mocks.resolveProviderEnvAuthLookupKeys.mockImplementation(originalKeysImpl); + } + if (originalEvidenceImpl) { + mocks.resolveProviderEnvAuthEvidence.mockImplementation(originalEvidenceImpl); + } + if (originalEnvImpl) { + mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); + } else if (defaultResolveEnvApiKeyImpl) { + mocks.resolveEnvApiKey.mockImplementation(defaultResolveEnvApiKeyImpl); + } else { + mocks.resolveEnvApiKey.mockImplementation(() => null); + } + } + }); + it("reports defaults source when --agent has no overrides", async () => { await withAgentScopeOverrides( {