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
30 changes: 15 additions & 15 deletions src/lib/messaging/channels/built-ins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@
// SPDX-License-Identifier: Apache-2.0

import type { ChannelManifestRegistry } from "../manifest";
import { createChannelManifestRegistry } from "../manifest";
import { discordManifest } from "./discord/manifest";
import { googlechatManifest } from "./googlechat/manifest";
import { slackManifest } from "./slack/manifest";
import { teamsManifest } from "./teams/manifest";
import { telegramManifest } from "./telegram/manifest";
import { wechatManifest } from "./wechat/manifest";
import { whatsappManifest } from "./whatsapp/manifest";
import { createChannelManifestRegistry } from "../manifest/registry.ts";
import { discordManifest } from "./discord/manifest.ts";
import { googlechatManifest } from "./googlechat/manifest.ts";
import { slackManifest } from "./slack/manifest.ts";
import { teamsManifest } from "./teams/manifest.ts";
import { telegramManifest } from "./telegram/manifest.ts";
import { wechatManifest } from "./wechat/manifest.ts";
import { whatsappManifest } from "./whatsapp/manifest.ts";

export { discordManifest } from "./discord/manifest";
export { googlechatManifest } from "./googlechat/manifest";
export { slackManifest } from "./slack/manifest";
export { teamsManifest } from "./teams/manifest";
export { telegramManifest } from "./telegram/manifest";
export { wechatManifest } from "./wechat/manifest";
export { whatsappManifest } from "./whatsapp/manifest";
export { discordManifest } from "./discord/manifest.ts";
export { googlechatManifest } from "./googlechat/manifest.ts";
export { slackManifest } from "./slack/manifest.ts";
export { teamsManifest } from "./teams/manifest.ts";
export { telegramManifest } from "./telegram/manifest.ts";
export { wechatManifest } from "./wechat/manifest.ts";
export { whatsappManifest } from "./whatsapp/manifest.ts";

