mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 12:12:54 +00:00
fix(ollama): bypass managed proxy for loopback embeddings (#85707)
* fix(ollama): bypass proxy for local embeddings * fix(ollama): keep managed proxy bypass loopback-only * fix(ollama): keep proxy bypass internal * fix(ollama): keep proxy bypass private * fix(ollama): harden internal proxy bypass * chore(plugin-sdk): refresh api baseline * fix(ollama): keep internal bypass out of qa aliases * test(ollama): keep ssrf runtime mock complete * fix(ollama): keep dist sdk aliases public-only * fix(ollama): keep fetch bypass out of infra runtime * fix(ollama): preserve packaged private sdk alias * test(ollama): harden private ssrf alias coverage * test(ollama): cover private ssrf resolver edges * fix(ollama): scope private sdk native aliases * test(ollama): audit blocked loopback bypasses * fix(plugins): keep staged sdk aliases public-only * test(ollama): harden proxy bypass proof * test(ollama): cover origin mismatch proxy path * test(ollama): cover ipv6 and batch bypass paths * fix lint findings in Ollama proxy tests * refactor: tighten Ollama proxy bypass * fix: widen private sdk owner registry type * test: stabilize Ollama proxy PR checks --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof mockEmbeddingFetch
|
||||
return body.input;
|
||||
}
|
||||
|
||||
function firstGuardedFetchCall(): Record<string, unknown> {
|
||||
const call = fetchConfiguredLocalOriginWithSsrFGuardMock.mock.calls[0]?.[0];
|
||||
if (!call || typeof call !== "object") {
|
||||
throw new Error("expected guarded fetch call");
|
||||
}
|
||||
return call as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectEmbeddingFetch(
|
||||
fetchMock: ReturnType<typeof mockEmbeddingFetch>,
|
||||
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 () => {
|
||||
|
||||
@@ -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<T>(params: {
|
||||
init?: RequestInit;
|
||||
signal?: AbortSignal;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
configuredLocalOriginBaseUrl: string;
|
||||
onResponse: (response: Response) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
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",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"talk-config-runtime",
|
||||
"ssrf-policy",
|
||||
"ssrf-runtime",
|
||||
"ssrf-runtime-internal",
|
||||
"media-runtime",
|
||||
"media-store",
|
||||
"media-mime",
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"qa-channel-protocol",
|
||||
"qa-lab",
|
||||
"qa-runtime",
|
||||
"ssrf-runtime-internal",
|
||||
"test-utils"
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
|
||||
77
src/infra/net/configured-local-origin-bypass.ts
Normal file
77
src/infra/net/configured-local-origin-bypass.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<T extends unknown[]>(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<typeof fetchConfiguredLocalOriginWithSsrFGuard>[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<string, unknown>)[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<typeof fetchConfiguredLocalOriginWithSsrFGuard>[0]["fetchImpl"]
|
||||
>;
|
||||
}): Promise<void> {
|
||||
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<void> {
|
||||
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<typeof fetchWithSsrFGuard>[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,
|
||||
|
||||
@@ -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<GuardedFetchResult> {
|
||||
const { managedProxyBypass: _ignoredManagedProxyBypass, ...publicParams } =
|
||||
params as GuardedFetchOptions & {
|
||||
managedProxyBypass?: unknown;
|
||||
};
|
||||
return await fetchWithSsrFGuardInternal(publicParams);
|
||||
}
|
||||
|
||||
export async function fetchConfiguredLocalOriginWithSsrFGuard({
|
||||
configuredLocalOriginBaseUrl,
|
||||
...params
|
||||
}: GuardedFetchConfiguredLocalOriginOptions): Promise<GuardedFetchResult> {
|
||||
return await fetchWithSsrFGuardInternal({
|
||||
...params,
|
||||
managedProxyBypass: {
|
||||
kind: "configured-local-origin",
|
||||
baseUrl: configuredLocalOriginBaseUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithSsrFGuardInternal(
|
||||
params: GuardedFetchInternalOptions,
|
||||
): Promise<GuardedFetchResult> {
|
||||
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<G
|
||||
if (canUseTrustedEnvProxy) {
|
||||
dispatcher = createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
|
||||
} else if (canUseManagedProxy) {
|
||||
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: policyForUrl,
|
||||
});
|
||||
dispatcher = createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
|
||||
dispatcher = shouldUseConfiguredLocalOriginManagedProxyBypass({
|
||||
url: parsedUrl,
|
||||
managedProxyBypass: params.managedProxyBypass,
|
||||
resolvedAddresses: pinned.addresses,
|
||||
})
|
||||
? createPinnedDispatcher(pinned, params.dispatcherPolicy, policyForUrl, timeoutMs)
|
||||
: createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
|
||||
} else if (usesTrustedExplicitProxyMode) {
|
||||
// Explicit proxy targets are still checked against the caller's hostname
|
||||
// policy, but the proxy does the DNS resolution for the final target.
|
||||
|
||||
@@ -135,7 +135,28 @@ describe("plugin activation boundary", () => {
|
||||
}),
|
||||
).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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
4
src/plugin-sdk/ssrf-runtime-internal.ts
Normal file
4
src/plugin-sdk/ssrf-runtime-internal.ts
Normal file
@@ -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";
|
||||
@@ -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<string>(reservedBundledPluginSdkEntrypoints);
|
||||
|
||||
@@ -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<string, string>;
|
||||
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", () => {
|
||||
|
||||
@@ -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<string, string> };
|
||||
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<string, string> };
|
||||
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({
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string> | undefined {
|
||||
const pluginSdkDir = path.join(distRoot, "plugin-sdk");
|
||||
if (!fs.existsSync(pluginSdkDir)) {
|
||||
return undefined;
|
||||
}
|
||||
const privateFileNames = readPrivateLocalOnlyPluginSdkDistFileNames(distRoot);
|
||||
const fileNames = new Set<string>();
|
||||
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<string> {
|
||||
const packageRoot = path.dirname(path.resolve(distRoot));
|
||||
const privateFileNames = new Set<string>(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<string> | undefined {
|
||||
const packageRoot = path.dirname(path.resolve(distRoot));
|
||||
const packageJson = tryReadJsonSync<OpenClawPackageJson>(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<string>();
|
||||
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<string> | undefined,
|
||||
): Record<string, string> {
|
||||
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<string> | 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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>();
|
||||
const allowedParentRoots = new Set<string>();
|
||||
const pluginSdkNativeAliases = new Map<string, NativeAliasEntry[]>();
|
||||
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<string>();
|
||||
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<readonly [string, string]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -268,13 +268,31 @@ const cachedBundledPluginPublicSurfaceAliasMaps = new PluginLruCache<Record<stri
|
||||
MAX_PLUGIN_LOADER_ALIAS_CACHE_ENTRIES,
|
||||
);
|
||||
const PLUGIN_SDK_PACKAGE_NAMES = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"] as const;
|
||||
const OFFICIAL_CODEX_PLUGIN_PACKAGE_NAME = "@openclaw/codex";
|
||||
const CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH = "codex-native-task-runtime";
|
||||
const CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH = "codex-mcp-projection";
|
||||
const BUNDLED_CODEX_PRIVATE_PLUGIN_SDK_SUBPATHS = new Set([
|
||||
CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH,
|
||||
CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH,
|
||||
]);
|
||||
const OLLAMA_CONFIGURED_LOCAL_ORIGIN_RUNTIME_PLUGIN_SDK_SUBPATH = "ssrf-runtime-internal";
|
||||
type PrivatePluginSdkSubpathOwner = {
|
||||
bundledPluginId: string;
|
||||
officialInstalledPackageName?: string;
|
||||
allowPrivateQaCli: boolean;
|
||||
subpaths: readonly string[];
|
||||
};
|
||||
const PRIVATE_PLUGIN_SDK_SUBPATH_OWNERS: readonly PrivatePluginSdkSubpathOwner[] = [
|
||||
{
|
||||
bundledPluginId: "codex",
|
||||
officialInstalledPackageName: "@openclaw/codex",
|
||||
allowPrivateQaCli: true,
|
||||
subpaths: [
|
||||
CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH,
|
||||
CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH,
|
||||
],
|
||||
},
|
||||
{
|
||||
bundledPluginId: "ollama",
|
||||
allowPrivateQaCli: false,
|
||||
subpaths: [OLLAMA_CONFIGURED_LOCAL_ORIGIN_RUNTIME_PLUGIN_SDK_SUBPATH],
|
||||
},
|
||||
];
|
||||
const PLUGIN_SDK_SOURCE_CANDIDATE_EXTENSIONS = [
|
||||
".ts",
|
||||
".mts",
|
||||
@@ -322,6 +340,7 @@ function readPrivateLocalOnlyPluginSdkSubpaths(packageRoot: string): string[] {
|
||||
...new Set([
|
||||
CODEX_NATIVE_TASK_RUNTIME_PLUGIN_SDK_SUBPATH,
|
||||
CODEX_MCP_PROJECTION_PLUGIN_SDK_SUBPATH,
|
||||
OLLAMA_CONFIGURED_LOCAL_ORIGIN_RUNTIME_PLUGIN_SDK_SUBPATH,
|
||||
...(Array.isArray(parsed)
|
||||
? parsed.filter((subpath): subpath is string => 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;
|
||||
|
||||
@@ -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<string, string> };
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user