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

/** Stable manifest identifiers shared with the separately registered hook implementation. */
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";
72 changes: 62 additions & 10 deletions src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,23 @@
import type {
MessagingHookHandler,
MessagingHookInputMap,
MessagingManagedStartupPlaceholderAuthorization,
MessagingHookOutputMap,
MessagingHookRegistration,
} from "../../../hooks/types";
import { normalizeWechatIlinkBaseUrl } from "../ilink-base-url";
import {
WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID,
} 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 {
WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID,
WECHAT_SEED_OPENCLAW_ACCOUNT_PLAN_HOOK_ID,
} from "../contract.ts";

export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount";
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 All @@ -34,6 +45,9 @@
return {
id: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID,
handler: createWechatSeedOpenClawAccountHook(options),
managedStartupPlaceholderAuthorizers: {
[WECHAT_OPENCLAW_ACCOUNT_FILE_OUTPUT_ID]: authorizeWechatAccountFilePlaceholders,
},
};
}

Expand Down Expand Up @@ -61,10 +75,10 @@
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 +123,55 @@
};
}

function authorizeWechatAccountFilePlaceholders(
value: unknown,
): readonly MessagingManagedStartupPlaceholderAuthorization[] {
if (!isPlainDataObject(value) || !isWechatAccountFilePath(ownDataPropertyValue(value, "path"))) {
return [];
}
return [{ path: ["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 (
accountId === "." ||
accountId === ".." ||
/[\\/\0-\x1F\x7F]/.test(accountId) ||
accountId.includes("..")
) {
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 "./contract.ts";

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
30 changes: 30 additions & 0 deletions src/lib/messaging/hooks/hook-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,36 @@ describe("MessagingHookRegistry", () => {
).toThrow("Duplicate messaging hook handler id 'wechat.ilinkLogin'");
});

it("keeps managed startup placeholder authorization on the registered hook output", () => {
const registry = new MessagingHookRegistry([
{
id: "wechat.seedOpenClawAccount",
handler: () => ({}),
managedStartupPlaceholderAuthorizers: {
accountFile: (value) =>
value === "canonical"
? [{ path: ["content", "token"], value: "openshell:resolve:env:BOT_TOKEN" }]
: [],
},
},
]);

expect(
registry.authorizeManagedStartupPlaceholders(
"wechat.seedOpenClawAccount",
"accountFile",
"canonical",
),
).toEqual([{ path: ["content", "token"], value: "openshell:resolve:env:BOT_TOKEN" }]);
expect(
registry.authorizeManagedStartupPlaceholders(
"wechat.seedOpenClawAccount",
"anotherOutput",
"canonical",
),
).toEqual([]);
});

it("reports missing handlers deterministically", async () => {
await expect(
runMessagingHook(HOST_QR_HOOK, new MessagingHookRegistry(), {
Expand Down
31 changes: 29 additions & 2 deletions src/lib/messaging/hooks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,43 @@ import type {
MessagingHookHandler,
MessagingHookHandlerId,
MessagingHookRegistration,
MessagingManagedStartupPlaceholderAuthorization,
MessagingManagedStartupPlaceholderAuthorizer,
} from "./types";

/** In-memory lookup table for manifest hook handler ids. */
export class MessagingHookRegistry {
private readonly handlers = new Map<MessagingHookHandlerId, MessagingHookHandler>();
private readonly managedStartupPlaceholderAuthorizers = new Map<
MessagingHookHandlerId,
Readonly<Record<string, MessagingManagedStartupPlaceholderAuthorizer>>
>();

constructor(registrations: readonly MessagingHookRegistration[] = []) {
for (const registration of registrations) {
this.register(registration.id, registration.handler);
this.register(
registration.id,
registration.handler,
registration.managedStartupPlaceholderAuthorizers,
);
}
}

register(id: MessagingHookHandlerId, handler: MessagingHookHandler): this {
register(
id: MessagingHookHandlerId,
handler: MessagingHookHandler,
placeholderAuthorizers: Readonly<
Record<string, MessagingManagedStartupPlaceholderAuthorizer>
> = {},
): this {
if (this.handlers.has(id)) {
throw new Error(`Duplicate messaging hook handler id '${id}'`);
}

this.handlers.set(id, handler);
if (Object.keys(placeholderAuthorizers).length > 0) {
this.managedStartupPlaceholderAuthorizers.set(id, placeholderAuthorizers);
}
return this;
}

Expand All @@ -41,6 +60,14 @@ export class MessagingHookRegistry {
listIds(): MessagingHookHandlerId[] {
return Array.from(this.handlers.keys());
}

authorizeManagedStartupPlaceholders(
handlerId: MessagingHookHandlerId,
outputId: string,
value: unknown,
): readonly MessagingManagedStartupPlaceholderAuthorization[] {
return this.managedStartupPlaceholderAuthorizers.get(handlerId)?.[outputId]?.(value) ?? [];
}
}

export function createMessagingHookRegistry(
Expand Down
14 changes: 14 additions & 0 deletions src/lib/messaging/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,24 @@ export type MessagingHookHandler = (
context: MessagingHookContext,
) => MessagingHookResult | Promise<MessagingHookResult>;

/** One credential placeholder path authorized inside a declared hook output value. */
export interface MessagingManagedStartupPlaceholderAuthorization {
readonly path: readonly string[];
readonly value: string;
}

/** Channel-owned validation for credential placeholders inside one hook output. */
export type MessagingManagedStartupPlaceholderAuthorizer = (
value: unknown,
) => readonly MessagingManagedStartupPlaceholderAuthorization[];

/** Constructor entry used to seed a hook registry in tests or later bootstraps. */
export interface MessagingHookRegistration {
readonly id: MessagingHookHandlerId;
readonly handler: MessagingHookHandler;
readonly managedStartupPlaceholderAuthorizers?: Readonly<
Record<string, MessagingManagedStartupPlaceholderAuthorizer>
>;
}

/** Serializable runner result for a completed hook. */
Expand Down
53 changes: 53 additions & 0 deletions src/lib/messaging/managed-startup-placeholders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { BUILT_IN_CHANNEL_MANIFESTS } from "./channels/built-ins";
import { BUILT_IN_MESSAGING_HOOK_REGISTRY } from "./hooks/builtins";
import type { MessagingManagedStartupPlaceholderAuthorization } from "./hooks/types";
import type { ChannelManifest } from "./manifest";

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

const manifests: readonly ChannelManifest[] = BUILT_IN_CHANNEL_MANIFESTS;
const manifest = manifests.find((entry) => entry.id === channelId);
const hook = manifest?.hooks.find((entry) => entry.id === hookId && entry.handler === handlerId);
const output = hook?.outputs?.find((entry) => entry.id === outputId);
if (
!hook ||
!output ||
output.kind !== kind ||
(output.required === true) !== required ||
(kind !== "build-arg" && kind !== "build-file" && kind !== "package-install")
) {
return [];
}

return BUILT_IN_MESSAGING_HOOK_REGISTRY.authorizeManagedStartupPlaceholders(
hook.handler,
output.id,
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;
}
Loading
Loading