export const BUILT_IN_CHANNEL_MANIFESTS = [
telegramManifest,
Expand Down
29 changes: 29 additions & 0 deletions src/lib/messaging/channels/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
listBuiltInMessagingChannelManifests,
listMessagingChannelsWithoutCredentials,
listMessagingConfigEnvKeys,
listMessagingCredentialEnvAssignments,
listMessagingPackageInstallSpecs,
listMessagingProviderNamesForChannel,
listOpenClawManagedChannelNames,
Expand Down Expand Up @@ -70,6 +71,34 @@ describe("built-in messaging channel metadata", () => {
expect(listMessagingChannelsWithoutCredentials()).toEqual(["whatsapp", "googlechat"]);
});

it("derives credential environment assignments from manifest render metadata", () => {
expect(
listMessagingCredentialEnvAssignments({ agent: "hermes" })
.filter(({ sourceEnvKey, targetEnvKey }) => sourceEnvKey !== targetEnvKey)
.map(({ channelId, sourceEnvKey, targetEnvKey }) => ({
channelId,
sourceEnvKey,
targetEnvKey,
})),
).toEqual([
{
channelId: "wechat",
sourceEnvKey: "WECHAT_BOT_TOKEN",
targetEnvKey: "WEIXIN_TOKEN",
},
{
channelId: "teams",
sourceEnvKey: "MSTEAMS_APP_PASSWORD",
targetEnvKey: "TEAMS_CLIENT_SECRET",
},
]);
expect(
listMessagingCredentialEnvAssignments({ agent: "openclaw" }).filter(
({ channelId }) => channelId === "teams",
),
).toEqual([]);
});

it("resolves config env keys from manifests and compatibility aliases from metadata", () => {
expect(listMessagingConfigEnvKeys()).toEqual([
"TELEGRAM_ALLOWED_IDS",
Expand Down
42 changes: 41 additions & 1 deletion src/lib/messaging/channels/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
ChannelPolicyPresetSpec,
MessagingAgentId,
} from "../manifest";
import { BUILT_IN_CHANNEL_MANIFESTS } from "./built-ins";
import { BUILT_IN_CHANNEL_MANIFESTS } from "./built-ins.ts";

const CONFIG_ENV_ALIASES_BY_ENV_KEY: Readonly<Record<string, readonly string[]>> = {
DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"],
Expand All @@ -35,6 +35,14 @@ export interface MessagingCredentialMetadata {
readonly primary: boolean;
}

export interface MessagingCredentialEnvAssignmentMetadata {
readonly channelId: string;
readonly agent: MessagingAgentId;
readonly sourceEnvKey: string;
readonly targetEnvKey: string;
readonly placeholder: string;
}

export interface MessagingConfigEnvMetadata {
readonly channelId: string;
readonly inputId: string;
Expand Down Expand Up @@ -96,6 +104,38 @@ export function listMessagingCredentialMetadata(
);
}

export function listMessagingCredentialEnvAssignments(
options: MessagingManifestMetadataOptions = {},
): MessagingCredentialEnvAssignmentMetadata[] {
return selectManifests(options).flatMap((manifest) => {
const credentialsByTemplate = new Map(
manifest.credentials.map((credential) => [
`{{credential.${credential.id}.placeholder}}`,
credential,
]),
);
return manifest.render.flatMap((render) => {
if (options.agent && render.agent !== options.agent) return [];
if (render.kind !== "env-lines") return [];
return render.lines.flatMap((line) => {
const separator = line.indexOf("=");
if (separator <= 0) return [];
const credential = credentialsByTemplate.get(line.slice(separator + 1));
if (!credential) return [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return [
{
channelId: manifest.id,
agent: render.agent,
sourceEnvKey: credential.providerEnvKey,
targetEnvKey: line.slice(0, separator),
placeholder: credential.placeholder,
},
];
});
});
});
}

export function getMessagingCredentialEnvKeysByChannel(
options: MessagingManifestMetadataOptions = {},
): Readonly<Record<string, readonly string[]>> {
Expand Down
19 changes: 19 additions & 0 deletions src/lib/messaging/plan-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,25 @@ describe("parseSandboxMessagingPlan", () => {
).toBeNull();
});

it("rejects agent render entries owned by a different plan agent", () => {
expect(
parseSandboxMessagingPlan(
makePlan({
agentRender: [
{
channelId: "telegram",
agent: "hermes",
target: "~/.hermes/.env",
kind: "env-lines",
lines: [],
templateRefs: [],
},
],
}),
),
).toBeNull();
});

it("rejects a lone noncanonical channel id even when related ids are canonical", () => {
const source = makePlan();
expect(
Expand Down
8 changes: 8 additions & 0 deletions src/lib/messaging/plan-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function parseSandboxMessagingPlan(
}
if (
!hasCanonicalChannelReferences(value.credentialBindings) ||
!hasMatchingAgentRenderEntries(value.agentRender, value.agent) ||
!hasCanonicalChannelReferences(value.agentRender) ||
!hasCanonicalChannelReferences(value.buildSteps) ||
!hasCanonicalChannelReferences(value.stateUpdates) ||
Expand All @@ -135,6 +136,13 @@ export function parseSandboxMessagingPlan(
);
}

function hasMatchingAgentRenderEntries(value: unknown, agent: string): boolean {
return (
!Array.isArray(value) ||
value.every((render) => isObjectRecord(render) && render.agent === agent)
);
}

export function cloneSandboxMessagingPlan(plan: SandboxMessagingPlan): SandboxMessagingPlan {
return JSON.parse(JSON.stringify(plan)) as SandboxMessagingPlan;
}
Expand Down
34 changes: 24 additions & 10 deletions src/lib/onboard/managed-startup-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";

import { HERMES_API_PORT_RANGE_END, HERMES_API_PORT_RANGE_START } from "../core/ports";
import { listMessagingCredentialEnvAssignments } from "../messaging/channels/metadata.ts";
import {
decodeManagedStartupProfile,
encodeManagedStartupProfile,
Expand Down Expand Up @@ -731,13 +732,13 @@ describe("managed startup profile", () => {
).toThrow(/credential-shaped field name/);
});

it("accepts schema-owned messaging package pins and credential placeholder lines (#9355)", () => {
it("accepts schema-owned messaging pins, placeholders, and credential environment aliases (#9355)", () => {
expect(() =>
validateManagedStartupProfile({
...OPENCLAW_PROFILE,
...HERMES_PROFILE,
messaging: {
plan: {
...OPENCLAW_PROFILE.messaging.plan,
...HERMES_PROFILE.messaging.plan,
buildSteps: [
{
channelId: "slack",
Expand All @@ -752,7 +753,6 @@ describe("managed startup profile", () => {
},
],
agentRender: [
...OPENCLAW_PROFILE.messaging.plan.agentRender,
{
channelId: "slack",
agent: "hermes",
Expand All @@ -762,6 +762,9 @@ describe("managed startup profile", () => {
"SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN",
"DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN",
"TELEGRAM_BOT_TOKEN=openshell:resolve:env:v1_TELEGRAM_BOT_TOKEN",
...listMessagingCredentialEnvAssignments({ agent: "hermes" })
.filter(({ sourceEnvKey, targetEnvKey }) => sourceEnvKey !== targetEnvKey)
.map(({ targetEnvKey, placeholder }) => `${targetEnvKey}=${placeholder}`),
],
templateRefs: ["credential.slackBotToken.placeholder"],
},
Expand All @@ -773,17 +776,28 @@ describe("managed startup profile", () => {
});

it.each([
...listMessagingCredentialEnvAssignments({ agent: "hermes" })
.filter(({ sourceEnvKey, targetEnvKey }) => sourceEnvKey !== targetEnvKey)
.map(({ targetEnvKey, placeholder }) => [
"a cross-agent credential environment alias",
`${targetEnvKey}=${placeholder}`,
] as const),
["a raw credential", `SLACK_BOT_TOKEN=xoxb-${"a".repeat(32)}`],
["a malformed assignment", "SLACK_BOT_TOKEN =openshell:resolve:env:SLACK_BOT_TOKEN"],
["more than one assignment", "SLACK_BOT_TOKEN=openshell:resolve:env:SLACK_BOT_TOKEN=FORGED"],
[
"a placeholder for a different environment key",
"SLACK_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN",
"a placeholder assigned to a non-credential environment key",
"CHANNEL_NAME=openshell:resolve:env:SLACK_BOT_TOKEN",
],
[
"a versioned placeholder for a different environment key",
"SLACK_BOT_TOKEN=openshell:resolve:env:v1_DISCORD_BOT_TOKEN",
"a placeholder with a noncanonical provider environment key",
"SLACK_BOT_TOKEN=openshell:resolve:env:slack_bot_token",
],
])("rejects %s in messaging environment lines (#9355)", (_label, line) => {
[
"a placeholder assigned to an unapproved credential environment key",
"AWS_SECRET_ACCESS_KEY=openshell:resolve:env:MSTEAMS_APP_PASSWORD",
],
] as const)("rejects %s in messaging environment lines (#9355)", (_label, line) => {
expect(() =>
validateManagedStartupProfile({
...OPENCLAW_PROFILE,
Expand All @@ -793,7 +807,7 @@ describe("managed startup profile", () => {
agentRender: [
{
channelId: "slack",
agent: "hermes",
agent: "openclaw",
target: "~/.hermes/.env",
kind: "env-lines",
lines: [line],
Expand Down
28 changes: 24 additions & 4 deletions src/lib/onboard/managed-startup/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { TextDecoder } from "node:util";
import { listMessagingCredentialEnvAssignments } from "../../messaging/channels/metadata.ts";
import { isValidDcodeUpstreamProvider } from "./dcode-upstream-provider.ts";

/**
Expand Down Expand Up @@ -59,6 +60,11 @@ const NON_SECRET_KEY_METADATA_NAMES = new Set([
]);
const MESSAGING_CREDENTIAL_PLACEHOLDER_RE =
/^(?:openshell:resolve:env:|[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-)(?:v[0-9]+_)?[A-Z][A-Z0-9_]*$/u;
const MESSAGING_CREDENTIAL_ENV_ALIASES = new Set(
listMessagingCredentialEnvAssignments()
.filter(({ sourceEnvKey, targetEnvKey }) => sourceEnvKey !== targetEnvKey)
.map(({ agent, sourceEnvKey, targetEnvKey }) => `${agent}\0${sourceEnvKey}\0${targetEnvKey}`),
);
const JSON_ARRAY_INDEX_SEGMENT_RE = /^\[(?:0|[1-9][0-9]*)\]$/u;
const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
/nvapi-[A-Za-z0-9_-]{10,}/u,
Expand Down Expand Up @@ -1028,6 +1034,7 @@ function containsMessagingCredentialPlaceholder(value: string): boolean {
}

function isMessagingCredentialPlaceholderAssignment(
selectedAgent: unknown,
path: readonly string[],
value: string,
): boolean {
Expand All @@ -1045,8 +1052,17 @@ function isMessagingCredentialPlaceholderAssignment(
const separator = value.indexOf("=");
if (separator <= 0 || value.indexOf("=", separator + 1) !== -1) return false;
const envKey = value.slice(0, separator);
const placeholderEnvKey = messagingCredentialPlaceholderEnvKey(value.slice(separator + 1));
return CREDENTIAL_ENV_NAME_PATTERN.test(envKey) && envKey === placeholderEnvKey;
const placeholder = value.slice(separator + 1);
const placeholderEnvKey = messagingCredentialPlaceholderEnvKey(placeholder);
return (
CREDENTIAL_ENV_NAME_PATTERN.test(envKey) &&
placeholderEnvKey !== null &&
(envKey === placeholderEnvKey ||
(typeof selectedAgent === "string" &&
MESSAGING_CREDENTIAL_ENV_ALIASES.has(
`${selectedAgent}\0${placeholderEnvKey}\0${envKey}`,
)))
);
}

function isMessagingRuntimeEnvAliasPath(path: readonly string[]): boolean {
Expand Down Expand Up @@ -1448,6 +1464,7 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void {
path: readonly string[];
}> = [{ value: root, depth: 0, path: [] }];
const allowedRuntimeAliasIndexes = new Set<string>();
const selectedAgent = isPlainObject(root) ? ownDataPropertyValue(root, "agent") : undefined;
let discoveredNodes = 1;
let observedBytes = 0;

Expand Down Expand Up @@ -1477,7 +1494,11 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void {
if (
!isAllowedMessagingRuntimeAliasStringPath(current.path, allowedRuntimeAliasIndexes) &&
!isMessagingCredentialPlaceholder(current.path, current.value) &&
!isMessagingCredentialPlaceholderAssignment(current.path, current.value) &&
!isMessagingCredentialPlaceholderAssignment(
selectedAgent,
current.path,
current.value,
) &&
(valueLooksLikeSecret(current.value) ||
containsMessagingCredentialPlaceholder(current.value))
) {
Expand Down Expand Up @@ -2039,7 +2060,6 @@ export function validateManagedStartupProfile(value: unknown): ManagedStartupPro
) {
invalid("messaging.plan must be a version 1 plan for the selected agent");
}

const corporateCa = requireRecord(profile.corporateCa, "corporateCa");
rejectUnknownKeys(corporateCa, CORPORATE_CA_KEYS, "corporateCa");
const bundleSha256 = corporateCa.bundleSha256;
Expand Down
Loading