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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions extensions/googlechat/config-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Keep the config-schema entry on a package-local barrel so bundled extension
// metadata flows stay within the extension boundary while avoiding the broader
// googlechat plugin facade.
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat-runtime-shared";
5 changes: 1 addition & 4 deletions extensions/googlechat/src/config-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import {
buildChannelConfigSchema,
GoogleChatConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../config-api.js";

export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);
4 changes: 2 additions & 2 deletions extensions/msteams/src/oauth.token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function exchangeMSTeamsCodeForTokens(params: {
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url: tokenUrl,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
fetchImpl: currentFetch,
init: {
method: "POST",
headers: {
Expand Down Expand Up @@ -103,7 +103,7 @@ export async function refreshMSTeamsDelegatedTokens(params: {
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url: tokenUrl,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
fetchImpl: currentFetch,
init: {
method: "POST",
headers: {
Expand Down
4 changes: 2 additions & 2 deletions extensions/msteams/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ async function updateActivityViaRest(params: {
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
fetchImpl: currentFetch,
init: {
method: "PUT",
headers,
Expand Down Expand Up @@ -508,7 +508,7 @@ async function deleteActivityViaRest(params: {
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
fetchImpl: currentFetch,
init: {
method: "DELETE",
headers,
Expand Down
3 changes: 3 additions & 0 deletions extensions/ollama/provider-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ function resolveOllamaDiscoveryApiKey(params: {
}

function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
if (env.OPENCLAW_TEST_ALLOW_AMBIENT_OLLAMA_DISCOVERY === "1") {
return false;
}
return Boolean(env.VITEST) || env.NODE_ENV === "test";
}

Expand Down
5 changes: 1 addition & 4 deletions extensions/telegram/src/config-schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
buildChannelConfigSchema,
TelegramConfigSchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { buildChannelConfigSchema, TelegramConfigSchema } from "../config-api.js";
import { telegramChannelConfigUiHints } from "./config-ui-hints.js";

export const TelegramChannelConfigSchema = buildChannelConfigSchema(TelegramConfigSchema, {
Expand Down
2 changes: 2 additions & 0 deletions extensions/x/config-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export { XConfigSchema } from "./src/config-schema.js";
3 changes: 1 addition & 2 deletions extensions/x/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import { XConfigSchema } from "./src/config-schema.js";
import { buildChannelConfigSchema, XConfigSchema } from "./config-api.js";

export default defineBundledChannelEntry({
id: "x",
Expand Down
26 changes: 26 additions & 0 deletions extensions/x/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ import { describe, it, expect } from "vitest";
import { xPlugin } from "./plugin.js";

describe("X plugin", () => {
describe("messaging.normalizeTarget", () => {
const normalizeTarget = xPlugin.messaging?.normalizeTarget;

it("should exist", () => {
expect(normalizeTarget).toBeTypeOf("function");
});

it("should normalize prefixed user targets", () => {
expect(normalizeTarget?.("twitter:user:1566488849252958208")).toBe(
"x:user:1566488849252958208",
);
expect(normalizeTarget?.("user:1566488849252958208")).toBe("x:user:1566488849252958208");
});

it("should preserve handles and bare numeric ids", () => {
expect(normalizeTarget?.("@wanglinfang2")).toBe("@wanglinfang2");
expect(normalizeTarget?.("1566488849252958208")).toBe("1566488849252958208");
});

it("should normalize twitter profile urls onto x.com", () => {
expect(normalizeTarget?.("https://twitter.com/elonmusk")).toBe("https://x.com/elonmusk");
expect(normalizeTarget?.("http://x.com/elonmusk")).toBe("https://x.com/elonmusk");
expect(normalizeTarget?.("https://www.x.com/elonmusk")).toBe("https://x.com/elonmusk");
});
});

describe("messaging.targetResolver.looksLikeId", () => {
const looksLikeId = xPlugin.messaging?.targetResolver?.looksLikeId;

Expand Down
21 changes: 21 additions & 0 deletions extensions/x/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ type XAccountConfig = {
proxy?: string;
};

function normalizeXMessagingTarget(target: string): string | undefined {
const trimmed = target.trim();
if (!trimmed) {
return undefined;
}
if (/^(?:x|twitter):(user|tweet):/i.test(trimmed)) {
return trimmed.replace(/^twitter:/i, "x:");
}
if (/^user:\d+$/i.test(trimmed)) {
return `x:${trimmed.toLowerCase()}`;
}
if (/^@\w{4,15}$/i.test(trimmed) || /^\d{10,}$/.test(trimmed)) {
return trimmed;
}
if (/^https?:\/\/(www\.)?(twitter\.com|x\.com)\//i.test(trimmed)) {
return trimmed.replace(/^https?:\/\/(?:www\.)?(?:twitter\.com|x\.com)\//i, "https://x.com/");
}
Comment thread
linfangw marked this conversation as resolved.
return undefined;
}

/**
* X channel plugin.
*/
Expand Down Expand Up @@ -119,6 +139,7 @@ export const xPlugin: ChannelPlugin<XAccountConfig> = {
},

messaging: {
normalizeTarget: normalizeXMessagingTarget,
targetResolver: {
looksLikeId: (raw: string) => {
const trimmed = raw.trim();
Expand Down
1 change: 1 addition & 0 deletions scripts/lib/bundled-runtime-sidecar-paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"dist/extensions/webhooks/runtime-api.js",
"dist/extensions/whatsapp/light-runtime-api.js",
"dist/extensions/whatsapp/runtime-api.js",
"dist/extensions/x/runtime-api.js",
"dist/extensions/zai/runtime-api.js",
"dist/extensions/zalo/runtime-api.js",
"dist/extensions/zalouser/runtime-api.js"
Expand Down
5 changes: 2 additions & 3 deletions src/agents/models-config.providers.ollama.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ describe("Ollama provider", () => {
const createAgentDir = () => mkdtempSync(join(tmpdir(), "openclaw-test-"));

const enableDiscoveryEnv = () => {
vi.stubEnv("VITEST", "");
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("OPENCLAW_TEST_ALLOW_AMBIENT_OLLAMA_DISCOVERY", "1");
};

const fetchCallUrls = (fetchMock: ReturnType<typeof vi.fn>): string[] =>
Expand Down Expand Up @@ -53,7 +52,7 @@ describe("Ollama provider", () => {
return withOllamaApiKey(() =>
resolveProvidersWithOllamaOnly({
agentDir,
env: { VITEST: "", NODE_ENV: "development" },
env: { OPENCLAW_TEST_ALLOW_AMBIENT_OLLAMA_DISCOVERY: "1" },
}),
);
}
Expand Down
31 changes: 31 additions & 0 deletions src/infra/net/fetch-guard.ssrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,37 @@ describe("fetchWithSsrFGuard hardening", () => {
}
});

it("skips pinned DNS when ambient global fetch is mocked without a custom lookup", async () => {
const originalGlobalFetch = globalThis.fetch;
const globalFetch = vi.fn(async () => okResponse());
(globalThis as Record<string, unknown>).fetch = globalFetch as typeof fetch;

try {
const result = await fetchWithSsrFGuard({
url: "https://api.test.invalid/resource",
});

expect(globalFetch).toHaveBeenCalledTimes(1);
await result.release();
} finally {
(globalThis as Record<string, unknown>).fetch = originalGlobalFetch;
}
});

it("skips pinned DNS for an explicit mocked fetch implementation", async () => {
const fetchImpl = vi.fn(async (_input?: RequestInfo | URL, _init?: RequestInit) =>
okResponse(),
);

const result = await fetchWithSsrFGuard({
url: "https://api.test.invalid/resource",
fetchImpl,
});

expect(fetchImpl).toHaveBeenCalledTimes(1);
await result.release();
});

it("fails closed when the runtime rejects the pinned dispatcher shape", async () => {
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const requestInit = init as RequestInit & { dispatcher?: unknown };
Expand Down
11 changes: 10 additions & 1 deletion src/infra/net/fetch-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
if (!defaultFetch) {
throw new Error("fetch is not available");
}
const usesMockedFetchWithoutPinnedDns =
params.lookupFn === undefined &&
params.dispatcherPolicy === undefined &&
isMockedFetch(defaultFetch);

const maxRedirects =
typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects)
Expand Down Expand Up @@ -337,7 +341,12 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
await assertExplicitProxyAllowed(params.dispatcherPolicy, params.lookupFn, params.policy);
const canUseTrustedEnvProxy =
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured();
if (canUseTrustedEnvProxy) {
if (usesMockedFetchWithoutPinnedDns) {
// Test-installed fetch mocks should not have to satisfy real DNS lookups before
// the request reaches the mock. Keep hostname policy checks, but skip pinned
// dispatcher resolution so mocked fetch implementations can receive the request.
assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);
} else if (canUseTrustedEnvProxy) {
dispatcher = createHttp1EnvHttpProxyAgent();
} else if (usesTrustedExplicitProxyMode) {
// Explicit proxy targets are still checked against the caller's hostname
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const BUNDLED_EXTENSION_CONFIG_IMPORT_GUARDS = [
},
{
path: "extensions/googlechat/src/config-schema.ts",
allowedSpecifier: "openclaw/plugin-sdk/googlechat",
allowedSpecifier: "../config-api.js",
},
// Teams keeps a package-local config barrel so production code does not
// self-import via openclaw/plugin-sdk/msteams from inside the same extension.
Expand Down
18 changes: 16 additions & 2 deletions src/plugins/roots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,25 @@ export type PluginCacheInputs = {
loadPaths: string[];
};

function resolvePluginHostEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (!env || env === process.env) {
return process.env;
}
const resolved = {
...process.env,
...env,
};
if (process.env.VITEST !== undefined) {
resolved.VITEST = process.env.VITEST;
}
return resolved;
}

export function resolvePluginSourceRoots(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): PluginSourceRoots {
const env = params.env ?? process.env;
const env = resolvePluginHostEnv(params.env);
const workspaceRoot = params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : undefined;
const stock = resolveBundledPluginsDir(env);
const global = path.join(resolveConfigDir(env), "extensions");
Expand All @@ -31,7 +45,7 @@ export function resolvePluginCacheInputs(params: {
loadPaths?: string[];
env?: NodeJS.ProcessEnv;
}): PluginCacheInputs {
const env = params.env ?? process.env;
const env = resolvePluginHostEnv(params.env);
const roots = resolvePluginSourceRoots({
workspaceDir: params.workspaceDir,
env,
Expand Down
12 changes: 12 additions & 0 deletions src/secrets/provider-env-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
listKnownProviderAuthEnvVarNames,
listKnownSecretEnvVarNames,
omitEnvKeysCaseInsensitive,
resolveProviderAuthEnvVarCandidates,
} from "./provider-env-vars.js";

describe("provider env vars", () => {
Expand Down Expand Up @@ -64,4 +65,15 @@ describe("provider env vars", () => {
expect(getProviderEnvVars("anthropic")).toEqual(["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]);
expect(getProviderEnvVars("fal")).toEqual(["FAL_KEY", "FAL_API_KEY"]);
});

it("keeps bundled provider auth env metadata available for sparse env snapshots", () => {
const candidates = resolveProviderAuthEnvVarCandidates({
env: { MINIMAX_API_KEY: "sk-test" } as NodeJS.ProcessEnv,
});

expect(candidates.minimax).toEqual(["MINIMAX_API_KEY"]);
expect(candidates.moonshot).toEqual(["MOONSHOT_API_KEY"]);
expect(candidates.nvidia).toEqual(["NVIDIA_API_KEY"]);
expect(candidates.vllm).toEqual(["VLLM_API_KEY"]);
});
});
Loading