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
176 changes: 176 additions & 0 deletions src/lib/onboard/managed-startup-profile-teams-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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/);
});
});
63 changes: 62 additions & 1 deletion src/lib/onboard/managed-startup/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,66 @@ function ownDataPropertyValue(value: Record<string, unknown>, 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<string, unknown>,
Expand Down Expand Up @@ -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`,
Expand Down
Loading