Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
80 changes: 80 additions & 0 deletions src/lib/messaging/channels/wechat/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Schema-owned identity for the WeChat account-file build output.
*
* The manifest, hook registration, and standalone managed-startup validator all
* consume this dependency-free contract. Keeping it free of imports is required
* because the validator also runs directly under Node's stripped-types loader.
*/
export const WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT = {
channelId: "wechat",
planHookId: "wechat-seed-openclaw-account",
handlerId: "wechat.seedOpenClawAccount",
outputId: "openclawWeixinAccountFile",
kind: "build-file",
required: true,
} as const;

export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId;
export const WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID =
WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId;
export const WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID =
WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId;

export const WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN";

export interface WechatManagedStartupPlaceholderAuthorization {
readonly path: readonly string[];
readonly value: string;
}

export function authorizeWechatAccountFilePlaceholders(
value: unknown,
): readonly WechatManagedStartupPlaceholderAuthorization[] {
if (!isPlainDataObject(value) || !isWechatAccountFilePath(ownDataPropertyValue(value, "path"))) {
return [];
}
return [{ path: ["content", "token"], value: WECHAT_TOKEN_PLACEHOLDER }];
}

export function wechatAccountFilePath(accountId: string): string {
return `openclaw-weixin/accounts/${accountId}.json`;
}

export function assertSafeWechatAccountId(accountId: string): void {
if (!isSafeWechatAccountId(accountId)) {
throw new Error("WeChat account id contains unsafe filename characters.");
}
}

function isWechatAccountFilePath(value: unknown): boolean {
if (typeof value !== "string") return false;
const prefix = "openclaw-weixin/accounts/";
const suffix = ".json";
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return false;
const accountId = value.slice(prefix.length, -suffix.length);
return accountId === accountId.trim() && isSafeWechatAccountId(accountId);
}

function isSafeWechatAccountId(accountId: string): boolean {
return (
accountId.length > 0 &&
accountId !== "." &&
accountId !== ".." &&
!/[\\/\0-\x1F\x7F]/.test(accountId) &&
!accountId.includes("..")
);
}

function isPlainDataObject(value: unknown): value is Record<string, unknown> {
return (
value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype
);
}

function ownDataPropertyValue(value: Record<string, unknown>, key: string): unknown {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && "value" in descriptor ? descriptor.value : undefined;
}
33 changes: 17 additions & 16 deletions src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@ import type {
MessagingHookOutputMap,
MessagingHookRegistration,
} from "../../../hooks/types";
import { normalizeWechatIlinkBaseUrl } from "../ilink-base-url";
import {
assertSafeWechatAccountId,
WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID,
WECHAT_TOKEN_PLACEHOLDER,
wechatAccountFilePath,
} from "../contract.ts";
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
import { normalizeWechatIlinkBaseUrl } from "../ilink-base-url.ts";

export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount";
export const WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN";
export {
WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID,
} from "../contract.ts";

export { WECHAT_TOKEN_PLACEHOLDER } from "../contract.ts";
export const WECHAT_PLUGIN_ID = "openclaw-weixin";
export const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin";

