fix: thread workspace auth evidence through model auth

This commit is contained in:
Shakker
2026-04-29 20:26:03 +01:00
parent 1db2e63519
commit c4e249114d
10 changed files with 173 additions and 14 deletions

View File

@@ -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,

View File

@@ -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<boolean> {
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<ResolvedProviderAuth> {
@@ -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,
});

View File

@@ -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 });
}
});
});

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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 ??

View File

@@ -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,

View File

@@ -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") {