diff --git a/AGENTS.md b/AGENTS.md index 2cb84400..d0ca6702 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,7 +284,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `docs/reference/g14.md`:G14 provider 节点、k3s 控制桥、legacy DEV/PROD 退役边界、当前 HWLAB runtime lane、device-agent 手动实验边界、Code Queue/CI 候选目标和节点本地 VPN proxy bootstrap 边界。 - `docs/reference/pk01.md`:PK01 腾讯云 provider-gateway、pikanode/MET Docker workload、SSH 透传、磁盘 GC 和 pikanode temp 长效 retention 边界。 - `docs/reference/yaml-first-ops.md`:YAML-first 异构分布式运维架构、现有 YAML 归属优先、公共 ops 层抽取、禁止硬编码 node/service 和薄 domain CLI 规则。 -- `docs/reference/platform-infra.md`:G14 `platform-infra` namespace、YAML-first shared service 配置、YAML-controlled Secret distribution/no runtime reverse inference、Sub2API/Codex pool、FRP 暴露和 on-demand availability probe 开发边界;Sub2API 日常操作统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。 +- `docs/reference/platform-infra.md`:G14 `platform-infra` namespace、YAML-first shared service 配置、YAML-controlled Secret distribution/no runtime reverse inference、Sub2API/Codex pool、WeChat archive / D601 WeChatFerry read-only upstream、FRP 暴露和 on-demand availability probe 开发边界;Sub2API 日常操作统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。 - `docs/reference/master-server-ops.md`:主 server 本机 Codex profile wrapper、ACX/GOCX/Moon Bridge 路由边界、默认模型、真实调用验收和 MiniMax session recovery 规则。 - `docs/reference/g14-observability-infra.md`:G14 原生 k3s 上 Prometheus Operator、`devops-infra` 监控基础设施、跨 namespace scrape 声明和安全边界。 - `docs/reference/gc.md`:UniDesk 主 server 和 provider 磁盘 GC、G14/HWLAB registry retention、safe-stop 线和长期防膨胀收益规则。 diff --git a/config/platform-infra/wechat-archive.yaml b/config/platform-infra/wechat-archive.yaml index 9dffd921..26757256 100644 --- a/config/platform-infra/wechat-archive.yaml +++ b/config/platform-infra/wechat-archive.yaml @@ -6,6 +6,7 @@ metadata: owner: unidesk relatedIssues: - 313 + - 326 target: id: G14 @@ -50,6 +51,82 @@ archiveCallback: tokenKey: UNIDESK_WECHAT_ARCHIVE_TOKEN timeoutMs: 90000 +personalWechatIngress: + enabled: true + mode: d601-windows-wcf-host-with-docker-collector + target: + id: D601 + windowsRoute: D601:win + hostRoute: D601 + dockerRoute: D601 + pcWechat: + isolation: dedicated-install + requiredVersion: 3.9.12.51 + installerAsset: WeChatSetup-3.9.12.51.exe + installRoot: C:/UniDesk/personal-wechat/wechat-3.9.12.51 + dataRoot: C:/UniDesk/personal-wechat/wechat-data + autoUpdatePolicy: disabled + wcfHost: + releaseTag: v39.5.2 + requiredWechatVersion: 3.9.12.51 + releaseUrl: https://github.com/lich0821/WeChatFerry/releases/tag/v39.5.2 + sdkRoot: C:/UniDesk/personal-wechat/wcf/v39.5.2 + stateRoot: C:/UniDesk/personal-wechat/wcf-state + commandPort: 10086 + messagePort: 10087 + bindHost: 0.0.0.0 + supervisor: windows-task-scheduler-user-session + rdpPolicy: disconnect-not-logout + firewall: + publicExposure: false + allowedClientRefs: + - windows-loopback + - d601-wsl + - d601-docker-bridge + collector: + runtime: d601-docker + containerName: personal-wechat-collector + wcfHost: host.docker.internal + commandPort: 10086 + messagePort: 10087 + stateRoot: /var/lib/unidesk/personal-wechat + queueMode: sqlite-wal + outboxMode: archive-callback + archiveCallbackRef: archiveCallback + readOnly: + enabled: true + sendCapability: false + allowedMethods: + - is_login + - get_self_wxid + - enable_receiving_msg + - get_msg + - download_attach + - decrypt_image + forbiddenMethods: + - send_text + - send_image + - send_file + - send_xml + - send_emotion + - send_rich_text + - send_pat_msg + - forward_msg + - accept_friend + - add_room_members + - del_room_members + - invite_room_members + - exec_db_query + - refresh_pyq + - receive_transfer + poc: + accountPolicy: test-account-first + observationWindowHours: 48 + requiredMessageTypes: + - text + - image + - file + baiduNetdisk: serviceId: baidu-netdisk proxyMode: backend-core-microservice-proxy diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fc441a8c..680415bc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -75,7 +75,7 @@ G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期 - `hwlab g14 git-mirror status|apply|sync|flush [--dry-run|--confirm]` 是 `devops-infra` git mirror/relay 的受控维护入口:`apply` 渲染并 server-side apply `devops-infra/git-mirror.yaml`,同时删除遗留 `git-mirror-hwlab-sync` CronJob;`sync` 创建一次性 manual Job,把 GitHub allowlist refs 拉入本地 mirror;`flush` 创建一次性 manual Job,把本地 `v0.2-gitops` 快进推回 GitHub。 `status` 返回 read/write URL、last sync/write/flush、本地 ref、GitHub staging ref 和 pending flush 状态,并在 `cache.summary` 给出 `localV02`、`localGitops`、`githubGitops`、`pendingFlush`、`flushNeeded`、`githubInSync` 和下一条受控 `flushCommand`。confirmed `sync` 和 `flush` 默认创建 `.state/jobs/` 异步 job 并立刻返回可查询状态,只有现场同步调试才显式加 `--wait`;mirror 不设置 CronJob。 如果 PipelineRun 的 `gitops-promote` 阶段报 git mirror 控制面漂移或 refs 不一致,先执行 `hwlab g14 git-mirror apply --confirm` 重新应用当前 `devops-infra/git-mirror.yaml` hook/ConfigMap,再执行 `hwlab g14 git-mirror sync --confirm --wait` 复核 refs;失败的同名 PipelineRun 只能通过 `hwlab g14 control-plane cleanup-runs --lane --pipeline-run --confirm` 受控清理后重试,不要用原生 `kubectl delete` 或手工改 mirror hook。修复后仍必须用 `control-plane status --pipeline-run ` 和 `git-mirror status` 分别确认 runtime closeout 与 GitHub flush。 -- `platform-infra sub2api|langbot|n8n|wechat-archive ...` 是 `platform-infra` namespace 内平台公共服务和公共工作流的受控入口;`sub2api` 支持 `plan|apply|status|validate|codex-pool`,`langbot` 和 `n8n` 支持各自 YAML-controlled `plan|apply|status|logs|validate` 等公共服务操作,`wechat-archive` 支持 `plan|apply|status|validate|pull`,用 `config/platform-infra/wechat-archive.yaml` 声明 LangBot/n8n/Baidu Netdisk 归档链路。`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/*.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整 Sub2API 日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 保留 namespace、YAML-first、路由、Secret 脱敏、PK01 Caddy+FRP 和探针开发边界。 +- `platform-infra sub2api|langbot|n8n|wechat-archive ...` 是 `platform-infra` namespace 内平台公共服务和公共工作流的受控入口;`sub2api` 支持 `plan|apply|status|validate|codex-pool`,`langbot` 和 `n8n` 支持各自 YAML-controlled `plan|apply|status|logs|validate` 等公共服务操作,`wechat-archive` 支持 `plan|apply|status|validate|pull`,用 `config/platform-infra/wechat-archive.yaml` 声明 LangBot/n8n/Baidu Netdisk 归档链路,以及 D601 Windows 隔离 PC 微信 + WeChatFerry host + Docker 只读 collector 的 personal WeChat upstream。`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/*.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整 Sub2API 日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 保留 namespace、YAML-first、路由、Secret 脱敏、PK01 Caddy+FRP 和探针开发边界。 - `secrets plan|sync|status --config config/secrets-distribution.yaml --scope platform-infra` 是平台服务本地 Secret sourceRef 到 Kubernetes Secret key 的受控下发入口。`plan` 只读展示 sourceRef、必需 key、目标 Secret/key、missingKeys 和 fingerprint;`sync --confirm` 只按 YAML 声明创建允许生成的本地 key 并下发到声明的目标 Secret;`status` 只验证 live Secret key presence。该入口禁止从 live pod 或 Kubernetes Secret 反推密码、API key、JWT secret、n8n encryption key 或 `DATABASE_URL`,输出也不得打印 base64、解码值或远端 raw transcript;即使显式 `--raw` 也只返回脱敏 summary 和 raw omission 标记。LangBot/n8n Secret 轮换和缺 key 修复规则见 `docs/reference/platform-infra.md#secret-distribution-boundary`。 - `hwlab g14 observability status|apply|query|targets|boundary|closeout [--lane v02] [--promql ] [--expect-count N] [--expect-value V] [--dry-run|--confirm]` 是 G14 `devops-infra` 共享监控基础设施和 HWLAB v0.2 监控 closeout 的受控入口。`apply` 固定安装 Prometheus Operator `v0.91.0`、Prometheus `v3.12.0`、Prometheus 发现 RBAC、`devops-infra` 内 Prometheus 实例和 ClusterIP query Service,并给被允许发现的 workload namespace 打低风险 label;它不把 Prometheus、Grafana 或 Alertmanager 部署到 `hwlab-v02`,也不接管 HWLAB runtime Deployment/Service。`status` 只读汇总 CRD、operator Deployment、Prometheus CR/pod/service、`hwlab-v02` ServiceMonitor/PrometheusRule 和 bounded `up` 查询;`query` 只通过 Kubernetes service proxy 查询 Prometheus,支持 `--expect-count` / `--expect-value` 输出 `assertion`、bad values 和 missing/extra series;`targets` 汇总 ServiceMonitor/PrometheusRule、metrics sidecar readiness/restart、三层指标值和 `metrics.k8s.io` 当前 CPU/内存资源快照;`boundary` 验证 workload namespace 没有 Prometheus/Alertmanager,并对 `19666/19667` 公网 `/metrics` 做负向验证;`closeout` 聚合平台 ready、scrape reachable、sidecar serving、business health probe、resource snapshot、namespace boundary 和 public metrics exposure 语义结论。长期边界见 `docs/reference/g14-observability-infra.md`。 - `hwlab g14 tools-image status|build --name ci-node-tools --tag [--dockerfile deploy/ci/hwlab-ci-node-tools.Dockerfile] [--dry-run|--confirm]` 是 G14 固定 HWLAB CI tools image 的受控 host build/push 入口;构建和 push 只发生在 G14 host 与本地 registry,不在 master server 构建,也不把 `apk add`/runtime install 塞进 Tekton PipelineRun。 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index c54ca9ad..734b4e3a 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -67,6 +67,9 @@ - For the current n8n runtime, production webhook reachability uses the registered path shape `workflowId/nodeName/webhookPath`; workflow node names used in generated webhooks should be ASCII path-safe, and `webhookPath` in YAML should remain one relative path segment. - Generated n8n workflows should use n8n-native HTTP Request nodes for outbound service callbacks. Code nodes may normalize payloads, but must not assume sandbox globals such as `fetch` exist in the runtime. - Personal WeChat ingestion must be read-only. The durable shape is a YAML-declared LangBot inbound webhook that mirrors messages to the archive workflow and returns `skip_pipeline=true`; the OpenClaw/LangBot bot must also have discard routing as fallback so webhook failure does not produce an automated reply. Do not connect personal WeChat through a normal reply pipeline, do not enable send-message surfaces for this purpose, and do not treat a successful archive upload as permission to reply. +- D601 personal WeChat ingestion is a YAML-declared upstream of the same archive workflow. `config/platform-infra/wechat-archive.yaml` owns the Windows host route, isolated PC WeChat version pin, WeChatFerry release pin, RPC ports, Windows user-session supervisor, firewall boundary, Docker collector runtime and read-only method allowlist. The Windows PC WeChat process and WeChatFerry SDK/RPC host must run in the same Windows user session; Docker may run only the collector/client that connects to the host RPC endpoint. +- The WeChatFerry raw RPC surface must not be exposed publicly or reused as a general bot API. A collector may call only the YAML allowlisted read operations and must report `sendCapability=false`; send, friend/group management, database query, timeline, transfer or other outbound/control methods are policy violations. Login state, WeChat profile data, WCF session material and client databases remain runtime state and must not be decoded, printed, copied into YAML, or reconstructed from the running host. +- The first D601 WCF-host PoC must use a test or low-risk WeChat account and the YAML-declared observation window before any production account promotion. RDP operations should disconnect instead of logging out so the Windows user-session processes keep running; this is an operational boundary until a controlled Windows supervisor/collector CLI fully owns start, status and validate. - If LangBot or n8n public HTTPS fails while in-cluster service and FRP local-port probes are healthy, restore the PK01 Caddy managed blocks through `platform-infra langbot apply --confirm --wait` or `platform-infra n8n apply --confirm --wait`. Do not manually edit Caddy as the durable fix. - The archive uses the same single PK01/Pika01 PostgreSQL instance indirectly through the existing LangBot and n8n databases. Adding this workflow must not create another PostgreSQL instance, in-cluster PostgreSQL StatefulSet, or ad hoc database namespace. - `platform-infra-wechat-archive` and future similar public workflow CLIs should reuse the common platform-infra operations library for YAML parsing, target selection, workflow sync, private microservice proxy calls, transfer polling, staging path mapping, redaction and bounded output. Service-specific modules should keep only their business mapping and workflow payload rendering. diff --git a/docs/reference/yaml-first-ops.md b/docs/reference/yaml-first-ops.md index ee71baee..b5bb1ac8 100644 --- a/docs/reference/yaml-first-ops.md +++ b/docs/reference/yaml-first-ops.md @@ -4,7 +4,7 @@ This document defines the UniDesk architecture for YAML-first heterogeneous dist ## Scope -YAML-first ops applies to UniDesk-owned distributed runtime management across heterogeneous targets: host services, k3s namespaces, public exposure bridges, external databases, app runtime Secrets, CI/CD control-plane bootstrap, workflow services and managed service probes. +YAML-first ops applies to UniDesk-owned distributed runtime management across heterogeneous targets: host services, Windows-hosted GUI bridge and collector pairs, k3s namespaces, public exposure bridges, external databases, app runtime Secrets, CI/CD control-plane bootstrap, workflow services and managed service probes. It is not a new global orchestrator. Existing domain ownership stays intact: diff --git a/scripts/src/platform-infra-wechat-archive.ts b/scripts/src/platform-infra-wechat-archive.ts index 2f5d5bc2..4cab1f12 100644 --- a/scripts/src/platform-infra-wechat-archive.ts +++ b/scripts/src/platform-infra-wechat-archive.ts @@ -64,6 +64,45 @@ interface WechatArchiveConfig { tokenKey: string; timeoutMs: number; }; + personalWechatIngress: { + enabled: boolean; + mode: string; + target: { id: string; windowsRoute: string; hostRoute: string; dockerRoute: string }; + pcWechat: { + isolation: string; + requiredVersion: string; + installerAsset: string; + installRoot: string; + dataRoot: string; + autoUpdatePolicy: string; + }; + wcfHost: { + releaseTag: string; + requiredWechatVersion: string; + releaseUrl: string; + sdkRoot: string; + stateRoot: string; + commandPort: number; + messagePort: number; + bindHost: string; + supervisor: string; + rdpPolicy: string; + firewall: { publicExposure: boolean; allowedClientRefs: string[] }; + }; + collector: { + runtime: string; + containerName: string; + wcfHost: string; + commandPort: number; + messagePort: number; + stateRoot: string; + queueMode: string; + outboxMode: string; + archiveCallbackRef: string; + readOnly: { enabled: boolean; sendCapability: boolean; allowedMethods: string[]; forbiddenMethods: string[] }; + }; + poc: { accountPolicy: string; observationWindowHours: number; requiredMessageTypes: string[] }; + }; baiduNetdisk: { serviceId: string; proxyMode: string; @@ -112,6 +151,7 @@ export function wechatArchiveHelp(): Record { boundary: { langbot: "Receives WeChat/OpenClaw events and forwards archive events to the YAML-declared n8n webhook.", n8n: "Stores a YAML-rendered workflow that normalizes WeChat payloads.", + personalWechatIngress: "Optionally declares the D601 Windows PC WeChat + WeChatFerry host and Docker read-only collector boundary.", baiduNetdisk: "Uploads and downloads through the private backend-core microservice proxy; Baidu credentials remain in the baidu-netdisk service.", database: "LangBot and n8n continue to use the single PK01/Pika01 external PostgreSQL instance with separate databases/roles.", }, @@ -325,6 +365,7 @@ function readConfig(): WechatArchiveConfig { const n8n = recordField(root, "n8n", configLabel); const n8nWorkflow = recordField(n8n, "workflow", `${configLabel}.n8n`); const archiveCallback = recordField(root, "archiveCallback", configLabel); + const personalWechatIngress = parsePersonalWechatIngress(recordField(root, "personalWechatIngress", configLabel)); const baiduNetdisk = recordField(root, "baiduNetdisk", configLabel); const staging = recordField(baiduNetdisk, "staging", `${configLabel}.baiduNetdisk`); const archive = recordField(baiduNetdisk, "archive", `${configLabel}.baiduNetdisk`); @@ -387,6 +428,7 @@ function readConfig(): WechatArchiveConfig { tokenKey: stringField(archiveCallback, "tokenKey", `${configLabel}.archiveCallback`), timeoutMs: numberField(archiveCallback, "timeoutMs", `${configLabel}.archiveCallback`), }, + personalWechatIngress, baiduNetdisk: { serviceId: stringField(baiduNetdisk, "serviceId", `${configLabel}.baiduNetdisk`), proxyMode: stringField(baiduNetdisk, "proxyMode", `${configLabel}.baiduNetdisk`), @@ -448,6 +490,7 @@ function validateConfig(config: WechatArchiveConfig): void { } if (!config.baiduNetdisk.archive.pathTemplate.includes("{{remoteRoot}}")) throw new Error(`${configLabel}.baiduNetdisk.archive.pathTemplate must include {{remoteRoot}}`); if (config.baiduNetdisk.proxyMode !== "backend-core-microservice-proxy") throw new Error(`${configLabel}.baiduNetdisk.proxyMode must be backend-core-microservice-proxy`); + validatePersonalWechatIngress(config.personalWechatIngress); } function assertTarget(config: WechatArchiveConfig, targetId: string): void { @@ -487,6 +530,7 @@ function configSummary(config: WechatArchiveConfig): Record { timeoutMs: config.archiveCallback.timeoutMs, valuesPrinted: false, }, + personalWechatIngress: personalWechatIngressSummary(config.personalWechatIngress), baiduNetdisk: { serviceId: config.baiduNetdisk.serviceId, proxyMode: config.baiduNetdisk.proxyMode, @@ -506,6 +550,12 @@ function policyChecks(config: WechatArchiveConfig): Array): WechatArchiveConfig["personalWechatIngress"] { + const target = recordField(raw, "target", `${configLabel}.personalWechatIngress`); + const pcWechat = recordField(raw, "pcWechat", `${configLabel}.personalWechatIngress`); + const wcfHost = recordField(raw, "wcfHost", `${configLabel}.personalWechatIngress`); + const firewall = recordField(wcfHost, "firewall", `${configLabel}.personalWechatIngress.wcfHost`); + const collector = recordField(raw, "collector", `${configLabel}.personalWechatIngress`); + const readOnly = recordField(collector, "readOnly", `${configLabel}.personalWechatIngress.collector`); + const poc = recordField(raw, "poc", `${configLabel}.personalWechatIngress`); + return { + enabled: booleanField(raw, "enabled", `${configLabel}.personalWechatIngress`), + mode: stringField(raw, "mode", `${configLabel}.personalWechatIngress`), + target: { + id: stringField(target, "id", `${configLabel}.personalWechatIngress.target`), + windowsRoute: stringField(target, "windowsRoute", `${configLabel}.personalWechatIngress.target`), + hostRoute: stringField(target, "hostRoute", `${configLabel}.personalWechatIngress.target`), + dockerRoute: stringField(target, "dockerRoute", `${configLabel}.personalWechatIngress.target`), + }, + pcWechat: { + isolation: stringField(pcWechat, "isolation", `${configLabel}.personalWechatIngress.pcWechat`), + requiredVersion: stringField(pcWechat, "requiredVersion", `${configLabel}.personalWechatIngress.pcWechat`), + installerAsset: stringField(pcWechat, "installerAsset", `${configLabel}.personalWechatIngress.pcWechat`), + installRoot: stringField(pcWechat, "installRoot", `${configLabel}.personalWechatIngress.pcWechat`), + dataRoot: stringField(pcWechat, "dataRoot", `${configLabel}.personalWechatIngress.pcWechat`), + autoUpdatePolicy: stringField(pcWechat, "autoUpdatePolicy", `${configLabel}.personalWechatIngress.pcWechat`), + }, + wcfHost: { + releaseTag: stringField(wcfHost, "releaseTag", `${configLabel}.personalWechatIngress.wcfHost`), + requiredWechatVersion: stringField(wcfHost, "requiredWechatVersion", `${configLabel}.personalWechatIngress.wcfHost`), + releaseUrl: stringField(wcfHost, "releaseUrl", `${configLabel}.personalWechatIngress.wcfHost`), + sdkRoot: stringField(wcfHost, "sdkRoot", `${configLabel}.personalWechatIngress.wcfHost`), + stateRoot: stringField(wcfHost, "stateRoot", `${configLabel}.personalWechatIngress.wcfHost`), + commandPort: numberField(wcfHost, "commandPort", `${configLabel}.personalWechatIngress.wcfHost`), + messagePort: numberField(wcfHost, "messagePort", `${configLabel}.personalWechatIngress.wcfHost`), + bindHost: stringField(wcfHost, "bindHost", `${configLabel}.personalWechatIngress.wcfHost`), + supervisor: stringField(wcfHost, "supervisor", `${configLabel}.personalWechatIngress.wcfHost`), + rdpPolicy: stringField(wcfHost, "rdpPolicy", `${configLabel}.personalWechatIngress.wcfHost`), + firewall: { + publicExposure: booleanField(firewall, "publicExposure", `${configLabel}.personalWechatIngress.wcfHost.firewall`), + allowedClientRefs: stringArrayField(firewall, "allowedClientRefs", `${configLabel}.personalWechatIngress.wcfHost.firewall`), + }, + }, + collector: { + runtime: stringField(collector, "runtime", `${configLabel}.personalWechatIngress.collector`), + containerName: stringField(collector, "containerName", `${configLabel}.personalWechatIngress.collector`), + wcfHost: stringField(collector, "wcfHost", `${configLabel}.personalWechatIngress.collector`), + commandPort: numberField(collector, "commandPort", `${configLabel}.personalWechatIngress.collector`), + messagePort: numberField(collector, "messagePort", `${configLabel}.personalWechatIngress.collector`), + stateRoot: stringField(collector, "stateRoot", `${configLabel}.personalWechatIngress.collector`), + queueMode: stringField(collector, "queueMode", `${configLabel}.personalWechatIngress.collector`), + outboxMode: stringField(collector, "outboxMode", `${configLabel}.personalWechatIngress.collector`), + archiveCallbackRef: stringField(collector, "archiveCallbackRef", `${configLabel}.personalWechatIngress.collector`), + readOnly: { + enabled: booleanField(readOnly, "enabled", `${configLabel}.personalWechatIngress.collector.readOnly`), + sendCapability: booleanField(readOnly, "sendCapability", `${configLabel}.personalWechatIngress.collector.readOnly`), + allowedMethods: stringArrayField(readOnly, "allowedMethods", `${configLabel}.personalWechatIngress.collector.readOnly`), + forbiddenMethods: stringArrayField(readOnly, "forbiddenMethods", `${configLabel}.personalWechatIngress.collector.readOnly`), + }, + }, + poc: { + accountPolicy: stringField(poc, "accountPolicy", `${configLabel}.personalWechatIngress.poc`), + observationWindowHours: numberField(poc, "observationWindowHours", `${configLabel}.personalWechatIngress.poc`), + requiredMessageTypes: stringArrayField(poc, "requiredMessageTypes", `${configLabel}.personalWechatIngress.poc`), + }, + }; +} + +function validatePersonalWechatIngress(config: WechatArchiveConfig["personalWechatIngress"]): void { + if (!config.enabled) return; + if (config.pcWechat.requiredVersion !== config.wcfHost.requiredWechatVersion) { + throw new Error(`${configLabel}.personalWechatIngress pcWechat.requiredVersion and wcfHost.requiredWechatVersion must match`); + } + if (config.collector.commandPort !== config.wcfHost.commandPort || config.collector.messagePort !== config.wcfHost.messagePort) { + throw new Error(`${configLabel}.personalWechatIngress collector ports must match wcfHost ports`); + } + if (config.wcfHost.firewall.publicExposure !== false) { + throw new Error(`${configLabel}.personalWechatIngress.wcfHost.firewall.publicExposure must be false`); + } + if (config.collector.readOnly.enabled !== true || config.collector.readOnly.sendCapability !== false) { + throw new Error(`${configLabel}.personalWechatIngress.collector.readOnly must enable read-only mode and set sendCapability=false`); + } + const allowed = new Set(config.collector.readOnly.allowedMethods); + const overlap = config.collector.readOnly.forbiddenMethods.filter((method) => allowed.has(method)); + if (overlap.length > 0) throw new Error(`${configLabel}.personalWechatIngress.collector.readOnly method allowlist overlaps forbiddenMethods: ${overlap.join(",")}`); + if (config.collector.archiveCallbackRef !== "archiveCallback") { + throw new Error(`${configLabel}.personalWechatIngress.collector.archiveCallbackRef must point at archiveCallback`); + } +} + +function personalWechatIngressSummary(config: WechatArchiveConfig["personalWechatIngress"]): Record { + return { + enabled: config.enabled, + mode: config.mode, + target: config.target, + pcWechat: { + isolation: config.pcWechat.isolation, + requiredVersion: config.pcWechat.requiredVersion, + installerAsset: config.pcWechat.installerAsset, + installRoot: config.pcWechat.installRoot, + dataRoot: config.pcWechat.dataRoot, + autoUpdatePolicy: config.pcWechat.autoUpdatePolicy, + }, + wcfHost: { + releaseTag: config.wcfHost.releaseTag, + requiredWechatVersion: config.wcfHost.requiredWechatVersion, + releaseUrl: config.wcfHost.releaseUrl, + sdkRoot: config.wcfHost.sdkRoot, + stateRoot: config.wcfHost.stateRoot, + commandPort: config.wcfHost.commandPort, + messagePort: config.wcfHost.messagePort, + bindHost: config.wcfHost.bindHost, + supervisor: config.wcfHost.supervisor, + rdpPolicy: config.wcfHost.rdpPolicy, + firewall: config.wcfHost.firewall, + }, + collector: { + runtime: config.collector.runtime, + containerName: config.collector.containerName, + wcfHost: config.collector.wcfHost, + commandPort: config.collector.commandPort, + messagePort: config.collector.messagePort, + stateRoot: config.collector.stateRoot, + queueMode: config.collector.queueMode, + outboxMode: config.collector.outboxMode, + archiveCallbackRef: config.collector.archiveCallbackRef, + readOnly: { + enabled: config.collector.readOnly.enabled, + sendCapability: config.collector.readOnly.sendCapability, + allowedMethods: config.collector.readOnly.allowedMethods, + forbiddenMethods: config.collector.readOnly.forbiddenMethods, + }, + }, + poc: config.poc, + valuesPrinted: false, + }; +} + +function stringArrayField(record: Record, key: string, label: string): string[] { + const value = record[key]; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) { + throw new Error(`${label}.${key} must be a string array`); + } + return value.map((item) => String(item)); +} + function parseReadOnlyRecord(raw: Record): WechatArchiveConfig["langbot"]["readOnlyRecord"] { return { enabled: booleanField(raw, "enabled", `${configLabel}.langbot.readOnlyRecord`),