diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index f70052c7707..33f3b66372a 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -16,9 +16,16 @@ const modelCatalogMocks = vi.hoisted(() => ({ const modelAuthLabelMocks = vi.hoisted(() => ({ resolveModelAuthLabel: vi.fn<(params: unknown) => string | undefined>(() => undefined), })); -const modelProviderAuthMocks = vi.hoisted(() => ({ - authenticatedProviders: new Set(["anthropic", "google", "openai"]), -})); +const modelProviderAuthMocks = vi.hoisted(() => { + const state = { + authenticatedProviders: new Set(["anthropic", "google", "openai"]), + createProviderAuthChecker: vi.fn(), + }; + state.createProviderAuthChecker.mockImplementation( + () => (provider: string) => state.authenticatedProviders.has(provider), + ); + return state; +}); const MODELS_ADD_DEPRECATED_TEXT = "⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models."; @@ -32,8 +39,7 @@ vi.mock("../../agents/model-auth-label.js", () => ({ })); vi.mock("../../agents/model-provider-auth.js", () => ({ - createProviderAuthChecker: () => (provider: string) => - modelProviderAuthMocks.authenticatedProviders.has(provider), + createProviderAuthChecker: modelProviderAuthMocks.createProviderAuthChecker, hasAuthForModelProvider: ({ provider }: { provider: string }) => modelProviderAuthMocks.authenticatedProviders.has(provider), })); @@ -104,6 +110,7 @@ beforeEach(() => { modelAuthLabelMocks.resolveModelAuthLabel.mockReset(); modelAuthLabelMocks.resolveModelAuthLabel.mockReturnValue(undefined); modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "google", "openai"]); + modelProviderAuthMocks.createProviderAuthChecker.mockClear(); setActivePluginRegistry( createTestRegistry([ ...textSurfaceModelsTestPlugins, @@ -179,6 +186,9 @@ describe("handleModelsCommand", () => { expect(result?.reply?.text).toContain("Use: /models "); expect(result?.reply?.text).toContain("Switch: /model "); expect(result?.reply?.text).not.toContain("Add: /models add"); + expect(modelProviderAuthMocks.createProviderAuthChecker).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: "/tmp" }), + ); }); it("hides unauthenticated providers by default and keeps all as explicit browse", async () => { diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index a496d23aa35..f7c3b4539a2 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,4 +1,8 @@ -import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveSessionAgentId, +} from "../../agents/agent-scope.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; @@ -11,6 +15,7 @@ import { resolveDefaultModelForAgent, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -63,7 +68,7 @@ type ParsedModelsCommand = export async function buildModelsProviderData( cfg: OpenClawConfig, agentId?: string, - options: { view?: "default" | "all" } = {}, + options: { view?: "default" | "all"; workspaceDir?: string } = {}, ): Promise { const resolvedDefault = resolveDefaultModelForAgent({ cfg, @@ -77,6 +82,10 @@ export async function buildModelsProviderData( defaultProvider: resolvedDefault.provider, defaultModel: resolvedDefault.model, agentId, + workspaceDir: + options.workspaceDir ?? + (agentId ? resolveAgentWorkspaceDir(cfg, agentId) : undefined) ?? + resolveDefaultAgentWorkspaceDir(), view: options.view, }); @@ -329,6 +338,7 @@ export async function resolveModelsCommandReply(params: { currentModel?: string; agentId?: string; agentDir?: string; + workspaceDir?: string; sessionEntry?: ModelsCommandSessionEntry; }): Promise { const body = params.commandBodyNormalized.trim(); @@ -342,7 +352,10 @@ export async function resolveModelsCommandReply(params: { const { byProvider, providers, modelNames } = await buildModelsProviderData( params.cfg, params.agentId, - parsed.action === "list" && parsed.all ? { view: "all" } : undefined, + { + ...(parsed.action === "list" && parsed.all ? { view: "all" as const } : {}), + workspaceDir: params.workspaceDir, + }, ); const commandPlugin = params.surface ? getChannelPlugin(params.surface) : null; const providerInfos = buildProviderInfos({ providers, byProvider }); @@ -523,6 +536,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma currentModel: params.model ? `${params.provider}/${params.model}` : undefined, agentId: modelsAgentId, agentDir: modelsAgentDir, + workspaceDir: modelsAgentId === currentAgentId ? params.workspaceDir : undefined, sessionEntry: targetSessionEntry, }); if (!reply) { diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 00136e04cd7..5c612cc0422 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -1,4 +1,8 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; let modelsListCommand: typeof import("./models/list.list-command.js").modelsListCommand; let loadModelRegistry: typeof import("./models/list.registry.js").loadModelRegistry; @@ -333,6 +337,35 @@ describe("models list/status", () => { modelRegistryState.available = available ? [ZAI_MODEL, OPENAI_MODEL] : []; } + async function writeWorkspaceAuthEvidencePlugin(workspaceDir: string) { + const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-cloud"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.ts"), "export default {}\n", "utf8"); + await fs.writeFile( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "workspace-cloud", + configSchema: { type: "object" }, + setup: { + providers: [ + { + id: "workspace-cloud", + authEvidence: [ + { + type: "local-file-with-env", + fileEnvVar: "WORKSPACE_CLOUD_CREDENTIALS", + credentialMarker: "workspace-cloud-local-credentials", + source: "workspace cloud credentials", + }, + ], + }, + ], + }, + }), + "utf8", + ); + } + beforeAll(async () => { ({ modelsListCommand } = await import("./models/list.list-command.js")); ({ loadModelRegistry, toModelRow } = await import("./models/list.registry.js")); @@ -410,6 +443,67 @@ describe("models list/status", () => { expect(payload.models[0]?.available).toBe(false); }); + it("models list uses trusted workspace plugin auth evidence for configured rows", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-models-list-auth-")); + const workspaceDir = path.join(tempRoot, "workspace"); + const bundledDir = path.join(tempRoot, "bundled"); + const stateDir = path.join(tempRoot, "state"); + const credentialsPath = path.join(tempRoot, "credentials.json"); + await fs.mkdir(bundledDir, { recursive: true }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(credentialsPath, "{}", "utf8"); + await writeWorkspaceAuthEvidencePlugin(workspaceDir); + getRuntimeConfig.mockReturnValue({ + agents: { + defaults: { + workspace: workspaceDir, + model: "workspace-cloud/model-a", + }, + }, + plugins: { allow: ["workspace-cloud"] }, + models: { + providers: { + "workspace-cloud": { + baseUrl: "https://workspace-cloud.example/v1", + api: "openai-responses", + models: [ + { + id: "model-a", + name: "Workspace Cloud Model A", + input: ["text"], + contextWindow: 8192, + maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, + }, + }); + const runtime = makeRuntime(); + + try { + await withEnvAsync( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_STATE_DIR: stateDir, + WORKSPACE_CLOUD_CREDENTIALS: credentialsPath, + }, + () => modelsListCommand({ all: true, provider: "workspace-cloud", json: true }, runtime), + ); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + + const payload = parseJsonLog(runtime); + expect(payload.models).toEqual([ + expect.objectContaining({ + key: "workspace-cloud/model-a", + available: true, + }), + ]); + }); + it("models list all includes unauthenticated provider catalog rows", async () => { setDefaultZaiRegistry({ available: false }); hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); diff --git a/src/commands/models/list.auth-index.test.ts b/src/commands/models/list.auth-index.test.ts index c96085d2fec..1a53d3b8932 100644 --- a/src/commands/models/list.auth-index.test.ts +++ b/src/commands/models/list.auth-index.test.ts @@ -1,5 +1,9 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { withEnvAsync } from "../../test-utils/env.js"; import { createModelListAuthIndex } from "./list.auth-index.js"; type PluginSnapshotResult = { @@ -61,6 +65,35 @@ function modelConfig(id: string) { }; } +async function writeWorkspaceAuthEvidencePlugin(workspaceDir: string) { + const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "workspace-cloud"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.ts"), "export default {}\n", "utf8"); + await fs.writeFile( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "workspace-cloud", + configSchema: { type: "object" }, + setup: { + providers: [ + { + id: "workspace-cloud", + authEvidence: [ + { + type: "local-file-with-env", + fileEnvVar: "WORKSPACE_CLOUD_CREDENTIALS", + credentialMarker: "workspace-cloud-local-credentials", + source: "workspace cloud credentials", + }, + ], + }, + ], + }, + }), + "utf8", + ); +} + describe("createModelListAuthIndex", () => { beforeEach(() => { envCandidateMocks.resolveProviderEnvApiKeyCandidates.mockClear(); @@ -113,6 +146,47 @@ describe("createModelListAuthIndex", () => { expect(index.hasProviderAuth("google-vertex")).toBe(true); }); + it("uses trusted workspace plugin auth evidence when workspace scope is supplied", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-list-auth-index-")); + const workspaceDir = path.join(tempRoot, "workspace"); + const bundledDir = path.join(tempRoot, "bundled"); + const stateDir = path.join(tempRoot, "state"); + const credentialsPath = path.join(tempRoot, "credentials.json"); + await fs.mkdir(bundledDir, { recursive: true }); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile(credentialsPath, "{}", "utf8"); + await writeWorkspaceAuthEvidencePlugin(workspaceDir); + + try { + await withEnvAsync( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_STATE_DIR: stateDir, + WORKSPACE_CLOUD_CREDENTIALS: credentialsPath, + }, + async () => { + const cfg = { plugins: { allow: ["workspace-cloud"] } }; + const withoutWorkspace = createModelListAuthIndex({ + cfg, + authStore: emptyStore, + env: process.env, + }); + const withWorkspace = createModelListAuthIndex({ + cfg, + authStore: emptyStore, + workspaceDir, + env: process.env, + }); + + expect(withoutWorkspace.hasProviderAuth("workspace-cloud")).toBe(false); + expect(withWorkspace.hasProviderAuth("workspace-cloud")).toBe(true); + }, + ); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it("records configured provider API keys", () => { const index = createModelListAuthIndex({ cfg: { diff --git a/src/commands/models/list.auth-index.ts b/src/commands/models/list.auth-index.ts index 3608de608a6..fbf3303355c 100644 --- a/src/commands/models/list.auth-index.ts +++ b/src/commands/models/list.auth-index.ts @@ -21,6 +21,7 @@ export type ModelListAuthIndex = { export type CreateModelListAuthIndexParams = { cfg: OpenClawConfig; authStore: AuthProfileStore; + workspaceDir?: string; env?: NodeJS.ProcessEnv; syntheticAuthProviderRefs?: readonly string[]; }; @@ -39,10 +40,12 @@ function normalizeAuthProvider( function listValidatedSyntheticAuthProviderRefs(params: { cfg: OpenClawConfig; + workspaceDir?: string; env: NodeJS.ProcessEnv; }): readonly string[] { const result = loadPluginRegistrySnapshotWithMetadata({ config: params.cfg, + workspaceDir: params.workspaceDir, env: params.env, }); if (result.source !== "persisted" && result.source !== "provided") { @@ -57,9 +60,14 @@ export function createModelListAuthIndex( params: CreateModelListAuthIndexParams, ): ModelListAuthIndex { const env = params.env ?? process.env; - const aliasMap = resolveProviderAuthAliasMap({ config: params.cfg, env }); - const envCandidateMap = resolveProviderEnvApiKeyCandidates({ config: params.cfg, env }); - const authEvidenceMap = resolveProviderEnvAuthEvidence({ config: params.cfg, env }); + const lookupParams = { + config: params.cfg, + workspaceDir: params.workspaceDir, + env, + }; + const aliasMap = resolveProviderAuthAliasMap(lookupParams); + const envCandidateMap = resolveProviderEnvApiKeyCandidates(lookupParams); + const authEvidenceMap = resolveProviderEnvAuthEvidence(lookupParams); const authenticatedProviders = new Set(); const syntheticAuthProviders = new Set(); const envProviderAuthCache = new Map(); @@ -90,6 +98,7 @@ export function createModelListAuthIndex( aliasMap, candidateMap: envCandidateMap, authEvidenceMap, + workspaceDir: params.workspaceDir, }) ) { addProvider(provider); @@ -110,7 +119,11 @@ export function createModelListAuthIndex( } for (const provider of params.syntheticAuthProviderRefs ?? - listValidatedSyntheticAuthProviderRefs({ cfg: params.cfg, env })) { + listValidatedSyntheticAuthProviderRefs({ + cfg: params.cfg, + workspaceDir: params.workspaceDir, + env, + })) { addSyntheticProvider(provider); } diff --git a/src/commands/models/list.auth-overview.ts b/src/commands/models/list.auth-overview.ts index fad56070c7c..86442763fdf 100644 --- a/src/commands/models/list.auth-overview.ts +++ b/src/commands/models/list.auth-overview.ts @@ -66,6 +66,7 @@ export function resolveProviderAuthOverview(params: { store: AuthProfileStore; modelsPath: string; agentDir?: string; + workspaceDir?: string; syntheticAuth?: { value: string; source: string }; }): ProviderAuthOverview { const { provider, cfg, store } = params; @@ -123,7 +124,10 @@ export function resolveProviderAuthOverview(params: { const tokenCount = profiles.filter((id) => store.profiles[id]?.type === "token").length; const apiKeyCount = profiles.filter((id) => store.profiles[id]?.type === "api_key").length; - const envKey = resolveEnvApiKey(provider); + const envKey = resolveEnvApiKey(provider, process.env, { + config: cfg, + workspaceDir: params.workspaceDir, + }); const customKey = getCustomProviderApiKey(cfg, provider); const usableCustomKey = resolveUsableCustomProviderApiKey({ cfg, provider }); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 04c2bdee827..2eabaf28009 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -65,18 +65,26 @@ export async function modelsListCommand( if (providerFilter === null) { return; } - const [{ loadAuthProfileStoreWithoutExternalProfiles }, { resolveOpenClawAgentDir }] = - await Promise.all([ - import("../../agents/auth-profiles/store.js"), - import("../../agents/agent-paths.js"), - ]); + const [ + { loadAuthProfileStoreWithoutExternalProfiles }, + { resolveOpenClawAgentDir }, + { resolveAgentWorkspaceDir, resolveDefaultAgentId }, + { resolveDefaultAgentWorkspaceDir }, + ] = await Promise.all([ + import("../../agents/auth-profiles/store.js"), + import("../../agents/agent-paths.js"), + import("../../agents/agent-scope.js"), + import("../../agents/workspace.js"), + ]); const { resolvedConfig: cfg } = await loadModelsConfigWithSource({ commandName: "models list", runtime, }); const authStore = loadAuthProfileStoreWithoutExternalProfiles(); const agentDir = resolveOpenClawAgentDir(); - const authIndex = createModelListAuthIndex({ cfg, authStore }); + const workspaceDir = + resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? resolveDefaultAgentWorkspaceDir(); + const authIndex = createModelListAuthIndex({ cfg, authStore, workspaceDir }); let modelRegistry: ModelRegistry | undefined; let registryModels: Model[] = []; diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index d90fd5715a0..259c1828e22 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -4,6 +4,8 @@ import { resolveAgentDir, resolveAgentExplicitModelPrimary, resolveAgentModelFallbacksOverride, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, } from "../../agents/agent-scope.js"; import { buildAuthHealthSummary, @@ -24,6 +26,7 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { createConfigIO } from "../../config/config.js"; import { resolveAgentModelFallbackValues, @@ -161,6 +164,9 @@ export async function modelsStatusCommand( const cfg = await loadModelsConfig({ commandName: "models status", runtime }); const agentId = resolveKnownAgentId({ cfg, rawAgentId: opts.agent }); const agentDir = agentId ? resolveAgentDir(cfg, agentId) : resolveOpenClawAgentDir(); + const workspaceAgentId = agentId ?? resolveDefaultAgentId(cfg); + const workspaceDir = + resolveAgentWorkspaceDir(cfg, workspaceAgentId) ?? resolveDefaultAgentWorkspaceDir(); const agentModelPrimary = agentId ? resolveAgentExplicitModelPrimary(cfg, agentId) : undefined; const agentFallbacksOverride = agentId ? resolveAgentModelFallbacksOverride(cfg, agentId) @@ -241,8 +247,12 @@ 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). - for (const provider of Object.keys(resolveProviderEnvApiKeyCandidates()).toSorted()) { - if (resolveEnvApiKey(provider)) { + const envCandidateMap = resolveProviderEnvApiKeyCandidates({ + config: cfg, + workspaceDir, + }); + for (const provider of Object.keys(envCandidateMap).toSorted()) { + if (resolveEnvApiKey(provider, process.env, { config: cfg, workspaceDir })) { providersFromEnv.add(provider); } } @@ -296,6 +306,7 @@ export async function modelsStatusCommand( store, modelsPath, agentDir, + workspaceDir, syntheticAuth: syntheticAuthByProvider.get(provider), }), ) diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index eb55c7b3b97..d93ea0dd864 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -38,6 +38,7 @@ const mocks = vi.hoisted(() => { resolveOpenClawAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"), resolveAgentDir: vi.fn().mockReturnValue("/tmp/openclaw-agent"), resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/openclaw-agent/workspace"), + resolveDefaultAgentId: vi.fn().mockReturnValue("main"), resolveAgentExplicitModelPrimary: vi.fn().mockReturnValue(undefined), resolveAgentEffectiveModelPrimary: vi.fn().mockReturnValue(undefined), resolveAgentModelFallbacksOverride: vi.fn().mockReturnValue(undefined), @@ -132,11 +133,15 @@ vi.mock("../../agents/agent-paths.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentDir: mocks.resolveAgentDir, resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, + resolveDefaultAgentId: mocks.resolveDefaultAgentId, resolveAgentExplicitModelPrimary: mocks.resolveAgentExplicitModelPrimary, resolveAgentEffectiveModelPrimary: mocks.resolveAgentEffectiveModelPrimary, resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride, listAgentIds: mocks.listAgentIds, })); +vi.mock("../../agents/workspace.js", () => ({ + resolveDefaultAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/openclaw-agent/workspace"), +})); vi.mock("../../agents/auth-profiles/display.js", () => ({ resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, })); diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 95a6c39d098..1f9226a3e8c 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -1,5 +1,7 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { resolveVisibleModelCatalog } from "../../agents/model-catalog-visibility.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { ErrorCodes, errorShape, @@ -30,6 +32,9 @@ export const modelsHandlers: GatewayRequestHandlers = { try { const catalog = await context.loadGatewayModelCatalog(); const cfg = context.getRuntimeConfig(); + const workspaceDir = + resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? + resolveDefaultAgentWorkspaceDir(); const view = resolveModelsListView(params); if (view === "all") { respond(true, { models: catalog }, undefined); @@ -39,6 +44,7 @@ export const modelsHandlers: GatewayRequestHandlers = { cfg, catalog, defaultProvider: DEFAULT_PROVIDER, + workspaceDir, view, }); respond(true, { models }, undefined);