Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
70 changes: 63 additions & 7 deletions src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
import { normalizeWechatIlinkBaseUrl } from "../ilink-base-url";

export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount";
export const WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID = "wechat-seed-openclaw-account";
export const WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID = "openclawWeixinAccountFile";
export const WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN";
export const WECHAT_PLUGIN_ID = "openclaw-weixin";
export const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin";
Expand Down Expand Up @@ -61,10 +63,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 +111,71 @@ export function buildWechatSeedOpenClawAccountOutputs(
};
}

function assertSafeWechatAccountId(accountId: string): void {
export interface WechatManagedStartupPlaceholderAuthorization {
readonly path: readonly string[];
readonly value: string;
}

export function authorizeWechatManagedStartupPlaceholders(
step: unknown,
): readonly WechatManagedStartupPlaceholderAuthorization[] {
if (!isPlainDataObject(step)) return [];
const value = ownDataPropertyValue(step, "value");
if (
accountId === "." ||
accountId === ".." ||
/[\\/\0-\x1F\x7F]/.test(accountId) ||
accountId.includes("..")
ownDataPropertyValue(step, "channelId") !== "wechat" ||
ownDataPropertyValue(step, "kind") !== "build-file" ||
ownDataPropertyValue(step, "hookId") !== WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID ||
ownDataPropertyValue(step, "handler") !== WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID ||
ownDataPropertyValue(step, "outputId") !== WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID ||
ownDataPropertyValue(step, "required") !== true ||
!isPlainDataObject(value) ||
!isWechatAccountFilePath(ownDataPropertyValue(value, "path"))
) {
return [];
}
return [{ path: ["value", "content", "token"], value: WECHAT_TOKEN_PLACEHOLDER }];
}

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

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 assertSafeWechatAccountId(accountId: string): void {
if (!isSafeWechatAccountId(accountId)) {
throw new Error("WeChat account id contains unsafe filename characters.");
}
}

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;
}

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

import type { ChannelManifest } from "../../manifest";
import {
WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID,
} from "./hooks/seed-openclaw-account";

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

import {
authorizeWechatManagedStartupPlaceholders,
type WechatManagedStartupPlaceholderAuthorization,
} from "./channels/wechat/hooks/seed-openclaw-account";

export type MessagingManagedStartupPlaceholderAuthorization =
WechatManagedStartupPlaceholderAuthorization;

const BUILD_STEP_AUTHORIZERS = [authorizeWechatManagedStartupPlaceholders] as const;

export function authorizeMessagingManagedStartupPlaceholders(
step: unknown,
): readonly MessagingManagedStartupPlaceholderAuthorization[] {
for (const authorize of BUILD_STEP_AUTHORIZERS) {
const authorizations = authorize(step);
if (authorizations.length > 0) return authorizations;
}
return [];
}
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