Expand Down Expand Up @@ -61,10 +73,10 @@ export function buildWechatSeedOpenClawAccountOutputs(
content: [accountId],
},
},
openclawWeixinAccountFile: {
[WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID]: {
kind: "build-file",
value: {
path: `openclaw-weixin/accounts/${accountId}.json`,
path: wechatAccountFilePath(accountId),
mode: "0600",
content: {
token,
Expand Down Expand Up @@ -109,17 +121,6 @@ export function buildWechatSeedOpenClawAccountOutputs(
};
}

function assertSafeWechatAccountId(accountId: string): void {
if (
accountId === "." ||
accountId === ".." ||
/[\\/\0-\x1F\x7F]/.test(accountId) ||
accountId.includes("..")
) {
throw new Error("WeChat account id contains unsafe filename characters.");
}
}

function requiredInputString(inputs: MessagingHookInputMap | undefined, key: string): string {
const value = optionalInputString(inputs, key);
if (!value) {
Expand Down
11 changes: 6 additions & 5 deletions src/lib/messaging/channels/wechat/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import type { ChannelManifest } from "../../manifest";
import { WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT } from "./contract.ts";

export const wechatManifest = {
schemaVersion: 1,
Expand Down Expand Up @@ -193,9 +194,9 @@ export const wechatManifest = {
],
},
{
id: "wechat-seed-openclaw-account",
id: WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.planHookId,
phase: "post-agent-install",
handler: "wechat.seedOpenClawAccount",
handler: WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.handlerId,
agents: ["openclaw"],
inputs: [
"wechatConfig.accountId",
Expand All @@ -210,9 +211,9 @@ export const wechatManifest = {
required: true,
},
{
id: "openclawWeixinAccountFile",
kind: "build-file",
required: true,
id: WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.outputId,
kind: WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.kind,
required: WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT.required,
},
{
id: "openclawConfigPatch",
Expand Down
46 changes: 46 additions & 0 deletions src/lib/messaging/managed-startup-placeholders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
authorizeWechatAccountFilePlaceholders,
WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT,
type WechatManagedStartupPlaceholderAuthorization,
} from "./channels/wechat/contract.ts";

export type MessagingManagedStartupPlaceholderAuthorization =
WechatManagedStartupPlaceholderAuthorization;

export function authorizeMessagingManagedStartupPlaceholders(
step: unknown,
): readonly MessagingManagedStartupPlaceholderAuthorization[] {
if (!isPlainDataObject(step)) return [];
const contract = WECHAT_OPENCLAW_ACCOUNT_FILE_CONTRACT;
if (
ownDataPropertyValue(step, "channelId") !== contract.channelId ||
ownDataPropertyValue(step, "hookId") !== contract.planHookId ||
ownDataPropertyValue(step, "handler") !== contract.handlerId ||
ownDataPropertyValue(step, "outputId") !== contract.outputId ||
ownDataPropertyValue(step, "kind") !== contract.kind ||
ownDataPropertyValue(step, "required") !== contract.required
) {
return [];
}

return authorizeWechatAccountFilePlaceholders(ownDataPropertyValue(step, "value")).map(
(authorization) => ({
...authorization,
path: ["value", ...authorization.path],
}),
);
}

function isPlainDataObject(value: unknown): value is Record<string, unknown> {
return (
value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype
);
}

function ownDataPropertyValue(value: Record<string, unknown>, key: string): unknown {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return descriptor && "value" in descriptor ? descriptor.value : undefined;
}
124 changes: 124 additions & 0 deletions src/lib/onboard/managed-startup-runtime-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import { describe, expect, it } from "vitest";
import { managedStartupE2eProfile } from "../../../scripts/checks/generate-managed-startup-profile-fixture.mts";
import { slackManifest } from "../messaging/channels/slack/manifest.ts";
import { wechatManifest } from "../messaging/channels/wechat/manifest.ts";
import { buildWechatSeedOpenClawAccountOutputs } from "../messaging/channels/wechat/hooks/seed-openclaw-account.ts";
import {
type ManagedStartupJsonObject,
type ManagedStartupProfile,
Expand All @@ -30,6 +32,51 @@ function profileWithAliases(aliases: readonly ManagedStartupJsonObject[]): Manag
};
}

function wechatAccountBuildStep(): ManagedStartupJsonObject {
const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-seed-openclaw-account")!;
const output = hook.outputs?.find((entry) => entry.id === "openclawWeixinAccountFile")!;
const result = buildWechatSeedOpenClawAccountOutputs(
{
"wechatConfig.accountId": "wechat-account",
},
{ now: () => "2026-08-18T00:00:00.000Z" },
).openclawWeixinAccountFile!;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {
channelId: wechatManifest.id,
kind: result.kind,
hookId: hook.id,
handler: hook.handler,
outputId: output.id,
required: output.required === true,
value: result.value!,
};
}

function profileWithBuildSteps(
buildSteps: readonly ManagedStartupJsonObject[],
): ManagedStartupProfile {
const profile = managedStartupE2eProfile("openclaw");
return {
...profile,
messaging: {
plan: {
schemaVersion: 1,
agent: "openclaw",
buildSteps,
},
},
};
}

function withWechatAccountToken(
step: ManagedStartupJsonObject,
token: string,
): ManagedStartupJsonObject {
const value = step.value as ManagedStartupJsonObject;
const content = value.content as ManagedStartupJsonObject;
return { ...step, value: { ...value, content: { ...content, token } } };
}

describe("managed startup runtime aliases", () => {
it("accepts the stock Slack runtime aliases (#9397)", () => {
expect(() =>
Expand Down Expand Up @@ -79,3 +126,80 @@ describe("managed startup runtime aliases", () => {
},
);
});

describe("managed startup messaging build files", () => {
it("accepts the stock WeChat account token placeholder (#9397)", () => {
expect(() =>
validateManagedStartupProfile(profileWithBuildSteps([wechatAccountBuildStep()])),
).not.toThrow();
});

it.each([
["a raw token", `wechat-${"a".repeat(32)}`],
["a placeholder for another key", "openshell:resolve:env:SLACK_BOT_TOKEN"],
["a malformed placeholder", "openshell:resolve:env:WECHAT BOT TOKEN"],
])("rejects %s in the WeChat account build file (#9397)", (_label, token) => {
expect(() =>
validateManagedStartupProfile(
profileWithBuildSteps([withWechatAccountToken(wechatAccountBuildStep(), token)]),
),
).toThrow(/credential-shaped/);
});

it("rejects the WeChat token placeholder at another build-file path (#9397)", () => {
const step = wechatAccountBuildStep();
const value = step.value as ManagedStartupJsonObject;
const content = value.content as ManagedStartupJsonObject;
const token = content.token as string;
const { token: _token, ...contentWithoutToken } = content;
expect(() =>
validateManagedStartupProfile(
profileWithBuildSteps([
{
...step,
value: {
...value,
content: contentWithoutToken,
metadata: { token },
},
},
]),
),
).toThrow(/credential-shaped/);
});

it.each([
["another channel", { channelId: "slack" }],
["another build kind", { kind: "build-arg" }],
["another hook", { hookId: "another-hook" }],
["another handler", { handler: "wechat.anotherHandler" }],
["another output", { outputId: "openclawConfigPatch" }],
["an optional output", { required: false }],
])("rejects the WeChat token placeholder in %s (#9397)", (_label, change) => {
expect(() =>
validateManagedStartupProfile(
profileWithBuildSteps([{ ...wechatAccountBuildStep(), ...change }]),
),
).toThrow(/credential-shaped/);
});

it.each([
["an unrelated build file", "unrelated/accounts/wechat-account.json"],
["a parent-traversal account file", "openclaw-weixin/accounts/../other.json"],
["a nested account file", "openclaw-weixin/accounts/a/b.json"],
["a whitespace-prefixed account file", "openclaw-weixin/accounts/ account.json"],
])("rejects the WeChat token placeholder in %s (#9397)", (_label, path) => {
const step = wechatAccountBuildStep();
const value = step.value as ManagedStartupJsonObject;
expect(() =>
validateManagedStartupProfile(
profileWithBuildSteps([
{
...step,
value: { ...value, path },
},
]),
),
).toThrow(/credential-shaped/);
});
});
Loading
Loading