diff --git a/Dockerfile b/Dockerfile index 48d1cd079e8..8f39cf5c239 100644 --- a/Dockerfile +++ b/Dockerfile @@ -266,6 +266,10 @@ ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= # (e.g. {"1234567890":{"requireMention":true,"users":["555"]}}). # Used to enable guild-channel responses for native Discord. Default: empty map. ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= +# Base64-encoded JSON Telegram config (e.g. {"requireMention":true}). +# When requireMention is true, Telegram groups get groupPolicy: mentions; +# otherwise groupPolicy: open (existing default). See #1737. Default: empty map. +ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= # Set to "1" to force-disable device-pairing auth. Also auto-disabled when # CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments) # since terminal-based pairing is impossible in those contexts. @@ -305,6 +309,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ + NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 5c86ed4d5df..7fcd8219209 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -26,6 +26,7 @@ NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded channel list NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 Base64-encoded allowed IDs map NEMOCLAW_DISCORD_GUILDS_B64 Base64-encoded Discord guild config + NEMOCLAW_TELEGRAM_CONFIG_B64 Base64-encoded Telegram config (e.g. {"requireMention": true}) NEMOCLAW_DISABLE_DEVICE_AUTH Set to "1" to force-disable device auth NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1) NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128) @@ -110,6 +111,11 @@ def build_config(env: dict | None = None) -> dict: env.get("NEMOCLAW_DISCORD_GUILDS_B64", "e30=") or "e30=" ).decode("utf-8") ) + _telegram_config = json.loads( + base64.b64decode( + env.get("NEMOCLAW_TELEGRAM_CONFIG_B64", "e30=") or "e30=" + ).decode("utf-8") + ) _token_keys = {"discord": "token", "telegram": "botToken", "slack": "botToken"} _env_keys = { @@ -145,7 +151,9 @@ def _placeholder(channel: str, env_key: str) -> str: if ch in ("telegram", "discord"): account["proxy"] = proxy_url if ch == "telegram": - account["groupPolicy"] = "open" + account["groupPolicy"] = ( + "mentions" if _telegram_config.get("requireMention") else "open" + ) if ch in _allowed_ids and _allowed_ids[ch]: account["dmPolicy"] = "allowlist" account["allowFrom"] = _allowed_ids[ch] diff --git a/src/lib/onboard-session.test.ts b/src/lib/onboard-session.test.ts index a088457404b..31996b996ac 100644 --- a/src/lib/onboard-session.test.ts +++ b/src/lib/onboard-session.test.ts @@ -182,6 +182,44 @@ describe("onboard session", () => { expect(fresh.messagingChannels).toBeNull(); }); + it("#1737: persists telegramConfig across save/load roundtrips (requireMention=true)", () => { + const created = session.createSession(); + created.telegramConfig = { requireMention: true }; + session.saveSession(created); + + const loaded = session.loadSession()!; + expect(loaded.telegramConfig).toEqual({ requireMention: true }); + }); + + it("#1737: persists telegramConfig across save/load roundtrips (requireMention=false)", () => { + const created = session.createSession(); + created.telegramConfig = { requireMention: false }; + session.saveSession(created); + + const loaded = session.loadSession()!; + expect(loaded.telegramConfig).toEqual({ requireMention: false }); + }); + + it("#1737: rejects malformed telegramConfig on load", () => { + // Simulate a hand-edited session file with garbage in telegramConfig. + // Going through saveSession() would re-normalize the value before it + // hits disk, so write raw JSON directly to exercise the load-time + // parseTelegramConfig() path. + const seed = session.createSession(); + session.saveSession(seed); + const onDisk = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf-8")); + onDisk.telegramConfig = { requireMention: "yes" }; + fs.writeFileSync(session.SESSION_FILE, JSON.stringify(onDisk)); + + const loaded = session.loadSession()!; + expect(loaded.telegramConfig).toBeNull(); + }); + + it("#1737: defaults telegramConfig to null for fresh sessions", () => { + const fresh = session.createSession(); + expect(fresh.telegramConfig).toBeNull(); + }); + it("persists and clears web search config through safe session updates", () => { session.saveSession(session.createSession()); session.markStepComplete("provider_selection", { @@ -409,6 +447,32 @@ describe("onboard session", () => { expect(loaded.messagingChannels).toEqual(["slack", "discord"]); }); + it("#1737: filterSafeUpdates routes telegramConfig through markStepComplete", () => { + session.saveSession(session.createSession()); + session.markStepComplete("provider_selection", { + telegramConfig: { requireMention: true }, + }); + + const loaded = session.loadSession()!; + expect(loaded.telegramConfig).toEqual({ requireMention: true }); + + // Explicit null (clearing the field) should also round-trip. + session.markStepComplete("provider_selection", { telegramConfig: null }); + const cleared = session.loadSession()!; + expect(cleared.telegramConfig).toBeNull(); + }); + + it("#1737: filterSafeUpdates drops malformed telegramConfig values", () => { + session.saveSession(session.createSession()); + // Non-boolean requireMention — must not leak through. + session.markStepComplete("provider_selection", { + telegramConfig: { requireMention: "yes" } as unknown as { requireMention: boolean }, + }); + + const loaded = session.loadSession()!; + expect(loaded.telegramConfig).toBeNull(); + }); + it("createSession with messagingChannels override", () => { const created = session.createSession({ messagingChannels: ["telegram", "slack"] }); expect(created.messagingChannels).toEqual(["telegram", "slack"]); diff --git a/src/lib/onboard-session.ts b/src/lib/onboard-session.ts index f5597965da8..94a386a4eb6 100644 --- a/src/lib/onboard-session.ts +++ b/src/lib/onboard-session.ts @@ -89,10 +89,15 @@ export interface Session { // migrated set is NOT seeded from the persisted record, so the cleanup // gate keeps the file until the *current* value is actually re-migrated. migratedLegacyValueHashes: Record | null; + telegramConfig: TelegramConfig | null; metadata: SessionMetadata; steps: Record; } +export interface TelegramConfig { + requireMention: boolean; +} + export interface LockInfo { pid: number; startedAt: string | null; @@ -120,6 +125,7 @@ export interface SessionUpdates { policyPresets?: string[]; messagingChannels?: string[]; migratedLegacyValueHashes?: Record; + telegramConfig?: TelegramConfig | null; metadata?: { gatewayName?: string; fromDockerfile?: string | null }; } @@ -209,6 +215,13 @@ function parseWebSearchConfig(value: SessionJsonValue | undefined): WebSearchCon return isObject(value) && value.fetchEnabled === true ? { fetchEnabled: true } : null; } +function parseTelegramConfig(value: unknown): TelegramConfig | null { + if (!isObject(value)) return null; + if (value.requireMention === true) return { requireMention: true }; + if (value.requireMention === false) return { requireMention: false }; + return null; +} + function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { if (!isObject(value)) return undefined; return { @@ -288,6 +301,7 @@ export function createSession(overrides: Partial = {}): Session { migratedLegacyValueHashes: overrides.migratedLegacyValueHashes ? readStringRecord(overrides.migratedLegacyValueHashes) : null, + telegramConfig: parseTelegramConfig(overrides.telegramConfig), metadata: { gatewayName: overrides.metadata?.gatewayName ?? "nemoclaw", fromDockerfile: overrides.metadata?.fromDockerfile ?? null, @@ -320,6 +334,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): policyPresets: readStringArray(data.policyPresets), messagingChannels: readStringArray(data.messagingChannels), migratedLegacyValueHashes: readStringRecord(data.migratedLegacyValueHashes), + telegramConfig: parseTelegramConfig(data.telegramConfig), lastStepStarted: readString(data.lastStepStarted), lastCompletedStep: readString(data.lastCompletedStep), failure: sanitizeFailure(isObject(data.failure) ? data.failure : null), @@ -633,6 +648,11 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { } safe.migratedLegacyValueHashes = cleaned; } + if (isObject(updates.telegramConfig) && typeof updates.telegramConfig.requireMention === "boolean") { + safe.telegramConfig = { requireMention: updates.telegramConfig.requireMention }; + } else if (updates.telegramConfig === null) { + safe.telegramConfig = null; + } if (isObject(updates.metadata) && typeof updates.metadata.gatewayName === "string") { safe.metadata = { gatewayName: updates.metadata.gatewayName, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1c24f6f6400..f6c161c8229 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -406,6 +406,18 @@ let RECREATE_SANDBOX = false; // null means "use auto-allocation" (skip dashboard port check in preflight). let _preflightDashboardPort: number | null = null; +// Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt +// or by the user's shell) and map it to a boolean, or null when the env var +// is unset / invalid. Used at build time to bake groupPolicy into +// openclaw.json and at resume time to detect drift against the recorded +// session state. See #1737 and the CodeRabbit follow-up on #2417. +function computeTelegramRequireMention(): boolean | null { + const raw = process.env.TELEGRAM_REQUIRE_MENTION; + if (raw === "1") return true; + if (raw === "0") return false; + return null; +} + function isNonInteractive(): boolean { return NON_INTERACTIVE || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; } @@ -1709,6 +1721,7 @@ function patchStagedDockerfile( messagingAllowedIds: LooseObject = {}, discordGuilds: LooseObject = {}, baseImageRef: string | null = null, + telegramConfig: LooseObject = {}, ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1850,6 +1863,12 @@ function patchStagedDockerfile( `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`, ); } + if (telegramConfig && Object.keys(telegramConfig).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m, + `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeDockerJsonArg(telegramConfig)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } @@ -4216,6 +4235,31 @@ async function createSandbox( }; } } + // Telegram mention-only mode — parity with Discord's requireMention. + // Off by default so existing sandboxes behave the same; opt-in via + // TELEGRAM_REQUIRE_MENTION=1 or the interactive prompt. See #1737. + const telegramConfig: { requireMention?: boolean } = {}; + if (enabledTokenEnvKeys.has("TELEGRAM_BOT_TOKEN")) { + const telegramRequireMention = computeTelegramRequireMention(); + if (telegramRequireMention !== null) { + telegramConfig.requireMention = telegramRequireMention; + } + } + // Persist the effective Telegram config into the session so a later resume + // can detect drift (TELEGRAM_REQUIRE_MENTION changed since last build) and + // force a sandbox recreate — otherwise the old groupPolicy would stay baked + // in. Mirrors the pattern used for webSearchConfig. See CodeRabbit on #2417. + if (typeof telegramConfig.requireMention === "boolean") { + onboardSession.updateSession((current) => { + current.telegramConfig = { requireMention: telegramConfig.requireMention as boolean }; + return current; + }); + } else { + onboardSession.updateSession((current) => { + current.telegramConfig = null; + return current; + }); + } // Pull the base image and resolve its digest so the Dockerfile is pinned to // exactly what we just fetched. This prevents stale :latest tags from // silently reusing a cached old image after NemoClaw upgrades (#1904). @@ -4254,6 +4298,7 @@ async function createSandbox( messagingAllowedIds, discordGuilds, resolved ? resolved.ref : null, + telegramConfig, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 @@ -6088,17 +6133,23 @@ async function setupMessagingChannels(): Promise { } } } - if (ch.requireMentionEnvKey && ch.serverIdEnvKey && process.env[ch.serverIdEnvKey]) { - const existingRequireMention = process.env[ch.requireMentionEnvKey]; + // Mention-control prompt: fires for any channel that exposes a + // requireMention env key. Discord gates the prompt behind a configured + // server ID (mention control only makes sense in a guild). Telegram + // has no serverIdEnvKey because mention control applies to every group + // the bot is added to, so the prompt always fires there. See #1737. + const requireMentionKey = ch.requireMentionEnvKey; + if (requireMentionKey && (!ch.serverIdEnvKey || Boolean(process.env[ch.serverIdEnvKey]))) { + const existingRequireMention = process.env[requireMentionKey]; if (existingRequireMention === "0" || existingRequireMention === "1") { const mode = existingRequireMention === "0" ? "all messages" : "@mentions only"; console.log(` ✓ ${ch.name} — reply mode already set: ${mode}`); } else { console.log(` ${ch.requireMentionHelp}`); const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); - process.env[ch.requireMentionEnvKey] = answer === "n" || answer === "no" ? "0" : "1"; + process.env[requireMentionKey] = answer === "n" || answer === "no" ? "0" : "1"; const mode = - process.env[ch.requireMentionEnvKey] === "0" ? "all messages" : "@mentions only"; + process.env[requireMentionKey] === "0" ? "all messages" : "@mentions only"; console.log(` ✓ ${ch.name} reply mode saved: ${mode}`); } } @@ -8179,9 +8230,27 @@ async function onboard(opts: OnboardOptions = {}): Promise { const sandboxReuseState = getSandboxReuseState(sandboxName); const webSearchConfigChanged = Boolean(session?.webSearchConfig) !== Boolean(webSearchConfig); + // Telegram mention-mode is baked into openclaw.json at sandbox build time, so + // changes to TELEGRAM_REQUIRE_MENTION only take effect after a rebuild. Treat + // a mismatch between the recorded config and the current env value as drift + // so the reuse path forces a recreate (mirrors webSearchConfigChanged). See + // #1737 and the CodeRabbit review on #2417. + // + // Compare *effective* modes — null and false both produce groupPolicy: open + // at config-generation time (default behavior), so they collapse to the same + // bucket here. Without this, a sandbox built before TELEGRAM_REQUIRE_MENTION + // existed (recordedTelegramRequireMention === null) would be reused with the + // old groupPolicy: open even after the user sets TELEGRAM_REQUIRE_MENTION=1, + // and vice versa. + const currentTelegramRequireMention = computeTelegramRequireMention(); + const recordedTelegramRequireMention = session?.telegramConfig?.requireMention ?? null; + const effectiveCurrent = currentTelegramRequireMention ?? false; + const effectiveRecorded = recordedTelegramRequireMention ?? false; + const telegramConfigChanged = effectiveCurrent !== effectiveRecorded; const resumeSandbox = resume && !webSearchConfigChanged && + !telegramConfigChanged && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; if (resumeSandbox) { @@ -8197,6 +8266,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { if (sandboxName) { registry.removeSandbox(sandboxName); } + } else if (telegramConfigChanged) { + note(" [resume] TELEGRAM_REQUIRE_MENTION changed; recreating sandbox."); + if (sandboxName) { + registry.removeSandbox(sandboxName); + } } else if (sandboxReuseState === "not_ready") { note( ` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`, diff --git a/src/lib/sandbox-channels.ts b/src/lib/sandbox-channels.ts index e56f5a5ab24..a7e9f1e4c2b 100644 --- a/src/lib/sandbox-channels.ts +++ b/src/lib/sandbox-channels.ts @@ -36,6 +36,9 @@ export const KNOWN_CHANNELS: Record = { userIdHelp: "Send /start to @userinfobot on Telegram to get your numeric user ID.", userIdLabel: "Telegram User ID (for DM access)", allowIdsMode: "dm", + requireMentionEnvKey: "TELEGRAM_REQUIRE_MENTION", + requireMentionHelp: + "Controls Telegram group-chat behavior only — reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS.", }, discord: { envKey: "DISCORD_BOT_TOKEN", diff --git a/test/onboard.test.ts b/test/onboard.test.ts index a127751f2dd..13d7a6c7238 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -548,6 +548,148 @@ describe("onboard helpers", () => { } }); + it("#1737: patches the staged Dockerfile with Telegram mention-only config", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-tg-mention-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", + "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-tg-mention", + "openai-api", + null, + null, + ["telegram"], + {}, + {}, + null, + { requireMention: true }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + const line = patched + .split("\n") + .find((l) => l.startsWith("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=")); + assert.ok(line, "expected telegram config build arg"); + const encoded = line.split("=")[1]; + const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + assert.deepEqual(decoded, { requireMention: true }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("#1737: patches the staged Dockerfile with Telegram open-group config when requireMention=false", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-tg-open-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", + "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-tg-open", + "openai-api", + null, + null, + ["telegram"], + {}, + {}, + null, + { requireMention: false }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + const line = patched + .split("\n") + .find((l) => l.startsWith("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=")); + assert.ok(line, "expected telegram config build arg"); + const encoded = line.split("=")[1]; + const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + assert.deepEqual(decoded, { requireMention: false }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("#1737: preserves default Telegram group-open behavior when telegramConfig is empty", () => { + // Backward compatibility guard: the ARG default stays at e30= ({} base64) + // and patchStagedDockerfile does not rewrite it when no config is passed. + // The Dockerfile Python generator reads empty config as requireMention=false + // which maps to groupPolicy=open (matches pre-#1737 behavior). + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-tg-empty-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", + "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-tg-default", + "openai-api", + null, + null, + ["telegram"], + {}, + {}, + null, + {}, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=$/m); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("maps NVIDIA Endpoints to the routed inference provider", () => { assert.deepEqual( getSandboxInferenceConfig("qwen/qwen3.5-397b-a17b", "nvidia-prod", "openai-completions"),