diff --git a/CHANGELOG.md b/CHANGELOG.md index cce97d530f2..183f356cab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre. - Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too. - Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks. - Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 20fa014c8fd..0a5e902e94b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e07c1b7a7bc8a6eb25a832961c2367f56d60a1fa54096dda460f8db1e572aa2a plugin-sdk-api-baseline.json -34f2af745b9ed47eec90350b2c2a9000566744b8982440feee1c4a405d0a28ca plugin-sdk-api-baseline.jsonl +434c62dfc32631e2c0cd862059f3257c0844d2c515e92db4d5670be7f3882a14 plugin-sdk-api-baseline.json +2f6c82614fc6521ea27209e3d9888a4a6cdec30fa3082500aef4f8975358d9bf plugin-sdk-api-baseline.jsonl diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index ea0d67e187e..d563e7d2a1b 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -916,6 +916,19 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s and API key, calls Ollama's current `/api/embed` endpoint, and batches multiple memory chunks into one `input` request when possible. + When `proxy.enabled=true`, Ollama memory embedding requests to the exact + host-local loopback origin derived from the configured `baseUrl` use + OpenClaw's guarded direct path instead of the managed forward proxy. The + configured hostname must itself be `localhost` or a loopback IP literal; + DNS names that merely resolve to loopback still use the managed proxy path. + LAN, tailnet, private-network, and public Ollama hosts also stay on the + managed proxy path. Redirects to another host or port do not inherit trust. + Operators can still set the global `proxy.loopbackMode: "proxy"` setting to + send loopback traffic through the proxy, or `proxy.loopbackMode: "block"` + to deny loopback connections before opening a connection; see + [Managed proxy](/security/network-proxy#gateway-loopback-mode) for the + process-wide effect of this setting. + | Property | Value | | ------------- | ------------------- | | Default model | `nomic-embed-text` | diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index b2386935d50..850936d69a6 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -87,7 +87,7 @@ OPENCLAW_PROXY_URL=http://127.0.0.1:3128 openclaw gateway run ### Gateway Loopback Mode -Local Gateway control-plane clients usually connect to a loopback WebSocket such as `ws://127.0.0.1:18789`. Use `proxy.loopbackMode` to choose how that traffic behaves while the managed proxy is active: +Local Gateway control-plane clients usually connect to a loopback WebSocket such as `ws://127.0.0.1:18789`. Use `proxy.loopbackMode` to choose how loopback managed-proxy exceptions behave while the managed proxy is active: ```yaml proxy: @@ -96,9 +96,9 @@ proxy: loopbackMode: gateway-only # gateway-only, proxy, or block ``` -- `gateway-only` (default): OpenClaw registers the Gateway loopback authority in Proxyline's managed bypass policy so local Gateway WebSocket traffic can connect directly. Custom loopback Gateway ports work because the active Gateway URL's host and port are registered. -- `proxy`: OpenClaw does not register a Gateway loopback bypass, so local Gateway traffic is sent through the managed proxy. If the proxy is remote, it must provide special routing for the OpenClaw host's loopback service, such as mapping it to a proxy-reachable hostname, IP, or tunnel. Standard remote proxies resolve `127.0.0.1` and `localhost` from the proxy host, not from the OpenClaw host. -- `block`: OpenClaw denies loopback Gateway control-plane connections before opening a socket. +- `gateway-only` (default): OpenClaw registers the Gateway loopback authority in Proxyline's managed bypass policy so local Gateway WebSocket traffic can connect directly. Custom loopback Gateway ports work because the active Gateway URL's host and port are registered. The bundled Ollama memory embedding provider can also use its own narrower guarded direct path for the exact configured host-local loopback embedding origin. +- `proxy`: OpenClaw does not register Gateway or Ollama loopback bypasses, so that loopback traffic is sent through the managed proxy. If the proxy is remote, it must provide special routing for the OpenClaw host's loopback service, such as mapping it to a proxy-reachable hostname, IP, or tunnel. Standard remote proxies resolve `127.0.0.1` and `localhost` from the proxy host, not from the OpenClaw host. +- `block`: OpenClaw denies Gateway loopback control-plane connections and guarded Ollama host-local embedding loopback connections before opening a socket. If `enabled=true` but no valid proxy URL is configured, protected commands fail startup instead of falling back to direct network access. @@ -253,7 +253,7 @@ proxy: - Raw `net`, `tls`, and `http2` sockets, native addons, and non-OpenClaw child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables. Forked OpenClaw child CLIs inherit the managed proxy URL and `proxy.loopbackMode` state. - IRC is a raw TCP/TLS channel outside operator-managed forward proxy routing. In deployments that require all egress through that forward proxy, set `channels.irc.enabled=false` unless direct IRC egress is explicitly approved. - The local debug proxy is diagnostic tooling and its direct upstream forwarding for proxy requests and CONNECT tunnels is disabled by default while managed proxy mode is active; enable direct forwarding only for approved local diagnostics. -- User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them. +- User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them. The bundled Ollama memory embedding provider is narrower: it can use a guarded direct path only for the exact host-local loopback embedding origin derived from the configured `baseUrl` so host-local embeddings keep working when the managed proxy cannot reach host loopback. LAN, tailnet, private-network, and public Ollama embedding hosts still use the managed proxy path. `proxy.loopbackMode: "proxy"` sends this Ollama loopback traffic through the managed proxy, and `proxy.loopbackMode: "block"` denies it before opening a connection. - Gateway control-plane proxy bypass is intentionally limited to `localhost` and literal loopback IP URLs. Use `ws://127.0.0.1:18789`, `ws://[::1]:18789`, or `ws://localhost:18789` for local direct Gateway control-plane connections; other hostnames route like ordinary hostname-based traffic. - OpenClaw does not inspect, test, or certify your proxy policy. - Treat proxy policy changes as security-sensitive operational changes. diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index 6f8acb607ec..0040365643c 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -1,22 +1,29 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ - fetchWithSsrFGuardMock: vi.fn(async ({ init, url }: { init?: RequestInit; url: string }) => ({ - response: await fetch(url, init), - release: async () => {}, - })), +const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchConfiguredLocalOriginWithSsrFGuardMock: vi.fn( + async ({ init, url }: { init?: RequestInit; url: string }) => ({ + response: await fetch(url, init), + release: async () => {}, + }), + ), })); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchWithSsrFGuardMock, + fetchWithSsrFGuard: vi.fn(), formatErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), - ssrfPolicyFromHttpBaseUrlAllowedHostname: (baseUrl: string) => { + ssrfPolicyFromHttpBaseUrlAllowedOrigin: (baseUrl: string) => { const parsed = new URL(baseUrl); - return { allowedHostnames: [parsed.hostname] }; + return { allowedOrigins: [parsed.origin] }; }, })); +// Import-resolution gating for this private helper is covered in sdk-alias.test.ts. +vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({ + fetchConfiguredLocalOriginWithSsrFGuard: fetchConfiguredLocalOriginWithSsrFGuardMock, +})); + let createOllamaEmbeddingProvider: typeof import("./embedding-provider.js").createOllamaEmbeddingProvider; let ollamaMemoryEmbeddingProviderAdapter: typeof import("./memory-embedding-adapter.js").ollamaMemoryEmbeddingProviderAdapter; @@ -26,7 +33,7 @@ beforeAll(async () => { }); beforeEach(() => { - fetchWithSsrFGuardMock.mockClear(); + fetchConfiguredLocalOriginWithSsrFGuardMock.mockClear(); }); afterEach(() => { @@ -67,6 +74,14 @@ function readFirstEmbeddingInput(fetchMock: ReturnType { + const call = fetchConfiguredLocalOriginWithSsrFGuardMock.mock.calls[0]?.[0]; + if (!call || typeof call !== "object") { + throw new Error("expected guarded fetch call"); + } + return call as Record; +} + function expectEmbeddingFetch( fetchMock: ReturnType, url: string, @@ -109,6 +124,50 @@ describe("ollama embedding provider", () => { expect(vector[1]).toBeCloseTo(0.8, 5); }); + it("marks the configured Ollama origin for managed-proxy direct routing", async () => { + const fetchMock = mockEmbeddingFetch([1, 0]); + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "http://127.0.0.1:11434/v1" }, + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(firstGuardedFetchCall()).toMatchObject({ + url: "http://127.0.0.1:11434/api/embed", + policy: { allowedOrigins: ["http://127.0.0.1:11434"] }, + configuredLocalOriginBaseUrl: "http://127.0.0.1:11434", + auditContext: "ollama-memory-embedding", + }); + }); + + it("passes cloud Ollama origins through the guarded fetch contract", async () => { + const fetchMock = mockEmbeddingFetch([1, 0]); + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "https://ollama.com" }, + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(firstGuardedFetchCall()).toMatchObject({ + url: "https://ollama.com/api/embed", + policy: { allowedOrigins: ["https://ollama.com"] }, + configuredLocalOriginBaseUrl: "https://ollama.com", + auditContext: "ollama-memory-embedding", + }); + }); + it("resolves configured base URL and headers without sending local marker auth", async () => { const fetchMock = mockEmbeddingFetch([1, 0]); @@ -249,6 +308,12 @@ describe("ollama embedding provider", () => { await expect(provider.embedBatch(["a", "bb", "ccc"])).resolves.toHaveLength(3); expect(fetchMock).toHaveBeenCalledTimes(1); expect(inputs).toEqual([["a", "bb", "ccc"]]); + expect(firstGuardedFetchCall()).toMatchObject({ + url: "http://127.0.0.1:11434/api/embed", + policy: { allowedOrigins: ["http://127.0.0.1:11434"] }, + configuredLocalOriginBaseUrl: "http://127.0.0.1:11434", + auditContext: "ollama-memory-embedding", + }); }); it("reports malformed embed JSON with a provider-owned error", async () => { diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index 2b4d3e85691..c110eefccf3 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -11,11 +11,11 @@ import { normalizeResolvedSecretInputString, } from "openclaw/plugin-sdk/secret-input"; import { - fetchWithSsrFGuard, formatErrorMessage, - ssrfPolicyFromHttpBaseUrlAllowedHostname, + ssrfPolicyFromHttpBaseUrlAllowedOrigin, type SsrFPolicy, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { fetchConfiguredLocalOriginWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime-internal"; import { OLLAMA_CLOUD_BASE_URL } from "./defaults.js"; import { normalizeOllamaWireModelId } from "./model-id.js"; import { readProviderBaseUrl } from "./provider-base-url.js"; @@ -92,14 +92,16 @@ async function withRemoteHttpResponse(params: { init?: RequestInit; signal?: AbortSignal; ssrfPolicy?: SsrFPolicy; + configuredLocalOriginBaseUrl: string; onResponse: (response: Response) => Promise; }): Promise { - const { response, release } = await fetchWithSsrFGuard({ + const { response, release } = await fetchConfiguredLocalOriginWithSsrFGuard({ url: params.url, init: params.init, signal: params.signal, policy: params.ssrfPolicy, - auditContext: "memory-remote", + configuredLocalOriginBaseUrl: params.configuredLocalOriginBaseUrl, + auditContext: "ollama-memory-embedding", }); try { return await params.onResponse(response); @@ -313,7 +315,7 @@ function resolveOllamaEmbeddingClient( return { baseUrl, headers, - ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl), + ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl), model, }; } @@ -328,6 +330,7 @@ export async function createOllamaEmbeddingProvider( const json = await withRemoteHttpResponse({ url: embedUrl, ssrfPolicy: client.ssrfPolicy, + configuredLocalOriginBaseUrl: client.baseUrl, signal, init: { method: "POST", diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 78585fd55ef..37526ea4494 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -74,6 +74,7 @@ "talk-config-runtime", "ssrf-policy", "ssrf-runtime", + "ssrf-runtime-internal", "media-runtime", "media-store", "media-mime", diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json index 91e92dd8870..513a63718c6 100644 --- a/scripts/lib/plugin-sdk-private-local-only-subpaths.json +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -4,5 +4,6 @@ "qa-channel-protocol", "qa-lab", "qa-runtime", + "ssrf-runtime-internal", "test-utils" ] diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 38cdab272a4..bbfb10d9a1a 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -73,22 +73,132 @@ function writeJsonFile(targetPath, value) { fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +const PRIVATE_LOCAL_ONLY_PLUGIN_SDK_DIST_FILE_NAME_FALLBACK = [ + "codex-mcp-projection.js", + "codex-native-task-runtime.js", + "qa-channel.js", + "qa-channel-protocol.js", + "qa-lab.js", + "qa-runtime.js", + "ssrf-runtime-internal.js", + "test-utils.js", +]; + +function tryReadJsonFile(targetPath) { + try { + return JSON.parse(fs.readFileSync(targetPath, "utf8")); + } catch { + return undefined; + } +} + +function isSafePluginSdkSubpathSegment(subpath) { + return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath); +} + +function readPrivateLocalOnlyPluginSdkDistFileNames(repoRoot) { + const privateFileNames = new Set(PRIVATE_LOCAL_ONLY_PLUGIN_SDK_DIST_FILE_NAME_FALLBACK); + const subpaths = tryReadJsonFile( + path.join(repoRoot, "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"), + ); + if (!Array.isArray(subpaths)) { + return privateFileNames; + } + for (const subpath of subpaths) { + if (typeof subpath === "string" && isSafePluginSdkSubpathSegment(subpath)) { + privateFileNames.add(`${subpath}.js`); + } + } + return privateFileNames; +} + +function collectLegacyPublicPluginSdkDistFileNames(params) { + const privateFileNames = readPrivateLocalOnlyPluginSdkDistFileNames(params.repoRoot); + const fileNames = new Set(); + for (const dirent of fs.readdirSync(params.pluginSdkDir, { withFileTypes: true })) { + if (!dirent.isFile() || path.extname(dirent.name) !== ".js") { + continue; + } + if (privateFileNames.has(dirent.name)) { + continue; + } + fileNames.add(dirent.name); + } + return fileNames.size > 0 ? fileNames : undefined; +} + +function readPublicPluginSdkDistFileNames(params) { + const packageJson = tryReadJsonFile(path.join(params.repoRoot, "package.json")); + if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) { + return collectLegacyPublicPluginSdkDistFileNames(params); + } + const packageExports = packageJson.exports; + if (!packageExports || typeof packageExports !== "object" || Array.isArray(packageExports)) { + return collectLegacyPublicPluginSdkDistFileNames(params); + } + + const fileNames = new Set(); + for (const exportKey of Object.keys(packageExports)) { + if (exportKey === "./plugin-sdk") { + fileNames.add("index.js"); + continue; + } + if (!exportKey.startsWith("./plugin-sdk/")) { + continue; + } + const subpath = exportKey.slice("./plugin-sdk/".length); + if (isSafePluginSdkSubpathSegment(subpath)) { + fileNames.add(`${subpath}.js`); + } + } + + return fileNames.size > 0 ? fileNames : collectLegacyPublicPluginSdkDistFileNames(params); +} + +function buildRuntimePluginSdkPackageExports(publicDistFileNames) { + if (!publicDistFileNames) { + return { + "./plugin-sdk": "./plugin-sdk/index.js", + }; + } + + const sortedFileNames = [...publicDistFileNames].toSorted((left, right) => { + if (left === "index.js") { + return -1; + } + if (right === "index.js") { + return 1; + } + return left.localeCompare(right); + }); + return Object.fromEntries( + sortedFileNames.map((fileName) => { + const subpath = fileName.slice(0, -".js".length); + return [ + subpath === "index" ? "./plugin-sdk" : `./plugin-sdk/${subpath}`, + `./plugin-sdk/${fileName}`, + ]; + }), + ); +} + function ensureOpenClawExtensionAlias(params) { const pluginSdkDir = path.join(params.repoRoot, "dist", "plugin-sdk"); if (!fs.existsSync(pluginSdkDir)) { return; } + const publicDistFileNames = readPublicPluginSdkDistFileNames({ + repoRoot: params.repoRoot, + pluginSdkDir, + }); const aliasDir = path.join(params.distExtensionsRoot, "node_modules", "openclaw"); const pluginSdkAliasPath = path.join(aliasDir, "plugin-sdk"); fs.mkdirSync(aliasDir, { recursive: true }); writeJsonFile(path.join(aliasDir, "package.json"), { name: "openclaw", type: "module", - exports: { - "./plugin-sdk": "./plugin-sdk/index.js", - "./plugin-sdk/*": "./plugin-sdk/*.js", - }, + exports: buildRuntimePluginSdkPackageExports(publicDistFileNames), }); removePathIfExists(pluginSdkAliasPath); fs.mkdirSync(pluginSdkAliasPath, { recursive: true }); @@ -96,6 +206,9 @@ function ensureOpenClawExtensionAlias(params) { if (!dirent.isFile() || path.extname(dirent.name) !== ".js") { continue; } + if (publicDistFileNames && !publicDistFileNames.has(dirent.name)) { + continue; + } writeRuntimeModuleWrapper( path.join(pluginSdkDir, dirent.name), path.join(pluginSdkAliasPath, dirent.name), diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index c32a8b040cb..74c2cf62d6f 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -487,7 +487,6 @@ describe("gateway hot reload", () => { hoisted.providerManager.startChannel.mockClear(); hoisted.activeEmbeddedRunCount.value = 1; embeddedRunMock.activeIds.add("reload-stuck"); - vi.useFakeTimers(); const reloadPromise = onHotReload?.( { changedPaths: ["channels.discord.token"], @@ -502,23 +501,19 @@ describe("gateway hot reload", () => { noopPaths: [], }, { - gateway: { reload: { deferralTimeoutMs: 1_000 } }, + gateway: { reload: { deferralTimeoutMs: 1 } }, channels: { discord: { token: "token" } }, }, ); try { await Promise.resolve(); - await vi.advanceTimersByTimeAsync(500); expect(hoisted.providerManager.stopChannel).not.toHaveBeenCalled(); expect(hoisted.providerManager.startChannel).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(500); await reloadPromise; } finally { hoisted.activeEmbeddedRunCount.value = 0; embeddedRunMock.activeIds.clear(); - await vi.advanceTimersByTimeAsync(500).catch(() => {}); - vi.useRealTimers(); await reloadPromise?.catch(() => {}); } diff --git a/src/infra/net/configured-local-origin-bypass.ts b/src/infra/net/configured-local-origin-bypass.ts new file mode 100644 index 00000000000..58987e7755b --- /dev/null +++ b/src/infra/net/configured-local-origin-bypass.ts @@ -0,0 +1,77 @@ +import { isLoopbackIpAddress } from "../../shared/net/ip.js"; +import { getActiveManagedProxyLoopbackMode } from "./proxy/active-proxy-state.js"; +import { SsrFBlockedError } from "./ssrf.js"; + +export type ConfiguredLocalOriginManagedProxyBypass = { + kind: "configured-local-origin"; + baseUrl: string; +}; + +function resolveHttpOrigin(value: string): string | undefined { + try { + const parsed = new URL(value.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + parsed.hostname = parsed.hostname.replace(/\.+$/, ""); + return parsed.origin.toLowerCase(); + } catch { + return undefined; + } +} + +function isLoopbackManagedProxyBypassHost(hostname: string): boolean { + const normalized = hostname + .trim() + .toLowerCase() + .replace(/\.+$/, "") + .replace(/^\[(.*)\]$/, "$1"); + return normalized === "localhost" || isLoopbackIpAddress(normalized); +} + +function isExactConfiguredLocalOriginBypass(params: { + url: URL; + managedProxyBypass: ConfiguredLocalOriginManagedProxyBypass | undefined; +}): boolean { + if (params.managedProxyBypass?.kind !== "configured-local-origin") { + return false; + } + const baseOrigin = resolveHttpOrigin(params.managedProxyBypass.baseUrl); + if (!baseOrigin) { + return false; + } + let baseHostname: string; + try { + baseHostname = new URL(params.managedProxyBypass.baseUrl.trim()).hostname; + } catch { + return false; + } + if (!isLoopbackManagedProxyBypassHost(baseHostname)) { + return false; + } + return resolveHttpOrigin(params.url.toString()) === baseOrigin; +} + +function isPinnedLoopbackTarget(addresses: readonly string[]): boolean { + return addresses.length > 0 && addresses.every((address) => isLoopbackIpAddress(address)); +} + +export function shouldUseConfiguredLocalOriginManagedProxyBypass(params: { + url: URL; + managedProxyBypass: ConfiguredLocalOriginManagedProxyBypass | undefined; + resolvedAddresses: readonly string[]; +}): boolean { + if (!isExactConfiguredLocalOriginBypass(params)) { + return false; + } + const loopbackMode = getActiveManagedProxyLoopbackMode(); + if (loopbackMode === "proxy") { + return false; + } + if (loopbackMode === "block" && isLoopbackManagedProxyBypassHost(params.url.hostname)) { + throw new SsrFBlockedError( + "proxy: configured local provider loopback connections are blocked by proxy.loopbackMode", + ); + } + return isPinnedLoopbackTarget(params.resolvedAddresses); +} diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 19b73eca93a..21d170d9a31 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + fetchConfiguredLocalOriginWithSsrFGuard, fetchWithSsrFGuard, GUARDED_FETCH_MODE, retainSafeHeadersForCrossOriginRedirectHeaders, @@ -97,6 +98,16 @@ function expectDispatcherAttached(value: unknown): void { expect(getDispatcherClassName(value)).toMatch(/^(Agent|Mock)$/u); } +function getFetchInputUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input.url; +} + function firstMockCall(mock: { mock: { calls: T[] } }): T | undefined { return mock.mock.calls[0]; } @@ -195,6 +206,16 @@ describe("fetchWithSsrFGuard hardening", () => { const createPublicLookup = (): LookupFn => vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]) as unknown as LookupFn; + const createLoopbackLookup = (): LookupFn => + vi.fn(async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn; + const createIpv6LoopbackLookup = (): LookupFn => + vi.fn(async () => [{ address: "::1", family: 6 }]) as unknown as LookupFn; + const createLoopbackThenPublicLookup = (): LookupFn => + vi.fn(async (hostname: string) => + hostname === "attacker.example" + ? [{ address: "93.184.216.34", family: 4 }] + : [{ address: "127.0.0.1", family: 4 }], + ) as unknown as LookupFn; function clearProxyEnv(): void { for (const key of PROXY_ENV_KEYS) { @@ -252,6 +273,89 @@ describe("fetchWithSsrFGuard hardening", () => { await result.release(); } + type ConfiguredLocalOriginFetchRequest = Omit< + Parameters[0], + "fetchImpl" + >; + type ManagedProxyLoopbackMode = "gateway-only" | "proxy" | "block"; + + function installManagedProxyRuntime(loopbackMode?: ManagedProxyLoopbackMode): void { + clearProxyEnv(); + vi.stubEnv("OPENCLAW_PROXY_ACTIVE", "1"); + if (loopbackMode) { + vi.stubEnv("OPENCLAW_PROXY_LOOPBACK_MODE", loopbackMode); + } + vi.stubEnv("http_proxy", "http://127.0.0.1:7890"); + (globalThis as Record)[TEST_UNDICI_RUNTIME_DEPS_KEY] = { + Agent: agentCtor, + EnvHttpProxyAgent: envHttpProxyAgentCtor, + ProxyAgent: proxyAgentCtor, + fetch: vi.fn(async () => okResponse()), + }; + } + + async function expectConfiguredLocalOriginManagedProxyFetch(params: { + request: ConfiguredLocalOriginFetchRequest; + loopbackMode?: ManagedProxyLoopbackMode; + expectedAgentCalls: number; + expectedEnvProxyCalls: number; + expectedFetchCalls?: number; + expectedFinalUrl?: string; + fetchImpl?: NonNullable< + Parameters[0]["fetchImpl"] + >; + }): Promise { + installManagedProxyRuntime(params.loopbackMode); + const fetchImpl = + params.fetchImpl ?? + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const requestInit = init as RequestInit & { dispatcher?: unknown }; + expectDispatcherAttached(requestInit.dispatcher); + return okResponse(); + }); + + const result = await fetchConfiguredLocalOriginWithSsrFGuard({ + ...params.request, + fetchImpl, + }); + + if (params.expectedFinalUrl) { + expect(result.finalUrl).toBe(params.expectedFinalUrl); + } + expect(fetchImpl).toHaveBeenCalledTimes(params.expectedFetchCalls ?? 1); + expect(agentCtor).toHaveBeenCalledTimes(params.expectedAgentCalls); + expect(envHttpProxyAgentCtor).toHaveBeenCalledTimes(params.expectedEnvProxyCalls); + await result.release(); + } + + async function expectConfiguredLocalOriginManagedProxyBlock(params: { + url: string; + baseUrl: string; + lookupFn: LookupFn; + expectedOrigin: string; + }): Promise { + installManagedProxyRuntime("block"); + const fetchImpl = vi.fn(async () => okResponse()); + + await expect( + fetchConfiguredLocalOriginWithSsrFGuard({ + url: params.url, + fetchImpl, + lookupFn: params.lookupFn, + policy: { allowedOrigins: [params.baseUrl] }, + configuredLocalOriginBaseUrl: params.baseUrl, + auditContext: "ollama-memory-embedding", + }), + ).rejects.toThrow("blocked by proxy.loopbackMode"); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(logWarnMock).toHaveBeenCalledTimes(1); + const [warning] = firstMockCall(logWarnMock) as [string]; + expect(warning).toContain( + `security: blocked URL fetch (ollama-memory-embedding) targetOrigin=${params.expectedOrigin}`, + ); + expect(warning).toContain("blocked by proxy.loopbackMode"); + } + beforeEach(() => { getDefaultAutoSelectFamily.mockReturnValue(true); isWSL2SyncMock.mockReturnValue(false); @@ -1283,6 +1387,239 @@ describe("fetchWithSsrFGuard hardening", () => { }); }); + it.each([ + { + name: "an exact configured local provider origin", + url: "http://127.0.0.1:11434/api/embed", + baseUrl: "http://127.0.0.1:11434", + lookupFn: createLoopbackLookup, + }, + { + name: "an exact configured IPv6 loopback provider origin", + url: "http://[::1]:11434/api/embed", + baseUrl: "http://[::1]:11434", + lookupFn: createIpv6LoopbackLookup, + }, + { + name: "localhost when DNS pins to loopback", + url: "http://localhost:11434/api/embed", + baseUrl: "http://localhost:11434", + lookupFn: createLoopbackLookup, + }, + { + name: "IPv4 loopback shorthand", + url: "http://127.1:11434/api/embed", + baseUrl: "http://127.0.0.1:11434", + lookupFn: createLoopbackLookup, + expectedFinalUrl: "http://127.1:11434/api/embed", + }, + ])("bypasses the managed proxy for $name", async (testCase) => { + await expectConfiguredLocalOriginManagedProxyFetch({ + request: { + url: testCase.url, + lookupFn: testCase.lookupFn(), + policy: { allowedOrigins: [testCase.baseUrl] }, + configuredLocalOriginBaseUrl: testCase.baseUrl, + }, + loopbackMode: "gateway-only", + expectedAgentCalls: 1, + expectedEnvProxyCalls: 0, + expectedFinalUrl: testCase.expectedFinalUrl, + }); + }); + + it.each([ + { + name: "localhost when any resolved address is public", + url: "http://localhost:11434/api/embed", + baseUrl: "http://localhost:11434", + loopbackMode: "gateway-only" as const, + lookupFn: () => + vi.fn(async () => [ + { address: "127.0.0.1", family: 4 }, + { address: "8.8.8.8", family: 4 }, + ]) as unknown as LookupFn, + }, + { + name: "origin/baseUrl mismatches before the first request", + url: "http://127.0.0.1:11435/api/embed", + baseUrl: "http://127.0.0.1:11434", + loopbackMode: "gateway-only" as const, + expectedFinalUrl: "http://127.0.0.1:11435/api/embed", + lookupFn: createLoopbackLookup, + allowedOrigins: ["http://127.0.0.1:11434", "http://127.0.0.1:11435"], + allowPrivateNetwork: true, + }, + { + name: "public configured origins", + url: "https://api.example.com/v1/embeddings", + baseUrl: "https://api.example.com", + lookupFn: createPublicLookup, + }, + { + name: "private-network configured local origins", + url: "http://192.168.1.10:11434/api/embed", + baseUrl: "http://192.168.1.10:11434", + loopbackMode: "gateway-only" as const, + lookupFn: () => + vi.fn(async () => [{ address: "192.168.1.10", family: 4 }]) as unknown as LookupFn, + }, + { + name: "private-network configured origins in loopback block mode", + url: "http://192.168.1.10:11434/api/embed", + baseUrl: "http://192.168.1.10:11434", + loopbackMode: "block" as const, + lookupFn: () => + vi.fn(async () => [{ address: "192.168.1.10", family: 4 }]) as unknown as LookupFn, + }, + { + name: "public configured origins when DNS resolves to loopback", + url: "https://api.example.com/v1/embeddings", + baseUrl: "https://api.example.com", + lookupFn: createLoopbackLookup, + }, + { + name: "exact local provider origins when proxy.loopbackMode=proxy", + url: "http://127.0.0.1:11434/api/embed", + baseUrl: "http://127.0.0.1:11434", + loopbackMode: "proxy" as const, + lookupFn: createLoopbackLookup, + }, + ])("keeps $name on the managed proxy path", async (testCase) => { + await expectConfiguredLocalOriginManagedProxyFetch({ + request: { + url: testCase.url, + lookupFn: testCase.lookupFn(), + policy: { + allowedOrigins: testCase.allowedOrigins ?? [testCase.baseUrl], + ...(testCase.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}), + }, + configuredLocalOriginBaseUrl: testCase.baseUrl, + }, + loopbackMode: testCase.loopbackMode, + expectedAgentCalls: 0, + expectedEnvProxyCalls: 1, + expectedFinalUrl: testCase.expectedFinalUrl, + }); + }); + + it("ignores hidden managed-proxy bypass markers on the public guarded fetch helper", async () => { + installManagedProxyRuntime("gateway-only"); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const requestInit = init as RequestInit & { dispatcher?: unknown }; + expectDispatcherAttached(requestInit.dispatcher); + return okResponse(); + }); + + const result = await fetchWithSsrFGuard({ + url: "http://127.0.0.1:11434/api/embed", + fetchImpl, + lookupFn: createLoopbackLookup(), + policy: { allowedOrigins: ["http://127.0.0.1:11434"] }, + managedProxyBypass: { + kind: "configured-local-origin", + baseUrl: "http://127.0.0.1:11434", + }, + } as Parameters[0] & { managedProxyBypass: unknown }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(envHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(agentCtor).not.toHaveBeenCalled(); + await result.release(); + }); + + it("does not carry managed-proxy direct routing across redirects to another loopback port", async () => { + installManagedProxyRuntime("gateway-only"); + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const requestInit = init as RequestInit & { dispatcher?: unknown }; + expectDispatcherAttached(requestInit.dispatcher); + const url = getFetchInputUrl(input); + if (url === "http://127.0.0.1:11434/api/embed") { + expect(getDispatcherClassName(requestInit.dispatcher)).not.toBe("EnvHttpProxyAgent"); + return redirectResponse("http://127.0.0.1:11435/api/embed"); + } + expect(url).toBe("http://127.0.0.1:11435/api/embed"); + return okResponse(); + }); + + const result = await fetchConfiguredLocalOriginWithSsrFGuard({ + url: "http://127.0.0.1:11434/api/embed", + fetchImpl, + lookupFn: createLoopbackLookup(), + policy: { + allowedOrigins: ["http://127.0.0.1:11434", "http://127.0.0.1:11435"], + allowPrivateNetwork: true, + }, + configuredLocalOriginBaseUrl: "http://127.0.0.1:11434", + }); + + expect(result.finalUrl).toBe("http://127.0.0.1:11435/api/embed"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(agentCtor).toHaveBeenCalledTimes(1); + expect(envHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + await result.release(); + }); + + it("does not carry managed-proxy direct routing across redirects to a public origin", async () => { + installManagedProxyRuntime("gateway-only"); + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const requestInit = init as RequestInit & { dispatcher?: unknown }; + expectDispatcherAttached(requestInit.dispatcher); + const url = getFetchInputUrl(input); + if (url === "http://127.0.0.1:11434/api/embed") { + expect(getDispatcherClassName(requestInit.dispatcher)).not.toBe("EnvHttpProxyAgent"); + return redirectResponse("https://attacker.example/collect"); + } + expect(url).toBe("https://attacker.example/collect"); + return okResponse(); + }); + + const result = await fetchConfiguredLocalOriginWithSsrFGuard({ + url: "http://127.0.0.1:11434/api/embed", + fetchImpl, + lookupFn: createLoopbackThenPublicLookup(), + policy: { allowedOrigins: ["http://127.0.0.1:11434"] }, + configuredLocalOriginBaseUrl: "http://127.0.0.1:11434", + }); + + expect(result.finalUrl).toBe("https://attacker.example/collect"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(agentCtor).toHaveBeenCalledTimes(1); + expect(envHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + await result.release(); + }); + + it.each([ + { + name: "exact local provider origins", + url: "http://127.0.0.1:11434/api/embed", + baseUrl: "http://127.0.0.1:11434", + lookupFn: createLoopbackLookup, + expectedOrigin: "http://127.0.0.1:11434", + }, + { + name: "localhost provider origins", + url: "http://localhost:11434/api/embed", + baseUrl: "http://localhost:11434", + lookupFn: createLoopbackLookup, + expectedOrigin: "http://localhost:11434", + }, + { + name: "bracketed IPv6 loopback provider origins", + url: "http://[::1]:11434/api/embed", + baseUrl: "http://[::1]:11434", + lookupFn: createIpv6LoopbackLookup, + expectedOrigin: "http://[::1]:11434", + }, + ])("honors proxy.loopbackMode=block for $name", async (testCase) => { + await expectConfiguredLocalOriginManagedProxyBlock({ + url: testCase.url, + baseUrl: testCase.baseUrl, + lookupFn: testCase.lookupFn(), + expectedOrigin: testCase.expectedOrigin, + }); + }); + it("routes through env proxy when trusted proxy mode is explicitly enabled", async () => { await runProxyModeDispatcherExpectation({ mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY, diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 07f737381aa..7de826f69fa 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -5,6 +5,10 @@ import { normalizeHeadersInitForFetch, normalizeRequestInitHeadersForFetch, } from "../fetch-headers.js"; +import { + shouldUseConfiguredLocalOriginManagedProxyBypass, + type ConfiguredLocalOriginManagedProxyBypass, +} from "./configured-local-origin-bypass.js"; import { hasProxyEnvConfigured, shouldUseEnvHttpProxyForUrl } from "./proxy-env.js"; import { retainSafeHeadersForCrossOriginRedirect as retainSafeRedirectHeaders } from "./redirect-headers.js"; import { @@ -93,6 +97,14 @@ export type GuardedFetchResult = { refreshTimeout?: () => void; }; +type GuardedFetchInternalOptions = GuardedFetchOptions & { + managedProxyBypass?: ConfiguredLocalOriginManagedProxyBypass; +}; + +export type GuardedFetchConfiguredLocalOriginOptions = GuardedFetchOptions & { + configuredLocalOriginBaseUrl: string; +}; + type GuardedFetchPresetOptions = Omit< GuardedFetchOptions, "mode" | "proxy" | "dangerouslyAllowEnvProxyWithoutPinnedDns" @@ -343,6 +355,29 @@ function rewriteRedirectInitForCrossOrigin(params: { export { fetchWithRuntimeDispatcher } from "./runtime-fetch.js"; export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { + const { managedProxyBypass: _ignoredManagedProxyBypass, ...publicParams } = + params as GuardedFetchOptions & { + managedProxyBypass?: unknown; + }; + return await fetchWithSsrFGuardInternal(publicParams); +} + +export async function fetchConfiguredLocalOriginWithSsrFGuard({ + configuredLocalOriginBaseUrl, + ...params +}: GuardedFetchConfiguredLocalOriginOptions): Promise { + return await fetchWithSsrFGuardInternal({ + ...params, + managedProxyBypass: { + kind: "configured-local-origin", + baseUrl: configuredLocalOriginBaseUrl, + }, + }); +} + +async function fetchWithSsrFGuardInternal( + params: GuardedFetchInternalOptions, +): Promise { const defaultFetch: FetchLike | undefined = params.fetchImpl ?? globalThis.fetch; if (!defaultFetch) { throw new Error("fetch is not available"); @@ -432,11 +467,17 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { }), ).toBe(true); expect(isStaticallyChannelConfigured({}, "whatsapp", {})).toBe(false); - const staticNormalize = { allowPluginNormalization: false }; + const staticNormalize = { + allowPluginNormalization: false, + manifestPlugins: [ + { + modelIdNormalization: { + providers: { + google: { + aliases: { + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3-pro-preview": "gemini-3.1-pro-preview", + }, + }, + xai: { + aliases: { + "grok-4-fast-reasoning": "grok-4-fast", + }, + }, + }, + }, + }, + ], + }; expect(normalizeModelRef("google", "gemini-3.1-pro", staticNormalize)).toEqual({ provider: "google", model: "gemini-3.1-pro-preview", diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index a2d11a73391..576eae83525 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -41,7 +41,18 @@ export * from "../infra/json-files.js"; export * from "../infra/local-file-access.js"; export * from "../infra/map-size.js"; export * from "../infra/net/hostname.ts"; -export * from "../infra/net/fetch-guard.js"; +export { + fetchWithRuntimeDispatcher, + fetchWithSsrFGuard, + GUARDED_FETCH_MODE, + retainSafeHeadersForCrossOriginRedirectHeaders, + withStrictGuardedFetchMode, + withTrustedEnvProxyGuardedFetchMode, + withTrustedExplicitProxyGuardedFetchMode, + type GuardedFetchMode, + type GuardedFetchOptions, + type GuardedFetchResult, +} from "../infra/net/fetch-guard.js"; export * from "../infra/net/proxy-env.js"; export * from "../infra/net/proxy-fetch.js"; export * from "../infra/net/undici-global-dispatcher.js"; diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index de9c2eb2f97..142c801be44 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -9,6 +9,7 @@ const moduleLoaders = new Map(); const pluginSdkSubpathsCache = new Map(); const pluginSdkPackageNames = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"]; const pluginSdkSourceExtensions = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"]; +const privateQaExcludedPluginSdkSubpaths = new Set(["ssrf-runtime-internal"]); const isDistRootAlias = __filename.includes( `${path.sep}dist${path.sep}plugin-sdk${path.sep}root-alias.cjs`, ); @@ -142,7 +143,10 @@ function listPrivateLocalOnlyPluginSdkSubpaths() { return []; } return parsed.filter( - (subpath) => typeof subpath === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath), + (subpath) => + typeof subpath === "string" && + /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath) && + !privateQaExcludedPluginSdkSubpaths.has(subpath), ); } catch { return []; diff --git a/src/plugin-sdk/ssrf-runtime-internal.ts b/src/plugin-sdk/ssrf-runtime-internal.ts new file mode 100644 index 00000000000..a836272b751 --- /dev/null +++ b/src/plugin-sdk/ssrf-runtime-internal.ts @@ -0,0 +1,4 @@ +// Private helper surface for the bundled Ollama plugin. +// Keep managed proxy bypass capabilities out of the public plugin SDK surface. + +export { fetchConfiguredLocalOriginWithSsrFGuard } from "../infra/net/fetch-guard.js"; diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index 8124456bc41..61235c2666d 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -692,6 +692,20 @@ describe("plugin-sdk package contract guardrails", () => { expect(collectPluginSdkPackageExports()).toEqual([...publicPluginSdkEntrypoints].toSorted()); }); + it("keeps configured local-origin fetch helpers out of deprecated infra-runtime", () => { + const source = fs.readFileSync(resolve(REPO_ROOT, "src/plugin-sdk/infra-runtime.ts"), "utf8"); + + expect(source).not.toMatch(/export\s+\*\s+from\s+["']\.\.\/infra\/net\/fetch-guard\.js["']/); + expect(source).not.toContain("fetchConfiguredLocalOriginWithSsrFGuard"); + expect(source).not.toContain("GuardedFetchConfiguredLocalOriginOptions"); + }); + + it("keeps configured local-origin fetch helpers out of the public SSRF runtime", async () => { + const ssrfRuntime = await import("../../plugin-sdk/ssrf-runtime.js"); + + expect(ssrfRuntime).not.toHaveProperty("fetchConfiguredLocalOriginWithSsrFGuard"); + }); + it("keeps bundled plugin SDK compatibility subpaths explicitly classified", () => { const entrypoints = new Set(pluginSdkEntrypoints); const reserved = new Set(reservedBundledPluginSdkEntrypoints); diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 90a4bab0b24..dcc615415ac 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -422,10 +422,17 @@ describe("plugin-sdk root alias", () => { it("ignores unsafe private local-only plugin-sdk subpaths in the CJS root alias", () => { const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath))); + const qaLabPath = path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts"); + const ssrfRuntimeInternalPath = path.join( + packageRoot, + "src", + "plugin-sdk", + "ssrf-runtime-internal.ts", + ); const lazyModule = loadRootAliasWithStubs({ env: { OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, - privateLocalOnlySubpaths: ["qa-lab", "../escape", "nested/path"], - existingPaths: [path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts")], + privateLocalOnlySubpaths: ["qa-lab", "../escape", "nested/path", "ssrf-runtime-internal"], + existingPaths: [qaLabPath, ssrfRuntimeInternalPath], monolithicExports: { slowHelper: (): string => "loaded", }, @@ -433,14 +440,12 @@ describe("plugin-sdk root alias", () => { expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded"); const aliasMap = (lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record; - expect(aliasMap["openclaw/plugin-sdk/qa-lab"]).toBe( - path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts"), - ); - expect(aliasMap["@openclaw/plugin-sdk/qa-lab"]).toBe( - path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts"), - ); + expect(aliasMap["openclaw/plugin-sdk/qa-lab"]).toBe(qaLabPath); + expect(aliasMap["@openclaw/plugin-sdk/qa-lab"]).toBe(qaLabPath); expect(aliasMap).not.toHaveProperty("openclaw/plugin-sdk/../escape"); expect(aliasMap).not.toHaveProperty("openclaw/plugin-sdk/nested/path"); + expect(aliasMap).not.toHaveProperty("openclaw/plugin-sdk/ssrf-runtime-internal"); + expect(aliasMap).not.toHaveProperty("@openclaw/plugin-sdk/ssrf-runtime-internal"); }); it("keeps non-QA private local-only plugin-sdk subpaths out of the CJS root alias", () => { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 3b80628ff08..5ecd16bce00 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1118,6 +1118,106 @@ describe("loadOpenClawPlugins", () => { expect(fs.readFileSync(path.join(aliasDir, "core.js"), "utf8")).toContain("core.js"); }); + it("keeps private local-only plugin-sdk artifacts out of package dist aliases", () => { + const packageRoot = makeTempDir(); + const distRoot = path.join(packageRoot, "dist"); + const pluginSdkDir = path.join(distRoot, "plugin-sdk"); + const aliasRoot = path.join(distRoot, "extensions", "node_modules", "openclaw"); + const aliasDir = path.join(aliasRoot, "plugin-sdk"); + mkdirSafe(pluginSdkDir); + mkdirSafe(aliasDir); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify( + { + name: "openclaw", + version: "2026.4.22", + type: "module", + exports: { + "./plugin-sdk": "./dist/plugin-sdk/index.js", + "./plugin-sdk/string-coerce-runtime": "./dist/plugin-sdk/string-coerce-runtime.js", + }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync(path.join(pluginSdkDir, "index.js"), "export const root = true;\n", "utf8"); + fs.writeFileSync( + path.join(pluginSdkDir, "string-coerce-runtime.js"), + "export const publicRuntime = true;\n", + "utf8", + ); + fs.writeFileSync( + path.join(pluginSdkDir, "ssrf-runtime-internal.js"), + "export const internal = true;\n", + "utf8", + ); + fs.writeFileSync( + path.join(aliasDir, "ssrf-runtime-internal.js"), + "export const staleInternal = true;\n", + "utf8", + ); + + ensureOpenClawPluginSdkAlias(distRoot); + + const aliasPackage = JSON.parse( + fs.readFileSync(path.join(aliasRoot, "package.json"), "utf8"), + ) as { exports?: Record }; + expect(aliasPackage.exports).toEqual({ + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/string-coerce-runtime": "./plugin-sdk/string-coerce-runtime.js", + }); + expect(fs.existsSync(path.join(aliasDir, "index.js"))).toBe(true); + expect(fs.existsSync(path.join(aliasDir, "string-coerce-runtime.js"))).toBe(true); + expect(fs.existsSync(path.join(aliasDir, "ssrf-runtime-internal.js"))).toBe(false); + }); + + it("keeps private local-only plugin-sdk artifacts out of legacy package dist aliases", () => { + const packageRoot = makeTempDir(); + const distRoot = path.join(packageRoot, "dist"); + const pluginSdkDir = path.join(distRoot, "plugin-sdk"); + const aliasRoot = path.join(distRoot, "extensions", "node_modules", "openclaw"); + const aliasDir = path.join(aliasRoot, "plugin-sdk"); + mkdirSafe(pluginSdkDir); + mkdirSafe(aliasDir); + fs.writeFileSync( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.22", type: "module" }, null, 2), + "utf8", + ); + fs.writeFileSync(path.join(pluginSdkDir, "index.js"), "export const root = true;\n", "utf8"); + fs.writeFileSync( + path.join(pluginSdkDir, "string-coerce-runtime.js"), + "export const publicRuntime = true;\n", + "utf8", + ); + fs.writeFileSync( + path.join(pluginSdkDir, "ssrf-runtime-internal.js"), + "export const internal = true;\n", + "utf8", + ); + fs.writeFileSync( + path.join(aliasDir, "ssrf-runtime-internal.js"), + "export const staleInternal = true;\n", + "utf8", + ); + + ensureOpenClawPluginSdkAlias(distRoot); + + const aliasPackage = JSON.parse( + fs.readFileSync(path.join(aliasRoot, "package.json"), "utf8"), + ) as { exports?: Record }; + expect(aliasPackage.exports).toEqual({ + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/string-coerce-runtime": "./plugin-sdk/string-coerce-runtime.js", + }); + expect(fs.existsSync(path.join(aliasDir, "index.js"))).toBe(true); + expect(fs.existsSync(path.join(aliasDir, "string-coerce-runtime.js"))).toBe(true); + expect(fs.existsSync(path.join(aliasDir, "ssrf-runtime-internal.js"))).toBe(false); + }); + it("disables bundled plugins by default", () => { const bundledDir = makeTempDir(); writePlugin({ diff --git a/src/plugins/plugin-sdk-dist-alias.ts b/src/plugins/plugin-sdk-dist-alias.ts index 42c72bf9263..13bd4e0e8d9 100644 --- a/src/plugins/plugin-sdk-dist-alias.ts +++ b/src/plugins/plugin-sdk-dist-alias.ts @@ -1,6 +1,136 @@ import fs from "node:fs"; import path from "node:path"; -import { writeJsonSync } from "../infra/json-files.js"; +import { tryReadJsonSync, writeJsonSync } from "../infra/json-files.js"; + +type OpenClawPackageJson = { + exports?: Record; +}; + +const PRIVATE_LOCAL_ONLY_PLUGIN_SDK_DIST_FILE_NAME_FALLBACK = [ + "codex-mcp-projection.js", + "codex-native-task-runtime.js", + "qa-channel.js", + "qa-channel-protocol.js", + "qa-lab.js", + "qa-runtime.js", + "ssrf-runtime-internal.js", + "test-utils.js", +] as const; + +function isSafePluginSdkSubpathSegment(subpath: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath); +} + +function collectLegacyPublicPluginSdkDistFileNames(distRoot: string): Set | undefined { + const pluginSdkDir = path.join(distRoot, "plugin-sdk"); + if (!fs.existsSync(pluginSdkDir)) { + return undefined; + } + const privateFileNames = readPrivateLocalOnlyPluginSdkDistFileNames(distRoot); + const fileNames = new Set(); + for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) { + if (!entry.isFile() || path.extname(entry.name) !== ".js") { + continue; + } + if (privateFileNames.has(entry.name)) { + continue; + } + fileNames.add(entry.name); + } + return fileNames.size > 0 ? fileNames : undefined; +} + +function readPrivateLocalOnlyPluginSdkDistFileNames(distRoot: string): Set { + const packageRoot = path.dirname(path.resolve(distRoot)); + const privateFileNames = new Set(PRIVATE_LOCAL_ONLY_PLUGIN_SDK_DIST_FILE_NAME_FALLBACK); + const subpaths = tryReadJsonSync( + path.join(packageRoot, "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"), + ); + if (!Array.isArray(subpaths)) { + return privateFileNames; + } + for (const subpath of subpaths) { + if (typeof subpath === "string" && isSafePluginSdkSubpathSegment(subpath)) { + privateFileNames.add(`${subpath}.js`); + } + } + return privateFileNames; +} + +function readPublicPluginSdkDistFileNames(distRoot: string): Set | undefined { + const packageRoot = path.dirname(path.resolve(distRoot)); + const packageJson = tryReadJsonSync(path.join(packageRoot, "package.json")); + if (!packageJson || typeof packageJson !== "object" || Array.isArray(packageJson)) { + return collectLegacyPublicPluginSdkDistFileNames(distRoot); + } + const packageExports = packageJson.exports; + if (!packageExports || typeof packageExports !== "object" || Array.isArray(packageExports)) { + return collectLegacyPublicPluginSdkDistFileNames(distRoot); + } + + const fileNames = new Set(); + for (const exportKey of Object.keys(packageExports)) { + if (exportKey === "./plugin-sdk") { + fileNames.add("index.js"); + continue; + } + if (!exportKey.startsWith("./plugin-sdk/")) { + continue; + } + const subpath = exportKey.slice("./plugin-sdk/".length); + if (isSafePluginSdkSubpathSegment(subpath)) { + fileNames.add(`${subpath}.js`); + } + } + + return fileNames.size > 0 ? fileNames : collectLegacyPublicPluginSdkDistFileNames(distRoot); +} + +function buildRuntimePluginSdkPackageExports( + publicDistFileNames: ReadonlySet | undefined, +): Record { + if (!publicDistFileNames) { + return { + "./plugin-sdk": "./plugin-sdk/index.js", + }; + } + + const sortedFileNames = [...publicDistFileNames].toSorted((left, right) => { + if (left === "index.js") { + return -1; + } + if (right === "index.js") { + return 1; + } + return left.localeCompare(right); + }); + return Object.fromEntries( + sortedFileNames.map((fileName) => { + const subpath = fileName.slice(0, -".js".length); + return [ + subpath === "index" ? "./plugin-sdk" : `./plugin-sdk/${subpath}`, + `./plugin-sdk/${fileName}`, + ]; + }), + ); +} + +function removeStalePrivatePluginSdkAliasFiles( + pluginSdkAliasDir: string, + publicDistFileNames: ReadonlySet | undefined, +): void { + if (!publicDistFileNames || !fs.existsSync(pluginSdkAliasDir)) { + return; + } + for (const entry of fs.readdirSync(pluginSdkAliasDir, { withFileTypes: true })) { + if (!entry.isFile() || path.extname(entry.name) !== ".js") { + continue; + } + if (!publicDistFileNames.has(entry.name)) { + fs.rmSync(path.join(pluginSdkAliasDir, entry.name), { force: true }); + } + } +} function writeRuntimeJsonFile(targetPath: string, value: unknown): void { writeJsonSync(targetPath, value); @@ -26,15 +156,13 @@ export function ensureOpenClawPluginSdkAlias(distRoot: string): void { return; } + const publicDistFileNames = readPublicPluginSdkDistFileNames(distRoot); const aliasDir = path.join(distRoot, "extensions", "node_modules", "openclaw"); const pluginSdkAliasDir = path.join(aliasDir, "plugin-sdk"); writeRuntimeJsonFile(path.join(aliasDir, "package.json"), { name: "openclaw", type: "module", - exports: { - "./plugin-sdk": "./plugin-sdk/index.js", - "./plugin-sdk/*": "./plugin-sdk/*.js", - }, + exports: buildRuntimePluginSdkPackageExports(publicDistFileNames), }); try { if (fs.existsSync(pluginSdkAliasDir) && !fs.lstatSync(pluginSdkAliasDir).isDirectory()) { @@ -44,10 +172,14 @@ export function ensureOpenClawPluginSdkAlias(distRoot: string): void { // Another process may be creating the alias at the same time. } fs.mkdirSync(pluginSdkAliasDir, { recursive: true }); + removeStalePrivatePluginSdkAliasFiles(pluginSdkAliasDir, publicDistFileNames); for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) { if (!entry.isFile() || path.extname(entry.name) !== ".js") { continue; } + if (publicDistFileNames && !publicDistFileNames.has(entry.name)) { + continue; + } writeRuntimeModuleWrapper( path.join(pluginSdkDir, entry.name), path.join(pluginSdkAliasDir, entry.name), diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index 7d64a8a8d0b..1033debb273 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -156,4 +156,52 @@ describe("installOpenClawPluginSdkNativeResolver", () => { const requireFromPlugin = createRequire(externalPluginEntry); expect(() => requireFromPlugin.resolve("openclaw/plugin-sdk/source-only")).toThrow(); }); + + it("scopes private Ollama SDK aliases to bundled Ollama native parents", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-ollama-")); + const { loaderModulePath } = writeFakeOpenClawPackage(root); + const internalPath = path.join(root, "dist", "plugin-sdk", "ssrf-runtime-internal.js"); + fs.writeFileSync(internalPath, "export const ssrfInternal = true;\n", "utf8"); + const ollamaEntry = path.join(root, "dist", "extensions", "ollama", "index.js"); + const runtimeOllamaEntry = path.join(root, "dist-runtime", "extensions", "ollama", "index.js"); + const otherEntry = path.join(root, "dist", "extensions", "demo", "index.js"); + fs.mkdirSync(path.dirname(ollamaEntry), { recursive: true }); + fs.mkdirSync(path.dirname(runtimeOllamaEntry), { recursive: true }); + fs.mkdirSync(path.dirname(otherEntry), { recursive: true }); + fs.writeFileSync(ollamaEntry, "export default {};\n", "utf8"); + fs.writeFileSync(runtimeOllamaEntry, "export default {};\n", "utf8"); + fs.writeFileSync(otherEntry, "export default {};\n", "utf8"); + + const installedAliases = installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: ollamaEntry, + pluginSdkResolution: "dist", + }); + installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: runtimeOllamaEntry, + pluginSdkResolution: "dist", + }); + installOpenClawPluginSdkNativeResolver({ + modulePath: loaderModulePath, + pluginModulePath: otherEntry, + pluginSdkResolution: "dist", + }); + + expect(installedAliases).toContain("openclaw/plugin-sdk/ssrf-runtime-internal"); + const requireFromOllama = createRequire(ollamaEntry); + expect( + fs.realpathSync(requireFromOllama.resolve("openclaw/plugin-sdk/ssrf-runtime-internal")), + ).toBe(fs.realpathSync(internalPath)); + + const requireFromRuntimeOllama = createRequire(runtimeOllamaEntry); + expect( + fs.realpathSync( + requireFromRuntimeOllama.resolve("openclaw/plugin-sdk/ssrf-runtime-internal"), + ), + ).toBe(fs.realpathSync(internalPath)); + + const requireFromOther = createRequire(otherEntry); + expect(() => requireFromOther.resolve("openclaw/plugin-sdk/ssrf-runtime-internal")).toThrow(); + }); }); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index b029fe7aadb..48b94d42333 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -15,6 +15,11 @@ type ModuleWithResolver = typeof Module & { _resolveFilename?: ResolveFilename; }; +type NativeAliasEntry = { + parentRoot: string; + target: string; +}; + export type InstallOpenClawPluginSdkNativeResolverOptions = { modulePath?: string; pluginModulePath?: string; @@ -27,8 +32,7 @@ export type InstallOpenClawPluginSdkNativeResolverOptions = { const moduleWithResolver = Module as ModuleWithResolver; const nodeResolveFilenameProperty = "_resolveFilename" as const; const PLUGIN_SDK_PACKAGE_PREFIXES = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"] as const; -const pluginSdkNativeAliases = new Map(); -const allowedParentRoots = new Set(); +const pluginSdkNativeAliases = new Map(); let installed = false; let previousResolveFilename: ResolveFilename | undefined; @@ -76,17 +80,69 @@ function findNearestPackageRoot(modulePath: string): string { return path.dirname(path.resolve(modulePath)); } -function addAllowedParentRoot(root: string): void { - allowedParentRoots.add(normalizePathForBoundary(root)); +function findBundledPluginRoot(modulePath: string): string | undefined { + const resolvedModulePath = normalizePathForBoundary(modulePath); + const packageRoot = normalizePathForBoundary(resolveLoaderPackageRootFromModulePath(modulePath)); + for (const relativeRoot of ["extensions", "dist/extensions", "dist-runtime/extensions"]) { + const bundledRoot = path.join(packageRoot, relativeRoot); + const relative = path.relative(bundledRoot, resolvedModulePath); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + continue; + } + const [pluginId] = relative.split(path.sep); + if (pluginId) { + return path.join(bundledRoot, pluginId); + } + } + return undefined; } -function registerAllowedParentRoots(options: InstallOpenClawPluginSdkNativeResolverOptions): void { +function resolveLoaderPackageRootFromModulePath(modulePath: string): string { + let cursor = path.dirname(path.resolve(modulePath)); + for (let i = 0; i < 12; i += 1) { + const packageJsonPath = path.join(cursor, "package.json"); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + bin?: unknown; + name?: unknown; + }; + if ( + packageJson.name === "openclaw" || + (typeof packageJson.bin === "object" && + packageJson.bin !== null && + typeof (packageJson.bin as { openclaw?: unknown }).openclaw === "string") + ) { + return cursor; + } + } catch { + // Keep walking; malformed package metadata should not widen alias scope. + } + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + return findNearestPackageRoot(modulePath); +} + +function resolveAllowedParentRoot(modulePath: string): string { + return findBundledPluginRoot(modulePath) ?? findNearestPackageRoot(modulePath); +} + +function resolveAllowedParentRoots( + options: InstallOpenClawPluginSdkNativeResolverOptions, +): string[] { + const roots = new Set(); if (options.pluginModulePath) { - addAllowedParentRoot(findNearestPackageRoot(options.pluginModulePath)); + roots.add(normalizePathForBoundary(resolveAllowedParentRoot(options.pluginModulePath))); } for (const root of options.allowedParentRoots ?? []) { - addAllowedParentRoot(root); + roots.add(normalizePathForBoundary(root)); } + return [...roots]; } function isWithinRoot(candidate: string, root: string): boolean { @@ -94,18 +150,22 @@ function isWithinRoot(candidate: string, root: string): boolean { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -function canResolveForParent(parent: NodeJS.Module | undefined): boolean { +function resolveAliasTargetForParent( + request: string, + parent: NodeJS.Module | undefined, +): string | undefined { + const entries = pluginSdkNativeAliases.get(request); const parentFilename = parent?.filename; - if (!parentFilename || allowedParentRoots.size === 0) { - return false; + if (!entries || !parentFilename) { + return undefined; } - return [...allowedParentRoots].some((root) => isWithinRoot(parentFilename, root)); + return entries.find((entry) => isWithinRoot(parentFilename, entry.parentRoot))?.target; } function listPluginSdkNativeAliases( options: InstallOpenClawPluginSdkNativeResolverOptions, ): Array { - const modulePath = resolveLoaderModulePath(options); + const modulePath = options.pluginModulePath ?? resolveLoaderModulePath(options); return Object.entries( buildPluginLoaderAliasMap( modulePath, @@ -135,8 +195,8 @@ function installResolver(): void { } previousResolveFilename = moduleWithResolver[nodeResolveFilenameProperty]; moduleWithResolver[nodeResolveFilenameProperty] = ((request, parent, isMain, options) => { - const aliasTarget = pluginSdkNativeAliases.get(request); - if (aliasTarget && canResolveForParent(parent)) { + const aliasTarget = resolveAliasTargetForParent(request, parent); + if (aliasTarget) { return aliasTarget; } return previousResolveFilename?.(request, parent, isMain, options) ?? request; @@ -144,20 +204,38 @@ function installResolver(): void { installed = true; } +function registerNativeAlias(params: { + request: string; + target: string; + parentRoots: readonly string[]; +}): void { + const entries = pluginSdkNativeAliases.get(params.request) ?? []; + for (const parentRoot of params.parentRoots) { + if ( + entries.some((entry) => entry.parentRoot === parentRoot && entry.target === params.target) + ) { + continue; + } + entries.push({ parentRoot, target: params.target }); + } + if (entries.length > 0) { + pluginSdkNativeAliases.set(params.request, entries); + } +} + export function installOpenClawPluginSdkNativeResolver( options: InstallOpenClawPluginSdkNativeResolverOptions = {}, ): string[] { + const parentRoots = resolveAllowedParentRoots(options); for (const [specifier, target] of listPluginSdkNativeAliases(options)) { - pluginSdkNativeAliases.set(specifier, target); + registerNativeAlias({ request: specifier, target, parentRoots }); } - registerAllowedParentRoots(options); installResolver(); return [...pluginSdkNativeAliases.keys()].toSorted(); } export function resetOpenClawPluginSdkNativeResolverForTest(): void { pluginSdkNativeAliases.clear(); - allowedParentRoots.clear(); if (installed && previousResolveFilename) { moduleWithResolver[nodeResolveFilenameProperty] = previousResolveFilename; } diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index d6f80e5c581..51ef2cf1d95 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -1071,6 +1071,161 @@ describe("plugin sdk alias helpers", () => { expect(shadowCodexAliases["openclaw/plugin-sdk/codex-native-task-runtime"]).toBeUndefined(); }); + it("aliases the Ollama SSRF internal helper only for the bundled Ollama plugin", async () => { + const fixture = createPluginSdkAliasFixture({ + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + }, + }); + const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"); + const distRootAlias = path.join(fixture.root, "dist", "plugin-sdk", "root-alias.cjs"); + const sourceSsrFInternalPath = path.join( + fixture.root, + "src", + "plugin-sdk", + "ssrf-runtime-internal.ts", + ); + const distSsrFInternalPath = path.join( + fixture.root, + "dist", + "plugin-sdk", + "ssrf-runtime-internal.js", + ); + fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8"); + fs.writeFileSync(distRootAlias, "module.exports = {};\n", "utf-8"); + fs.rmSync(path.join(fixture.root, "scripts"), { force: true, recursive: true }); + fs.writeFileSync(sourceSsrFInternalPath, "export const ssrfInternal = true;\n", "utf-8"); + fs.writeFileSync(distSsrFInternalPath, "export const ssrfInternal = true;\n", "utf-8"); + const sourceOllamaEntry = writePluginEntry( + fixture.root, + bundledPluginFile("ollama", "index.ts"), + ); + const sourceOtherPluginEntry = writePluginEntry( + fixture.root, + bundledPluginFile("demo", "index.ts"), + ); + const entryBody = [ + 'import { ssrfInternal } from "openclaw/plugin-sdk/ssrf-runtime-internal";', + "export const loadedSsrFInternal = ssrfInternal;", + "", + ].join("\n"); + fs.writeFileSync(sourceOllamaEntry, entryBody, "utf-8"); + fs.writeFileSync(sourceOtherPluginEntry, entryBody, "utf-8"); + const distOllamaEntry = writePluginEntry( + fixture.root, + bundledDistPluginFile("ollama", "index.js"), + ); + const distRuntimeOllamaEntry = writePluginEntry( + fixture.root, + path.join("dist-runtime", "extensions", "ollama", "index.js"), + ); + fs.writeFileSync(distOllamaEntry, entryBody, "utf-8"); + fs.writeFileSync(distRuntimeOllamaEntry, entryBody, "utf-8"); + const { packageRoot: installedOllamaRoot, pluginEntry: installedOllamaEntry } = + writeInstalledPluginEntry({ + installRoot: path.join(makeTempDir(), ".openclaw", "npm"), + packageName: "@openclaw/ollama", + }); + + const sourceSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () => + listPluginSdkExportedSubpaths({ + modulePath: sourceOllamaEntry, + }), + ); + const privateQaOtherSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, () => + listPluginSdkExportedSubpaths({ + modulePath: sourceOtherPluginEntry, + }), + ); + const sourceAliases = withEnv( + { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, + () => buildPluginLoaderAliasMap(sourceOllamaEntry), + ); + const distAliases = withEnv( + { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, + () => buildPluginLoaderAliasMap(distOllamaEntry, undefined, undefined, "dist"), + ); + const distRuntimeAliases = withEnv( + { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, + () => buildPluginLoaderAliasMap(distRuntimeOllamaEntry, undefined, undefined, "dist"), + ); + const otherAliases = withEnv( + { OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, + () => buildPluginLoaderAliasMap(sourceOtherPluginEntry), + ); + const privateQaOtherAliases = withEnv( + { OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", NODE_ENV: undefined }, + () => buildPluginLoaderAliasMap(sourceOtherPluginEntry), + ); + const installedAliases = withCwd(installedOllamaRoot, () => + withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined }, () => + buildPluginLoaderAliasMap( + installedOllamaEntry, + path.join(fixture.root, "openclaw.mjs"), + undefined, + "dist", + ), + ), + ); + + expect(sourceSubpaths).toEqual(["core", "ssrf-runtime-internal"]); + expect(privateQaOtherSubpaths).toEqual(["core"]); + expect(fs.realpathSync(sourceAliases["openclaw/plugin-sdk/ssrf-runtime-internal"] ?? "")).toBe( + fs.realpathSync(sourceSsrFInternalPath), + ); + expect(fs.realpathSync(distAliases["openclaw/plugin-sdk/ssrf-runtime-internal"] ?? "")).toBe( + fs.realpathSync(distSsrFInternalPath), + ); + expect( + fs.realpathSync(distRuntimeAliases["openclaw/plugin-sdk/ssrf-runtime-internal"] ?? ""), + ).toBe(fs.realpathSync(distSsrFInternalPath)); + expect(otherAliases["openclaw/plugin-sdk/ssrf-runtime-internal"]).toBeUndefined(); + expect(privateQaOtherAliases["openclaw/plugin-sdk/ssrf-runtime-internal"]).toBeUndefined(); + expect(installedAliases["openclaw/plugin-sdk/ssrf-runtime-internal"]).toBeUndefined(); + + const createJiti = await getCreateJiti(); + const sourceLoaderBaseUrl = pathToFileURL( + path.join(fixture.root, "src", "plugins", "loader.ts"), + ).href; + const ollamaLoader = createJiti(sourceLoaderBaseUrl, { + ...buildPluginLoaderJitiOptions(sourceAliases), + tryNative: false, + }); + const loadedOllama = ollamaLoader(sourceOllamaEntry) as { loadedSsrFInternal?: unknown }; + expect(loadedOllama.loadedSsrFInternal).toBe(true); + + const distLoader = createJiti(sourceLoaderBaseUrl, { + ...buildPluginLoaderJitiOptions(distAliases), + tryNative: true, + }); + const loadedDistOllama = distLoader(distOllamaEntry) as { + loadedSsrFInternal?: unknown; + }; + expect(loadedDistOllama.loadedSsrFInternal).toBe(true); + + const distRuntimeLoader = createJiti(sourceLoaderBaseUrl, { + ...buildPluginLoaderJitiOptions(distRuntimeAliases), + tryNative: true, + }); + const loadedDistRuntimeOllama = distRuntimeLoader(distRuntimeOllamaEntry) as { + loadedSsrFInternal?: unknown; + }; + expect(loadedDistRuntimeOllama.loadedSsrFInternal).toBe(true); + + const otherLoader = createJiti(sourceLoaderBaseUrl, { + ...buildPluginLoaderJitiOptions(privateQaOtherAliases), + tryNative: false, + }); + let otherLoadError: unknown; + try { + otherLoader(sourceOtherPluginEntry); + } catch (error) { + otherLoadError = error; + } + expect(otherLoadError).toBeInstanceOf(Error); + expect((otherLoadError as Error).message).toContain("ssrf-runtime-internal"); + }); + it("applies explicit dist resolution to plugin-sdk subpath aliases too", () => { const { fixture, distRootAlias, distChannelRuntimePath } = createPluginSdkAliasTargetFixture(); const sourcePluginEntry = writePluginEntry( diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index a739d6a7465..522e65c2771 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -268,13 +268,31 @@ const cachedBundledPluginPublicSurfaceAliasMaps = new PluginLruCache isSafePluginSdkSubpathSegment(subpath)) : []), @@ -475,12 +494,16 @@ function shouldIncludePrivateLocalOnlyPluginSdkSubpaths() { return process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1"; } -function isBundledCodexPluginModulePath(params: { packageRoot: string; modulePath: string }) { +function isBundledPluginModulePath(params: { + packageRoot: string; + modulePath: string; + pluginId: string; +}) { const normalizedModulePath = path.resolve(params.modulePath); const roots = [ - path.join(params.packageRoot, "extensions", "codex"), - path.join(params.packageRoot, "dist", "extensions", "codex"), - path.join(params.packageRoot, "dist-runtime", "extensions", "codex"), + path.join(params.packageRoot, "extensions", params.pluginId), + path.join(params.packageRoot, "dist", "extensions", params.pluginId), + path.join(params.packageRoot, "dist-runtime", "extensions", params.pluginId), ]; return roots.some( (root) => @@ -488,22 +511,32 @@ function isBundledCodexPluginModulePath(params: { packageRoot: string; modulePat ); } -function isOfficialInstalledCodexPluginPackageRoot(packageRoot: string) { - const segments = path.resolve(packageRoot).split(path.sep).filter(Boolean); +function isOfficialInstalledPluginPackageRoot(params: { + packageRoot: string; + packageName: string; +}) { + const [scope, name] = params.packageName.split("/"); + if (!scope || !name) { + return false; + } + const segments = path.resolve(params.packageRoot).split(path.sep).filter(Boolean); const last = segments.at(-1); - const scope = segments.at(-2); + const packageScope = segments.at(-2); const nodeModules = segments.at(-3); - return last === "codex" && scope === "@openclaw" && nodeModules === "node_modules"; + return last === name && packageScope === scope && nodeModules === "node_modules"; } -function isOfficialInstalledCodexPluginModulePath(params: { modulePath: string }) { +function isOfficialInstalledPluginModulePath(params: { modulePath: string; packageName: string }) { let cursor = path.dirname(path.resolve(params.modulePath)); for (let depth = 0; depth < 12; depth += 1) { const packageJson = tryReadJsonSync<{ name?: unknown }>(path.join(cursor, "package.json")); if (packageJson) { return ( - packageJson.name === OFFICIAL_CODEX_PLUGIN_PACKAGE_NAME && - isOfficialInstalledCodexPluginPackageRoot(cursor) + packageJson.name === params.packageName && + isOfficialInstalledPluginPackageRoot({ + packageRoot: cursor, + packageName: params.packageName, + }) ); } const parent = path.dirname(cursor); @@ -515,11 +548,41 @@ function isOfficialInstalledCodexPluginModulePath(params: { modulePath: string } return false; } -function isTrustedCodexPluginModulePath(params: { packageRoot: string; modulePath: string }) { - return ( - isBundledCodexPluginModulePath(params) || - isOfficialInstalledCodexPluginModulePath({ modulePath: params.modulePath }) - ); +function isTrustedPrivatePluginSdkOwnerPath(params: { + packageRoot: string; + modulePath: string; + owner: PrivatePluginSdkSubpathOwner; +}) { + if ( + isBundledPluginModulePath({ + packageRoot: params.packageRoot, + modulePath: params.modulePath, + pluginId: params.owner.bundledPluginId, + }) + ) { + return true; + } + return params.owner.officialInstalledPackageName + ? isOfficialInstalledPluginModulePath({ + modulePath: params.modulePath, + packageName: params.owner.officialInstalledPackageName, + }) + : false; +} + +function findPrivatePluginSdkSubpathOwner( + subpath: string, +): PrivatePluginSdkSubpathOwner | undefined { + return PRIVATE_PLUGIN_SDK_SUBPATH_OWNERS.find((owner) => owner.subpaths.includes(subpath)); +} + +function listTrustedPrivatePluginSdkOwnerKeys(params: { + packageRoot: string; + modulePath: string; +}): string[] { + return PRIVATE_PLUGIN_SDK_SUBPATH_OWNERS.filter((owner) => + isTrustedPrivatePluginSdkOwnerPath({ ...params, owner }), + ).map((owner) => owner.bundledPluginId); } function shouldIncludePrivateLocalOnlyPluginSdkSubpath(params: { @@ -527,13 +590,13 @@ function shouldIncludePrivateLocalOnlyPluginSdkSubpath(params: { modulePath: string; subpath: string; }) { + const owner = findPrivatePluginSdkSubpathOwner(params.subpath); + if (!owner) { + return shouldIncludePrivateLocalOnlyPluginSdkSubpaths(); + } return ( - shouldIncludePrivateLocalOnlyPluginSdkSubpaths() || - (BUNDLED_CODEX_PRIVATE_PLUGIN_SDK_SUBPATHS.has(params.subpath) && - isTrustedCodexPluginModulePath({ - packageRoot: params.packageRoot, - modulePath: params.modulePath, - })) + isTrustedPrivatePluginSdkOwnerPath({ ...params, owner }) || + (owner.allowPrivateQaCli && shouldIncludePrivateLocalOnlyPluginSdkSubpaths()) ); } @@ -590,8 +653,8 @@ export function listPluginSdkExportedSubpaths( if (!packageRoot) { return []; } - const includeCodexPrivateRuntime = isTrustedCodexPluginModulePath({ packageRoot, modulePath }); - const cacheKey = `${packageRoot}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}::codexPrivate=${includeCodexPrivateRuntime ? "1" : "0"}`; + const trustedPrivateOwners = listTrustedPrivatePluginSdkOwnerKeys({ packageRoot, modulePath }); + const cacheKey = `${packageRoot}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}::privateOwners=${trustedPrivateOwners.join(",")}`; const cached = cachedPluginSdkExportedSubpaths.get(cacheKey); if (cached) { return cached; @@ -628,8 +691,8 @@ export function resolvePluginSdkScopedAliasMap( isProduction: process.env.NODE_ENV === "production", pluginSdkResolution: params.pluginSdkResolution, }); - const includeCodexPrivateRuntime = isTrustedCodexPluginModulePath({ packageRoot, modulePath }); - const cacheKey = `${packageRoot}::${orderedKinds.join(",")}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}::codexPrivate=${includeCodexPrivateRuntime ? "1" : "0"}`; + const trustedPrivateOwners = listTrustedPrivatePluginSdkOwnerKeys({ packageRoot, modulePath }); + const cacheKey = `${packageRoot}::${orderedKinds.join(",")}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}::privateOwners=${trustedPrivateOwners.join(",")}`; const cached = cachedPluginSdkScopedAliasMaps.get(cacheKey); if (cached) { return cached; diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 8af37ce6122..230f0b790ad 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -106,6 +106,7 @@ describe("stageBundledPluginRuntime", () => { "dist/plugin-sdk/index.js": "export const sdk = true;\n", "dist/plugin-sdk/channel-entry-contract.js": "export { contract } from '../channel-entry-contract-abc.js';\n", + "dist/plugin-sdk/ssrf-runtime-internal.js": "export const internal = true;\n", "dist/channel-entry-contract-abc.js": "export const contract = true;\n", [bundledDistPluginFile("diffs", "index.js")]: "export default {}\n", [bundledDistPluginFile("diffs", "node_modules/@pierre/diffs/index.js")]: @@ -135,17 +136,22 @@ describe("stageBundledPluginRuntime", () => { .isSymbolicLink(), ).toBe(false); expect( - fs.readFileSync( - path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "package.json"), - "utf8", - ), - ).toContain('"./plugin-sdk": "./plugin-sdk/index.js"'); + JSON.parse( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "package.json"), + "utf8", + ), + ).exports, + ).toMatchObject({ + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/channel-entry-contract": "./plugin-sdk/channel-entry-contract.js", + }); expect( fs.readFileSync( path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "package.json"), "utf8", ), - ).toContain('"./plugin-sdk/*": "./plugin-sdk/*.js"'); + ).not.toContain('"./plugin-sdk/*"'); expect( fs.readFileSync( path.join( @@ -160,9 +166,65 @@ describe("stageBundledPluginRuntime", () => { "utf8", ), ).toContain("../../../../plugin-sdk/channel-entry-contract.js"); + expect( + fs.existsSync( + path.join( + repoRoot, + "dist", + "extensions", + "node_modules", + "openclaw", + "plugin-sdk", + "ssrf-runtime-internal.js", + ), + ), + ).toBe(false); expect(fs.existsSync(path.join(runtimePluginDir, "node_modules", "openclaw"))).toBe(false); }); + it("stages only public plugin-sdk package exports for bundled runtime aliases", () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sdk-public-"); + createDistPluginDir(repoRoot, "ollama"); + setupRepoFiles(repoRoot, { + "package.json": JSON.stringify( + { + name: "openclaw", + type: "module", + exports: { + "./plugin-sdk": "./dist/plugin-sdk/index.js", + "./plugin-sdk/channel-entry-contract": "./dist/plugin-sdk/channel-entry-contract.js", + }, + }, + null, + 2, + ), + "dist/plugin-sdk/index.js": "export const sdk = true;\n", + "dist/plugin-sdk/channel-entry-contract.js": "export const contract = true;\n", + "dist/plugin-sdk/source-only.js": "export const sourceOnly = true;\n", + "dist/plugin-sdk/ssrf-runtime-internal.js": "export const internal = true;\n", + [bundledDistPluginFile("ollama", "index.js")]: "export default {}\n", + }); + + stageBundledPluginRuntime({ repoRoot }); + + const aliasRoot = path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw"); + const packageJson = JSON.parse( + fs.readFileSync(path.join(aliasRoot, "package.json"), "utf8"), + ) as { exports: Record }; + expect(packageJson.exports).toEqual({ + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/channel-entry-contract": "./plugin-sdk/channel-entry-contract.js", + }); + expect(fs.existsSync(path.join(aliasRoot, "plugin-sdk", "index.js"))).toBe(true); + expect(fs.existsSync(path.join(aliasRoot, "plugin-sdk", "channel-entry-contract.js"))).toBe( + true, + ); + expect(fs.existsSync(path.join(aliasRoot, "plugin-sdk", "source-only.js"))).toBe(false); + expect(fs.existsSync(path.join(aliasRoot, "plugin-sdk", "ssrf-runtime-internal.js"))).toBe( + false, + ); + }); + it("keeps extension-local plugin-sdk wrappers resolving canonical dist chunks", async () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sdk-wrapper-"); createDistPluginDir(repoRoot, "diffs");