Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/.generated/plugin-sdk-api-baseline.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
240429e28a6f2db286421605e6cf4b65c2e4faf654b4b994e1d509e69b0e18f8 module/agent-harness-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
6ee8bb70cd7b8a5a976ee84cd0c6e632dbfa5e4a047f616cdc00fa5b27879b27 module/agent-runtime
7a5d703261cef5a2f6273ecb8467946365d34760e98c549c5d8856653fc6ff3b module/agent-runtime
56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from
55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit
7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime
Expand Down Expand Up @@ -94,7 +94,7 @@ ea56ea0455c62f292e1c7b3e56cac6eb3fea00e548f9ab2f4a69e0b41b0a2d96 module/memory-
987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime
d7dd3c82a4b1df9e4144b078caf0e6ab95e7b11c5046b53a38f01143a7fa0eec module/plugin-entry
8bc85c9f7df434c87cd7d35cc33080e2577a1893353f92bff8a0cc63d2c4eb0e module/plugin-runtime
de508df6d9cfa9d21c4524989dd8bd142fb60cb4ae980193e0907767d76e6022 module/provider-auth
4a505605103d4ff47f824d80a48268f163273db4d2286b4c97f9406d37555b6d module/provider-auth
71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime
56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture
aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/question-gateway-runtime
Expand Down
3 changes: 2 additions & 1 deletion extensions/comfy/image-generation-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ export function buildComfyImageGenerationProvider(): ImageGenerationProvider {
label: "ComfyUI",
defaultModel: DEFAULT_COMFY_MODEL,
models: [DEFAULT_COMFY_MODEL],
isConfigured: ({ cfg, agentDir }) =>
isConfigured: ({ cfg, agentDir, workspaceDir }) =>
isComfyCapabilityConfigured({
cfg,
agentDir,
workspaceDir,
capability: "image",
}),
capabilities: {
Expand Down
3 changes: 2 additions & 1 deletion extensions/comfy/music-generation-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@ export function buildComfyMusicGenerationProvider(): MusicGenerationProvider {
label: "ComfyUI",
defaultModel: DEFAULT_COMFY_MODEL,
models: [DEFAULT_COMFY_MODEL],
isConfigured: ({ cfg, agentDir }) =>
isConfigured: ({ cfg, agentDir, workspaceDir }) =>
isComfyCapabilityConfigured({
cfg,
agentDir,
workspaceDir,
capability: "music",
}),
capabilities: {
Expand Down
3 changes: 2 additions & 1 deletion extensions/comfy/video-generation-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ export function buildComfyVideoGenerationProvider(): VideoGenerationProvider {
label: "ComfyUI",
defaultModel: DEFAULT_COMFY_MODEL,
models: [DEFAULT_COMFY_MODEL],
isConfigured: ({ cfg, agentDir }) =>
isConfigured: ({ cfg, agentDir, workspaceDir }) =>
isComfyCapabilityConfigured({
cfg,
agentDir,
workspaceDir,
capability: "video",
}),
capabilities: {
Expand Down
2 changes: 2 additions & 0 deletions extensions/comfy/workflow-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,7 @@ async function downloadOutputFile(params: {
export function isComfyCapabilityConfigured(params: {
cfg?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
capability: ComfyCapability;
}): boolean {
const config = getComfyConfig(params.cfg);
Expand All @@ -649,6 +650,7 @@ export function isComfyCapabilityConfigured(params: {
provider: "comfy",
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
});
}

Expand Down
20 changes: 16 additions & 4 deletions extensions/openai/image-generation-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,10 @@ function shouldAllowPrivateImageEndpoint(req: {
}

function resolveRequestAuthStore(req: {
cfg?: OpenClawConfig;
authStore?: AuthProfileStore;
agentDir?: string;
workspaceDir?: string;
}): AuthProfileStore | undefined {
if (req.authStore) {
return req.authStore;
Expand All @@ -359,12 +361,15 @@ function resolveRequestAuthStore(req: {
}
return ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
config: req.cfg,
workspaceDir: req.workspaceDir,
});
}

function hasDirectOpenAIImageApiKeyAuth(params: {
cfg?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
}): boolean {
if (hasExplicitOpenAIImageApiKeyConfig(params.cfg)) {
return true;
Expand All @@ -375,6 +380,8 @@ function hasDirectOpenAIImageApiKeyAuth(params: {
const store = params.agentDir
? ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
config: params.cfg,
workspaceDir: params.workspaceDir,
})
: undefined;
if (!store) {
Expand All @@ -388,8 +395,10 @@ function hasDirectOpenAIImageApiKeyAuth(params: {
}

function hasCodexResponseTransportProfileConfigured(req: {
cfg?: OpenClawConfig;
authStore?: AuthProfileStore;
agentDir?: string;
workspaceDir?: string;
}): boolean {
const store = resolveRequestAuthStore(req);
if (!store) {
Expand Down Expand Up @@ -900,7 +909,7 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
return createOpenAIImageGenerationProviderBase({
id: "openai",
label: "OpenAI",
isConfigured: ({ cfg, agentDir }) => {
isConfigured: ({ cfg, agentDir, workspaceDir }) => {
// generateImage already authenticates from a config apiKey; count a
// usable one (non-blank literal or secret ref) as configured here too,
// so image gen works from config alone, like chat.
Expand All @@ -913,19 +922,22 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
if (
isProviderApiKeyConfigured({
provider: "openai",
cfg,
agentDir,
workspaceDir,
})
) {
return (
hasPublicOpenAIBaseUrl ||
hasDirectOpenAIImageApiKeyAuth({ cfg, agentDir }) ||
(hasChatGPTRouteConfig && hasCodexResponseTransportProfileConfigured({ agentDir }))
hasDirectOpenAIImageApiKeyAuth({ cfg, agentDir, workspaceDir }) ||
(hasChatGPTRouteConfig &&
hasCodexResponseTransportProfileConfigured({ cfg, agentDir, workspaceDir }))
);
}
if (!hasPublicOpenAIBaseUrl && !hasChatGPTRouteConfig) {
return false;
}
return hasCodexResponseTransportProfileConfigured({ agentDir });
return hasCodexResponseTransportProfileConfigured({ cfg, agentDir, workspaceDir });
},
async generateImage(req) {
const inputImages = req.inputImages ?? [];
Expand Down
1 change: 1 addition & 0 deletions src/agents/agent-auth-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function resolveAgentCredentialsForDiscovery(
const storeOptions = {
allowKeychainPrompt: false,
...(options?.config ? { config: options.config } : {}),
...(options?.workspaceDir ? { workspaceDir: options.workspaceDir } : {}),
...(options?.externalCli ? { externalCli: options.externalCli } : {}),
...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}),
};
Expand Down
18 changes: 15 additions & 3 deletions src/agents/auth-profiles/external-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type ResolveExternalAuthProfiles = typeof resolveExternalAuthProfilesWithPlugins
type ExternalCliOverlayOptions = {
allowKeychainPrompt?: boolean;
config?: OpenClawConfig;
workspaceDir?: string;
externalCliProviderIds?: Iterable<string>;
externalCliProfileIds?: Iterable<string>;
};
Expand Down Expand Up @@ -67,6 +68,7 @@ function isExternalAuthProfileAllowed(
config: OpenClawConfig | undefined,
explicitProfileIds: ReadonlySet<string> | undefined,
env: NodeJS.ProcessEnv,
workspaceDir?: string,
): boolean {
// A provider pin protects its declared auth from ambient takeover. Stored and explicitly
// bound profiles deliberately keep precedence; see runtime-plan/prepare-auth.test.ts.
Expand All @@ -75,7 +77,7 @@ function isExternalAuthProfileAllowed(
}
return isAmbientCredentialAllowedByProviderAuthPin({
config,
authAliasLookupParams: { env },
authAliasLookupParams: { env, workspaceDir },
provider: profile.credential.provider,
type: profile.credential.type,
});
Expand All @@ -93,10 +95,11 @@ function resolveExternalAuthProfileMap(params: {
const profiles = resolveProfiles({
env,
config: params.externalCli?.config,
workspaceDir: params.externalCli?.workspaceDir,
context: {
config: params.externalCli?.config,
agentDir: params.agentDir,
workspaceDir: undefined,
workspaceDir: params.externalCli?.workspaceDir,
env,
store: params.store,
},
Expand All @@ -118,6 +121,7 @@ function resolveExternalAuthProfileMap(params: {
params.externalCli?.config,
explicitProfileIds,
env,
params.externalCli?.workspaceDir,
)
) {
continue;
Expand All @@ -140,6 +144,7 @@ function resolveExternalAuthProfileMap(params: {
params.externalCli?.config,
explicitProfileIds,
env,
params.externalCli?.workspaceDir,
)
) {
continue;
Expand Down Expand Up @@ -221,7 +226,14 @@ export function syncPersistedExternalCliAuthProfiles(
const persistedProfiles = cliProfiles.filter(
(profile) =>
profile.persistence === "persisted" &&
isExternalAuthProfileAllowed(profile, store, params?.config, explicitProfileIds, env),
isExternalAuthProfileAllowed(
profile,
store,
params?.config,
explicitProfileIds,
env,
params?.workspaceDir,
),
);
if (persistedProfiles.length === 0) {
return store;
Expand Down
112 changes: 112 additions & 0 deletions src/agents/auth-profiles/external-auth.workspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { testing as externalAuthTesting } from "./external-auth.test-support.js";
import type { ExternalCliAuthDiscovery } from "./external-cli-discovery.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
loadAuthProfileStoreForSecretsRuntime,
saveAuthProfileStore,
} from "./store.js";

vi.mock("../cli-credentials.js", () => ({
readClaudeCliCredentialsCached: () => null,
readCodexCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
}));

describe("workspace-owned external auth overlays", () => {
afterEach(() => {
externalAuthTesting.resetResolveExternalAuthProfilesForTest();
clearRuntimeAuthProfileStoreSnapshots();
});

it.each([
{ label: "default discovery", externalCli: undefined },
{ label: "disabled CLI discovery", externalCli: { mode: "none" } },
{ label: "existing CLI discovery", externalCli: { mode: "existing" } },
{ label: "scoped CLI discovery", externalCli: { mode: "scoped", providerIds: [] } },
] satisfies Array<{ label: string; externalCli: ExternalCliAuthDiscovery | undefined }>)(
"keeps $label scoped while re-reading current external credentials",
async ({ externalCli }) => {
const root = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-external-workspace-")),

Check warning on line 36 in src/agents/auth-profiles/external-auth.workspace.test.ts

View workflow job for this annotation

GitHub Actions / check-guards

new mkdtemp temp directory creation: prefer useAutoCleanupTempDirTracker(afterEach) from test/helpers/temp-dir.ts for new test-owned temp directories.
);
const agentDir = path.join(root, "agent");
const workspaceA = path.join(root, "project-a");
const workspaceB = path.join(root, "project-b");
const profileId = "workspace-provider:stored";
const config: OpenClawConfig = { plugins: { enabled: false } };
let generation = 1;
let available = true;
externalAuthTesting.setResolveExternalAuthProfilesForTest((params) => {
expect(params.config).toBe(config);
expect(params.context.config).toBe(config);
expect(params.workspaceDir).toBe(params.context.workspaceDir);
expect(params.context.agentDir).toBe(agentDir);
if (!available) {
return [];
}
return [
{
profileId,
credential: {
type: "oauth",
provider: "workspace-provider",
access: `${params.context.workspaceDir}:${generation}`,
refresh: "external-refresh",
expires: Date.now() + 60_000,
},
},
];
});
try {
saveAuthProfileStore(
{
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "workspace-provider",
access: "persisted-fallback",
refresh: "persisted-refresh",
expires: Date.now() + 60_000,
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
const read = (workspaceDir: string) =>
ensureAuthProfileStore(agentDir, {
config,
workspaceDir,
inheritedAuthDir: agentDir,
externalCli,
readOnly: true,
syncExternalCli: false,
}).profiles[profileId];

expect(read(workspaceA)).toMatchObject({ access: `${workspaceA}:1` });
expect(read(workspaceB)).toMatchObject({ access: `${workspaceB}:1` });
generation += 1;
expect(read(workspaceA)).toMatchObject({ access: `${workspaceA}:2` });
expect(
loadAuthProfileStoreForSecretsRuntime(agentDir, {
config,
workspaceDir: workspaceB,
inheritedAuthDir: agentDir,
externalCli,
}).profiles[profileId],
).toMatchObject({ access: `${workspaceB}:2` });
available = false;
expect(read(workspaceA)).toMatchObject({ access: "persisted-fallback" });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
},
);
});
Loading