@@ -1785,10 +1847,15 @@ const capsRestarting = ref(false);
const sandboxRestarting = ref(false);
type WindowsNodeMxcStatus = Awaited>;
type WindowsNodeMxcMode = WindowsNodeMxcStatus["requestedMode"];
+type WeixinRemoteApprovalStatus = Awaited<
+ ReturnType
+>;
type WindowsNodeMxcAmbientEntry = NonNullable<
WindowsNodeMxcStatus["ambientAccess"]
>["entries"][number];
const windowsNodeMxcStatus = ref(null);
+const weixinRemoteApprovalStatus = ref(null);
+const weixinRemoteApprovalApplying = ref(false);
const windowsNodeMxcApplying = ref(false);
const windowsNodeMxcFolderApplying = ref(false);
const windowsNodeMxcTechnicalDetailsOpen = ref(false);
@@ -2010,6 +2077,44 @@ async function loadWindowsNodeMxcStatus() {
}
}
+async function loadWeixinRemoteApprovalStatus() {
+ try {
+ weixinRemoteApprovalStatus.value =
+ await window.openclaw.plugin.weixin.getRemoteApprovalStatus();
+ } catch (error) {
+ ElMessage.error(error instanceof Error ? error.message : String(error));
+ }
+}
+
+async function setWeixinRemoteApprovalEnabled(enabled: boolean) {
+ if (weixinRemoteApprovalApplying.value) return;
+ weixinRemoteApprovalApplying.value = true;
+ try {
+ const result = await window.openclaw.plugin.weixin.setRemoteApprovalEnabled(enabled);
+ if (!result.ok && !result.cancelled) {
+ throw new Error(result.error || t("settings.weixinRemoteApprovalFailed"));
+ }
+ await loadWeixinRemoteApprovalStatus();
+ } catch (error) {
+ ElMessage.error(error instanceof Error ? error.message : String(error));
+ } finally {
+ weixinRemoteApprovalApplying.value = false;
+ }
+}
+
+async function revokeWeixinRemoteApproval() {
+ if (weixinRemoteApprovalApplying.value) return;
+ weixinRemoteApprovalApplying.value = true;
+ try {
+ await window.openclaw.plugin.weixin.revokeRemoteApproval();
+ await loadWeixinRemoteApprovalStatus();
+ } catch (error) {
+ ElMessage.error(error instanceof Error ? error.message : String(error));
+ } finally {
+ weixinRemoteApprovalApplying.value = false;
+ }
+}
+
function selectWindowsNodeMxcMode(mode: WindowsNodeMxcMode) {
if (
windowsNodeMxcApplying.value ||
@@ -2847,7 +2952,11 @@ watch(activeSection, (v) => {
void refreshModelsConfig();
}
if (v === "security") {
- void Promise.all([loadSandboxStatus(), loadWindowsNodeMxcStatus()]);
+ void Promise.all([
+ loadSandboxStatus(),
+ loadWindowsNodeMxcStatus(),
+ loadWeixinRemoteApprovalStatus(),
+ ]);
}
});
@@ -2928,7 +3037,7 @@ function handleSettingsWindowFocus(): void {
if (activeSection.value === "models" && !showProviderSetup.value) {
void refreshModelsConfig();
} else if (activeSection.value === "security") {
- void loadWindowsNodeMxcStatus();
+ void Promise.all([loadWindowsNodeMxcStatus(), loadWeixinRemoteApprovalStatus()]);
}
}
@@ -2970,7 +3079,11 @@ onMounted(async () => {
// Load web search provider configuration
loadSearchConfig(config);
if (activeSection.value === "security") {
- await Promise.all([loadSandboxStatus(), loadWindowsNodeMxcStatus()]);
+ await Promise.all([
+ loadSandboxStatus(),
+ loadWindowsNodeMxcStatus(),
+ loadWeixinRemoteApprovalStatus(),
+ ]);
}
});
@@ -4329,6 +4442,31 @@ async function clearChatHistory() {
.mxc-limited-checkbox input {
margin-top: 3px;
}
+.mxc-remote-approval {
+ margin-top: 14px;
+ padding: 14px;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--warning-color, #e6a23c) 6%, transparent);
+}
+.mxc-remote-approval p {
+ margin: 8px 0;
+}
+.mxc-remote-owner {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.mxc-remote-owner code {
+ overflow-wrap: anywhere;
+}
+.mxc-remote-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-top: 10px;
+}
@container (max-width: 760px) {
.mxc-mode-grid {
grid-template-columns: 1fr;
diff --git a/desktop/src/main.ts b/desktop/src/main.ts
index 00659e5..908baa7 100644
--- a/desktop/src/main.ts
+++ b/desktop/src/main.ts
@@ -235,13 +235,29 @@ import {
type BundledWindowsNodeFolder,
} from "./bundled-windows-node-host";
import {
+ clearInheritedWindowsNodeMxcWeixinEnvironment,
getWindowsNodeMxcWeixinEnvironment,
preflightWindowsNodeMxcWeixinAuthorization,
prepareWindowsNodeMxcWeixinContext,
+ readWindowsNodeMxcWeixinAllowedSenders,
releaseWindowsNodeMxcWeixinIngress,
+ resolveStoredWindowsNodeMxcWeixinAccountPath,
revokeWindowsNodeMxcWeixinIngress,
type WindowsNodeMxcWeixinContext,
} from "./windows-node-mxc-weixin";
+import {
+ migrateWeixinRemoteApprovalConfiguration,
+ normalizeWeixinRemoteApprovalOwner,
+ WindowsNodeMxcWeixinRemoteApprovalBroker,
+ type WeixinRemoteApprovalOwnerBinding,
+ type WeixinRemoteApprovalNotice,
+ type WeixinRemoteApprovalSecurityBinding,
+ type WeixinRemoteApprovalSource,
+} from "./windows-node-mxc-weixin-remote-approval";
+import {
+ WindowsNodeMxcWeixinRemoteApprovalServer,
+ type WeixinRemoteApprovalInboundEnvelope,
+} from "./windows-node-mxc-weixin-remote-approval-transport";
/**
* Normalize a directory path for comparison/storage.
@@ -335,6 +351,11 @@ const settingsStore = new Store<{
windowsNodeMxcSmoke?: StoredWindowsNodeMxcSmoke;
/** One-shot durable fence that prevents Cron catch-up when leaving MXC. */
windowsNodeMxcCronResumeFence?: WindowsNodeMxcCronResumeFence;
+ /** Optional, locally confirmed owner binding for experimental WeChat remote approvals. */
+ windowsNodeMxcWeixinRemoteApproval?: {
+ enabled: boolean;
+ owner: WeixinRemoteApprovalOwnerBinding | null;
+ };
/** Recoverable policy state retained across a failed apply/reactivate transaction. */
windowsNodeMxcFolderPolicyRecovery?: {
previous: WindowsNodeMxcFolderPolicy;
@@ -525,6 +546,24 @@ let windowsNodeMxcCurrentAmbientAccess: BundledWindowsNodeAmbientAccess | null =
let windowsNodeMxcCurrentEffectiveTier: string | null = null;
let windowsNodeMxcIngressGeneration: string | null = null;
let windowsNodeMxcWeixinContext: WindowsNodeMxcWeixinContext | null = null;
+const windowsNodeMxcWeixinRemoteApprovalBroker = new WindowsNodeMxcWeixinRemoteApprovalBroker(
+ (event) => {
+ try {
+ const auditDir = path.join(app.getPath("userData"), "security-audit");
+ fs.mkdirSync(auditDir, { recursive: true });
+ fs.appendFileSync(
+ path.join(auditDir, "weixin-remote-approval.jsonl"),
+ `${JSON.stringify(event)}\n`,
+ "utf8",
+ );
+ } catch (error) {
+ console.error("[weixin-remote-approval] Could not write audit event:", error);
+ }
+ },
+);
+let windowsNodeMxcWeixinRemoteApprovalServer: WindowsNodeMxcWeixinRemoteApprovalServer | null =
+ null;
+const windowsNodeMxcWeixinRemoteApprovalSources = new Map();
let windowsNodeMxcActivationInProgress = false;
let windowsNodeMxcGatewayStartPolicyOverride: "active" | "locked" | null = null;
let windowsNodeMxcSecurityTransitionInProgress = false;
@@ -547,6 +586,11 @@ let windowsNodeMxcDurableApprovalStore: WindowsNodeMxcDurableApprovalStore | nul
let windowsNodeMxcDurableApprovalOperation: Promise = Promise.resolve();
let windowsNodeMxcApprovalResolutionInProgress = false;
let windowsNodeMxcApprovalResolutionCompletion: Promise | null = null;
+let windowsNodeMxcGatewayApprovalResolutionTarget: {
+ id: string;
+ decision: "allow-once" | "deny";
+ observedDecision: "allow-once" | "deny" | null;
+} | null = null;
const WINDOWS_NODE_MXC_CRON_RESUME_ACK_TIMEOUT_MS = 30_000;
type WindowsNodeMxcFolderPolicyRecovery = {
previous: WindowsNodeMxcFolderPolicy;
@@ -1155,6 +1199,233 @@ function readConfig(): any {
}
}
+function getWindowsNodeMxcWeixinRemoteApprovalConfiguration(): {
+ enabled: boolean;
+ owner: WeixinRemoteApprovalOwnerBinding | null;
+} {
+ const stored = settingsStore.get("windowsNodeMxcWeixinRemoteApproval");
+ const migrated = migrateWeixinRemoteApprovalConfiguration(stored);
+ if (JSON.stringify(stored) !== JSON.stringify(migrated)) {
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", migrated);
+ }
+ const configuration = {
+ enabled:
+ migrated.enabled &&
+ isWindowsNodeMxcDesired() &&
+ isAuthenticatedWeixinRemoteApprovalOwner(migrated.owner),
+ owner: migrated.owner,
+ };
+ try {
+ windowsNodeMxcWeixinRemoteApprovalBroker.configure(configuration);
+ } catch (error) {
+ console.error("[weixin-remote-approval] Invalid stored owner binding:", error);
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", {
+ enabled: false,
+ owner: null,
+ });
+ windowsNodeMxcWeixinRemoteApprovalBroker.configure({ enabled: false, owner: null });
+ return { enabled: false, owner: null };
+ }
+ return configuration;
+}
+
+function readAuthenticatedWeixinOwners(): WeixinRemoteApprovalOwnerBinding[] {
+ const stateDir = getOpenClawStateDir();
+ const root = path.join(stateDir, "openclaw-weixin");
+ const indexPath = path.join(root, "accounts.json");
+ try {
+ const parsed = JSON.parse(fs.readFileSync(indexPath, "utf8"));
+ if (!Array.isArray(parsed)) return [];
+ const owners: WeixinRemoteApprovalOwnerBinding[] = [];
+ for (const rawAccountId of parsed) {
+ if (typeof rawAccountId !== "string" || !rawAccountId.trim()) continue;
+ const accountId = rawAccountId.trim().toLowerCase();
+ const accountPath = resolveStoredWindowsNodeMxcWeixinAccountPath(stateDir, rawAccountId);
+ const account = JSON.parse(fs.readFileSync(accountPath, "utf8")) as {
+ token?: unknown;
+ userId?: unknown;
+ };
+ if (
+ typeof account.token !== "string" ||
+ !account.token.trim() ||
+ typeof account.userId !== "string" ||
+ !account.userId.trim()
+ ) {
+ continue;
+ }
+ const owner = normalizeWeixinRemoteApprovalOwner(accountId, account.userId);
+ const allowedSenders = readWindowsNodeMxcWeixinAllowedSenders({
+ stateDir,
+ accountId,
+ }).map((senderId) => senderId.trim().toLowerCase());
+ if (!allowedSenders.includes(owner.userId)) continue;
+ owners.push(owner);
+ }
+
+ return owners;
+ } catch {
+ return [];
+ }
+}
+
+function isAuthenticatedWeixinRemoteApprovalOwner(
+ owner: WeixinRemoteApprovalOwnerBinding | null,
+): owner is WeixinRemoteApprovalOwnerBinding {
+ return Boolean(
+ owner &&
+ readAuthenticatedWeixinOwners().some(
+ (candidate) => candidate.accountId === owner.accountId && candidate.userId === owner.userId,
+ ),
+ );
+}
+
+function currentWindowsNodeMxcWeixinRemoteApprovalSecurity(): WeixinRemoteApprovalSecurityBinding | null {
+ const proof = windowsNodeMxcApprovalProofContext;
+ const status = bundledWindowsNodeHost.status();
+ const lease = status.activationLease;
+ const requestedMode = getWindowsNodeMxcRequestedMode();
+ if (
+ !proof ||
+ !lease ||
+ requestedMode === "off" ||
+ proof.gatewayGeneration !== gatewayGenerationId ||
+ lease.gatewayGeneration !== gatewayGenerationId ||
+ lease.policyFingerprint !== proof.policyFingerprint
+ ) {
+ return null;
+ }
+ return {
+ gatewayGeneration: gatewayGenerationId,
+ policyFingerprint: proof.policyFingerprint,
+ nodeId: proof.nodeId,
+ activationLeaseHash: createHash("sha256").update(JSON.stringify(lease), "utf8").digest("hex"),
+ requestedMode,
+ effectiveMode: windowsNodeMxcCurrentEffectiveTier ?? status.effectiveTier ?? requestedMode,
+ };
+}
+
+function closeWindowsNodeMxcWeixinRemoteApproval(reason: string): void {
+ const notices = windowsNodeMxcWeixinRemoteApprovalBroker.invalidateAll(reason);
+ windowsNodeMxcWeixinRemoteApprovalSources.clear();
+ const server = windowsNodeMxcWeixinRemoteApprovalServer;
+ windowsNodeMxcWeixinRemoteApprovalServer = null;
+ if (!server) return;
+ if (notices.length === 0) {
+ server.close();
+ return;
+ }
+ void Promise.allSettled(
+ notices.map((notice) => deliverWindowsNodeMxcWeixinRemoteApprovalNotice(server, notice)),
+ ).finally(() => server.close());
+}
+
+async function deliverWindowsNodeMxcWeixinRemoteApprovalNotice(
+ server: WindowsNodeMxcWeixinRemoteApprovalServer,
+ notice: WeixinRemoteApprovalNotice,
+): Promise {
+ await server.deliver({
+ accountId: notice.accountId,
+ to: notice.senderId,
+ text: notice.acknowledgement,
+ });
+}
+
+async function handleWindowsNodeMxcWeixinRemoteApprovalInbound(
+ inbound: WeixinRemoteApprovalInboundEnvelope,
+): Promise<{ consumed: boolean; acknowledgement?: string }> {
+ if (inbound.sourcePhase === "complete") {
+ const existing = windowsNodeMxcWeixinRemoteApprovalSources.get(inbound.sessionKey);
+ if (existing?.messageId === inbound.messageId) {
+ windowsNodeMxcWeixinRemoteApprovalSources.delete(inbound.sessionKey);
+ }
+ return { consumed: false };
+ }
+ const exactCandidate = /^([123]) ([23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8})$/i.test(inbound.text);
+ const candidateLike =
+ inbound.sourcePhase === "candidate" && (exactCandidate || inbound.ambiguous);
+ const configuration = getWindowsNodeMxcWeixinRemoteApprovalConfiguration();
+ if (!configuration.enabled || !configuration.owner) {
+ if (!candidateLike) return { consumed: false };
+ const inactiveResult = windowsNodeMxcWeixinRemoteApprovalBroker.resolveCandidate({
+ ...inbound,
+ ambiguous: inbound.ambiguous,
+ });
+ return {
+ consumed: true,
+ acknowledgement: inactiveResult.consumed ? inactiveResult.acknowledgement : undefined,
+ };
+ }
+ const pending = pendingWindowsNodeMxcGatewayApproval;
+ if (pending && candidateLike) {
+ try {
+ await requireEffectiveWindowsNodeMxc(true, false);
+ } catch {
+ return {
+ consumed: true,
+ acknowledgement: "MicroClaw Security Approval: invalid or no longer active.",
+ };
+ }
+ }
+ const security = currentWindowsNodeMxcWeixinRemoteApprovalSecurity();
+ const candidateResult = windowsNodeMxcWeixinRemoteApprovalBroker.resolveCandidate({
+ ...inbound,
+ ambiguous: inbound.ambiguous,
+ requests: pending ? [{ approval: pending.request }] : undefined,
+ security: pending ? (security ?? undefined) : undefined,
+ });
+ if (candidateResult.consumed) {
+ if (candidateResult.status === "approved" || candidateResult.status === "denied") {
+ try {
+ if (!security) {
+ throw new Error("The remote approval security binding is unavailable");
+ }
+ for (const approvalId of candidateResult.approvalIds) {
+ await resolvePendingWindowsNodeMxcGatewayApproval(
+ approvalId,
+ candidateResult.decision,
+ false,
+ security,
+ );
+ }
+ windowsNodeMxcWeixinRemoteApprovalBroker.completeResolution(
+ candidateResult.challengeId,
+ candidateResult.status,
+ );
+ return { consumed: true, acknowledgement: candidateResult.acknowledgement };
+ } catch (error) {
+ windowsNodeMxcWeixinRemoteApprovalBroker.failResolution(
+ candidateResult.challengeId,
+ "electron-resolution-failed",
+ );
+ console.error("[weixin-remote-approval] Resolution failed closed:", error);
+ return {
+ consumed: true,
+ acknowledgement: "MicroClaw Security Approval: invalid or no longer active.",
+ };
+ }
+ }
+ return { consumed: true, acknowledgement: candidateResult.acknowledgement };
+ }
+ if (
+ inbound.sourcePhase === "start" &&
+ inbound.direct &&
+ !inbound.forwarded &&
+ !inbound.quoted &&
+ inbound.accountId.trim().toLowerCase() === configuration.owner.accountId &&
+ inbound.senderId.trim().toLowerCase() === configuration.owner.userId &&
+ inbound.conversationId.trim().toLowerCase() === configuration.owner.userId &&
+ inbound.sessionKey.trim() &&
+ inbound.agentId.trim()
+ ) {
+ windowsNodeMxcWeixinRemoteApprovalSources.set(inbound.sessionKey, {
+ ...inbound,
+ accountId: inbound.accountId.trim().toLowerCase(),
+ senderId: inbound.senderId.trim().toLowerCase(),
+ });
+ }
+ return { consumed: false };
+}
+
function getWindowsNodeMxcSelection(): WindowsNodeMxcSelection {
const current = settingsStore.get("windowsNodeMxcSelection");
const migrated = migrateWindowsNodeMxcSelection(
@@ -1178,6 +1449,14 @@ function isWindowsNodeMxcDesired(): boolean {
function closeWindowsNodeMxcIngress(): void {
windowsNodeMxcIngressGeneration = null;
revokeWindowsNodeMxcWeixinIngress(windowsNodeMxcWeixinContext);
+ const notices = windowsNodeMxcWeixinRemoteApprovalBroker.invalidateAll("security-relock");
+ windowsNodeMxcWeixinRemoteApprovalSources.clear();
+ const server = windowsNodeMxcWeixinRemoteApprovalServer;
+ if (server) {
+ void Promise.allSettled(
+ notices.map((notice) => deliverWindowsNodeMxcWeixinRemoteApprovalNotice(server, notice)),
+ );
+ }
}
function releaseWindowsNodeMxcIngress(gatewayGeneration: string): void {
@@ -1363,7 +1642,10 @@ function getBundledWindowsNodeFolders(): BundledWindowsNodeFolder[] {
];
}
-async function requireEffectiveWindowsNodeMxc(deferRelock = false): Promise {
+async function requireEffectiveWindowsNodeMxc(
+ deferRelock = false,
+ renewActivationLease = true,
+): Promise {
if (windowsNodeMxcSecurityTransitionInProgress) {
throw new Error("Chat ingress is locked during the security-mode transition");
}
@@ -1402,13 +1684,15 @@ async function requireEffectiveWindowsNodeMxc(deferRelock = false): Promise {
+ const notice = windowsNodeMxcWeixinRemoteApprovalBroker.expireChallenge(
+ delivery.challengeId,
+ );
+ if (notice) {
+ void deliverWindowsNodeMxcWeixinRemoteApprovalNotice(deliveryServer, notice).catch(
+ (error) =>
+ console.error("[weixin-remote-approval] Expiry acknowledgement failed:", error),
+ );
+ }
+ },
+ Math.max(0, delivery.expiresAtUnixMs - Date.now()) + 10,
+ );
+ expiryTimer.unref();
+ void deliveryServer
+ .deliver({
+ accountId: delivery.accountId,
+ to: delivery.senderId,
+ text: delivery.text,
+ })
+ .catch((error) => {
+ console.error(
+ "[weixin-remote-approval] Prompt delivery failed; local approval remains pending:",
+ error,
+ );
+ windowsNodeMxcWeixinRemoteApprovalBroker.invalidateChallenge(
+ delivery.challengeId,
+ "prompt-send-failed",
+ );
+ });
+ }
+ }
}
function getPendingWindowsNodeMxcApprovalForRenderer() {
@@ -1522,6 +1859,113 @@ function getPendingWindowsNodeMxcApprovalForRenderer() {
: null;
}
+async function resolvePendingWindowsNodeMxcGatewayApproval(
+ requestId: string,
+ decision: WindowsNodeMxcApprovalDecision,
+ allowDurable: boolean,
+ expectedRemoteSecurity?: WeixinRemoteApprovalSecurityBinding,
+): Promise {
+ if (decision === "allow-always" && !allowDurable) {
+ throw new Error("Remote approvals are one-use only");
+ }
+ if (decision === "allow-always" && getWindowsNodeMxcRequestedMode() === "limited-dacl") {
+ throw new Error("Durable command approvals are disabled under Limited filesystem containment");
+ }
+ const gatewayApproval = pendingWindowsNodeMxcGatewayApproval;
+ if (gatewayApproval?.request.id !== requestId) {
+ throw new Error("The Gateway approval is no longer pending");
+ }
+ await withWindowsNodeMxcApprovalResolution(async () => {
+ const approvalGeneration = gatewayApproval.gatewayGeneration;
+ const proofContext = windowsNodeMxcApprovalProofContext;
+ const client = gwClient;
+ if (approvalGeneration !== gatewayGenerationId || !proofContext) {
+ throw new Error("The Gateway approval belongs to a stale security generation");
+ }
+ if (!gatewayApproval.request.allowedDecisions.includes(decision)) {
+ throw new Error("The requested Gateway approval decision is unavailable");
+ }
+ if (expectedRemoteSecurity) {
+ await requireEffectiveWindowsNodeMxc(true, false);
+ } else if (decision !== "deny") {
+ await requireEffectiveWindowsNodeMxc(true);
+ }
+ if (
+ windowsNodeMxcSecurityTransitionInProgress ||
+ pendingWindowsNodeMxcGatewayApproval !== gatewayApproval ||
+ approvalGeneration !== gatewayGenerationId ||
+ proofContext !== windowsNodeMxcApprovalProofContext ||
+ !isWindowsNodeMxcIngressReleased(
+ true,
+ gatewayGenerationId,
+ windowsNodeMxcIngressGeneration,
+ windowsNodeMxcActivationInProgress,
+ ) ||
+ !client?.connected ||
+ client !== gwClient
+ ) {
+ throw new Error("The Gateway approval became stale before it could be resolved");
+ }
+ if (
+ expectedRemoteSecurity &&
+ JSON.stringify(currentWindowsNodeMxcWeixinRemoteApprovalSecurity()) !==
+ JSON.stringify(expectedRemoteSecurity)
+ ) {
+ throw new Error("The remote approval security binding drifted before resolution");
+ }
+ const durableIdentity =
+ decision === "allow-always"
+ ? durableApprovalIdentityFromGateway(
+ gatewayApproval.request,
+ proofContext.policyFingerprint,
+ )
+ : null;
+ if (decision === "allow-always" && !durableIdentity) {
+ throw new Error("This command is not eligible for an exact durable MXC approval");
+ }
+ const wireDecision = decision === "allow-always" ? "allow-once" : decision;
+ const resolutionTarget = {
+ id: requestId,
+ decision: wireDecision,
+ observedDecision: null as "allow-once" | "deny" | null,
+ };
+ windowsNodeMxcGatewayApprovalResolutionTarget = resolutionTarget;
+ try {
+ try {
+ await client.request("exec.approval.resolve", {
+ id: requestId,
+ decision: wireDecision,
+ });
+ } catch (error) {
+ if (resolutionTarget.observedDecision !== resolutionTarget.decision) throw error;
+ }
+ if (
+ resolutionTarget.observedDecision &&
+ resolutionTarget.observedDecision !== resolutionTarget.decision
+ ) {
+ throw new Error("The Gateway resolved the approval with a different decision");
+ }
+ } finally {
+ if (windowsNodeMxcGatewayApprovalResolutionTarget === resolutionTarget) {
+ windowsNodeMxcGatewayApprovalResolutionTarget = null;
+ }
+ }
+ if (durableIdentity) {
+ await withWindowsNodeMxcDurableApprovalLock(() =>
+ getWindowsNodeMxcDurableApprovalStore().add(durableIdentity),
+ );
+ }
+ pendingWindowsNodeMxcGatewayApproval = null;
+ if (!expectedRemoteSecurity) {
+ windowsNodeMxcWeixinRemoteApprovalBroker.markLocallyResolved(
+ requestId,
+ decision === "deny" ? "denied" : "approved",
+ );
+ }
+ sendToWindow(mainWindow, "windows-node-mxc:approval-request", null);
+ });
+}
+
function assertWindowsNodeMxcConfigurationMutable(): void {
localModelService.assertIdle();
sessionModelService.assertIdle();
@@ -3051,6 +3495,7 @@ async function waitForGatewayReady(
/** Kill the managed gateway and any listener still holding gatewayPort. */
function stopGatewayProcess(): void {
+ closeWindowsNodeMxcWeixinRemoteApproval("gateway-stopped");
stopBundledWindowsNodeHost();
gatewayHttpReadyGeneration = null;
windowsNodeMxcApprovalProofContext = null;
@@ -3164,6 +3609,7 @@ function terminateGatewayProcessTree(pid: number): void {
}
async function stopGatewayForSecurityTransition(port: number): Promise {
+ closeWindowsNodeMxcWeixinRemoteApproval("security-transition");
pendingWindowsNodeMxcGatewayApproval = null;
sendToWindow(mainWindow, "windows-node-mxc:approval-request", null);
stopBundledWindowsNodeHost(true);
@@ -4008,14 +4454,35 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise {
containmentPreparation?.ambientAccess ?? null,
)
: null;
+ const nextRemoteApprovalConfiguration = getWindowsNodeMxcWeixinRemoteApprovalConfiguration();
const nextWeixinContext = nextApprovalProofContext
? prepareWindowsNodeMxcWeixinContext(
app.getPath("userData"),
nextApprovalProofContext.gatewayGeneration,
nextApprovalProofContext.policyFingerprint,
getBundledWindowsNodeFolders(),
+ nextRemoteApprovalConfiguration.enabled,
)
: null;
+ closeWindowsNodeMxcWeixinRemoteApproval("gateway-generation-change");
+ if (
+ nextWeixinContext?.remoteApprovalPipe &&
+ nextWeixinContext.remoteApprovalToken &&
+ nextRemoteApprovalConfiguration.enabled
+ ) {
+ const server = new WindowsNodeMxcWeixinRemoteApprovalServer(
+ nextWeixinContext.remoteApprovalPipe,
+ nextWeixinContext.remoteApprovalToken,
+ handleWindowsNodeMxcWeixinRemoteApprovalInbound,
+ );
+ try {
+ await server.start();
+ windowsNodeMxcWeixinRemoteApprovalServer = server;
+ } catch (error) {
+ server.close();
+ console.error("[weixin-remote-approval] Broker transport failed to start:", error);
+ }
+ }
const nextHooksEnvironment =
nextApprovalProofContext && windowsNodeMxcRequestedMode !== "off"
? getWindowsNodeMxcHooksEnvironment(
@@ -4030,6 +4497,9 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise {
}),
)
: {};
+ const nextWeixinEnvironment = nextWeixinContext
+ ? getWindowsNodeMxcWeixinEnvironment(nextWeixinContext)
+ : {};
const gwEnv: Record = {
...gatewayEnvironment,
OPENCLAW_STATE_DIR: stateDir,
@@ -4042,7 +4512,6 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise {
OPENCLAW_NO_RESPAWN: "1",
// HMAC key for verifying the external apps whitelist file
OPENCLAW_SANDBOX_HMAC_KEY: sandboxHmacKey,
- ...(nextWeixinContext ? getWindowsNodeMxcWeixinEnvironment(nextWeixinContext) : {}),
};
for (const reservedName of [
"MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED",
@@ -4055,6 +4524,8 @@ async function startGatewayInner(startupRetriesRemaining = 1): Promise {
]) {
delete gwEnv[reservedName];
}
+ clearInheritedWindowsNodeMxcWeixinEnvironment(gwEnv);
+ Object.assign(gwEnv, nextWeixinEnvironment);
if (windowsNodeMxcDesired) {
gwEnv.MICROCLAW_MXC_CRON_PAUSED = "1";
gwEnv.MICROCLAW_MXC_CANVAS_PHASE1 = "1";
@@ -5003,11 +5474,25 @@ function connectGatewayWs(): void {
return;
}
if (evt.event === "exec.approval.resolved") {
- const id =
+ const payload =
evt.payload && typeof evt.payload === "object" && !Array.isArray(evt.payload)
- ? (evt.payload as Record).id
+ ? (evt.payload as Record)
+ : null;
+ const id = payload?.id;
+ const decision =
+ payload?.decision === "allow-once" || payload?.decision === "deny"
+ ? payload.decision
: null;
- if (id === pendingWindowsNodeMxcGatewayApproval?.request.id) {
+ if (typeof id === "string" && id === pendingWindowsNodeMxcGatewayApproval?.request.id) {
+ const resolutionTarget = windowsNodeMxcGatewayApprovalResolutionTarget;
+ if (resolutionTarget?.id === id) {
+ if (decision) resolutionTarget.observedDecision = decision;
+ return;
+ }
+ windowsNodeMxcWeixinRemoteApprovalBroker.markLocallyResolved(
+ id,
+ decision === "allow-once" ? "approved" : "denied",
+ );
pendingWindowsNodeMxcGatewayApproval = null;
mainWindow?.webContents.send("windows-node-mxc:approval-request", null);
}
@@ -6123,6 +6608,93 @@ function registerIpcHandlers(): void {
};
});
+ ipcMain.handle("plugin:weixin:remote-approval:get-status", () => {
+ const stored = migrateWeixinRemoteApprovalConfiguration(
+ settingsStore.get("windowsNodeMxcWeixinRemoteApproval"),
+ );
+ const owners = readAuthenticatedWeixinOwners();
+ const effective = getWindowsNodeMxcWeixinRemoteApprovalConfiguration();
+ return {
+ experimental: true,
+ enabled: effective.enabled,
+ configured: stored.owner != null,
+ owner: stored.owner,
+ availableOwners: owners,
+ blockedReason: !isWindowsNodeMxcDesired()
+ ? "mxc-off"
+ : owners.length !== 1
+ ? "owner-ambiguous"
+ : null,
+ };
+ });
+
+ ipcMain.handle("plugin:weixin:remote-approval:set-enabled", async (_event, enabled: boolean) => {
+ if (!enabled) {
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", {
+ enabled: false,
+ owner: settingsStore.get("windowsNodeMxcWeixinRemoteApproval")?.owner ?? null,
+ });
+ closeWindowsNodeMxcWeixinRemoteApproval("feature-disabled");
+ if (gatewaySpawnedByUs) {
+ await restartManagedGateway("Disabling WeChat remote approval");
+ }
+ return { ok: true };
+ }
+ if (!isWindowsNodeMxcDesired()) {
+ return { ok: false, error: "Windows Node + MXC must be enabled first" };
+ }
+ const owners = readAuthenticatedWeixinOwners();
+ if (owners.length !== 1) {
+ return {
+ ok: false,
+ error: "Exactly one authenticated WeChat owner account is required",
+ };
+ }
+ const owner = owners[0];
+ const locale = resolveSupportedLocale(settingsStore.get("language") ?? "en-US");
+ const confirmation = await dialog.showMessageBox(mainWindow!, {
+ type: "warning",
+ title:
+ locale === "zh-CN"
+ ? "启用微信远程审批(实验性)"
+ : "Enable WeChat remote approval (Experimental)",
+ message:
+ locale === "zh-CN"
+ ? `将已登录且位于显式发送者白名单中的微信身份 ${owner.userId} 绑定为远程审批所有者。其当前私聊可批准一次性 MXC 命令;远程设备、微信会话、插件或 Gateway 被控制时可能导致本机命令执行。`
+ : `Bind the authenticated, explicitly allowlisted WeChat identity ${owner.userId} as the remote approval owner. Its current direct chat can approve one-use MXC commands; a compromised remote device, WeChat session, plugin, or Gateway could authorize local command execution.`,
+ detail:
+ locale === "zh-CN"
+ ? "产品将白名单 openclaw-weixin 插件和 Gateway 提供的标准化账户、发送者、私聊、消息及游标元数据视为真实输入,不要求独立的微信服务器签名。不会创建永久授权;错误、过期、转发、群聊或身份不匹配的回复将失败关闭。"
+ : "The product treats normalized account, sender, direct-chat, message, and cursor metadata from the allowlisted openclaw-weixin plugin and Gateway as authentic input; independent WeChat server signatures are not required. No durable approval is created, and wrong, expired, forwarded, group, or identity-mismatched replies fail closed.",
+ buttons: [locale === "zh-CN" ? "启用" : "Enable", locale === "zh-CN" ? "取消" : "Cancel"],
+ defaultId: 1,
+ cancelId: 1,
+ noLink: true,
+ });
+ if (confirmation.response !== 0) return { ok: false, cancelled: true };
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", {
+ enabled: true,
+ owner,
+ });
+ windowsNodeMxcWeixinRemoteApprovalBroker.configure({ enabled: true, owner });
+ if (gatewaySpawnedByUs) {
+ await restartManagedGateway("Applying WeChat remote approval setting");
+ }
+ return { ok: true };
+ });
+
+ ipcMain.handle("plugin:weixin:remote-approval:revoke", async () => {
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", {
+ enabled: false,
+ owner: null,
+ });
+ closeWindowsNodeMxcWeixinRemoteApproval("owner-revoked");
+ if (gatewaySpawnedByUs) {
+ await restartManagedGateway("Revoking WeChat remote approval owner");
+ }
+ return { ok: true };
+ });
+
ipcMain.handle("plugin:weixin:set-enabled", async (_event, enabled: boolean) => {
assertWindowsNodeMxcConfigurationMutable();
const config = readConfig() || {};
@@ -6130,6 +6702,13 @@ function registerIpcHandlers(): void {
if (!config.plugins.entries) config.plugins.entries = {};
if (!config.plugins.entries["openclaw-weixin"]) config.plugins.entries["openclaw-weixin"] = {};
config.plugins.entries["openclaw-weixin"].enabled = enabled;
+ if (!enabled) {
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", {
+ enabled: false,
+ owner: null,
+ });
+ closeWindowsNodeMxcWeixinRemoteApproval("weixin-disabled");
+ }
// Ensure plugins.allow includes openclaw-weixin so the gateway loads it synchronously
if (!config.plugins.allow) config.plugins.allow = [];
if (enabled && !config.plugins.allow.includes("openclaw-weixin")) {
@@ -6277,6 +6856,11 @@ function registerIpcHandlers(): void {
ipcMain.handle("plugin:weixin:disconnect", async (_event, params?: { accountId?: string }) => {
assertWindowsNodeMxcConfigurationMutable();
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", {
+ enabled: false,
+ owner: null,
+ });
+ closeWindowsNodeMxcWeixinRemoteApproval("weixin-logout");
// Remove all account files and restart gateway
try {
const stateDir = getOpenClawStateDir();
@@ -6974,6 +7558,11 @@ function registerIpcHandlers(): void {
settingsStore.set("windowsNodeMxcToolBackups", {});
settingsStore.delete("windowsNodeMxcSmoke");
settingsStore.delete("windowsNodeMxcAmbientAccess");
+ settingsStore.set("windowsNodeMxcWeixinRemoteApproval", {
+ enabled: false,
+ owner: settingsStore.get("windowsNodeMxcWeixinRemoteApproval")?.owner ?? null,
+ });
+ closeWindowsNodeMxcWeixinRemoteApproval("mxc-off");
settingsStore.set("windowsNodeMxcSelection", nextSelection);
settingsStore.set("securityMode", "appcontainer");
windowsNodeMxcCurrentAmbientAccess = null;
@@ -7075,57 +7664,7 @@ function registerIpcHandlers(): void {
}
const gatewayApproval = pendingWindowsNodeMxcGatewayApproval;
if (gatewayApproval?.request.id === requestId) {
- await withWindowsNodeMxcApprovalResolution(async () => {
- const approvalGeneration = gatewayApproval.gatewayGeneration;
- const proofContext = windowsNodeMxcApprovalProofContext;
- const client = gwClient;
- if (approvalGeneration !== gatewayGenerationId || !proofContext) {
- throw new Error("The Gateway approval belongs to a stale security generation");
- }
- if (!gatewayApproval.request.allowedDecisions.includes(normalizedDecision)) {
- throw new Error("The requested Gateway approval decision is unavailable");
- }
- if (normalizedDecision !== "deny") await requireEffectiveWindowsNodeMxc(true);
- if (
- windowsNodeMxcSecurityTransitionInProgress ||
- pendingWindowsNodeMxcGatewayApproval !== gatewayApproval ||
- approvalGeneration !== gatewayGenerationId ||
- proofContext !== windowsNodeMxcApprovalProofContext ||
- !isWindowsNodeMxcIngressReleased(
- true,
- gatewayGenerationId,
- windowsNodeMxcIngressGeneration,
- windowsNodeMxcActivationInProgress,
- ) ||
- !client?.connected ||
- client !== gwClient
- ) {
- throw new Error("The Gateway approval became stale before it could be resolved");
- }
- const durableIdentity =
- normalizedDecision === "allow-always"
- ? durableApprovalIdentityFromGateway(
- gatewayApproval.request,
- proofContext.policyFingerprint,
- )
- : null;
- if (normalizedDecision === "allow-always" && !durableIdentity) {
- throw new Error("This command is not eligible for an exact durable MXC approval");
- }
- await client.request("exec.approval.resolve", {
- id: requestId,
- // MicroClaw owns the exact durable identity. Never create an upstream
- // allow-always entry whose scope is weaker than the prepared node plan.
- decision: normalizedDecision === "allow-always" ? "allow-once" : normalizedDecision,
- });
- if (durableIdentity) {
- await withWindowsNodeMxcDurableApprovalLock(() =>
- getWindowsNodeMxcDurableApprovalStore().add(durableIdentity),
- );
- }
- pendingWindowsNodeMxcGatewayApproval = null;
- sendToWindow(mainWindow, "windows-node-mxc:approval-request", null);
- });
+ await resolvePendingWindowsNodeMxcGatewayApproval(requestId, normalizedDecision, true);
return;
}
if (normalizedDecision === "allow-always") {
diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts
index a006517..00d3aab 100644
--- a/desktop/src/preload.ts
+++ b/desktop/src/preload.ts
@@ -221,6 +221,10 @@ contextBridge.exposeInMainWorld("openclaw", {
ipcRenderer.invoke("plugin:weixin:login-qr-wait", params),
disconnect: (params?: { accountId?: string }) =>
ipcRenderer.invoke("plugin:weixin:disconnect", params),
+ getRemoteApprovalStatus: () => ipcRenderer.invoke("plugin:weixin:remote-approval:get-status"),
+ setRemoteApprovalEnabled: (enabled: boolean) =>
+ ipcRenderer.invoke("plugin:weixin:remote-approval:set-enabled", enabled),
+ revokeRemoteApproval: () => ipcRenderer.invoke("plugin:weixin:remote-approval:revoke"),
onLoginOutput: (callback: (text: string) => void) => {
const handler = (_event: any, text: string) => callback(text);
ipcRenderer.on("plugin:weixin:login-output", handler);
@@ -355,8 +359,7 @@ contextBridge.exposeInMainWorld("openclaw", {
requestedMode?: "off" | "full" | "limited-dacl";
acknowledgeLimitedFilesystem?: boolean;
nodeId?: string;
- }) =>
- ipcRenderer.invoke("windows-node-mxc:set-enabled", params),
+ }) => ipcRenderer.invoke("windows-node-mxc:set-enabled", params),
validateFolderPolicy: (draft: { rw: string[]; ro: string[] }) =>
ipcRenderer.invoke("windows-node-mxc:validate-folder-policy", draft),
applyFolderPolicy: (draft: { rw: string[]; ro: string[] }) =>
diff --git a/desktop/src/windows-node-mxc-weixin-remote-approval-transport.test.ts b/desktop/src/windows-node-mxc-weixin-remote-approval-transport.test.ts
new file mode 100644
index 0000000..622d7bd
--- /dev/null
+++ b/desktop/src/windows-node-mxc-weixin-remote-approval-transport.test.ts
@@ -0,0 +1,173 @@
+import net from "node:net";
+import { randomUUID } from "node:crypto";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { WindowsNodeMxcWeixinRemoteApprovalServer } from "./windows-node-mxc-weixin-remote-approval-transport";
+
+const contract = "microclaw.windows-weixin-remote-approval.v1";
+
+describe("WeChat remote approval transport", () => {
+ const servers: WindowsNodeMxcWeixinRemoteApprovalServer[] = [];
+
+ afterEach(() => {
+ for (const server of servers.splice(0)) server.close();
+ });
+
+ it("exposes only authenticated inbound interception and system delivery", async () => {
+ const pipe = `\\\\.\\pipe\\microclaw-weixin-approval-test-${randomUUID()}`;
+ const server = new WindowsNodeMxcWeixinRemoteApprovalServer(
+ pipe,
+ "secret-token",
+ async (source) => ({
+ consumed: source.text === "1 23456789",
+ acknowledgement: "ack",
+ }),
+ );
+ servers.push(server);
+ await server.start();
+
+ const socket = net.createConnection(pipe);
+ socket.setEncoding("utf8");
+ const frames: Array> = [];
+ let buffer = "";
+ socket.on("data", (chunk) => {
+ buffer += chunk;
+ while (buffer.includes("\n")) {
+ const newline = buffer.indexOf("\n");
+ frames.push(JSON.parse(buffer.slice(0, newline)) as Record);
+ buffer = buffer.slice(newline + 1);
+ }
+ });
+ await new Promise((resolve) => socket.once("connect", resolve));
+ socket.write(
+ `${JSON.stringify({
+ contract,
+ type: "register",
+ token: "secret-token",
+ accountId: "bot@im.wechat",
+ })}\n`,
+ );
+ await waitFor(() => frames.some((frame) => frame.type === "registered"));
+
+ socket.write(
+ `${JSON.stringify({
+ contract,
+ type: "inbound",
+ requestId: "request-1",
+ source: {
+ accountId: "bot@im.wechat",
+ senderId: "owner@im.wechat",
+ conversationId: "owner@im.wechat",
+ messageId: "2",
+ cursor: 2,
+ agentId: "main",
+ sessionKey: "agent:main:weixin:owner",
+ direct: true,
+ forwarded: false,
+ quoted: false,
+ receivedAtUnixMs: 1,
+ text: "1 23456789",
+ ambiguous: false,
+ sourcePhase: "candidate",
+ },
+ })}\n`,
+ );
+ await waitFor(() => frames.some((frame) => frame.type === "inbound-result"));
+ expect(frames.find((frame) => frame.type === "inbound-result")).toMatchObject({
+ requestId: "request-1",
+ consumed: true,
+ acknowledgement: "ack",
+ });
+
+ const delivery = server.deliver({
+ accountId: "bot@im.wechat",
+ to: "owner@im.wechat",
+ text: "[MicroClaw Security Approval #23456789]",
+ });
+ await waitFor(() => frames.some((frame) => frame.type === "deliver"));
+ const deliveryFrame = frames.find((frame) => frame.type === "deliver")!;
+ socket.write(
+ `${JSON.stringify({
+ contract,
+ type: "delivery-result",
+ deliveryId: deliveryFrame.deliveryId,
+ ok: true,
+ })}\n`,
+ );
+ await delivery;
+ socket.destroy();
+ });
+
+ it("leaves local approval usable when no plugin transport is registered", async () => {
+ const pipe = `\\\\.\\pipe\\microclaw-weixin-approval-test-${randomUUID()}`;
+ const server = new WindowsNodeMxcWeixinRemoteApprovalServer(pipe, "secret-token", async () => ({
+ consumed: false,
+ }));
+ servers.push(server);
+ await server.start();
+ await expect(
+ server.deliver({
+ accountId: "bot@im.wechat",
+ to: "owner@im.wechat",
+ text: "approval",
+ }),
+ ).rejects.toThrow("transport is unavailable");
+ });
+
+ it("rejects pending deliveries immediately when a plugin client is replaced", async () => {
+ const pipe = `\\\\.\\pipe\\microclaw-weixin-approval-test-${randomUUID()}`;
+ const server = new WindowsNodeMxcWeixinRemoteApprovalServer(pipe, "secret-token", async () => ({
+ consumed: false,
+ }));
+ servers.push(server);
+ await server.start();
+
+ const connect = async () => {
+ const socket = net.createConnection(pipe);
+ socket.setEncoding("utf8");
+ const frames: Array> = [];
+ let buffer = "";
+ socket.on("data", (chunk) => {
+ buffer += chunk;
+ while (buffer.includes("\n")) {
+ const newline = buffer.indexOf("\n");
+ frames.push(JSON.parse(buffer.slice(0, newline)) as Record);
+ buffer = buffer.slice(newline + 1);
+ }
+ });
+ await new Promise((resolve) => socket.once("connect", resolve));
+ socket.write(
+ `${JSON.stringify({
+ contract,
+ type: "register",
+ token: "secret-token",
+ accountId: "bot@im.wechat",
+ })}\n`,
+ );
+ await waitFor(() => frames.some((frame) => frame.type === "registered"));
+ return { socket, frames };
+ };
+
+ const first = await connect();
+ const pendingDelivery = server.deliver({
+ accountId: "bot@im.wechat",
+ to: "owner@im.wechat",
+ text: "approval",
+ });
+ await waitFor(() => first.frames.some((frame) => frame.type === "deliver"));
+
+ const rejectedDelivery = expect(pendingDelivery).rejects.toThrow("disconnected");
+ const second = await connect();
+ await rejectedDelivery;
+ first.socket.destroy();
+ second.socket.destroy();
+ });
+});
+
+async function waitFor(predicate: () => boolean): Promise {
+ const deadline = Date.now() + 2_000;
+ while (!predicate()) {
+ if (Date.now() > deadline) throw new Error("Timed out waiting for transport frame");
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+}
diff --git a/desktop/src/windows-node-mxc-weixin-remote-approval-transport.ts b/desktop/src/windows-node-mxc-weixin-remote-approval-transport.ts
new file mode 100644
index 0000000..7dd68d1
--- /dev/null
+++ b/desktop/src/windows-node-mxc-weixin-remote-approval-transport.ts
@@ -0,0 +1,250 @@
+import net from "node:net";
+import { randomUUID } from "node:crypto";
+
+import {
+ WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ type WeixinRemoteApprovalSource,
+} from "./windows-node-mxc-weixin-remote-approval";
+
+const MAX_FRAME_BYTES = 64 * 1024;
+const DELIVERY_TIMEOUT_MS = 10_000;
+
+export interface WeixinRemoteApprovalInboundEnvelope extends WeixinRemoteApprovalSource {
+ text: string;
+ ambiguous: boolean;
+ sourcePhase: "candidate" | "start" | "complete";
+}
+
+interface RegisteredClient {
+ accountId: string;
+ socket: net.Socket;
+ pendingDeliveries: Map<
+ string,
+ {
+ resolve: () => void;
+ reject: (error: Error) => void;
+ timer: NodeJS.Timeout;
+ }
+ >;
+}
+
+type ClientFrame =
+ | {
+ contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT;
+ type: "register";
+ token: string;
+ accountId: string;
+ }
+ | {
+ contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT;
+ type: "inbound";
+ requestId: string;
+ source: WeixinRemoteApprovalInboundEnvelope;
+ }
+ | {
+ contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT;
+ type: "delivery-result";
+ deliveryId: string;
+ ok: boolean;
+ error?: string;
+ };
+
+export class WindowsNodeMxcWeixinRemoteApprovalServer {
+ private server: net.Server | null = null;
+ private readonly clients = new Map();
+
+ constructor(
+ private readonly pipeName: string,
+ private readonly token: string,
+ private readonly onInbound: (
+ source: WeixinRemoteApprovalInboundEnvelope,
+ ) => Promise<{ consumed: boolean; acknowledgement?: string }>,
+ ) {}
+
+ async start(): Promise {
+ if (this.server) return;
+ const server = net.createServer((socket) => this.accept(socket));
+ this.server = server;
+ await new Promise((resolve, reject) => {
+ const onError = (error: Error) => {
+ server.off("listening", onListening);
+ reject(error);
+ };
+ const onListening = () => {
+ server.off("error", onError);
+ resolve();
+ };
+ server.once("error", onError);
+ server.once("listening", onListening);
+ server.listen(this.pipeName);
+ });
+ }
+
+ async deliver(params: { accountId: string; to: string; text: string }): Promise {
+ const client = this.clients.get(params.accountId.trim().toLowerCase());
+ if (!client || client.socket.destroyed) {
+ throw new Error("The WeChat remote approval transport is unavailable");
+ }
+ const deliveryId = randomUUID();
+ const promise = new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ client.pendingDeliveries.delete(deliveryId);
+ reject(new Error("The WeChat remote approval message timed out"));
+ }, DELIVERY_TIMEOUT_MS);
+ client.pendingDeliveries.set(deliveryId, { resolve, reject, timer });
+ });
+ this.write(client.socket, {
+ contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ type: "deliver",
+ deliveryId,
+ to: params.to,
+ text: params.text,
+ });
+ await promise;
+ }
+
+ close(): void {
+ for (const client of this.clients.values()) {
+ for (const pending of client.pendingDeliveries.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(new Error("The WeChat remote approval transport was closed"));
+ }
+ client.pendingDeliveries.clear();
+ client.socket.destroy();
+ }
+ this.clients.clear();
+ this.server?.close();
+ this.server = null;
+ }
+
+ private accept(socket: net.Socket): void {
+ socket.setEncoding("utf8");
+ let buffer = "";
+ let registered: RegisteredClient | null = null;
+ let destroyed = false;
+ const destroy = () => {
+ if (destroyed) return;
+ destroyed = true;
+ if (registered && this.clients.get(registered.accountId) === registered) {
+ this.clients.delete(registered.accountId);
+ }
+ if (registered) {
+ for (const pending of registered.pendingDeliveries.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(new Error("The WeChat remote approval transport disconnected"));
+ }
+ registered.pendingDeliveries.clear();
+ }
+ socket.destroy();
+ };
+ socket.on("data", (chunk) => {
+ buffer += chunk;
+ if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
+ destroy();
+ return;
+ }
+ while (true) {
+ const newline = buffer.indexOf("\n");
+ if (newline < 0) break;
+ const line = buffer.slice(0, newline);
+ buffer = buffer.slice(newline + 1);
+ let frame: ClientFrame;
+ try {
+ frame = JSON.parse(line) as ClientFrame;
+ } catch {
+ destroy();
+ return;
+ }
+ if (frame.contract !== WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT) {
+ destroy();
+ return;
+ }
+ if (!registered) {
+ if (frame.type !== "register" || frame.token !== this.token || !frame.accountId.trim()) {
+ destroy();
+ return;
+ }
+ const accountId = frame.accountId.trim().toLowerCase();
+ this.clients.get(accountId)?.socket.destroy();
+ registered = { accountId, socket, pendingDeliveries: new Map() };
+ this.clients.set(accountId, registered);
+ this.write(socket, {
+ contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ type: "registered",
+ });
+ continue;
+ }
+ if (frame.type === "delivery-result") {
+ const pending = registered.pendingDeliveries.get(frame.deliveryId);
+ if (!pending) continue;
+ registered.pendingDeliveries.delete(frame.deliveryId);
+ clearTimeout(pending.timer);
+ if (frame.ok) pending.resolve();
+ else pending.reject(new Error(frame.error || "WeChat remote approval delivery failed"));
+ continue;
+ }
+ if (frame.type !== "inbound" || !frame.requestId || !frame.source) {
+ destroy();
+ return;
+ }
+ if (!isInboundEnvelope(frame.source)) {
+ destroy();
+ return;
+ }
+ if (frame.source.accountId.trim().toLowerCase() !== registered.accountId) {
+ destroy();
+ return;
+ }
+ void this.onInbound(frame.source)
+ .then((result) => {
+ this.write(socket, {
+ contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ type: "inbound-result",
+ requestId: frame.requestId,
+ ...result,
+ });
+ })
+ .catch(() => {
+ this.write(socket, {
+ contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ type: "inbound-result",
+ requestId: frame.requestId,
+ consumed: true,
+ acknowledgement: "MicroClaw Security Approval: invalid or unavailable.",
+ });
+ });
+ }
+ });
+ socket.on("close", destroy);
+ socket.on("error", destroy);
+ }
+
+ private write(socket: net.Socket, frame: object): void {
+ if (!socket.destroyed) socket.write(`${JSON.stringify(frame)}\n`);
+ }
+}
+
+function isInboundEnvelope(value: unknown): value is WeixinRemoteApprovalInboundEnvelope {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const source = value as Record;
+ return (
+ typeof source.accountId === "string" &&
+ typeof source.senderId === "string" &&
+ typeof source.conversationId === "string" &&
+ typeof source.messageId === "string" &&
+ typeof source.cursor === "number" &&
+ Number.isSafeInteger(source.cursor) &&
+ typeof source.agentId === "string" &&
+ typeof source.sessionKey === "string" &&
+ typeof source.direct === "boolean" &&
+ typeof source.forwarded === "boolean" &&
+ typeof source.quoted === "boolean" &&
+ typeof source.receivedAtUnixMs === "number" &&
+ Number.isSafeInteger(source.receivedAtUnixMs) &&
+ typeof source.text === "string" &&
+ typeof source.ambiguous === "boolean" &&
+ (source.sourcePhase === "candidate" ||
+ source.sourcePhase === "start" ||
+ source.sourcePhase === "complete")
+ );
+}
diff --git a/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts b/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts
new file mode 100644
index 0000000..a9ea9ae
--- /dev/null
+++ b/desktop/src/windows-node-mxc-weixin-remote-approval.test.ts
@@ -0,0 +1,560 @@
+import { describe, expect, it } from "vitest";
+
+import type { WindowsNodeMxcGatewayApproval } from "./windows-node-mxc";
+import {
+ migrateWeixinRemoteApprovalConfiguration,
+ normalizeWeixinRemoteApprovalOwner,
+ renderWeixinRemoteApprovalPrompt,
+ WindowsNodeMxcWeixinRemoteApprovalBroker,
+ WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_MAX_ATTEMPTS,
+ type WeixinRemoteApprovalCandidate,
+ type WeixinRemoteApprovalCreateInput,
+} from "./windows-node-mxc-weixin-remote-approval";
+
+const approval: WindowsNodeMxcGatewayApproval = {
+ id: "approval-1",
+ command: "whoami.exe /all",
+ executable: "whoami.exe",
+ arguments: ["/all"],
+ executablePath: "C:\\Windows\\System32\\whoami.exe",
+ executableSha256: "a".repeat(64),
+ canonicalCwd: "isolated-scratch:v1",
+ agent: "main",
+ sessionKey: "agent:main:weixin:owner",
+ declaredAccess: [],
+ hasMutableFileOperand: false,
+ allowedDecisions: ["allow-once", "deny"],
+};
+
+function input(now = 1_000): WeixinRemoteApprovalCreateInput {
+ return {
+ source: {
+ accountId: "BOT@IM.WECHAT",
+ senderId: "OWNER@IM.WECHAT",
+ conversationId: "OWNER@IM.WECHAT",
+ messageId: "100",
+ cursor: 100,
+ agentId: "main",
+ sessionKey: approval.sessionKey,
+ direct: true,
+ forwarded: false,
+ quoted: false,
+ receivedAtUnixMs: now,
+ },
+ requests: [{ approval }],
+ security: {
+ gatewayGeneration: "generation-1",
+ policyFingerprint: "b".repeat(64),
+ nodeId: "c".repeat(64),
+ activationLeaseHash: "d".repeat(64),
+ requestedMode: "limited-dacl",
+ effectiveMode: "limited-dacl",
+ },
+ locale: "en-US",
+ now,
+ };
+}
+
+function configuredBroker(
+ audit?: ConstructorParameters[0],
+): WindowsNodeMxcWeixinRemoteApprovalBroker {
+ const broker = new WindowsNodeMxcWeixinRemoteApprovalBroker(audit);
+ broker.configure({
+ enabled: true,
+ owner: normalizeWeixinRemoteApprovalOwner(
+ "BOT@IM.WECHAT",
+ "OWNER@IM.WECHAT",
+ "2026-10-14T00:00:00.000Z",
+ ),
+ });
+ return broker;
+}
+
+function candidate(
+ code: string,
+ overrides: Partial = {},
+): WeixinRemoteApprovalCandidate {
+ const base = input();
+ return {
+ accountId: base.source.accountId,
+ senderId: base.source.senderId,
+ conversationId: base.source.conversationId,
+ messageId: "101",
+ cursor: 101,
+ direct: true,
+ forwarded: false,
+ quoted: false,
+ text: `1 ${code}`,
+ now: 2_000,
+ requests: base.requests,
+ security: base.security,
+ ...overrides,
+ };
+}
+
+function codeFromPrompt(text: string): string {
+ const match = /#([23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8})\]/.exec(text);
+ if (!match) throw new Error("challenge code missing");
+ return match[1];
+}
+
+describe("WeChat remote approval broker", () => {
+ it("migrates missing or invalid settings to default-off and preserves valid bindings", () => {
+ expect(migrateWeixinRemoteApprovalConfiguration(undefined)).toEqual({
+ enabled: false,
+ owner: null,
+ });
+ expect(migrateWeixinRemoteApprovalConfiguration({ enabled: true, owner: null })).toEqual({
+ enabled: false,
+ owner: null,
+ });
+ expect(
+ migrateWeixinRemoteApprovalConfiguration({
+ enabled: true,
+ owner: {
+ accountId: "BOT@IM.WECHAT",
+ userId: "OWNER@IM.WECHAT",
+ boundAt: "2026-10-14T00:00:00.000Z",
+ },
+ }),
+ ).toEqual({
+ enabled: true,
+ owner: normalizeWeixinRemoteApprovalOwner(
+ "bot@im.wechat",
+ "owner@im.wechat",
+ "2026-10-14T00:00:00.000Z",
+ ),
+ });
+ });
+
+ it("is default-off and requires an exact authenticated owner binding", () => {
+ const broker = new WindowsNodeMxcWeixinRemoteApprovalBroker();
+ expect(broker.createChallenge(input())).toBeNull();
+ expect(() => normalizeWeixinRemoteApprovalOwner("bot", "display name")).toThrow();
+ });
+
+ it.each([
+ ["1", "allow-once", ["approval-1"]],
+ ["2", "deny", ["approval-1"]],
+ ["3", "allow-once", ["approval-1"]],
+ ] as const)("resolves owner option %s exactly once", (option, decision, ids) => {
+ const broker = configuredBroker();
+ const delivery = broker.createChallenge(input())!;
+ const code = codeFromPrompt(delivery.text);
+ const result = broker.resolveCandidate(candidate(code, { text: `${option} ${code}` }));
+ expect(result).toMatchObject({
+ consumed: true,
+ status: decision === "deny" ? "denied" : "approved",
+ decision,
+ approvalIds: ids,
+ });
+ if (result.status === "approved" || result.status === "denied") {
+ broker.completeResolution(result.challengeId, result.status);
+ }
+ expect(
+ broker.resolveCandidate(
+ candidate(code, {
+ cursor: 102,
+ messageId: "102",
+ requests: undefined,
+ security: undefined,
+ }),
+ ),
+ ).toMatchObject({ consumed: true, status: "rejected", reason: "replayed" });
+ });
+
+ it("limits option 3 to the explicitly enumerated originating-message batch", () => {
+ const broker = configuredBroker();
+ const batchInput = input();
+ batchInput.requests = [
+ { approval },
+ {
+ approval: {
+ ...approval,
+ id: "approval-2",
+ command: "hostname.exe",
+ executable: "hostname.exe",
+ executablePath: "C:\\Windows\\System32\\hostname.exe",
+ arguments: [],
+ },
+ },
+ ];
+ const delivery = broker.createChallenge(batchInput)!;
+ const code = codeFromPrompt(delivery.text);
+ expect(
+ broker.resolveCandidate(
+ candidate(code, { text: `3 ${code}`, requests: batchInput.requests }),
+ ),
+ ).toMatchObject({
+ status: "approved",
+ decision: "allow-once",
+ approvalIds: ["approval-1", "approval-2"],
+ });
+ });
+
+ it("keeps bare numeric messages ordinary but consumes exact or ambiguous code-shaped text", () => {
+ const broker = configuredBroker();
+ broker.createChallenge(input());
+ expect(broker.resolveCandidate(candidate("", { text: "1" }))).toEqual({
+ consumed: false,
+ status: "not-candidate",
+ });
+ expect(
+ broker.resolveCandidate(candidate("", { text: " 1 23456789 ", ambiguous: true })),
+ ).toMatchObject({ consumed: true, status: "rejected", reason: "malformed" });
+ });
+
+ it("cannot create approval state from an agent-generated fake prompt", () => {
+ const broker = configuredBroker();
+ expect(broker.resolveCandidate(candidate("23456789"))).toMatchObject({
+ consumed: true,
+ status: "rejected",
+ reason: "inactive",
+ });
+ expect(broker.getAuditEvents()).toEqual([]);
+ });
+
+ it.each([
+ [{ senderId: "other@im.wechat" }, "identity"],
+ [{ accountId: "other-bot@im.wechat" }, "identity"],
+ [{ conversationId: "other@im.wechat" }, "identity"],
+ [{ direct: false }, "group"],
+ [{ forwarded: true }, "forwarded"],
+ [{ quoted: true }, "quoted"],
+ [{ cursor: 99 }, "cursor"],
+ [{ cursor: 101, messageId: "100" }, "cursor"],
+ ] as const)("fails closed for mismatched reply %#", (overrides, reason) => {
+ const broker = configuredBroker();
+ const delivery = broker.createChallenge(input())!;
+ const code = codeFromPrompt(delivery.text);
+ expect(broker.resolveCandidate(candidate(code, overrides))).toMatchObject({
+ consumed: true,
+ status: "rejected",
+ reason,
+ });
+ });
+
+ it("expires, bounds wrong-code attempts, and never logs the raw challenge", () => {
+ const broker = configuredBroker();
+ const delivery = broker.createChallenge(input())!;
+ const code = codeFromPrompt(delivery.text);
+ for (let index = 0; index < WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_MAX_ATTEMPTS; index++) {
+ expect(
+ broker.resolveCandidate(
+ candidate("AAAAAAAA", {
+ cursor: 101 + index,
+ messageId: String(101 + index),
+ }),
+ ),
+ ).toMatchObject({ consumed: true, status: "rejected", reason: "code" });
+ }
+ expect(JSON.stringify(broker.getAuditEvents())).not.toContain(code);
+
+ const expired = configuredBroker();
+ const expiredDelivery = expired.createChallenge(input())!;
+ expect(
+ expired.resolveCandidate(candidate(codeFromPrompt(expiredDelivery.text), { now: 200_000 })),
+ ).toMatchObject({ status: "rejected", reason: "expired" });
+ });
+
+ it("enforces local-vs-remote first-response-wins", () => {
+ const localFirst = configuredBroker();
+ const localDelivery = localFirst.createChallenge(input())!;
+ localFirst.markLocallyResolved("approval-1", "denied");
+ expect(
+ localFirst.resolveCandidate(candidate(codeFromPrompt(localDelivery.text))),
+ ).toMatchObject({ status: "rejected", reason: "replayed" });
+
+ const remoteFirst = configuredBroker();
+ const remoteDelivery = remoteFirst.createChallenge(input())!;
+ const result = remoteFirst.resolveCandidate(candidate(codeFromPrompt(remoteDelivery.text)));
+ expect(result.status).toBe("approved");
+ remoteFirst.markLocallyResolved("approval-1", "denied");
+ if (result.status === "approved") {
+ remoteFirst.completeResolution(result.challengeId, "approved");
+ }
+ expect(remoteFirst.getAuditEvents()).toEqual([
+ expect.objectContaining({ result: "invalidated", reason: "local-denied" }),
+ ]);
+ });
+
+ it.each([
+ ["command", { command: "powershell.exe -File other.ps1" }],
+ ["executable", { executable: "pwsh.exe" }],
+ ["executablePath", { executablePath: "C:\\Other\\powershell.exe" }],
+ ["executableSha256", { executableSha256: "f".repeat(64) }],
+ ["arguments", { arguments: ["-File", "other.ps1"] }],
+ ["canonicalCwd", { canonicalCwd: "C:\\Temp" }],
+ ["declaredAccess", { declaredAccess: [{ access: "rw" as const, path: "C:\\Temp" }] }],
+ ["agent", { agent: "other" }],
+ ["sessionKey", { sessionKey: "agent:other:weixin:owner" }],
+ ["mutable operand", { hasMutableFileOperand: true }],
+ [
+ "decisions",
+ { allowedDecisions: ["deny"] as WindowsNodeMxcGatewayApproval["allowedDecisions"] },
+ ],
+ ])("rejects %s approval binding drift", (_field, approvalOverride) => {
+ const broker = configuredBroker();
+ const delivery = broker.createChallenge(input())!;
+ expect(
+ broker.resolveCandidate(
+ candidate(codeFromPrompt(delivery.text), {
+ requests: [{ approval: { ...approval, ...approvalOverride } }],
+ }),
+ ),
+ ).toMatchObject({ status: "rejected", reason: "stale" });
+ });
+
+ it.each([
+ ["gatewayGeneration", { gatewayGeneration: "generation-2" }],
+ ["policyFingerprint", { policyFingerprint: "e".repeat(64) }],
+ ["nodeId", { nodeId: "f".repeat(64) }],
+ ["activationLeaseHash", { activationLeaseHash: "1".repeat(64) }],
+ ["requestedMode", { requestedMode: "full" as const }],
+ ["effectiveMode", { effectiveMode: "base-container" }],
+ ])("rejects %s security binding drift", (_field, securityOverride) => {
+ const broker = configuredBroker();
+ const delivery = broker.createChallenge(input())!;
+ expect(
+ broker.resolveCandidate(
+ candidate(codeFromPrompt(delivery.text), {
+ security: { ...input().security, ...securityOverride },
+ }),
+ ),
+ ).toMatchObject({ status: "rejected", reason: "stale" });
+ });
+
+ it("renders safely classified values without trimming, path rewriting, or number conversion", () => {
+ const broker = configuredBroker();
+ const exact = input();
+ const exactPath = "C:\\Users\\other-user\\Documents\\Data-01.txt";
+ exact.requests = [
+ {
+ approval: {
+ ...approval,
+ command: `whoami.exe ${exactPath} --mode -7.50`,
+ arguments: [exactPath, "--mode", "-7.50"],
+ },
+ },
+ ];
+ const delivery = broker.createChallenge(exact)!;
+ expect(delivery.text).toContain(JSON.stringify(exactPath));
+ expect(delivery.text).toContain(JSON.stringify("-7.50"));
+ expect(delivery.text).not.toContain("%USERPROFILE%");
+ });
+
+ it.each([
+ '{"safe":1,"safe":2,"number":9007199254740993}',
+ "Cafe\u0301",
+ "任意文本",
+ ])("keeps unclassified exact text local-only without transforming it: %s", (value) => {
+ const broker = configuredBroker();
+ const exact = input();
+ exact.requests = [
+ {
+ approval: {
+ ...approval,
+ command: `whoami.exe "${value}"`,
+ arguments: [value],
+ },
+ },
+ ];
+ expect(broker.createChallenge(exact)).toBeNull();
+ });
+
+ it.each([
+ ["C0 control", "\u0001"],
+ ["C1 control", "\u0085"],
+ ["line separator", "\u2028"],
+ ["paragraph separator", "\u2029"],
+ ["bidi override", "\u202e"],
+ ["bidi isolate", "\u2066"],
+ ["zero-width space", "\u200b"],
+ ["variation selector", "\ufe0f"],
+ ["combining grapheme joiner", "\u034f"],
+ ["unpaired surrogate", "\ud800"],
+ ])("keeps %s authorization values local-only", (_name, unsafeCharacter) => {
+ const broker = configuredBroker();
+ const unsafe = input();
+ unsafe.requests = [
+ {
+ approval: {
+ ...approval,
+ arguments: ["-Command", `Write-Output safe${unsafeCharacter}Remove-Item C:\\Sensitive`],
+ },
+ },
+ ];
+ expect(broker.createChallenge(unsafe)).toBeNull();
+ });
+
+ it.each([
+ "--******",
+ "--passphrase=correct-horse-battery-staple",
+ "AccountKey=abcdefghijklmnopqrstuvwxyz0123456789+/=",
+ "sv=2026-01-01&sig=abcdefghijklmnopqrstuvwxyz0123456789",
+ "npm_abcdefghijklmnopqrstuvwxyz0123456789",
+ ["xoxb", "123456789012", "123456789012", "abcdefghijklmnopqrstuvwxyz"].join("-"),
+ ])("keeps known missed credential forms local-only: %s", (secretValue) => {
+ const broker = configuredBroker();
+ const unsafe = input();
+ unsafe.requests = [{ approval: { ...approval, arguments: [secretValue] } }];
+ expect(broker.createChallenge(unsafe)).toBeNull();
+ expect(JSON.stringify(broker.getAuditEvents())).not.toContain(secretValue);
+ });
+
+ it.each([
+ "--token=inline-secret",
+ "--password adjacent-secret",
+ "Authorization: Bearer abcdef",
+ "Cookie: session=secret",
+ '{"password":"json-secret"}',
+ '{"clientSecret":"oauth-secret"}',
+ "--myPrivateKeyFile=C:\\key.pem",
+ "ghp_123456789012345678901234567890",
+ "github_pat_123456789012345678901234567890",
+ "https://owner:github_pat_123456789012345678901234567890@github.com/org/private.git",
+ "https://opaque-credential@example.test/private",
+ "-----BEGIN PRIVATE KEY-----",
+ ])("keeps secret-bearing value local-only: %s", (secretValue) => {
+ const broker = configuredBroker();
+ const unsafe = input();
+ unsafe.requests = [{ approval: { ...approval, arguments: [secretValue] } }];
+ expect(broker.createChallenge(unsafe)).toBeNull();
+ expect(JSON.stringify(broker.getAuditEvents())).not.toContain(secretValue);
+ });
+
+ it.each([
+ "--opaque=value",
+ "hunter2",
+ "shortvalue",
+ "123456",
+ "654321",
+ "correct horse battery staple",
+ "abcdefghijklmnopqrstuvwxyz0123456789",
+ "https://example.test/path",
+ '{"unknown":"free-text"}',
+ ])("keeps unknown free-form value local-only: %s", (unknownValue) => {
+ const broker = configuredBroker();
+ const unsafe = input();
+ unsafe.requests = [{ approval: { ...approval, arguments: [unknownValue] } }];
+ expect(broker.createChallenge(unsafe)).toBeNull();
+ });
+
+ it("keeps an unknown switch value local-only even when both tokens look harmless", () => {
+ const broker = configuredBroker();
+ const unsafe = input();
+ unsafe.requests = [
+ { approval: { ...approval, arguments: ["--opaque", "shortvalue"] } },
+ ];
+ expect(broker.createChallenge(unsafe)).toBeNull();
+ });
+
+ it("enforces deterministic request, argument, declaration, line, and total caps", () => {
+ const broker = configuredBroker();
+ const tooManyArguments = input();
+ tooManyArguments.requests = [
+ {
+ approval: {
+ ...approval,
+ arguments: Array.from({ length: 9 }, (_, index) => `arg-${index}`),
+ },
+ },
+ ];
+ expect(broker.createChallenge(tooManyArguments)).toBeNull();
+
+ const tooManyDeclarations = input();
+ tooManyDeclarations.requests = [
+ {
+ approval: {
+ ...approval,
+ declaredAccess: Array.from({ length: 9 }, (_, index) => ({
+ access: "ro" as const,
+ path: `C:\\safe\\${index}`,
+ })),
+ },
+ },
+ ];
+ expect(broker.createChallenge(tooManyDeclarations)).toBeNull();
+
+ const oversizedLine = input();
+ oversizedLine.requests = [{ approval: { ...approval, arguments: ["x".repeat(200)] } }];
+ expect(broker.createChallenge(oversizedLine)).toBeNull();
+
+ const tooManyRequests = input();
+ tooManyRequests.requests = Array.from({ length: 9 }, (_, index) => ({
+ approval: { ...approval, id: `approval-${index}`, command: `cmd-${index}` },
+ }));
+ expect(broker.createChallenge(tooManyRequests)).toBeNull();
+ });
+
+ it("renders truthful English and Chinese one-use option copy", () => {
+ for (const locale of ["en-US", "zh-CN"] as const) {
+ const prompt = renderWeixinRemoteApprovalPrompt({
+ code: "23456789",
+ expiresAtUnixMs: 123_000,
+ requests: [{ approval }],
+ security: input().security,
+ locale,
+ });
+ expect(prompt).toContain(`1 23456789`);
+ expect(prompt).toContain(`2 23456789`);
+ expect(prompt).toContain(`3 23456789`);
+ expect(prompt).toContain(approval.executableSha256);
+ expect(prompt).toContain(JSON.stringify(approval.canonicalCwd));
+ expect(prompt).toMatch(
+ locale === "zh-CN" ? /不会创建永久授权/ : /no durable or future approval/,
+ );
+ }
+ });
+
+ it("audits only hashes and non-secret metadata after actual Electron resolution", () => {
+ const events: unknown[] = [];
+ const broker = configuredBroker((event) => events.push(event));
+ const delivery = broker.createChallenge(input())!;
+ const code = codeFromPrompt(delivery.text);
+ const result = broker.resolveCandidate(candidate(code));
+ expect(events).toEqual([]);
+ expect(result.status).toBe("approved");
+ if (result.status === "approved") {
+ broker.completeResolution(result.challengeId, "approved");
+ }
+ const serialized = JSON.stringify(events);
+ expect(serialized).not.toContain(code);
+ expect(serialized).not.toContain(approval.id);
+ expect(serialized).not.toContain(approval.command);
+ expect(events).toEqual([
+ expect.objectContaining({
+ result: "approved",
+ reason: "approved",
+ approvalIdHashes: [expect.stringMatching(/^[a-f0-9]{64}$/)],
+ }),
+ ]);
+ });
+
+ it("invalidates on relock, Off, restart, logout, rebind, disable, and request supersession", () => {
+ for (const reason of [
+ "security-relock",
+ "mxc-off",
+ "gateway-generation-change",
+ "weixin-logout",
+ "configuration-changed",
+ "feature-disabled",
+ "superseded",
+ ]) {
+ const broker = configuredBroker();
+ const delivery = broker.createChallenge(input())!;
+ if (reason === "superseded") {
+ broker.createChallenge({ ...input(), source: { ...input().source, messageId: "101" } });
+ } else {
+ broker.invalidateAll(reason);
+ }
+ const result = broker.resolveCandidate(
+ candidate(codeFromPrompt(delivery.text), { cursor: 102 }),
+ );
+ expect(result).toMatchObject({ consumed: true, status: "rejected" });
+ if (reason !== "superseded") {
+ expect(result).toMatchObject({ reason: "replayed" });
+ }
+ }
+ });
+});
diff --git a/desktop/src/windows-node-mxc-weixin-remote-approval.ts b/desktop/src/windows-node-mxc-weixin-remote-approval.ts
new file mode 100644
index 0000000..24491ea
--- /dev/null
+++ b/desktop/src/windows-node-mxc-weixin-remote-approval.ts
@@ -0,0 +1,964 @@
+import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
+
+import type { WindowsNodeMxcGatewayApproval } from "./windows-node-mxc";
+
+export const WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT =
+ "microclaw.windows-weixin-remote-approval.v1";
+export const WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_TTL_MS = 2 * 60_000;
+export const WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_MAX_ATTEMPTS = 3;
+
+const CHALLENGE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
+const CHALLENGE_CODE_LENGTH = 8;
+const MAX_GLOBAL_ATTEMPTS_PER_MINUTE = 20;
+const MAX_PROMPT_ARGUMENTS = 8;
+const MAX_PROMPT_DECLARATIONS = 8;
+const MAX_PROMPT_REQUESTS = 8;
+const MAX_PROMPT_LINE = 160;
+const MAX_PROMPT_LENGTH = 6_000;
+const DANGEROUS_PROMPT_UNICODE_PATTERN =
+ /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\p{Default_Ignorable_Code_Point}]/u;
+const SAFE_SIMPLE_ARGUMENT_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,15}$/;
+const SAFE_NUMERIC_ARGUMENT_PATTERN = /^-?(?:0|[1-9][0-9]{0,18})(?:\.[0-9]{1,8})?$/;
+const SAFE_SWITCH_PATTERN =
+ /^(?:--?[A-Za-z][A-Za-z0-9-]{0,31}|\/[A-Za-z][A-Za-z0-9-]{0,31})$/;
+const SAFE_RELATIVE_FILE_PATTERN =
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\.[A-Za-z0-9]{1,8}$/;
+const SAFE_STRUCTURAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:._/@-]{0,159}$/;
+const SENSITIVE_SEMANTIC_PATTERN =
+ /(?:accountkey|authorization|auth|bearer|clientsecret|cookie|credential|key|passphrase|passwd|password|privatekey|proxyauthorization|sas|secret|setcookie|signature|sig|token)/i;
+const SAFE_PATH_VALUE_SWITCHES = new Set([
+ "-File",
+ "--file",
+ "--output",
+ "--project",
+ "--cwd",
+ "-o",
+]);
+const SAFE_ENUM_VALUE_SWITCHES = new Set([
+ "--color",
+ "--configuration",
+ "--format",
+ "--mode",
+ "--target",
+]);
+const SAFE_LITERAL_ARGUMENTS = new Set(["false", "null", "true"]);
+
+export interface WeixinRemoteApprovalOwnerBinding {
+ contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT;
+ accountId: string;
+ userId: string;
+ boundAt: string;
+}
+
+export interface WeixinRemoteApprovalConfiguration {
+ enabled: boolean;
+ owner: WeixinRemoteApprovalOwnerBinding | null;
+}
+
+export function migrateWeixinRemoteApprovalConfiguration(
+ value: unknown,
+): WeixinRemoteApprovalConfiguration {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return { enabled: false, owner: null };
+ }
+ const record = value as Record;
+ const ownerValue = record.owner;
+ if (!ownerValue || typeof ownerValue !== "object" || Array.isArray(ownerValue)) {
+ return { enabled: false, owner: null };
+ }
+ const owner = ownerValue as Record;
+ try {
+ const normalized = normalizeWeixinRemoteApprovalOwner(
+ typeof owner.accountId === "string" ? owner.accountId : "",
+ typeof owner.userId === "string" ? owner.userId : "",
+ typeof owner.boundAt === "string" ? owner.boundAt : new Date(0).toISOString(),
+ );
+ return { enabled: record.enabled === true, owner: normalized };
+ } catch {
+ return { enabled: false, owner: null };
+ }
+}
+
+export interface WeixinRemoteApprovalSource {
+ accountId: string;
+ senderId: string;
+ conversationId: string;
+ messageId: string;
+ cursor: number;
+ agentId: string;
+ sessionKey: string;
+ direct: boolean;
+ forwarded: boolean;
+ quoted: boolean;
+ receivedAtUnixMs: number;
+}
+
+export interface WeixinRemoteApprovalSecurityBinding {
+ gatewayGeneration: string;
+ policyFingerprint: string;
+ nodeId: string;
+ activationLeaseHash: string;
+ requestedMode: "limited-dacl" | "full";
+ effectiveMode: string;
+}
+
+export interface WeixinRemoteApprovalBatchRequest {
+ approval: WindowsNodeMxcGatewayApproval;
+}
+
+export interface WeixinRemoteApprovalCreateInput {
+ source: WeixinRemoteApprovalSource;
+ requests: WeixinRemoteApprovalBatchRequest[];
+ security: WeixinRemoteApprovalSecurityBinding;
+ locale: "en-US" | "zh-CN";
+ now?: number;
+}
+
+export interface WeixinRemoteApprovalDelivery {
+ contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT;
+ challengeId: string;
+ challengeHash: string;
+ accountId: string;
+ senderId: string;
+ conversationId: string;
+ sourceMessageId: string;
+ sourceCursor: number;
+ expiresAtUnixMs: number;
+ text: string;
+}
+
+export interface WeixinRemoteApprovalCandidate {
+ accountId: string;
+ senderId: string;
+ conversationId: string;
+ messageId: string;
+ cursor: number;
+ direct: boolean;
+ forwarded: boolean;
+ quoted: boolean;
+ ambiguous?: boolean;
+ text: string;
+ now?: number;
+ requests?: WeixinRemoteApprovalBatchRequest[];
+ security?: WeixinRemoteApprovalSecurityBinding;
+}
+
+export type WeixinRemoteApprovalResolution =
+ | {
+ consumed: false;
+ status: "not-candidate";
+ }
+ | {
+ consumed: true;
+ status: "approved" | "denied";
+ challengeId: string;
+ approvalIds: string[];
+ decision: "allow-once" | "deny";
+ acknowledgement: string;
+ }
+ | {
+ consumed: true;
+ status: "rejected";
+ reason:
+ | "identity"
+ | "chat"
+ | "group"
+ | "forwarded"
+ | "quoted"
+ | "cursor"
+ | "code"
+ | "expired"
+ | "stale"
+ | "rate-limited"
+ | "replayed"
+ | "inactive"
+ | "malformed";
+ acknowledgement: string;
+ };
+
+export interface WeixinRemoteApprovalAuditEvent {
+ contract: typeof WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT;
+ timestamp: string;
+ challengeHash: string;
+ ownerHash: string;
+ chatHash: string;
+ approvalIdHashes: string[];
+ result: string;
+ reason: string;
+ gatewayGenerationHash: string;
+ policyFingerprintHash: string;
+ nodeIdHash: string;
+ activationLeaseHash: string;
+}
+
+interface ActiveChallenge {
+ id: string;
+ code: string;
+ codeHash: string;
+ challengeHash: string;
+ bindingHash: string;
+ source: WeixinRemoteApprovalSource;
+ security: WeixinRemoteApprovalSecurityBinding;
+ approvalIds: string[];
+ createdAtUnixMs: number;
+ expiresAtUnixMs: number;
+ attempts: number;
+ lastCandidateCursor: number;
+ locale: "en-US" | "zh-CN";
+}
+
+interface ClosedChallenge {
+ codeHash: string;
+ expiresAtUnixMs: number;
+ locale: "en-US" | "zh-CN";
+}
+
+export interface WeixinRemoteApprovalNotice {
+ challengeId: string;
+ accountId: string;
+ senderId: string;
+ acknowledgement: string;
+}
+
+function normalizeIdentity(value: string): string {
+ return value.trim().toLowerCase();
+}
+
+function normalizeOpaqueId(value: string): string {
+ return value.trim();
+}
+
+export function normalizeWeixinRemoteApprovalOwner(
+ accountId: string,
+ userId: string,
+ boundAt = new Date().toISOString(),
+): WeixinRemoteApprovalOwnerBinding {
+ const normalizedAccountId = normalizeIdentity(accountId);
+ const normalizedUserId = normalizeIdentity(userId);
+ if (!normalizedAccountId || !normalizedUserId || !normalizedUserId.endsWith("@im.wechat")) {
+ throw new Error("A valid authenticated WeChat account and owner user ID are required");
+ }
+ return {
+ contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ accountId: normalizedAccountId,
+ userId: normalizedUserId,
+ boundAt,
+ };
+}
+
+function hash(value: string): string {
+ return createHash("sha256").update(value, "utf8").digest("hex");
+}
+
+function canonicalize(value: unknown): string {
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
+ if (value && typeof value === "object") {
+ const record = value as Record;
+ return `{${Object.keys(record)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function securityBindingHash(
+ source: WeixinRemoteApprovalSource,
+ requests: WeixinRemoteApprovalBatchRequest[],
+ security: WeixinRemoteApprovalSecurityBinding,
+ challenge: {
+ id: string;
+ codeHash: string;
+ createdAtUnixMs: number;
+ expiresAtUnixMs: number;
+ },
+): string {
+ return hash(
+ canonicalize({
+ source: {
+ accountId: normalizeIdentity(source.accountId),
+ senderId: normalizeIdentity(source.senderId),
+ conversationId: normalizeOpaqueId(source.conversationId),
+ messageId: normalizeOpaqueId(source.messageId),
+ cursor: source.cursor,
+ agentId: normalizeOpaqueId(source.agentId),
+ sessionKey: normalizeOpaqueId(source.sessionKey),
+ direct: source.direct,
+ forwarded: source.forwarded,
+ quoted: source.quoted,
+ receivedAtUnixMs: source.receivedAtUnixMs,
+ },
+ requests: requests.map(({ approval }) => ({
+ id: approval.id,
+ command: approval.command,
+ executable: approval.executable,
+ executablePath: approval.executablePath,
+ executableSha256: approval.executableSha256.toLowerCase(),
+ arguments: approval.arguments,
+ canonicalCwd: approval.canonicalCwd,
+ declaredAccess: approval.declaredAccess,
+ agent: approval.agent,
+ sessionKey: approval.sessionKey,
+ hasMutableFileOperand: approval.hasMutableFileOperand,
+ allowedDecisions: approval.allowedDecisions,
+ })),
+ security,
+ challenge,
+ }),
+ );
+}
+
+function constantTimeCodeEquals(left: string, right: string): boolean {
+ const leftBuffer = Buffer.from(left, "utf8");
+ const rightBuffer = Buffer.from(right, "utf8");
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
+}
+
+function createCode(): string {
+ const bytes = randomBytes(CHALLENGE_CODE_LENGTH);
+ let result = "";
+ for (const byte of bytes) result += CHALLENGE_ALPHABET[byte % CHALLENGE_ALPHABET.length];
+ return result;
+}
+
+function exactPromptValue(value: string): string {
+ /*
+ .replace(BEARER_PATTERN, "Bearer [REDACTED]")
+ */
+ return JSON.stringify(value);
+}
+
+function isSafeAsciiDisplayValue(value: string): boolean {
+ return (
+ value.length > 0 &&
+ /^[\x20-\x7e]+$/.test(value) &&
+ !DANGEROUS_PROMPT_UNICODE_PATTERN.test(value)
+ );
+}
+
+function hasSensitiveSemantics(value: string): boolean {
+ return SENSITIVE_SEMANTIC_PATTERN.test(value.replace(/[^A-Za-z0-9]/g, ""));
+}
+
+function isSafeWindowsPath(value: string): boolean {
+ if (
+ !isSafeAsciiDisplayValue(value) ||
+ !/^[A-Za-z]:\\/.test(value) ||
+ /["'<>|?*=;&]/.test(value)
+ ) {
+ return false;
+ }
+ const segments = value.slice(3).split("\\");
+ return segments.every(
+ (segment) =>
+ segment.length > 0 &&
+ segment.length <= 80 &&
+ !segment.endsWith(" ") &&
+ !segment.endsWith(".") &&
+ !hasSensitiveSemantics(segment),
+ );
+}
+
+function isSafeRelativeFile(value: string): boolean {
+ return (
+ isSafeAsciiDisplayValue(value) &&
+ SAFE_RELATIVE_FILE_PATTERN.test(value) &&
+ !hasSensitiveSemantics(value)
+ );
+}
+
+function isSafeSimpleArgument(value: string): boolean {
+ return (
+ isSafeAsciiDisplayValue(value) &&
+ (SAFE_LITERAL_ARGUMENTS.has(value.toLowerCase()) ||
+ SAFE_NUMERIC_ARGUMENT_PATTERN.test(value) ||
+ (SAFE_SIMPLE_ARGUMENT_PATTERN.test(value) && !hasSensitiveSemantics(value)))
+ );
+}
+
+function isSafeSwitch(value: string): boolean {
+ return (
+ isSafeAsciiDisplayValue(value) &&
+ SAFE_SWITCH_PATTERN.test(value) &&
+ !hasSensitiveSemantics(value)
+ );
+}
+
+function argumentsCanBeSafelyRendered(values: string[]): boolean {
+ for (let index = 0; index < values.length; index += 1) {
+ const value = values[index];
+ if (isSafeWindowsPath(value) || isSafeRelativeFile(value)) continue;
+ if (isSafeSwitch(value)) {
+ if (SAFE_PATH_VALUE_SWITCHES.has(value)) {
+ const pathValue = values[index + 1];
+ if (
+ pathValue === undefined ||
+ (!isSafeWindowsPath(pathValue) && !isSafeRelativeFile(pathValue))
+ ) {
+ return false;
+ }
+ index += 1;
+ } else if (SAFE_ENUM_VALUE_SWITCHES.has(value)) {
+ const enumValue = values[index + 1];
+ if (enumValue === undefined || !isSafeSimpleArgument(enumValue)) return false;
+ index += 1;
+ } else if (values[index + 1] !== undefined && !isSafeSwitch(values[index + 1])) {
+ return false;
+ }
+ continue;
+ }
+ if (SAFE_LITERAL_ARGUMENTS.has(value.toLowerCase())) continue;
+ return false;
+ }
+ return true;
+}
+
+function quoteWindowsArgument(value: string): string {
+ if (value.length > 0 && !/\s/.test(value) && !value.includes('"')) return value;
+ let output = '"';
+ let slashes = 0;
+ for (const character of value) {
+ if (character === "\\") {
+ slashes += 1;
+ continue;
+ }
+ if (character === '"') {
+ output += `${"\\".repeat(slashes * 2 + 1)}"`;
+ slashes = 0;
+ continue;
+ }
+ output += `${"\\".repeat(slashes)}${character}`;
+ slashes = 0;
+ }
+ return `${output}${"\\".repeat(slashes * 2)}"`;
+}
+
+function exactWindowsCommand(values: string[]): string {
+ return values.map(quoteWindowsArgument).join(" ");
+}
+
+function approvalHasSafeStructuredPresentation(
+ approval: WindowsNodeMxcGatewayApproval,
+): boolean {
+ const canonicalCwdIsSafe =
+ approval.canonicalCwd === "isolated-scratch:v1" || isSafeWindowsPath(approval.canonicalCwd);
+ return (
+ (isSafeRelativeFile(approval.executable) || isSafeWindowsPath(approval.executable)) &&
+ isSafeWindowsPath(approval.executablePath) &&
+ canonicalCwdIsSafe &&
+ SAFE_STRUCTURAL_ID_PATTERN.test(approval.sessionKey) &&
+ (approval.agent === null || SAFE_STRUCTURAL_ID_PATTERN.test(approval.agent)) &&
+ argumentsCanBeSafelyRendered(approval.arguments) &&
+ approval.command === exactWindowsCommand([approval.executable, ...approval.arguments]) &&
+ approval.declaredAccess.every((entry) => isSafeWindowsPath(entry.path))
+ );
+}
+
+function renderExactArguments(values: string[]): string {
+ return values.map(exactPromptValue).join(" ") || "(none)";
+}
+
+function renderExactDeclarations(values: Array<{ access: string; path: string }>): string {
+ return (
+ values.map((entry) => `${entry.access}:${exactPromptValue(entry.path)}`).join(" ") || "(none)"
+ );
+}
+
+function promptCanRepresentRequests(requests: WeixinRemoteApprovalBatchRequest[]): boolean {
+ if (requests.length === 0 || requests.length > MAX_PROMPT_REQUESTS) return false;
+ return requests.every(({ approval }) => {
+ if (
+ approval.arguments.length > MAX_PROMPT_ARGUMENTS ||
+ approval.declaredAccess.length > MAX_PROMPT_DECLARATIONS ||
+ !/^[a-f0-9]{64}$/i.test(approval.executableSha256) ||
+ !approvalHasSafeStructuredPresentation(approval)
+ ) {
+ return false;
+ }
+ return [
+ exactPromptValue(approval.command),
+ exactPromptValue(approval.executablePath || approval.executable),
+ approval.executableSha256,
+ renderExactArguments(approval.arguments),
+ exactPromptValue(approval.canonicalCwd),
+ renderExactDeclarations(approval.declaredAccess),
+ exactPromptValue(approval.agent ?? ""),
+ exactPromptValue(approval.sessionKey),
+ String(approval.hasMutableFileOperand),
+ approval.allowedDecisions.join(","),
+ ].every((line) => line.length <= MAX_PROMPT_LINE);
+ });
+}
+
+export function renderWeixinRemoteApprovalPrompt(params: {
+ code: string;
+ expiresAtUnixMs: number;
+ requests: WeixinRemoteApprovalBatchRequest[];
+ security: WeixinRemoteApprovalSecurityBinding;
+ locale: "en-US" | "zh-CN";
+}): string {
+ const lines =
+ params.locale === "zh-CN"
+ ? [
+ `[MicroClaw 安全审批 #${params.code}]`,
+ "仅批准下列当前请求;不会创建永久授权,也不会批准未来请求。",
+ ...params.requests.flatMap(({ approval }, index) => [
+ `请求 ${index + 1} 命令: ${exactPromptValue(approval.command)}`,
+ `可执行文件: ${exactPromptValue(approval.executablePath || approval.executable)}`,
+ `SHA-256: ${approval.executableSha256}`,
+ `参数: ${renderExactArguments(approval.arguments)}`,
+ `工作目录: ${exactPromptValue(approval.canonicalCwd)}`,
+ `文件访问: ${renderExactDeclarations(approval.declaredAccess)}`,
+ `代理/会话: ${exactPromptValue(approval.agent ?? "")} / ${exactPromptValue(approval.sessionKey)}`,
+ `可变文件操作数: ${approval.hasMutableFileOperand ? "是" : "否"}`,
+ `本地可用决策: ${approval.allowedDecisions.join(", ")}`,
+ ]),
+ `模式: ${params.security.requestedMode} / ${exactPromptValue(params.security.effectiveMode)}`,
+ `过期时间: ${new Date(params.expiresAtUnixMs).toISOString()}`,
+ `回复: 1 ${params.code}(仅批准此请求一次)`,
+ ` 2 ${params.code}(拒绝)`,
+ ` 3 ${params.code}(仅批准本消息列出的全部 ${params.requests.length} 个请求一次)`,
+ ]
+ : [
+ `[MicroClaw Security Approval #${params.code}]`,
+ "This approves only the current request(s); it creates no durable or future approval.",
+ ...params.requests.flatMap(({ approval }, index) => [
+ `Request ${index + 1} command: ${exactPromptValue(approval.command)}`,
+ `Executable: ${exactPromptValue(approval.executablePath || approval.executable)}`,
+ `SHA-256: ${approval.executableSha256}`,
+ `Args: ${renderExactArguments(approval.arguments)}`,
+ `CWD: ${exactPromptValue(approval.canonicalCwd)}`,
+ `File access: ${renderExactDeclarations(approval.declaredAccess)}`,
+ `Agent/session: ${exactPromptValue(approval.agent ?? "")} / ${exactPromptValue(approval.sessionKey)}`,
+ `Mutable file operand: ${approval.hasMutableFileOperand ? "yes" : "no"}`,
+ `Locally available decisions: ${approval.allowedDecisions.join(", ")}`,
+ ]),
+ `Mode: ${params.security.requestedMode} / ${exactPromptValue(params.security.effectiveMode)}`,
+ `Expires: ${new Date(params.expiresAtUnixMs).toISOString()}`,
+ `Reply: 1 ${params.code} (approve this request once)`,
+ ` 2 ${params.code} (deny)`,
+ ` 3 ${params.code} (approve all ${params.requests.length} listed request(s) once)`,
+ ];
+ return lines.join("\n");
+}
+
+function acknowledgement(locale: "en-US" | "zh-CN", result: string): string {
+ const zh: Record = {
+ approved: "MicroClaw 安全审批:已批准一次。",
+ denied: "MicroClaw 安全审批:已拒绝。",
+ expired: "MicroClaw 安全审批:已过期。",
+ invalid: "MicroClaw 安全审批:无效或已失效。",
+ };
+ const en: Record = {
+ approved: "MicroClaw Security Approval: approved once.",
+ denied: "MicroClaw Security Approval: denied.",
+ expired: "MicroClaw Security Approval: expired.",
+ invalid: "MicroClaw Security Approval: invalid or no longer active.",
+ };
+ return (locale === "zh-CN" ? zh : en)[result] ?? en.invalid;
+}
+
+export class WindowsNodeMxcWeixinRemoteApprovalBroker {
+ private configuration: WeixinRemoteApprovalConfiguration = { enabled: false, owner: null };
+ private readonly challengesByChat = new Map();
+ private readonly closedByChat = new Map();
+ private readonly pendingResolutions = new Map();
+ private readonly globalAttemptTimes: number[] = [];
+ private readonly auditEvents: WeixinRemoteApprovalAuditEvent[] = [];
+
+ constructor(private readonly onAudit?: (event: WeixinRemoteApprovalAuditEvent) => void) {}
+
+ configure(configuration: WeixinRemoteApprovalConfiguration): void {
+ const changed =
+ this.configuration.enabled !== configuration.enabled ||
+ canonicalize(this.configuration.owner) !== canonicalize(configuration.owner);
+ this.configuration = {
+ enabled: configuration.enabled,
+ owner: configuration.owner
+ ? normalizeWeixinRemoteApprovalOwner(
+ configuration.owner.accountId,
+ configuration.owner.userId,
+ configuration.owner.boundAt,
+ )
+ : null,
+ };
+ if (changed) this.invalidateAll("configuration-changed");
+ }
+
+ getConfiguration(): WeixinRemoteApprovalConfiguration {
+ return {
+ enabled: this.configuration.enabled,
+ owner: this.configuration.owner ? { ...this.configuration.owner } : null,
+ };
+ }
+
+ getAuditEvents(): WeixinRemoteApprovalAuditEvent[] {
+ return this.auditEvents.map((event) => ({
+ ...event,
+ approvalIdHashes: [...event.approvalIdHashes],
+ }));
+ }
+
+ createChallenge(input: WeixinRemoteApprovalCreateInput): WeixinRemoteApprovalDelivery | null {
+ const now = input.now ?? Date.now();
+ const owner = this.configuration.owner;
+ if (
+ !this.configuration.enabled ||
+ !owner ||
+ !input.source.direct ||
+ input.source.forwarded ||
+ input.source.quoted ||
+ normalizeIdentity(input.source.accountId) !== owner.accountId ||
+ normalizeIdentity(input.source.senderId) !== owner.userId ||
+ normalizeOpaqueId(input.source.conversationId) !== normalizeOpaqueId(input.source.senderId) ||
+ !Number.isSafeInteger(input.source.cursor) ||
+ input.source.cursor < 0 ||
+ !promptCanRepresentRequests(input.requests) ||
+ !SAFE_STRUCTURAL_ID_PATTERN.test(input.security.effectiveMode)
+ ) {
+ return null;
+ }
+ const approvalIds = input.requests.map(({ approval }) => approval.id);
+ if (
+ new Set(approvalIds).size !== approvalIds.length ||
+ input.requests.some(
+ ({ approval }) =>
+ approval.sessionKey !== input.source.sessionKey ||
+ (approval.agent ?? "") !== input.source.agentId,
+ )
+ ) {
+ return null;
+ }
+ const chatKey = this.chatKey(input.source.accountId, input.source.conversationId);
+ this.invalidateChat(chatKey, "superseded");
+ const code = createCode();
+ const id = randomBytes(16).toString("hex");
+ const codeHash = hash(code);
+ const challengeHash = hash(id);
+ const expiresAtUnixMs = now + WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_TTL_MS;
+ const challenge: ActiveChallenge = {
+ id,
+ code,
+ codeHash,
+ challengeHash,
+ bindingHash: securityBindingHash(input.source, input.requests, input.security, {
+ id,
+ codeHash,
+ createdAtUnixMs: now,
+ expiresAtUnixMs,
+ }),
+ source: {
+ ...input.source,
+ accountId: normalizeIdentity(input.source.accountId),
+ senderId: normalizeIdentity(input.source.senderId),
+ },
+ security: { ...input.security },
+ approvalIds,
+ createdAtUnixMs: now,
+ expiresAtUnixMs,
+ attempts: 0,
+ lastCandidateCursor: input.source.cursor,
+ locale: input.locale,
+ };
+ const text = renderWeixinRemoteApprovalPrompt({
+ code,
+ expiresAtUnixMs,
+ requests: input.requests,
+ security: input.security,
+ locale: input.locale,
+ });
+ if (
+ text.length > MAX_PROMPT_LENGTH ||
+ text.split("\n").some((line) => line.length > MAX_PROMPT_LINE)
+ ) {
+ return null;
+ }
+ this.challengesByChat.set(chatKey, challenge);
+ return {
+ contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ challengeId: id,
+ challengeHash,
+ accountId: challenge.source.accountId,
+ senderId: challenge.source.senderId,
+ conversationId: challenge.source.conversationId,
+ sourceMessageId: challenge.source.messageId,
+ sourceCursor: challenge.source.cursor,
+ expiresAtUnixMs,
+ text,
+ };
+ }
+
+ resolveCandidate(candidate: WeixinRemoteApprovalCandidate): WeixinRemoteApprovalResolution {
+ const chatKey = this.chatKey(candidate.accountId, candidate.conversationId);
+ const challenge = this.challengesByChat.get(chatKey);
+ if (candidate.ambiguous) {
+ if (!challenge) {
+ return {
+ consumed: true,
+ status: "rejected",
+ reason: "inactive",
+ acknowledgement: acknowledgement("en-US", "invalid"),
+ };
+ }
+ return this.rejectChallenge(chatKey, challenge, "malformed", true);
+ }
+
+ const parsed = /^([123]) ([23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8})$/i.exec(candidate.text);
+ if (!parsed) return { consumed: false, status: "not-candidate" };
+
+ const now = candidate.now ?? Date.now();
+ this.globalAttemptTimes.push(now);
+ while (this.globalAttemptTimes[0] != null && this.globalAttemptTimes[0] < now - 60_000) {
+ this.globalAttemptTimes.shift();
+ }
+ if (!challenge) {
+ const candidateCodeHash = hash(parsed[2].toUpperCase());
+ for (const [activeChatKey, active] of this.challengesByChat) {
+ if (!constantTimeCodeEquals(candidateCodeHash, active.codeHash)) continue;
+ active.attempts += 1;
+ if (active.attempts >= WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_MAX_ATTEMPTS) {
+ this.invalidateChat(activeChatKey, "identity");
+ }
+ return {
+ consumed: true,
+ status: "rejected",
+ reason: "identity",
+ acknowledgement: acknowledgement(active.locale, "invalid"),
+ };
+ }
+ const closed = this.closedByChat.get(chatKey);
+ if (
+ !closed ||
+ now > closed.expiresAtUnixMs ||
+ !constantTimeCodeEquals(hash(parsed[2].toUpperCase()), closed.codeHash)
+ ) {
+ return {
+ consumed: true,
+ status: "rejected",
+ reason: "inactive",
+ acknowledgement: acknowledgement("en-US", "invalid"),
+ };
+ }
+ return {
+ consumed: true,
+ status: "rejected",
+ reason: "replayed",
+ acknowledgement: acknowledgement(closed.locale, "invalid"),
+ };
+ }
+ const reject = (
+ reason: Exclude<
+ Extract["reason"],
+ never
+ >,
+ invalidate = false,
+ ): WeixinRemoteApprovalResolution => {
+ return this.rejectChallenge(chatKey, challenge, reason, invalidate);
+ };
+
+ if (this.globalAttemptTimes.length > MAX_GLOBAL_ATTEMPTS_PER_MINUTE) {
+ return reject("rate-limited", true);
+ }
+ if (now > challenge.expiresAtUnixMs) return reject("expired", true);
+ if (!candidate.direct) return reject("group", true);
+ if (candidate.forwarded) return reject("forwarded", true);
+ if (candidate.quoted) return reject("quoted", true);
+ if (normalizeIdentity(candidate.accountId) !== challenge.source.accountId) {
+ return reject("identity");
+ }
+ if (normalizeIdentity(candidate.senderId) !== challenge.source.senderId) {
+ return reject("identity");
+ }
+ if (normalizeOpaqueId(candidate.conversationId) !== challenge.source.conversationId) {
+ return reject("chat");
+ }
+ if (
+ !Number.isSafeInteger(candidate.cursor) ||
+ candidate.cursor <= challenge.source.cursor ||
+ candidate.cursor <= challenge.lastCandidateCursor ||
+ normalizeOpaqueId(candidate.messageId) === challenge.source.messageId
+ ) {
+ return reject("cursor");
+ }
+ challenge.lastCandidateCursor = candidate.cursor;
+ if (!constantTimeCodeEquals(parsed[2].toUpperCase(), challenge.code)) {
+ return reject("code");
+ }
+ if (!candidate.requests || !candidate.security) {
+ return reject("stale", true);
+ }
+ if (
+ canonicalize(candidate.security) !== canonicalize(challenge.security) ||
+ securityBindingHash(challenge.source, candidate.requests, candidate.security, {
+ id: challenge.id,
+ codeHash: challenge.codeHash,
+ createdAtUnixMs: challenge.createdAtUnixMs,
+ expiresAtUnixMs: challenge.expiresAtUnixMs,
+ }) !== challenge.bindingHash
+ ) {
+ return reject("stale", true);
+ }
+ this.challengesByChat.delete(chatKey);
+ this.closedByChat.set(chatKey, {
+ codeHash: challenge.codeHash,
+ expiresAtUnixMs: challenge.expiresAtUnixMs,
+ locale: challenge.locale,
+ });
+ this.pendingResolutions.set(challenge.id, challenge);
+ const option = parsed[1];
+ const decision = option === "2" ? "deny" : "allow-once";
+ const approvalIds = option === "3" ? [...challenge.approvalIds] : [challenge.approvalIds[0]];
+ return {
+ consumed: true,
+ status: decision === "deny" ? "denied" : "approved",
+ challengeId: challenge.id,
+ approvalIds,
+ decision,
+ acknowledgement: acknowledgement(
+ challenge.locale,
+ decision === "deny" ? "denied" : "approved",
+ ),
+ };
+ }
+
+ markLocallyResolved(approvalId: string, result: "approved" | "denied"): void {
+ for (const [chatKey, challenge] of this.challengesByChat) {
+ if (!challenge.approvalIds.includes(approvalId)) continue;
+ this.recordAudit(challenge, "invalidated", `local-${result}`);
+ this.closedByChat.set(chatKey, {
+ codeHash: challenge.codeHash,
+ expiresAtUnixMs: challenge.expiresAtUnixMs,
+ locale: challenge.locale,
+ });
+ this.challengesByChat.delete(chatKey);
+ }
+ for (const [challengeId, challenge] of this.pendingResolutions) {
+ if (!challenge.approvalIds.includes(approvalId)) continue;
+ this.recordAudit(challenge, "invalidated", `local-${result}`);
+ this.pendingResolutions.delete(challengeId);
+ }
+ }
+
+ completeResolution(challengeId: string, result: "approved" | "denied"): void {
+ const challenge = this.pendingResolutions.get(challengeId);
+ if (!challenge) return;
+ this.pendingResolutions.delete(challengeId);
+ this.recordAudit(challenge, result, result);
+ }
+
+ failResolution(challengeId: string, reason: string): void {
+ const challenge = this.pendingResolutions.get(challengeId);
+ if (!challenge) return;
+ this.pendingResolutions.delete(challengeId);
+ this.recordAudit(challenge, "invalidated", reason);
+ }
+
+ expireChallenge(challengeId: string, now = Date.now()): WeixinRemoteApprovalNotice | null {
+ for (const [chatKey, challenge] of this.challengesByChat) {
+ if (challenge.id !== challengeId || now < challenge.expiresAtUnixMs) continue;
+ this.invalidateChat(chatKey, "expired");
+ return this.notice(challenge, "expired");
+ }
+ return null;
+ }
+
+ invalidateChallenge(challengeId: string, reason: string): WeixinRemoteApprovalNotice | null {
+ for (const [chatKey, challenge] of this.challengesByChat) {
+ if (challenge.id !== challengeId) continue;
+ this.invalidateChat(chatKey, reason);
+ return this.notice(challenge, "invalid");
+ }
+ const pending = this.pendingResolutions.get(challengeId);
+ if (!pending) return null;
+ this.pendingResolutions.delete(challengeId);
+ this.recordAudit(pending, "invalidated", reason);
+ return this.notice(pending, "invalid");
+ }
+
+ invalidateAll(reason: string): WeixinRemoteApprovalNotice[] {
+ const notices: WeixinRemoteApprovalNotice[] = [];
+ for (const [chatKey, challenge] of this.challengesByChat) {
+ this.recordAudit(challenge, "invalidated", reason);
+ this.closedByChat.set(chatKey, {
+ codeHash: challenge.codeHash,
+ expiresAtUnixMs: challenge.expiresAtUnixMs,
+ locale: challenge.locale,
+ });
+ notices.push(this.notice(challenge, "invalid"));
+ }
+ this.challengesByChat.clear();
+ for (const [challengeId, challenge] of this.pendingResolutions) {
+ this.recordAudit(challenge, "invalidated", reason);
+ this.pendingResolutions.delete(challengeId);
+ }
+ return notices;
+ }
+
+ private invalidateChat(chatKey: string, reason: string): void {
+ const challenge = this.challengesByChat.get(chatKey);
+ if (!challenge) return;
+ this.recordAudit(challenge, "invalidated", reason);
+ this.closedByChat.set(chatKey, {
+ codeHash: challenge.codeHash,
+ expiresAtUnixMs: challenge.expiresAtUnixMs,
+ locale: challenge.locale,
+ });
+ this.challengesByChat.delete(chatKey);
+ }
+
+ private rejectChallenge(
+ chatKey: string,
+ challenge: ActiveChallenge,
+ reason: Extract["reason"],
+ invalidate: boolean,
+ ): WeixinRemoteApprovalResolution {
+ challenge.attempts += 1;
+ if (invalidate || challenge.attempts >= WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_MAX_ATTEMPTS) {
+ this.invalidateChat(chatKey, reason);
+ }
+ return {
+ consumed: true,
+ status: "rejected",
+ reason,
+ acknowledgement: acknowledgement(
+ challenge.locale,
+ reason === "expired" ? "expired" : "invalid",
+ ),
+ };
+ }
+
+ private chatKey(accountId: string, conversationId: string): string {
+ return `${normalizeIdentity(accountId)}\n${normalizeOpaqueId(conversationId)}`;
+ }
+
+ private recordAudit(challenge: ActiveChallenge, result: string, reason: string): void {
+ const event: WeixinRemoteApprovalAuditEvent = {
+ contract: WINDOWS_NODE_MXC_WEIXIN_REMOTE_APPROVAL_CONTRACT,
+ timestamp: new Date().toISOString(),
+ challengeHash: challenge.challengeHash,
+ ownerHash: hash(`${challenge.source.accountId}\n${challenge.source.senderId}`),
+ chatHash: hash(challenge.source.conversationId),
+ approvalIdHashes: challenge.approvalIds.map(hash),
+ result,
+ reason,
+ gatewayGenerationHash: hash(challenge.security.gatewayGeneration),
+ policyFingerprintHash: hash(challenge.security.policyFingerprint),
+ nodeIdHash: hash(challenge.security.nodeId),
+ activationLeaseHash: challenge.security.activationLeaseHash,
+ };
+ this.auditEvents.push(event);
+ this.onAudit?.({ ...event, approvalIdHashes: [...event.approvalIdHashes] });
+ }
+
+ private notice(
+ challenge: ActiveChallenge,
+ result: "expired" | "invalid",
+ ): WeixinRemoteApprovalNotice {
+ return {
+ challengeId: challenge.id,
+ accountId: challenge.source.accountId,
+ senderId: challenge.source.senderId,
+ acknowledgement: acknowledgement(challenge.locale, result),
+ };
+ }
+}
diff --git a/desktop/src/windows-node-mxc-weixin.test.ts b/desktop/src/windows-node-mxc-weixin.test.ts
index 820ed47..e0a3e96 100644
--- a/desktop/src/windows-node-mxc-weixin.test.ts
+++ b/desktop/src/windows-node-mxc-weixin.test.ts
@@ -4,12 +4,16 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
+ clearInheritedWindowsNodeMxcWeixinEnvironment,
getWindowsNodeMxcWeixinEnvironment,
preflightWindowsNodeMxcWeixinAuthorization,
prepareWindowsNodeMxcWeixinContext,
+ readWindowsNodeMxcWeixinAllowedSenders,
releaseWindowsNodeMxcWeixinIngress,
+ resolveStoredWindowsNodeMxcWeixinAccountPath,
revokeWindowsNodeMxcWeixinIngress,
WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT,
+ WINDOWS_NODE_MXC_WEIXIN_ENVIRONMENT_KEYS,
WINDOWS_NODE_MXC_WEIXIN_MEDIA_POLICY_CONTRACT,
} from "./windows-node-mxc-weixin";
@@ -30,11 +34,19 @@ describe("Windows Node MXC WeChat lifecycle", () => {
"generation-1",
"a".repeat(64),
[{ path: selected, access: "rw" }],
+ true,
);
expect(fs.existsSync(context.releasePath)).toBe(false);
const environment = getWindowsNodeMxcWeixinEnvironment(context);
expect(environment.MICROCLAW_MXC_INGRESS_RELEASE_TOKEN).toBe(context.releaseToken);
+ expect(environment.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE).toBe(context.remoteApprovalPipe);
+ expect(environment.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN).toBe(
+ context.remoteApprovalToken,
+ );
+ expect(Object.keys(environment).sort()).toEqual(
+ [...WINDOWS_NODE_MXC_WEIXIN_ENVIRONMENT_KEYS].sort(),
+ );
const policy = JSON.parse(
Buffer.from(environment.MICROCLAW_MXC_WEIXIN_MEDIA_POLICY, "base64").toString("utf8"),
);
@@ -62,6 +74,15 @@ describe("Windows Node MXC WeChat lifecycle", () => {
expect(fs.existsSync(context.releasePath)).toBe(false);
});
+ it("scrubs inherited app-owned WeChat transport variables before fresh injection", () => {
+ const environment = Object.fromEntries(
+ WINDOWS_NODE_MXC_WEIXIN_ENVIRONMENT_KEYS.map((key) => [key, "attacker-controlled"]),
+ );
+ environment.UNRELATED = "preserved";
+ clearInheritedWindowsNodeMxcWeixinEnvironment(environment);
+ expect(environment).toEqual({ UNRELATED: "preserved" });
+ });
+
it("migrates a stored owner id and accepts explicit allowlists for enabled accounts", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "microclaw-weixin-auth-"));
roots.push(root);
@@ -104,6 +125,30 @@ describe("Windows Node MXC WeChat lifecycle", () => {
),
),
).toMatchObject({ version: 1, allowFrom: ["owner@im.wechat"] });
+ expect(
+ readWindowsNodeMxcWeixinAllowedSenders({
+ stateDir: root,
+ credentialsDir,
+ accountId: "owner-im-bot",
+ }),
+ ).toEqual(["owner@im.wechat"]);
+ });
+
+ it("resolves legacy account filenames without weakening explicit sender allowlists", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "microclaw-weixin-legacy-"));
+ roots.push(root);
+ const accountsDir = path.join(root, "openclaw-weixin", "accounts");
+ fs.mkdirSync(accountsDir, { recursive: true });
+ const legacyPath = path.join(accountsDir, "owner@im.wechat.json");
+ fs.writeFileSync(legacyPath, JSON.stringify({ userId: "owner@im.wechat" }));
+
+ expect(resolveStoredWindowsNodeMxcWeixinAccountPath(root, "owner-im-wechat")).toBe(legacyPath);
+ expect(
+ readWindowsNodeMxcWeixinAllowedSenders({
+ stateDir: root,
+ accountId: "owner-im-wechat",
+ }),
+ ).toEqual([]);
});
it("blocks ingress release when an enabled account has no explicit sender identity", () => {
diff --git a/desktop/src/windows-node-mxc-weixin.ts b/desktop/src/windows-node-mxc-weixin.ts
index 1992567..8e574be 100644
--- a/desktop/src/windows-node-mxc-weixin.ts
+++ b/desktop/src/windows-node-mxc-weixin.ts
@@ -4,10 +4,21 @@ import path from "node:path";
import type { BundledWindowsNodeFolder } from "./bundled-windows-node-host";
-export const WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT =
- "microclaw.windows-ingress-release.v1";
-export const WINDOWS_NODE_MXC_WEIXIN_MEDIA_POLICY_CONTRACT =
- "microclaw.windows-weixin-media.v1";
+export const WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT = "microclaw.windows-ingress-release.v1";
+export const WINDOWS_NODE_MXC_WEIXIN_MEDIA_POLICY_CONTRACT = "microclaw.windows-weixin-media.v1";
+export const WINDOWS_NODE_MXC_WEIXIN_ENVIRONMENT_KEYS = [
+ "MICROCLAW_MXC_INGRESS_RELEASE_PATH",
+ "MICROCLAW_MXC_INGRESS_RELEASE_TOKEN",
+ "MICROCLAW_MXC_WEIXIN_MEDIA_POLICY",
+ "MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE",
+ "MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN",
+] as const;
+
+export function clearInheritedWindowsNodeMxcWeixinEnvironment(
+ environment: Record,
+): void {
+ for (const key of WINDOWS_NODE_MXC_WEIXIN_ENVIRONMENT_KEYS) delete environment[key];
+}
export interface WindowsNodeMxcWeixinContext {
gatewayGeneration: string;
@@ -17,14 +28,14 @@ export interface WindowsNodeMxcWeixinContext {
inboundRoot: string;
outboundTempRoot: string;
mediaPolicyBase64: string;
+ remoteApprovalPipe: string | null;
+ remoteApprovalToken: string | null;
}
type JsonRecord = Record;
function asRecord(value: unknown): JsonRecord {
- return value && typeof value === "object" && !Array.isArray(value)
- ? (value as JsonRecord)
- : {};
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function safeAccountKey(raw: string): string {
@@ -55,7 +66,10 @@ function readAllowFrom(filePath: string): string[] {
: [];
}
-function resolveStoredAccountPath(stateDir: string, accountId: string): string {
+export function resolveStoredWindowsNodeMxcWeixinAccountPath(
+ stateDir: string,
+ accountId: string,
+): string {
const accountsDir = path.join(stateDir, "openclaw-weixin", "accounts");
const primary = path.join(accountsDir, `${accountId}.json`);
if (fs.existsSync(primary)) return primary;
@@ -68,6 +82,17 @@ function resolveStoredAccountPath(stateDir: string, accountId: string): string {
return primary;
}
+export function readWindowsNodeMxcWeixinAllowedSenders(params: {
+ stateDir: string;
+ credentialsDir?: string;
+ accountId: string;
+}): string[] {
+ const credentialsDir = params.credentialsDir?.trim() || path.join(params.stateDir, "credentials");
+ return readAllowFrom(
+ path.join(credentialsDir, `openclaw-weixin-${safeAccountKey(params.accountId)}-allowFrom.json`),
+ );
+}
+
export function preflightWindowsNodeMxcWeixinAuthorization(params: {
stateDir: string;
credentialsDir?: string;
@@ -82,34 +107,34 @@ export function preflightWindowsNodeMxcWeixinAuthorization(params: {
const accountsPath = path.join(params.stateDir, "openclaw-weixin", "accounts.json");
if (!fs.existsSync(accountsPath)) return { migratedAccountIds: [] };
- let accountIds: string[] = [];
- try {
- const parsed: unknown = JSON.parse(fs.readFileSync(accountsPath, "utf8"));
- if (
- !Array.isArray(parsed) ||
- parsed.some((value) => typeof value !== "string" || value.trim().length === 0)
- ) {
- throw new Error("account index is invalid");
+ const accountIds = (() => {
+ try {
+ const parsed: unknown = JSON.parse(fs.readFileSync(accountsPath, "utf8"));
+ if (
+ !Array.isArray(parsed) ||
+ parsed.some((value) => typeof value !== "string" || value.trim().length === 0)
+ ) {
+ throw new Error("account index is invalid");
+ }
+ return parsed as string[];
+ } catch (error) {
+ throw new Error(
+ `MXC WeChat authorization preflight failed: account index is unreadable: ${String(error)}`,
+ { cause: error },
+ );
}
- accountIds = parsed;
- } catch (error) {
- throw new Error(
- `MXC WeChat authorization preflight failed: account index is unreadable: ${String(error)}`,
- );
- }
+ })();
const channel = asRecord(asRecord(root.channels)["openclaw-weixin"]);
const configuredAccounts = asRecord(channel.accounts);
- const credentialsDir =
- params.credentialsDir?.trim() || path.join(params.stateDir, "credentials");
+ const credentialsDir = params.credentialsDir?.trim() || path.join(params.stateDir, "credentials");
const migratedAccountIds: string[] = [];
const blockedAccountIds: string[] = [];
for (const accountId of accountIds) {
- const accountConfig =
- Object.prototype.hasOwnProperty.call(configuredAccounts, accountId)
- ? asRecord(configuredAccounts[accountId])
- : channel;
+ const accountConfig = Object.prototype.hasOwnProperty.call(configuredAccounts, accountId)
+ ? asRecord(configuredAccounts[accountId])
+ : channel;
if (accountConfig.enabled === false) continue;
const allowFromPath = path.join(
@@ -118,9 +143,10 @@ export function preflightWindowsNodeMxcWeixinAuthorization(params: {
);
if (readAllowFrom(allowFromPath).length > 0) continue;
- const account = readJsonRecord(resolveStoredAccountPath(params.stateDir, accountId));
- const ownerUserId =
- typeof account?.userId === "string" ? account.userId.trim() : "";
+ const account = readJsonRecord(
+ resolveStoredWindowsNodeMxcWeixinAccountPath(params.stateDir, accountId),
+ );
+ const ownerUserId = typeof account?.userId === "string" ? account.userId.trim() : "";
if (!ownerUserId) {
blockedAccountIds.push(accountId);
continue;
@@ -168,13 +194,13 @@ export function prepareWindowsNodeMxcWeixinContext(
gatewayGeneration: string,
policyFingerprint: string,
folders: BundledWindowsNodeFolder[],
+ remoteApprovalEnabled = false,
): WindowsNodeMxcWeixinContext {
if (!gatewayGeneration.trim()) throw new Error("Gateway generation is required");
if (!/^[a-f0-9]{64}$/i.test(policyFingerprint)) {
throw new Error("MXC policy fingerprint is invalid");
}
- const { inboundRoot, outboundTempRoot } =
- resolveWindowsNodeMxcWeixinMediaRoots(userDataPath);
+ const { inboundRoot, outboundTempRoot } = resolveWindowsNodeMxcWeixinMediaRoots(userDataPath);
fs.mkdirSync(inboundRoot, { recursive: true });
fs.mkdirSync(outboundTempRoot, { recursive: true });
const releasePath = path.join(userDataPath, "windows-node", "ingress-release.json");
@@ -212,22 +238,29 @@ export function prepareWindowsNodeMxcWeixinContext(
inboundRoot,
outboundTempRoot,
mediaPolicyBase64,
+ remoteApprovalPipe: remoteApprovalEnabled
+ ? `\\\\.\\pipe\\microclaw-weixin-remote-approval-${process.pid}-${randomBytes(12).toString("hex")}`
+ : null,
+ remoteApprovalToken: remoteApprovalEnabled ? randomBytes(32).toString("base64url") : null,
};
}
export function getWindowsNodeMxcWeixinEnvironment(
context: WindowsNodeMxcWeixinContext,
): Record {
- return {
+ const environment: Record = {
MICROCLAW_MXC_INGRESS_RELEASE_PATH: context.releasePath,
MICROCLAW_MXC_INGRESS_RELEASE_TOKEN: context.releaseToken,
MICROCLAW_MXC_WEIXIN_MEDIA_POLICY: context.mediaPolicyBase64,
};
+ if (context.remoteApprovalPipe && context.remoteApprovalToken) {
+ environment.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE = context.remoteApprovalPipe;
+ environment.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN = context.remoteApprovalToken;
+ }
+ return environment;
}
-export function releaseWindowsNodeMxcWeixinIngress(
- context: WindowsNodeMxcWeixinContext,
-): void {
+export function releaseWindowsNodeMxcWeixinIngress(context: WindowsNodeMxcWeixinContext): void {
const record = {
contract: WINDOWS_NODE_MXC_INGRESS_RELEASE_CONTRACT,
gatewayGeneration: context.gatewayGeneration,
diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md
index 213de79..d256064 100644
--- a/docs/experimental-windows-node-mxc.md
+++ b/docs/experimental-windows-node-mxc.md
@@ -177,6 +177,83 @@ is immutable environment data bound to the Gateway generation and MXC policy fin
stale, malformed, or out-of-root policy fails closed without falling back to unrestricted host file
access.
+## WeChat remote approval (Experimental)
+
+WeChat remote approval is a separate, default-off capability under **Settings > Security**. Enabling
+or rebinding requires a local Electron confirmation and binds exactly one authenticated QR-login
+account ID plus its saved WeChat `userId`, which must also be present in the explicit sender
+allowlist; a display name is never an identity. Phase 1 accepts only that owner in a one-to-one chat.
+Group, forwarded, quoted, ambiguous-account, stale-cursor, and out-of-order forms cannot approve.
+
+The product threat model intentionally treats normalized ingress metadata from the allowlisted
+`openclaw-weixin` plugin and Gateway as authentic user input: account identity, sender identity,
+direct-chat identity, upstream message ID and cursor/order, exact content, and quote/forward/group
+metadata. This trust assumption applies to every WeChat command, not only MXC remote approval.
+MicroClaw does not require an independent WeChat server signature or immutable upstream lookup.
+Compromise of the authenticated WeChat session, the allowlisted plugin, or the Gateway therefore
+remains an explicit product risk and is disclosed in Settings.
+
+Electron main remains the sole approval authority. The WeChat plugin has a generation-local,
+token-authenticated named-pipe transport with only two operations: report a normalized inbound
+message before agent dispatch, and deliver a desktop-generated system message. It cannot list
+pending approvals, read a challenge, choose an approval ID, mutate approval state, mint execution
+proofs, or expose the path through the ordinary `message` tool. Exact candidate replies are
+intercepted before session recording and agent dispatch. Mixed, malformed, or otherwise ambiguous
+challenge-shaped replies are also consumed and fail closed, including when the broker transport is
+uncertain. Normal messages, including bare `1`, `2`, or `3`, remain ordinary conversation input.
+The long-poll loop advances its in-memory server cursor before placing ordinary messages on its
+serialized dispatch queue, allowing an exact approval reply to be consumed while the originating
+agent run is waiting. It persists the cursor only after the covered ordinary dispatches complete,
+so a crash replays rather than drops them.
+
+The reserved plain-text prompt begins `[MicroClaw Security Approval #CODE]`. The code is eight
+characters from an unambiguous 32-symbol alphabet (40 bits), generated with `randomBytes`, valid for
+two minutes, one-use, and limited to three wrong attempts plus a global attempt window. The prompt
+renders authorization-relevant strings exactly and reversibly with JSON string quoting. It does not
+trim or NFC-normalize, rewrite user-profile paths, parse and reserialize JSON, convert numeric text,
+collapse duplicate keys, redact partial values, or otherwise change code points. It includes the
+complete command text, executable canonical path and hash, argv, canonical CWD, declared file
+access, agent/session, requested/effective MXC mode, expiry, and every request covered by option 3.
+If those exact fields exceed deterministic request/argument/declaration/line/total limits, contain
+control, C0/C1, surrogate, bidi/format/default-ignorable, line/paragraph separator, zero-width,
+variation-selector, or similar concealment characters, or appear secret-bearing or ambiguously
+redacted, MicroClaw creates no remote challenge and leaves the normal local approval pending.
+OpenClaw 2026.10.2 does not carry reliable sensitive-value provenance for every command display
+field, so Phase 1 additionally uses a fail-closed structured presentation allowlist. The full
+command must exactly reconstruct from the signed argv; executable/CWD/declaration values must be
+canonical local paths or fixed structural bindings; and arguments are limited to bounded
+parameterless switches, explicitly classified path/enum switch values, boolean literals, and short
+relative filenames. Arbitrary positional text or numbers, inline option values, URLs, long opaque
+values, credential-semantic names, and every other unknown form remain local-only.
+This is intentionally conservative: a non-secret command may still require local approval when its
+display sensitivity cannot be proven from the current execution contract.
+
+- `1 CODE` approves the exact first/current request once.
+- `2 CODE` denies the exact request.
+- `3 CODE` approves only the explicitly enumerated requests from the same originating WeChat
+ message once. Phase 1 enumerates a one-item batch, so options 1 and 3 are equivalent.
+
+The in-memory challenge binds the authenticated sender/account/chat, source message ID and cursor,
+agent/session, exact approval IDs and batch, executable canonical path and SHA-256, argv, CWD,
+declarations, requested/effective mode, Gateway generation, policy fingerprint, node identity,
+active-lease hash, creation/expiry, challenge ID, and code hash. Electron revalidates the current
+pending approval, ingress release, Gateway client/generation, policy proof context, and active lease
+immediately before the existing `exec.approval.resolve` call. Remote approval always sends
+`allow-once`; it never creates or consumes a durable approval, widens folders, or changes Limited
+mode's durable-approval prohibition.
+
+First resolution wins across local and remote UI. Local resolution, timeout, relock, Gateway/Node
+restart, policy or lease drift, MXC Off, logout/rebind, feature disable/revoke, shutdown, prompt-send
+failure, and excessive invalid attempts invalidate the challenge. A prompt-send failure leaves the
+normal local approval pending. Acknowledgements are system messages and never enter agent history.
+An acknowledgement delivery failure does not re-dispatch the consumed approval reply. The plugin
+advances its in-memory long-poll cursor while approval is pending, but persists each cursor only
+after every ordinary message covered by it has completed dispatch; a restart therefore replays
+rather than loses queued messages.
+The local JSONL security audit records only timestamps, hashed owner/chat/challenge, hashed approval
+and security identifiers, and result/reason; it never records the raw code, account token, challenge
+state, secrets, or full command values.
+
## CWD and approval contract
The exact attestation contract is `microclaw.windows-cwd.v1`. A supplied CWD must:
diff --git a/docs/plans/2026-03-30-remote-permission-approval.md b/docs/plans/2026-03-30-remote-permission-approval.md
index 3d25e16..53d47d5 100644
--- a/docs/plans/2026-03-30-remote-permission-approval.md
+++ b/docs/plans/2026-03-30-remote-permission-approval.md
@@ -1,7 +1,9 @@
# 远程权限审批:通过微信消息流授权 MicroClaw 操作
**日期**: 2026-03-30
-**状态**: 设计阶段
+**状态**: 已由 `docs/experimental-windows-node-mxc.md` 中的 “WeChat remote approval
+(Experimental)” 安全架构取代。本文仅保留为历史设计记录,不得实现其中的 `Y/N`、通用权限、
+ACL 扩权或 response-file 方案。
**依赖**: AppContainer 沙箱权限系统、openclaw-weixin 插件
---
diff --git a/plugins/openclaw-weixin/src/messaging/process-message.ts b/plugins/openclaw-weixin/src/messaging/process-message.ts
index 81f7382..2439865 100644
--- a/plugins/openclaw-weixin/src/messaging/process-message.ts
+++ b/plugins/openclaw-weixin/src/messaging/process-message.ts
@@ -41,6 +41,7 @@ import {
resolveWeixinOutboundTempRoot,
stageMxcWeixinInboundMedia,
} from "../security/mxc-policy.js";
+import type { WeixinRemoteApprovalTransport } from "../security/remote-approval.js";
const LEGACY_MEDIA_OUTBOUND_TEMP_DIR = path.join(
resolvePreferredOpenClawTmpDir(),
@@ -56,10 +57,35 @@ export type ProcessMessageDeps = {
cdnBaseUrl: string;
token?: string;
typingTicket?: string;
+ remoteApprovalTransport?: WeixinRemoteApprovalTransport;
log: (msg: string) => void;
errLog: (m: string) => void;
};
+export async function interceptWeixinRemoteApprovalMessage(
+ full: WeixinMessage,
+ deps: ProcessMessageDeps,
+ sourcePhase: "candidate" | "start" | "complete" = "candidate",
+): Promise {
+ if (!deps.remoteApprovalTransport) return false;
+ const senderId = full.from_user_id?.trim() ?? "";
+ if (full.context_token && senderId) {
+ setContextToken(deps.accountId, senderId, full.context_token);
+ }
+ const route = deps.channelRuntime.routing.resolveAgentRoute({
+ cfg: deps.config,
+ channel: "openclaw-weixin",
+ accountId: deps.accountId,
+ peer: { kind: "direct", id: senderId },
+ });
+ return deps.remoteApprovalTransport.intercept({
+ message: full,
+ agentId: route.agentId ?? "",
+ sessionKey: route.sessionKey ?? "",
+ sourcePhase,
+ });
+}
+
/** Extract text body from item_list (for slash command detection). */
function extractTextBody(itemList?: import("../api/types.js").MessageItem[]): string {
if (!itemList?.length) return "";
@@ -240,6 +266,22 @@ export async function processOneMessage(
// the correct session (matching the dmScope from config) instead of falling back
// to agent:main:main.
ctx.SessionKey = route.sessionKey;
+ const contextToken = getContextTokenFromMsgContext(ctx);
+ if (contextToken) {
+ setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
+ }
+ if (
+ deps.remoteApprovalTransport &&
+ (await deps.remoteApprovalTransport.intercept({
+ message: full,
+ agentId: route.agentId ?? "",
+ sessionKey: route.sessionKey ?? "",
+ sourcePhase: "start",
+ }))
+ ) {
+ logger.info("remote approval: consumed system approval reply before agent dispatch");
+ return;
+ }
const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
agentId: route.agentId,
});
@@ -269,10 +311,6 @@ export async function processOneMessage(
`recordInboundSession: done storePath=${storePath} sessionKey=${route.sessionKey ?? "(none)"}`,
);
- const contextToken = getContextTokenFromMsgContext(ctx);
- if (contextToken) {
- setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
- }
const runId = randomUUID();
const replyProgressSender = resolveReplyProgressMessagesEnabled(deps.config)
? new WeixinReplyProgressSender({
diff --git a/plugins/openclaw-weixin/src/monitor/monitor.remote-approval.test.ts b/plugins/openclaw-weixin/src/monitor/monitor.remote-approval.test.ts
new file mode 100644
index 0000000..3e77160
--- /dev/null
+++ b/plugins/openclaw-weixin/src/monitor/monitor.remote-approval.test.ts
@@ -0,0 +1,202 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ getUpdates: vi.fn(),
+ intercept: vi.fn(),
+ process: vi.fn(),
+ loadCursor: vi.fn(),
+ saveCursor: vi.fn(),
+}));
+
+vi.mock("../api/api.js", () => ({
+ getUpdates: mocks.getUpdates,
+ classifyFetchError: () => ({ type: "test", description: "test" }),
+}));
+vi.mock("../api/config-cache.js", () => ({
+ WeixinConfigManager: class {
+ async getForUser() {
+ return {};
+ }
+ },
+}));
+vi.mock("../api/session-guard.js", () => ({
+ STALE_TOKEN_ERRCODE: 40001,
+ pauseSession: vi.fn(),
+ getRemainingPauseMs: () => 0,
+}));
+vi.mock("../messaging/process-message.js", () => ({
+ interceptWeixinRemoteApprovalMessage: mocks.intercept,
+ processOneMessage: mocks.process,
+}));
+vi.mock("../storage/sync-buf.js", () => ({
+ getSyncBufFilePath: () => "cursor.json",
+ loadGetUpdatesBuf: mocks.loadCursor,
+ saveGetUpdatesBuf: mocks.saveCursor,
+}));
+vi.mock("../util/logger.js", () => ({
+ logger: {
+ withAccount: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+ }),
+ },
+}));
+vi.mock("../util/redact.js", () => ({ redactBody: (value: string) => value }));
+vi.mock("../security/mxc-policy.js", () => ({
+ isMxcWeixinIngressReleased: () => true,
+ waitForMxcWeixinIngressRelease: vi.fn(),
+}));
+vi.mock("../security/remote-approval.js", () => ({
+ classifyWeixinRemoteApprovalCandidate: () => ({ candidateLike: true }),
+ WeixinRemoteApprovalTransport: class {
+ enabled = true;
+ start = vi.fn();
+ close = vi.fn();
+ },
+}));
+
+import { monitorWeixinProvider } from "./monitor.js";
+
+function message(id: number) {
+ return {
+ seq: id,
+ message_id: id,
+ from_user_id: "owner@im.wechat",
+ to_user_id: "bot@im.wechat",
+ message_type: 1,
+ item_list: [{ type: 1, text_item: { text: `message-${id}` } }],
+ };
+}
+
+function options(signal: AbortSignal) {
+ return {
+ baseUrl: "https://weixin.test",
+ cdnBaseUrl: "https://cdn.test",
+ token: "token",
+ accountId: "bot@im.wechat",
+ config: {},
+ channelRuntime: {} as never,
+ abortSignal: signal,
+ longPollTimeoutMs: 1,
+ };
+}
+
+async function waitFor(predicate: () => boolean): Promise {
+ const deadline = Date.now() + 2_000;
+ while (!predicate()) {
+ if (Date.now() > deadline) throw new Error("timed out");
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+}
+
+describe("WeChat long-poll remote approval ordering", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.loadCursor.mockReturnValue("cursor-0");
+ mocks.intercept.mockResolvedValue(false);
+ });
+
+ it("consumes approval replies out-of-band and persists their cursor without agent dispatch", async () => {
+ const abort = new AbortController();
+ mocks.intercept.mockResolvedValueOnce(true);
+ mocks.getUpdates
+ .mockResolvedValueOnce({ ret: 0, get_updates_buf: "cursor-1", msgs: [message(1)] })
+ .mockImplementationOnce(async () => {
+ abort.abort();
+ throw new Error("aborted");
+ });
+
+ await monitorWeixinProvider(options(abort.signal));
+
+ expect(mocks.process).not.toHaveBeenCalled();
+ await waitFor(() => mocks.saveCursor.mock.calls.length === 1);
+ expect(mocks.saveCursor).toHaveBeenCalledWith("cursor.json", "cursor-1");
+ });
+
+ it("does not persist a cursor until every covered ordinary message finishes dispatch", async () => {
+ const abort = new AbortController();
+ let finishDispatch!: () => void;
+ const dispatch = new Promise((resolve) => {
+ finishDispatch = resolve;
+ });
+ mocks.process.mockReturnValueOnce(dispatch);
+ mocks.getUpdates
+ .mockResolvedValueOnce({ ret: 0, get_updates_buf: "cursor-1", msgs: [message(1)] })
+ .mockImplementationOnce(async () => {
+ await dispatch;
+ abort.abort();
+ throw new Error("aborted");
+ });
+
+ const monitoring = monitorWeixinProvider(options(abort.signal));
+ await waitFor(() => mocks.process.mock.calls.length === 1);
+ expect(mocks.saveCursor).not.toHaveBeenCalled();
+ finishDispatch();
+ await monitoring;
+ await waitFor(() => mocks.saveCursor.mock.calls.length === 1);
+ expect(mocks.saveCursor).toHaveBeenCalledWith("cursor.json", "cursor-1");
+ });
+
+ it("reuses the last durable cursor after dispatch failure instead of losing the message", async () => {
+ const abort = new AbortController();
+ let releaseSecondPoll!: () => void;
+ const secondPoll = new Promise((resolve) => {
+ releaseSecondPoll = resolve;
+ });
+ mocks.process.mockRejectedValueOnce(new Error("dispatch failed"));
+ mocks.getUpdates
+ .mockResolvedValueOnce({ ret: 0, get_updates_buf: "cursor-1", msgs: [message(1)] })
+ .mockImplementationOnce(async () => {
+ await secondPoll;
+ return { ret: 0, get_updates_buf: "cursor-2", msgs: [] };
+ })
+ .mockImplementationOnce(async () => {
+ abort.abort();
+ throw new Error("aborted");
+ });
+
+ const monitoring = monitorWeixinProvider(options(abort.signal));
+ await waitFor(() => mocks.process.mock.calls.length === 1);
+ releaseSecondPoll();
+ await monitoring;
+
+ expect(mocks.saveCursor).not.toHaveBeenCalled();
+ expect(mocks.getUpdates.mock.calls.at(-1)?.[0]).toMatchObject({
+ get_updates_buf: "cursor-0",
+ });
+ });
+
+ it("drains queued dispatch before monitor shutdown completes", async () => {
+ const abort = new AbortController();
+ let finishDispatch!: () => void;
+ const dispatch = new Promise((resolve) => {
+ finishDispatch = resolve;
+ });
+ let rejectPoll!: (error: Error) => void;
+ mocks.process.mockReturnValueOnce(dispatch);
+ mocks.getUpdates
+ .mockResolvedValueOnce({ ret: 0, get_updates_buf: "cursor-1", msgs: [message(1)] })
+ .mockImplementationOnce(
+ () =>
+ new Promise((_resolve, reject) => {
+ rejectPoll = reject;
+ }),
+ );
+
+ let settled = false;
+ const monitoring = monitorWeixinProvider(options(abort.signal)).finally(() => {
+ settled = true;
+ });
+ await waitFor(() => mocks.process.mock.calls.length === 1 && Boolean(rejectPoll));
+ abort.abort();
+ rejectPoll(new Error("aborted"));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(settled).toBe(false);
+
+ finishDispatch();
+ await monitoring;
+ expect(settled).toBe(true);
+ expect(mocks.saveCursor).toHaveBeenCalledWith("cursor.json", "cursor-1");
+ });
+});
diff --git a/plugins/openclaw-weixin/src/monitor/monitor.ts b/plugins/openclaw-weixin/src/monitor/monitor.ts
index 66943c5..99fd3e1 100644
--- a/plugins/openclaw-weixin/src/monitor/monitor.ts
+++ b/plugins/openclaw-weixin/src/monitor/monitor.ts
@@ -4,7 +4,10 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { getUpdates, classifyFetchError } from "../api/api.js";
import { WeixinConfigManager } from "../api/config-cache.js";
import { STALE_TOKEN_ERRCODE, pauseSession, getRemainingPauseMs } from "../api/session-guard.js";
-import { processOneMessage } from "../messaging/process-message.js";
+import {
+ interceptWeixinRemoteApprovalMessage,
+ processOneMessage,
+} from "../messaging/process-message.js";
import { getSyncBufFilePath, loadGetUpdatesBuf, saveGetUpdatesBuf } from "../storage/sync-buf.js";
import { logger } from "../util/logger.js";
import type { Logger } from "../util/logger.js";
@@ -13,6 +16,10 @@ import {
isMxcWeixinIngressReleased,
waitForMxcWeixinIngressRelease,
} from "../security/mxc-policy.js";
+import {
+ classifyWeixinRemoteApprovalCandidate,
+ WeixinRemoteApprovalTransport,
+} from "../security/remote-approval.js";
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
const MAX_CONSECUTIVE_FAILURES = 3;
@@ -58,6 +65,20 @@ export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise {});
const errLog = opts.runtime?.error ?? ((m: string) => log(m));
const aLog: Logger = logger.withAccount(accountId);
+ const remoteApprovalTransport = new WeixinRemoteApprovalTransport({
+ accountId,
+ baseUrl,
+ token,
+ error: errLog,
+ });
+ if (remoteApprovalTransport.enabled) {
+ try {
+ await remoteApprovalTransport.start();
+ } catch (error) {
+ errLog(`weixin remote approval transport unavailable: ${String(error)}`);
+ }
+ }
+ abortSignal?.addEventListener("abort", () => remoteApprovalTransport.close(), { once: true });
if (!channelRuntime) {
const msg =
@@ -76,6 +97,10 @@ export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise {}),
errLog,
- });
+ };
+ let consumedByRemoteApproval = false;
+ try {
+ consumedByRemoteApproval = await interceptWeixinRemoteApprovalMessage(
+ full,
+ baseMessageDeps,
+ );
+ } catch (error) {
+ errLog(`weixin remote approval interception failed: ${String(error)}`);
+ consumedByRemoteApproval =
+ remoteApprovalTransport.enabled &&
+ classifyWeixinRemoteApprovalCandidate(full).candidateLike;
+ }
+ if (consumedByRemoteApproval) {
+ aLog.info("remote approval: consumed system reply before agent dispatch");
+ continue;
+ }
+ dispatchTail = dispatchTail
+ .then(async () => {
+ if (replayRequired) return;
+ const fromUserId = full.from_user_id ?? "";
+ const cachedConfig = await configManager.getForUser(
+ fromUserId,
+ full.context_token,
+ );
+ await processOneMessage(full, {
+ ...baseMessageDeps,
+ typingTicket: cachedConfig.typingTicket,
+ });
+ })
+ .finally(() =>
+ interceptWeixinRemoteApprovalMessage(full, baseMessageDeps, "complete").catch(
+ () => false,
+ ),
+ )
+ .catch((error) => {
+ replayRequired = true;
+ getUpdatesBuf = lastPersistedUpdatesBuf;
+ errLog(`weixin message dispatch failed: ${String(error)}`);
+ });
+ }
+ if (responseUpdatesBuf) {
+ dispatchTail = dispatchTail
+ .then(async () => {
+ if (replayRequired) return;
+ for (;;) {
+ try {
+ saveGetUpdatesBuf(syncFilePath, responseUpdatesBuf);
+ lastPersistedUpdatesBuf = responseUpdatesBuf;
+ cursorPersistenceBlocked = false;
+ aLog.debug(`Saved new get_updates_buf (${responseUpdatesBuf.length} bytes)`);
+ return;
+ } catch (error) {
+ cursorPersistenceBlocked = true;
+ errLog(`weixin sync cursor persistence failed; retrying: ${String(error)}`);
+ await sleep(RETRY_DELAY_MS, abortSignal);
+ }
+ }
+ })
+ .catch((error) => {
+ if (!abortSignal?.aborted) {
+ errLog(`weixin sync cursor persistence stopped: ${String(error)}`);
+ }
+ });
}
} catch (err) {
if (abortSignal?.aborted) {
+ await dispatchTail;
aLog.info(`Monitor stopped (aborted)`);
return;
}
@@ -219,6 +311,7 @@ export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise ({ getContextToken: vi.fn() }));
+vi.mock("../messaging/send.js", () => ({ sendMessageWeixin: vi.fn() }));
+
+import { MessageItemType, MessageType, type WeixinMessage } from "../api/types.js";
+import {
+ classifyWeixinRemoteApprovalCandidate,
+ normalizeInboundSource,
+ WeixinRemoteApprovalTransport,
+} from "./remote-approval.js";
+
+function message(overrides: Partial = {}): WeixinMessage {
+ return {
+ seq: 42,
+ message_id: 1001,
+ from_user_id: "OWNER@IM.WECHAT",
+ to_user_id: "BOT@IM.WECHAT",
+ message_type: MessageType.USER,
+ item_list: [{ type: MessageItemType.TEXT, text_item: { text: "1 23456789" } }],
+ ...overrides,
+ };
+}
+
+describe("WeChat remote approval ingress normalization", () => {
+ it("normalizes exact authenticated direct-chat identity fields", () => {
+ expect(
+ normalizeInboundSource(
+ message(),
+ "BOT@IM.WECHAT",
+ "main",
+ "agent:main:weixin:owner",
+ ),
+ ).toMatchObject({
+ accountId: "bot@im.wechat",
+ senderId: "owner@im.wechat",
+ conversationId: "owner@im.wechat",
+ messageId: "1001",
+ cursor: 42,
+ agentId: "main",
+ sessionKey: "agent:main:weixin:owner",
+ direct: true,
+ forwarded: false,
+ quoted: false,
+ text: "1 23456789",
+ });
+ });
+
+ it("marks group, forwarded, and quoted forms as ineligible", () => {
+ expect(normalizeInboundSource(message({ group_id: "group-1" }), "bot", "main", "s")).toMatchObject({
+ direct: false,
+ });
+ expect(
+ normalizeInboundSource(
+ message({ message_type: MessageType.BOT }),
+ "bot",
+ "main",
+ "s",
+ ),
+ ).toMatchObject({ forwarded: true, direct: false });
+ expect(
+ normalizeInboundSource(
+ message({
+ item_list: [
+ {
+ type: MessageItemType.TEXT,
+ text_item: { text: "1 23456789" },
+ ref_msg: { title: "forwarded approval" },
+ },
+ ],
+ }),
+ "bot",
+ "main",
+ "s",
+ ),
+ ).toMatchObject({ quoted: true });
+ });
+
+ it("does not normalize mixed or split content into an approval reply", () => {
+ expect(
+ normalizeInboundSource(
+ message({
+ item_list: [
+ { type: MessageItemType.TEXT, text_item: { text: "1 " } },
+ { type: MessageItemType.TEXT, text_item: { text: "23456789" } },
+ ],
+ }),
+ "bot",
+ "main",
+ "s",
+ ),
+ ).toMatchObject({ text: "" });
+ });
+
+ it("preserves exact text and classifies exact, mixed, malformed, and ordinary replies", () => {
+ expect(classifyWeixinRemoteApprovalCandidate(message())).toEqual({
+ candidateLike: true,
+ ambiguous: false,
+ text: "1 23456789",
+ });
+ expect(
+ classifyWeixinRemoteApprovalCandidate(
+ message({
+ item_list: [
+ { type: MessageItemType.TEXT, text_item: { text: "prefix 1 23456789 suffix" } },
+ ],
+ }),
+ ),
+ ).toEqual({
+ candidateLike: true,
+ ambiguous: true,
+ text: "prefix 1 23456789 suffix",
+ });
+ expect(
+ classifyWeixinRemoteApprovalCandidate(
+ message({
+ item_list: [{ type: MessageItemType.TEXT, text_item: { text: "1-2345678" } }],
+ }),
+ ),
+ ).toEqual({
+ candidateLike: true,
+ ambiguous: true,
+ text: "1-2345678",
+ });
+ for (const text of ["1", "2", "3"]) {
+ expect(
+ classifyWeixinRemoteApprovalCandidate(
+ message({
+ item_list: [{ type: MessageItemType.TEXT, text_item: { text } }],
+ }),
+ ).candidateLike,
+ ).toBe(false);
+ }
+ expect(
+ normalizeInboundSource(
+ message({
+ item_list: [{ type: MessageItemType.TEXT, text_item: { text: " 1 23456789 " } }],
+ }),
+ "bot",
+ "main",
+ "s",
+ ),
+ ).toMatchObject({ text: " 1 23456789 " });
+ });
+
+ it("rejects ambiguous identity, cursor, message, agent, and session forms", () => {
+ expect(normalizeInboundSource(message({ from_user_id: "display name" }), "bot", "main", "s")).toBeNull();
+ expect(normalizeInboundSource(message({ seq: undefined }), "bot", "main", "s")).toBeNull();
+ expect(
+ normalizeInboundSource(message({ message_id: undefined }), "bot", "main", "s"),
+ ).toBeNull();
+ expect(normalizeInboundSource(message(), "bot", "", "s")).toBeNull();
+ expect(normalizeInboundSource(message(), "bot", "main", "")).toBeNull();
+ });
+
+ it("makes monitor-generation transport shutdown terminal", () => {
+ const previousPipe = process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE;
+ const previousToken = process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN;
+ process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE = "\\\\.\\pipe\\test";
+ process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN = "token";
+ try {
+ const transport = new WeixinRemoteApprovalTransport({
+ accountId: "bot@im.wechat",
+ baseUrl: "https://weixin.test",
+ });
+ expect(transport.enabled).toBe(true);
+ transport.close();
+ expect(transport.enabled).toBe(false);
+ } finally {
+ if (previousPipe === undefined) delete process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE;
+ else process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE = previousPipe;
+ if (previousToken === undefined) delete process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN;
+ else process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN = previousToken;
+ }
+ });
+});
diff --git a/plugins/openclaw-weixin/src/security/remote-approval.ts b/plugins/openclaw-weixin/src/security/remote-approval.ts
new file mode 100644
index 0000000..b95c706
--- /dev/null
+++ b/plugins/openclaw-weixin/src/security/remote-approval.ts
@@ -0,0 +1,327 @@
+import net from "node:net";
+import { randomUUID } from "node:crypto";
+
+import type { WeixinMessage } from "../api/types.js";
+import { MessageItemType, MessageType } from "../api/types.js";
+import { getContextToken } from "../messaging/inbound.js";
+import { sendMessageWeixin } from "../messaging/send.js";
+
+const CONTRACT = "microclaw.windows-weixin-remote-approval.v1";
+const MAX_FRAME_BYTES = 64 * 1024;
+const REQUEST_TIMEOUT_MS = 5_000;
+const CHALLENGE_CODE = "[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}";
+const EXACT_CANDIDATE_PATTERN = new RegExp(`^[123] ${CHALLENGE_CODE}$`, "i");
+const EMBEDDED_CANDIDATE_PATTERN = /[123] [A-Z0-9]{8}/i;
+const MALFORMED_CANDIDATE_PATTERN = new RegExp(
+ "^[123](?:\\s+|[:-])?[A-Z0-9]{6,12}$",
+ "i",
+);
+
+interface InboundSource {
+ accountId: string;
+ senderId: string;
+ conversationId: string;
+ messageId: string;
+ cursor: number;
+ agentId: string;
+ sessionKey: string;
+ direct: boolean;
+ forwarded: boolean;
+ quoted: boolean;
+ receivedAtUnixMs: number;
+ text: string;
+ ambiguous: boolean;
+}
+
+interface RemoteApprovalTransportOptions {
+ accountId: string;
+ baseUrl: string;
+ token?: string;
+ error?: (message: string) => void;
+}
+
+export class WeixinRemoteApprovalTransport {
+ private socket: net.Socket | null = null;
+ private disposed = false;
+ private buffer = "";
+ private readonly pending = new Map<
+ string,
+ {
+ resolve: (value: { consumed: boolean; acknowledgement?: string }) => void;
+ timer: NodeJS.Timeout;
+ consumeOnDisconnect: boolean;
+ }
+ >();
+
+ constructor(private readonly options: RemoteApprovalTransportOptions) {}
+
+ get enabled(): boolean {
+ return Boolean(
+ !this.disposed &&
+ process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE &&
+ process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN,
+ );
+ }
+
+ async start(): Promise {
+ if (!this.enabled || this.socket) return;
+ const pipe = process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_PIPE!;
+ const token = process.env.MICROCLAW_MXC_WEIXIN_REMOTE_APPROVAL_TOKEN!;
+ const socket = net.createConnection(pipe);
+ socket.setEncoding("utf8");
+ this.socket = socket;
+ socket.on("data", (chunk) => this.onData(chunk));
+ socket.on("close", () => this.disconnect());
+ socket.on("error", () => this.disconnect());
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => {
+ this.disconnect();
+ reject(new Error("Remote approval registration timed out"));
+ },
+ 5000,
+ );
+ const onRegistered = (frame: Record) => {
+ if (frame.type !== "registered") return;
+ clearTimeout(timer);
+ this.registeredListener = null;
+ resolve();
+ };
+ this.registeredListener = onRegistered;
+ socket.once("connect", () => {
+ this.write({
+ contract: CONTRACT,
+ type: "register",
+ token,
+ accountId: this.options.accountId,
+ });
+ });
+ socket.once("error", (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+ });
+ }
+
+ async intercept(params: {
+ message: WeixinMessage;
+ agentId: string;
+ sessionKey: string;
+ sourcePhase: "candidate" | "start" | "complete";
+ }): Promise {
+ if (!this.enabled) return false;
+ const candidate = classifyWeixinRemoteApprovalCandidate(params.message);
+ if (params.sourcePhase === "candidate" && !candidate.candidateLike) return false;
+ const reservedCandidate = params.sourcePhase === "candidate" && candidate.candidateLike;
+ if (!this.socket) {
+ try {
+ await this.start();
+ } catch {
+ return reservedCandidate;
+ }
+ }
+ const source = normalizeInboundSource(
+ params.message,
+ this.options.accountId,
+ params.agentId,
+ params.sessionKey,
+ );
+ if (!source) return reservedCandidate;
+ const requestId = randomUUID();
+ const result = await new Promise<{ consumed: boolean; acknowledgement?: string }>((resolve) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(requestId);
+ resolve({ consumed: reservedCandidate });
+ }, REQUEST_TIMEOUT_MS);
+ this.pending.set(requestId, { resolve, timer, consumeOnDisconnect: reservedCandidate });
+ this.write({
+ contract: CONTRACT,
+ type: "inbound",
+ requestId,
+ source: {
+ ...source,
+ text: params.sourcePhase === "candidate" ? candidate.text : source.text,
+ ambiguous: params.sourcePhase === "candidate" && candidate.ambiguous,
+ sourcePhase: params.sourcePhase,
+ },
+ });
+ });
+ if (result.acknowledgement) {
+ try {
+ await this.sendSystemMessage(source.senderId, result.acknowledgement);
+ } catch (error) {
+ this.options.error?.(`weixin remote approval acknowledgement failed: ${String(error)}`);
+ }
+ }
+ return result.consumed;
+ }
+
+ close(): void {
+ this.disposed = true;
+ this.disconnect();
+ }
+
+ private disconnect(): void {
+ const socket = this.socket;
+ this.socket = null;
+ if (socket && !socket.destroyed) socket.destroy();
+ for (const pending of this.pending.values()) {
+ clearTimeout(pending.timer);
+ pending.resolve({ consumed: pending.consumeOnDisconnect });
+ }
+ this.pending.clear();
+ this.buffer = "";
+ this.registeredListener = null;
+ }
+
+ private registeredListener: ((frame: Record) => void) | null = null;
+
+ private onData(chunk: string): void {
+ this.buffer += chunk;
+ if (Buffer.byteLength(this.buffer, "utf8") > MAX_FRAME_BYTES) {
+ this.close();
+ return;
+ }
+ while (true) {
+ const newline = this.buffer.indexOf("\n");
+ if (newline < 0) return;
+ const line = this.buffer.slice(0, newline);
+ this.buffer = this.buffer.slice(newline + 1);
+ let frame: Record;
+ try {
+ frame = JSON.parse(line) as Record;
+ } catch {
+ this.close();
+ return;
+ }
+ if (frame.contract !== CONTRACT) {
+ this.close();
+ return;
+ }
+ this.registeredListener?.(frame);
+ if (frame.type === "inbound-result" && typeof frame.requestId === "string") {
+ const pending = this.pending.get(frame.requestId);
+ if (!pending) continue;
+ this.pending.delete(frame.requestId);
+ clearTimeout(pending.timer);
+ pending.resolve({
+ consumed: frame.consumed === true,
+ acknowledgement:
+ typeof frame.acknowledgement === "string" ? frame.acknowledgement : undefined,
+ });
+ continue;
+ }
+ if (
+ frame.type === "deliver" &&
+ typeof frame.deliveryId === "string" &&
+ typeof frame.to === "string" &&
+ typeof frame.text === "string"
+ ) {
+ void this.sendSystemMessage(frame.to, frame.text)
+ .then(() =>
+ this.write({
+ contract: CONTRACT,
+ type: "delivery-result",
+ deliveryId: frame.deliveryId,
+ ok: true,
+ }),
+ )
+ .catch((error) =>
+ this.write({
+ contract: CONTRACT,
+ type: "delivery-result",
+ deliveryId: frame.deliveryId,
+ ok: false,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ }
+ }
+ }
+
+ private async sendSystemMessage(to: string, text: string): Promise {
+ await sendMessageWeixin({
+ to,
+ text,
+ opts: {
+ baseUrl: this.options.baseUrl,
+ token: this.options.token,
+ contextToken: getContextToken(this.options.accountId, to),
+ },
+ });
+ }
+
+ private write(frame: object): void {
+ if (this.socket && !this.socket.destroyed) {
+ this.socket.write(`${JSON.stringify(frame)}\n`);
+ }
+ }
+}
+
+export function normalizeInboundSource(
+ message: WeixinMessage,
+ accountId: string,
+ agentId: string,
+ sessionKey: string,
+): InboundSource | null {
+ const senderId = message.from_user_id?.trim() ?? "";
+ const messageId = message.message_id;
+ const cursor = message.seq;
+ const textItems = message.item_list?.filter((item) => item.type === MessageItemType.TEXT) ?? [];
+ const text =
+ textItems.length === 1 && message.item_list?.length === 1
+ ? (textItems[0]?.text_item?.text ?? "")
+ : "";
+ if (
+ !accountId.trim() ||
+ !senderId ||
+ !senderId.toLowerCase().endsWith("@im.wechat") ||
+ !Number.isSafeInteger(messageId) ||
+ !Number.isSafeInteger(cursor) ||
+ !agentId.trim() ||
+ !sessionKey.trim()
+ ) {
+ return null;
+ }
+ const quoted = textItems.some((item) => Boolean(item.ref_msg));
+ const forwarded = message.message_type !== MessageType.USER;
+ const direct = message.message_type === MessageType.USER && !message.group_id && Boolean(message.to_user_id);
+ return {
+ accountId: accountId.trim().toLowerCase(),
+ senderId: senderId.toLowerCase(),
+ conversationId: senderId.toLowerCase(),
+ messageId: String(messageId),
+ cursor: cursor!,
+ agentId: agentId.trim(),
+ sessionKey: sessionKey.trim(),
+ direct,
+ forwarded: Boolean(forwarded),
+ quoted,
+ receivedAtUnixMs: Date.now(),
+ text,
+ ambiguous: false,
+ };
+}
+
+export function classifyWeixinRemoteApprovalCandidate(message: WeixinMessage): {
+ candidateLike: boolean;
+ ambiguous: boolean;
+ text: string;
+} {
+ const items = message.item_list ?? [];
+ const textItems = items.filter((item) => item.type === MessageItemType.TEXT);
+ const exactSingleText =
+ items.length === 1 && textItems.length === 1 ? (textItems[0]?.text_item?.text ?? "") : null;
+ if (exactSingleText != null && EXACT_CANDIDATE_PATTERN.test(exactSingleText)) {
+ return { candidateLike: true, ambiguous: false, text: exactSingleText };
+ }
+ const combinedText = textItems.map((item) => item.text_item?.text ?? "").join("\n");
+ const candidateLike =
+ EMBEDDED_CANDIDATE_PATTERN.test(combinedText) ||
+ MALFORMED_CANDIDATE_PATTERN.test(combinedText);
+ return {
+ candidateLike,
+ ambiguous: candidateLike,
+ text: combinedText,
+ };
+}