diff --git a/extensions/googlechat/config-api.ts b/extensions/googlechat/config-api.ts new file mode 100644 index 0000000000000..108f5d8bb93b7 --- /dev/null +++ b/extensions/googlechat/config-api.ts @@ -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"; diff --git a/extensions/googlechat/src/config-schema.ts b/extensions/googlechat/src/config-schema.ts index 21249d67e7344..b7bf4ce0edac5 100644 --- a/extensions/googlechat/src/config-schema.ts +++ b/extensions/googlechat/src/config-schema.ts @@ -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); diff --git a/extensions/msteams/src/oauth.token.ts b/extensions/msteams/src/oauth.token.ts index 4f38ebb3f89d3..79efef738f4f5 100644 --- a/extensions/msteams/src/oauth.token.ts +++ b/extensions/msteams/src/oauth.token.ts @@ -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: { @@ -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: { diff --git a/extensions/msteams/src/sdk.ts b/extensions/msteams/src/sdk.ts index 856f0cac1ee21..c087653a0db86 100644 --- a/extensions/msteams/src/sdk.ts +++ b/extensions/msteams/src/sdk.ts @@ -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, @@ -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, diff --git a/extensions/ollama/provider-discovery.ts b/extensions/ollama/provider-discovery.ts index 31e37917da39a..d09177c11aafe 100644 --- a/extensions/ollama/provider-discovery.ts +++ b/extensions/ollama/provider-discovery.ts @@ -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"; } diff --git a/extensions/telegram/src/config-schema.ts b/extensions/telegram/src/config-schema.ts index 16849c4ee571a..a4f3b2ad043dd 100644 --- a/extensions/telegram/src/config-schema.ts +++ b/extensions/telegram/src/config-schema.ts @@ -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, { diff --git a/extensions/x/config-api.ts b/extensions/x/config-api.ts new file mode 100644 index 0000000000000..9e09c36e2b49b --- /dev/null +++ b/extensions/x/config-api.ts @@ -0,0 +1,2 @@ +export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; +export { XConfigSchema } from "./src/config-schema.js"; diff --git a/extensions/x/index.ts b/extensions/x/index.ts index d68acbe620730..594b9d2883f33 100644 --- a/extensions/x/index.ts +++ b/extensions/x/index.ts @@ -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", diff --git a/extensions/x/src/plugin.test.ts b/extensions/x/src/plugin.test.ts index d85d87f569124..32f2a02b0c322 100644 --- a/extensions/x/src/plugin.test.ts +++ b/extensions/x/src/plugin.test.ts @@ -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; diff --git a/extensions/x/src/plugin.ts b/extensions/x/src/plugin.ts index 0264da23e4ea4..676a6dfa82ebb 100644 --- a/extensions/x/src/plugin.ts +++ b/extensions/x/src/plugin.ts @@ -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/"); + } + return undefined; +} + /** * X channel plugin. */ @@ -119,6 +139,7 @@ export const xPlugin: ChannelPlugin = { }, messaging: { + normalizeTarget: normalizeXMessagingTarget, targetResolver: { looksLikeId: (raw: string) => { const trimmed = raw.trim(); diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json index 9d4e3264a5ae8..62a9c76b57474 100644 --- a/scripts/lib/bundled-runtime-sidecar-paths.json +++ b/scripts/lib/bundled-runtime-sidecar-paths.json @@ -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" diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index 19f816cf1b5e4..02ac315774398 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -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): string[] => @@ -53,7 +52,7 @@ describe("Ollama provider", () => { return withOllamaApiKey(() => resolveProvidersWithOllamaOnly({ agentDir, - env: { VITEST: "", NODE_ENV: "development" }, + env: { OPENCLAW_TEST_ALLOW_AMBIENT_OLLAMA_DISCOVERY: "1" }, }), ); } diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 099566092ee4b..545c91196fffb 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -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).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).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 }; diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index ab010779e2646..1fa66fd299969 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -284,6 +284,10 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise { @@ -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"]); + }); });