From c4e249114d9629a2b37911198aa71c548c2239d8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 29 Apr 2026 20:26:03 +0100 Subject: [PATCH] fix: thread workspace auth evidence through model auth --- .../codex/src/app-server/run-attempt.ts | 4 +- src/agents/model-auth.ts | 26 ++-- .../model-auth.workspace-plugin.test.ts | 119 ++++++++++++++++++ src/agents/model-catalog-visibility.ts | 2 + src/agents/model-provider-auth.ts | 4 + src/agents/pi-embedded-runner/compact.ts | 4 +- src/agents/pi-embedded-runner/run/attempt.ts | 4 +- src/auto-reply/reply/agent-runner.ts | 8 +- src/flows/model-picker.ts | 15 ++- .../runtime/runtime-model-auth.runtime.ts | 1 + 10 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 src/agents/model-auth.workspace-plugin.test.ts diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 4b3a9e4e79a..08274fce69a 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1116,7 +1116,9 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { : undefined, modelApi: params.model.api, modelContextWindowTokens: params.model.contextWindow, - modelAuthMode: resolveModelAuthMode(params.model.provider, params.config), + modelAuthMode: resolveModelAuthMode(params.model.provider, params.config, undefined, { + workspaceDir: input.effectiveWorkspace, + }), currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, currentMessageId: params.currentMessageId, diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 71095633264..c540db12401 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -52,8 +52,9 @@ const log = createSubsystemLogger("model-auth"); function resolveConfigAwareEnvApiKey( cfg: OpenClawConfig | undefined, provider: string, + workspaceDir?: string, ): EnvApiKeyResult | null { - return resolveEnvApiKey(provider, process.env, { config: cfg }); + return resolveEnvApiKey(provider, process.env, { config: cfg, workspaceDir }); } function resolveProviderConfig( @@ -315,6 +316,7 @@ export function hasSyntheticLocalProviderAuthConfig(params: { export function hasRuntimeAvailableProviderAuth(params: { provider: string; cfg?: OpenClawConfig; + workspaceDir?: string; env?: NodeJS.ProcessEnv; }): boolean { const provider = normalizeProviderId(params.provider); @@ -322,7 +324,12 @@ export function hasRuntimeAvailableProviderAuth(params: { if (authOverride === "aws-sdk") { return true; } - if (resolveEnvApiKey(provider, params.env)) { + if ( + resolveEnvApiKey(provider, params.env, { + config: params.cfg, + workspaceDir: params.workspaceDir, + }) + ) { return true; } if (resolveUsableCustomProviderApiKey({ cfg: params.cfg, provider, env: params.env })) { @@ -489,6 +496,7 @@ export async function resolveApiKeyForProvider(params: { preferredProfile?: string; store?: AuthProfileStore; agentDir?: string; + workspaceDir?: string; /** When true, treat profileId as a user-locked selection that must not be * silently overridden by env/config credentials. */ lockedProfile?: boolean; @@ -553,7 +561,7 @@ export async function resolveApiKeyForProvider(params: { } if (params.credentialPrecedence === "env-first") { - const envResolved = resolveConfigAwareEnvApiKey(cfg, provider); + const envResolved = resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir); if (envResolved) { const resolvedMode: ResolvedProviderAuth["mode"] = envResolved.source.includes("OAUTH_TOKEN") ? "oauth" @@ -575,7 +583,7 @@ export async function resolveApiKeyForProvider(params: { mode: "api-key", }; } - const localMarkerEnv = resolveConfigAwareEnvApiKey(cfg, provider); + const localMarkerEnv = resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir); if (localMarkerEnv && isNonSecretApiKeyMarker(localMarkerEnv.apiKey)) { return { apiKey: localMarkerEnv.apiKey, @@ -626,7 +634,7 @@ export async function resolveApiKeyForProvider(params: { } } - const envResolved = resolveConfigAwareEnvApiKey(cfg, provider); + const envResolved = resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir); if (envResolved) { const resolvedMode: ResolvedProviderAuth["mode"] = envResolved.source.includes("OAUTH_TOKEN") ? "oauth" @@ -699,6 +707,7 @@ export function resolveModelAuthMode( provider?: string, cfg?: OpenClawConfig, store?: AuthProfileStore, + options?: { workspaceDir?: string }, ): ModelAuthMode | undefined { const resolved = provider?.trim(); if (!resolved) { @@ -739,7 +748,7 @@ export function resolveModelAuthMode( return "aws-sdk"; } - const envKey = resolveConfigAwareEnvApiKey(cfg, resolved); + const envKey = resolveConfigAwareEnvApiKey(cfg, resolved, options?.workspaceDir); if (envKey?.apiKey) { return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; } @@ -764,6 +773,7 @@ export async function hasAvailableAuthForProvider(params: { preferredProfile?: string; store?: AuthProfileStore; agentDir?: string; + workspaceDir?: string; }): Promise { const { provider, cfg, preferredProfile } = params; @@ -771,7 +781,7 @@ export async function hasAvailableAuthForProvider(params: { if (authOverride === "aws-sdk") { return true; } - if (resolveConfigAwareEnvApiKey(cfg, provider)) { + if (resolveConfigAwareEnvApiKey(cfg, provider, params.workspaceDir)) { return true; } if (resolveUsableCustomProviderApiKey({ cfg, provider })) { @@ -816,6 +826,7 @@ export async function getApiKeyForModel(params: { preferredProfile?: string; store?: AuthProfileStore; agentDir?: string; + workspaceDir?: string; lockedProfile?: boolean; credentialPrecedence?: ProviderCredentialPrecedence; }): Promise { @@ -826,6 +837,7 @@ export async function getApiKeyForModel(params: { preferredProfile: params.preferredProfile, store: params.store, agentDir: params.agentDir, + workspaceDir: params.workspaceDir, lockedProfile: params.lockedProfile, credentialPrecedence: params.credentialPrecedence, }); diff --git a/src/agents/model-auth.workspace-plugin.test.ts b/src/agents/model-auth.workspace-plugin.test.ts new file mode 100644 index 00000000000..68ed3ad5ce0 --- /dev/null +++ b/src/agents/model-auth.workspace-plugin.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import type { AuthProfileStore } from "./auth-profiles.js"; +import { resolveEnvApiKey } from "./model-auth-env.js"; +import { + hasAvailableAuthForProvider, + resolveApiKeyForProvider, + resolveModelAuthMode, +} from "./model-auth.js"; +import { hasAuthForModelProvider } from "./model-provider-auth.js"; + +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("workspace plugin model auth evidence", () => { + it("uses trusted workspace plugin auth evidence across runtime and picker auth checks", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-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); + + const cfg: OpenClawConfig = { + plugins: { + allow: ["workspace-cloud"], + }, + }; + const store: AuthProfileStore = { version: 1, profiles: {} }; + + try { + await withEnvAsync( + { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + OPENCLAW_STATE_DIR: stateDir, + WORKSPACE_CLOUD_CREDENTIALS: credentialsPath, + }, + async () => { + expect(resolveEnvApiKey("workspace-cloud", process.env, { config: cfg })).toBeNull(); + expect( + resolveEnvApiKey("workspace-cloud", process.env, { + config: cfg, + workspaceDir, + }), + ).toEqual({ + apiKey: "workspace-cloud-local-credentials", + source: "workspace cloud credentials", + }); + await expect( + resolveApiKeyForProvider({ + provider: "workspace-cloud", + cfg, + workspaceDir, + store, + }), + ).resolves.toMatchObject({ + apiKey: "workspace-cloud-local-credentials", + source: "workspace cloud credentials", + mode: "api-key", + }); + expect(resolveModelAuthMode("workspace-cloud", cfg, store, { workspaceDir })).toBe( + "api-key", + ); + await expect( + hasAvailableAuthForProvider({ + provider: "workspace-cloud", + cfg, + workspaceDir, + store, + }), + ).resolves.toBe(true); + expect( + hasAuthForModelProvider({ + provider: "workspace-cloud", + cfg, + workspaceDir, + store, + }), + ).toBe(true); + }, + ); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/model-catalog-visibility.ts b/src/agents/model-catalog-visibility.ts index a0fe795847a..0fd8b1a15e5 100644 --- a/src/agents/model-catalog-visibility.ts +++ b/src/agents/model-catalog-visibility.ts @@ -32,6 +32,7 @@ export function resolveVisibleModelCatalog(params: { defaultModel?: string; agentId?: string; agentDir?: string; + workspaceDir?: string; env?: NodeJS.ProcessEnv; view?: ModelCatalogVisibilityView; }): ModelCatalogEntry[] { @@ -55,6 +56,7 @@ export function resolveVisibleModelCatalog(params: { ); const hasAuth = createProviderAuthChecker({ cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, env: params.env, }); diff --git a/src/agents/model-provider-auth.ts b/src/agents/model-provider-auth.ts index c76a17d3163..3ce649988e8 100644 --- a/src/agents/model-provider-auth.ts +++ b/src/agents/model-provider-auth.ts @@ -10,6 +10,7 @@ import { normalizeProviderId } from "./model-selection.js"; export function hasAuthForModelProvider(params: { provider: string; cfg?: OpenClawConfig; + workspaceDir?: string; agentDir?: string; env?: NodeJS.ProcessEnv; store?: AuthProfileStore; @@ -19,6 +20,7 @@ export function hasAuthForModelProvider(params: { hasRuntimeAvailableProviderAuth({ provider, cfg: params.cfg, + workspaceDir: params.workspaceDir, env: params.env, }) ) { @@ -37,6 +39,7 @@ export function hasAuthForModelProvider(params: { export function createProviderAuthChecker(params: { cfg?: OpenClawConfig; + workspaceDir?: string; agentDir?: string; env?: NodeJS.ProcessEnv; }): (provider: string) => boolean { @@ -53,6 +56,7 @@ export function createProviderAuthChecker(params: { const value = hasAuthForModelProvider({ provider: key, cfg: params.cfg, + workspaceDir: params.workspaceDir, agentDir: params.agentDir, env: params.env, store, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 61853e9e759..70306c5c63b 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -572,7 +572,9 @@ export async function compactEmbeddedPiSessionDirect( modelCompat: extractModelCompat(effectiveModel), modelApi: model.api, modelContextWindowTokens: ctxInfo.tokens, - modelAuthMode: resolveModelAuthMode(model.provider, params.config), + modelAuthMode: resolveModelAuthMode(model.provider, params.config, undefined, { + workspaceDir: effectiveWorkspace, + }), }); const toolsEnabled = supportsModelTools(runtimeModel); const runtimePlanModelContext = { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6fcfeec8cd7..05c6fce9f4b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -762,7 +762,9 @@ export async function runEmbeddedAttempt( modelCompat: extractModelCompat(params.model), modelApi: params.model.api, modelContextWindowTokens: params.model.contextWindow, - modelAuthMode: resolveModelAuthMode(params.model.provider, params.config), + modelAuthMode: resolveModelAuthMode(params.model.provider, params.config, undefined, { + workspaceDir: effectiveWorkspace, + }), currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, currentMessageId: params.currentMessageId, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index ab96bb3667d..379e538ab55 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1515,7 +1515,9 @@ export async function runReplyAgent(params: { (sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined); const responseUsageMode = resolveResponseUsageMode(responseUsageRaw); if (responseUsageMode !== "off" && hasNonzeroUsage(usage)) { - const authMode = resolveModelAuthMode(providerUsed, cfg); + const authMode = resolveModelAuthMode(providerUsed, cfg, undefined, { + workspaceDir: followupRun.run.workspaceDir, + }); const showCost = authMode === "api-key"; const costConfig = showCost ? resolveModelCostConfig({ @@ -1678,7 +1680,9 @@ export async function runReplyAgent(params: { authMode: runResult.meta?.requestShaping?.authMode ?? (cfg?.models?.providers && providerUsed in cfg.models.providers - ? (resolveModelAuthMode(providerUsed, cfg) ?? undefined) + ? (resolveModelAuthMode(providerUsed, cfg, undefined, { + workspaceDir: followupRun.run.workspaceDir, + }) ?? undefined) : undefined), thinking: runResult.meta?.requestShaping?.thinking ?? diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index f9c12e8fac5..e0794e26fa6 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -713,6 +713,7 @@ export async function promptDefaultModel( defaultProvider: DEFAULT_PROVIDER, defaultModel: resolved.model, agentDir: params.agentDir, + workspaceDir: params.workspaceDir, env: params.env, }); if (models.length === 0) { @@ -749,7 +750,12 @@ export async function promptDefaultModel( const hasPreferredProvider = preferredProvider ? filteredModels.some((entry) => matchesPreferredProvider?.(entry.provider)) : false; - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir, env: params.env }); + const hasAuth = createProviderAuthChecker({ + cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + env: params.env, + }); const literalPrefixProviders = await resolveCachedLiteralPrefixProviders(); // Show the literal form (e.g. nvidia/nvidia/...) in the "Keep current" label @@ -912,7 +918,12 @@ export async function promptModelAllowlist(params: { fallbackKeys.length > 0 || (params.initialSelections?.length ?? 0) > 0 || configuredRaw.length > 0; - const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir, env: params.env }); + const hasAuth = createProviderAuthChecker({ + cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + env: params.env, + }); const matchesPreferredProvider = preferredProvider ? createPreferredProviderMatcher({ preferredProvider, diff --git a/src/plugins/runtime/runtime-model-auth.runtime.ts b/src/plugins/runtime/runtime-model-auth.runtime.ts index 74f740f2365..5c37bff3dd7 100644 --- a/src/plugins/runtime/runtime-model-auth.runtime.ts +++ b/src/plugins/runtime/runtime-model-auth.runtime.ts @@ -31,6 +31,7 @@ export async function getRuntimeAuthForModel(params: { const resolvedAuth = await resolveModelApiKey({ model: params.model, cfg: params.cfg, + workspaceDir: params.workspaceDir, }); if (!resolvedAuth.apiKey || resolvedAuth.mode === "aws-sdk") {