Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
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/);
});
});
80 changes: 76 additions & 4 deletions src/lib/onboard/managed-startup/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,11 @@ function valueLooksLikeSecret(value: string): boolean {
return false;
}

function isMessagingCredentialPlaceholder(path: readonly string[], value: unknown): boolean {
function isMessagingCredentialPlaceholder(
path: readonly string[],
value: unknown,
allowedWechatAccountBuildStepIndexes: ReadonlySet<string>,
): boolean {
if (typeof value !== "string" || !MESSAGING_CREDENTIAL_PLACEHOLDER_RE.test(value)) {
return false;
}
Expand All @@ -1011,7 +1015,63 @@ function isMessagingCredentialPlaceholder(path: readonly string[], value: unknow
path[2] === "agentRender" &&
JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "") &&
path[4] === "value";
return isCredentialBindingPlaceholder || isAgentRenderValuePlaceholder;
const isWechatAccountTokenPlaceholder =
path.length === 7 &&
path[0] === "messaging" &&
path[1] === "plan" &&
path[2] === "buildSteps" &&
allowedWechatAccountBuildStepIndexes.has(path[3] ?? "") &&
path[4] === "value" &&
path[5] === "content" &&
path[6] === "token" &&
messagingCredentialPlaceholderEnvKey(value) === "WECHAT_BOT_TOKEN";
return (
isCredentialBindingPlaceholder ||
isAgentRenderValuePlaceholder ||
isWechatAccountTokenPlaceholder
);
}

function isCanonicalWechatAccountBuildFilePath(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.length > 0 &&
accountId === accountId.trim() &&
accountId !== "." &&
accountId !== ".." &&
!/[\\/\0-\x1F\x7F]/u.test(accountId) &&
!accountId.includes("..")
);
}

function isCanonicalWechatAccountBuildStep(
path: readonly string[],
value: Record<string, unknown>,
): boolean {
if (
path.length !== 4 ||
path[0] !== "messaging" ||
path[1] !== "plan" ||
path[2] !== "buildSteps" ||
!JSON_ARRAY_INDEX_SEGMENT_RE.test(path[3] ?? "")
) {
return false;
}
const buildValue = ownDataPropertyValue(value, "value");
return (
ownDataPropertyValue(value, "channelId") === "wechat" &&
ownDataPropertyValue(value, "kind") === "build-file" &&
ownDataPropertyValue(value, "hookId") === "wechat-seed-openclaw-account" &&
ownDataPropertyValue(value, "handler") === "wechat.seedOpenClawAccount" &&
ownDataPropertyValue(value, "outputId") === "openclawWeixinAccountFile" &&
ownDataPropertyValue(value, "required") === true &&
isPlainObject(buildValue) &&
isCanonicalWechatAccountBuildFilePath(ownDataPropertyValue(buildValue, "path"))
);
}

function messagingCredentialPlaceholderEnvKey(value: string): string | null {
Expand Down Expand Up @@ -1448,6 +1508,7 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void {
path: readonly string[];
}> = [{ value: root, depth: 0, path: [] }];
const allowedRuntimeAliasIndexes = new Set<string>();
const allowedWechatAccountBuildStepIndexes = new Set<string>();
let discoveredNodes = 1;
let observedBytes = 0;

Expand Down Expand Up @@ -1476,7 +1537,11 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void {
observeText(current.value);
if (
!isAllowedMessagingRuntimeAliasStringPath(current.path, allowedRuntimeAliasIndexes) &&
!isMessagingCredentialPlaceholder(current.path, current.value) &&
!isMessagingCredentialPlaceholder(
current.path,
current.value,
allowedWechatAccountBuildStepIndexes,
) &&
!isMessagingCredentialPlaceholderAssignment(current.path, current.value) &&
(valueLooksLikeSecret(current.value) ||
containsMessagingCredentialPlaceholder(current.value))
Expand Down Expand Up @@ -1538,6 +1603,9 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void {
if (isCanonicalMessagingRuntimeEnvAlias(current.path, current.value)) {
allowedRuntimeAliasIndexes.add(current.path[4] as string);
}
if (isCanonicalWechatAccountBuildStep(current.path, current.value)) {
allowedWechatAccountBuildStepIndexes.add(current.path[3] as string);
}
const keys = Object.getOwnPropertyNames(current.value);
if (
Object.getOwnPropertySymbols(current.value).length > 0 ||
Expand Down Expand Up @@ -1567,7 +1635,11 @@ function assertPayloadStructureAndCredentialShapes(root: unknown): void {
const child = descriptor.value;
if (
isCredentialShapedName(key) &&
!isMessagingCredentialPlaceholder([...current.path, key], child) &&
!isMessagingCredentialPlaceholder(
[...current.path, key],
child,
allowedWechatAccountBuildStepIndexes,
) &&
!isMessagingPackagePin([...current.path, key], child)
) {
invalid(
Expand Down
Loading