From 8962b364874ae054529ad31217cea0f23c799935 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:29:26 -0700 Subject: [PATCH] fix(onboard): accept owned Teams webhook field Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ...aged-startup-profile-teams-webhook.test.ts | 176 ++++++++++++++++++ src/lib/onboard/managed-startup/profile.ts | 63 ++++++- 2 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/lib/onboard/managed-startup-profile-teams-webhook.test.ts diff --git a/src/lib/onboard/managed-startup-profile-teams-webhook.test.ts b/src/lib/onboard/managed-startup-profile-teams-webhook.test.ts new file mode 100644 index 00000000000..e49fe5e31ea --- /dev/null +++ b/src/lib/onboard/managed-startup-profile-teams-webhook.test.ts @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + validateManagedStartupProfile, +} from "./managed-startup/profile"; + +const TEAMS_OPENCLAW_RENDER = { + channelId: "teams", + renderId: "teams-openclaw-channel", + hookId: "teams-openclaw-channel", + handler: "common.staticOutputs", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "channels.msteams", + value: { + enabled: true, + appId: "00000000-0000-0000-0000-000000000001", + appPassword: "openshell:resolve:env:MSTEAMS_APP_PASSWORD", + tenantId: "00000000-0000-0000-0000-000000000002", + webhook: { port: 3978, path: "/api/messages" }, + healthMonitor: { enabled: false }, + streaming: { mode: "off" }, + dmPolicy: "allowlist", + allowFrom: ["00000000-0000-0000-0000-000000000003"], + groupPolicy: "open", + requireMention: true, + }, + templateRefs: ["credential.teamsClientSecret.placeholder"], +} as const; + +const BASE_OPENCLAW_PROFILE = { + schemaVersion: MANAGED_STARTUP_PROFILE_SCHEMA_VERSION, + agent: "openclaw", + agentConfig: { + agent: "openclaw", + webSearch: { enabled: false, provider: "brave" }, + otel: { + enabled: true, + endpointUrl: "http://host.openshell.internal:4318", + serviceName: "openclaw-gateway", + sampleRate: 0.75, + }, + agentTimeoutSeconds: 900, + heartbeatEvery: "30m", + extraAgents: { agents: [], defaults: {}, main: {} }, + deviceAuth: { disabled: true, optOutSource: "managed-onboard" }, + minimalBootstrap: true, + }, + inference: { + routeProvider: "inference", + upstreamProvider: "nvidia", + model: "nvidia/nemotron-3-super-120b-a12b", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: null, + api: "openai-responses", + primaryModelRef: "inference/nvidia/nemotron-3-super-120b-a12b", + compatibility: null, + inputModalities: ["text"], + }, + proxy: { + managedHost: "10.200.0.1", + managedPort: 3128, + hostHttpUrl: null, + hostHttpsUrl: null, + hostNoProxy: ["inference.local", "localhost"], + }, + dashboard: { + agent: "openclaw", + mode: "loopback", + url: "http://127.0.0.1:18789", + port: 18_789, + bindAddress: "127.0.0.1", + wslExposure: false, + }, + tools: { disclosure: "progressive", enabledGateways: [] }, + messaging: { + plan: { schemaVersion: 1, agent: "openclaw", agentRender: [] }, + }, + tuning: { + contextWindow: 131_072, + maxTokens: 8192, + reasoning: true, + reasoningEffort: "high", + }, + corporateCa: { bundleSha256: null }, +} as const; + +function profileWithRender(render: Record): unknown { + return { + ...BASE_OPENCLAW_PROFILE, + messaging: { + plan: { + ...BASE_OPENCLAW_PROFILE.messaging.plan, + agentRender: [render], + }, + }, + }; +} + +describe("managed startup profile Microsoft Teams webhook", () => { + it.each([1, 3978, 65_535])( + "accepts a stock Microsoft Teams OpenClaw webhook on port %i (#9610)", + (port) => { + expect(() => + validateManagedStartupProfile( + profileWithRender({ + ...TEAMS_OPENCLAW_RENDER, + value: { + ...TEAMS_OPENCLAW_RENDER.value, + webhook: { port, path: "/api/messages" }, + }, + }), + ), + ).not.toThrow(); + }, + ); + + it.each([ + ["a zero port", { port: 0, path: "/api/messages" }], + ["a port above 65535", { port: 65_536, path: "/api/messages" }], + ["a fractional port", { port: 3978.5, path: "/api/messages" }], + ["a string port", { port: "3978", path: "/api/messages" }], + ["a missing port", { path: "/api/messages" }], + ["a missing path", { port: 3978 }], + ["another path", { port: 3978, path: "/messages" }], + ["a string value", "http://127.0.0.1:3978/api/messages"], + ["a null value", null], + ["an array value", [3978, "/api/messages"]], + ["an extra field", { port: 3978, path: "/api/messages", enabled: true }], + ["credential material", { port: 3978, path: "/api/messages", token: `ghp_${"a".repeat(32)}` }], + ])("rejects a Microsoft Teams OpenClaw webhook with %s (#9610)", (_label, webhook) => { + expect(() => + validateManagedStartupProfile( + profileWithRender({ + ...TEAMS_OPENCLAW_RENDER, + value: { ...TEAMS_OPENCLAW_RENDER.value, webhook }, + }), + ), + ).toThrow(/credential-shaped/); + }); + + it.each([ + ["channel", { channelId: "slack" }], + ["render", { renderId: "other-render" }], + ["hook", { hookId: "other-hook" }], + ["handler", { handler: "other.handler" }], + ["agent", { agent: "hermes" }], + ["target", { target: "~/.openclaw/openclaw.json" }], + ["render kind", { kind: "env-lines" }], + ["configuration path", { path: "channels.other" }], + ])( + "rejects a webhook when its Microsoft Teams OpenClaw %s differs (#9610)", + (_label, override) => { + expect(() => + validateManagedStartupProfile(profileWithRender({ ...TEAMS_OPENCLAW_RENDER, ...override })), + ).toThrow(/credential-shaped/); + }, + ); + + it("rejects the stock webhook object outside its owned render value (#9610)", () => { + expect(() => + validateManagedStartupProfile({ + ...BASE_OPENCLAW_PROFILE, + inference: { + ...BASE_OPENCLAW_PROFILE.inference, + compatibility: { webhook: TEAMS_OPENCLAW_RENDER.value.webhook }, + }, + }), + ).toThrow(/credential-shaped/); + }); +}); diff --git a/src/lib/onboard/managed-startup/profile.ts b/src/lib/onboard/managed-startup/profile.ts index 1dddc7b3187..79185126291 100644 --- a/src/lib/onboard/managed-startup/profile.ts +++ b/src/lib/onboard/managed-startup/profile.ts @@ -1097,6 +1097,66 @@ function ownDataPropertyValue(value: Record, key: string): unkn return descriptor && "value" in descriptor ? descriptor.value : undefined; } +function isStockTeamsOpenClawWebhook( + root: unknown, + path: readonly string[], + value: unknown, +): boolean { + if ( + path.length !== 6 || + path[0] !== "messaging" || + path[1] !== "plan" || + path[2] !== "agentRender" || + !JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "") || + path[4] !== "value" || + path[5] !== "webhook" || + !isPlainObject(root) || + ownDataPropertyValue(root, "agent") !== "openclaw" + ) { + return false; + } + + const messaging = ownDataPropertyValue(root, "messaging"); + if (!isPlainObject(messaging)) return false; + const plan = ownDataPropertyValue(messaging, "plan"); + if (!isPlainObject(plan) || ownDataPropertyValue(plan, "agent") !== "openclaw") return false; + const agentRender = ownDataPropertyValue(plan, "agentRender"); + if (!Array.isArray(agentRender)) return false; + const entryIndex = (path[3] as string).slice(1, -1); + const entryDescriptor = Object.getOwnPropertyDescriptor(agentRender, entryIndex); + const entry = entryDescriptor && "value" in entryDescriptor ? entryDescriptor.value : undefined; + if (!isPlainObject(entry)) return false; + + const renderValue = ownDataPropertyValue(entry, "value"); + if (!isPlainObject(renderValue) || ownDataPropertyValue(renderValue, "webhook") !== value) { + return false; + } + if ( + ownDataPropertyValue(entry, "channelId") !== "teams" || + ownDataPropertyValue(entry, "renderId") !== "teams-openclaw-channel" || + ownDataPropertyValue(entry, "hookId") !== "teams-openclaw-channel" || + ownDataPropertyValue(entry, "handler") !== "common.staticOutputs" || + ownDataPropertyValue(entry, "kind") !== "json-fragment" || + ownDataPropertyValue(entry, "agent") !== "openclaw" || + ownDataPropertyValue(entry, "target") !== "openclaw.json" || + ownDataPropertyValue(entry, "path") !== "channels.msteams" || + !isPlainObject(value) + ) { + return false; + } + + const keys = Object.getOwnPropertyNames(value); + if (keys.length !== 2 || !keys.includes("port") || !keys.includes("path")) return false; + const port = ownDataPropertyValue(value, "port"); + return ( + typeof port === "number" && + Number.isInteger(port) && + port >= 1 && + port <= 65_535 && + ownDataPropertyValue(value, "path") === "/api/messages" + ); +} + function isCanonicalMessagingRuntimeEnvAlias( path: readonly string[], value: Record, @@ -1627,7 +1687,8 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void { child, allowedBuildStepPlaceholders, ) && - !isMessagingPackagePin([...current.path, key], child) + !isMessagingPackagePin([...current.path, key], child) && + !isStockTeamsOpenClawWebhook(root, [...current.path, key], child) ) { invalid( `payload field ${payloadPath([...current.path, key])} has a credential-shaped field name`,