nodus-wechat 0.2.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nodus-wechat
2
2
 
3
- CLI skeleton for the upcoming Nodus WeChat local agent installer.
3
+ CLI installer for Nodus WeChat, Hermes common settings, and the local OpeniLink webhook runtime.
4
4
 
5
5
  Run:
6
6
 
@@ -11,25 +11,79 @@ npx nodus-wechat
11
11
  ## Commands
12
12
 
13
13
  ```sh
14
- npx nodus-wechat setup --api-key <your-sub2api-key>
14
+ npx nodus-wechat setup
15
+ npx nodus-wechat setup --install-hermes
16
+ npx nodus-wechat install-hermes
15
17
  npx nodus-wechat doctor
16
18
  npx nodus-wechat start
19
+ npx nodus-wechat status
20
+ npx nodus-wechat logs
21
+ npx nodus-wechat stop
17
22
  npx nodus-wechat uninstall --yes
18
23
  ```
19
24
 
25
+ For a server-bound OpeniLink origin:
26
+
27
+ ```sh
28
+ npx nodus-wechat setup \
29
+ --openilink-origin http://192.220.25.138:9800 \
30
+ --openilink-rp-id 192.220.25.138
31
+ ```
32
+
33
+ `setup` prompts for the AstraGate API key when `--api-key` is not supplied.
34
+ The default gateway base URL is `https://api.nodus.sbs/`.
35
+ Use `--install-hermes` or `install-hermes` to run the official Hermes installer
36
+ with `--skip-setup` and the same Hermes home used by this CLI.
37
+
20
38
  ## Current behavior
21
39
 
22
40
  - Creates local configuration at `~/.nodus-wechat/config.json`.
23
- - Stores sub2api base URL, api key, model, and local runtime status fields.
24
- - Checks Node.js and local configuration with `doctor`.
25
- - Reads configuration with `start` and reports that runtime integration is still pending.
41
+ - Can install Hermes Agent CLI through the official NousResearch installer.
42
+ - Installs the OpeniLink + webhook POC runtime at `~/.nodus-wechat/runtime`.
43
+ - Writes Hermes common settings to `~/.hermes/config.yaml`.
44
+ - Writes the AstraGate key to `~/.hermes/.env` as `ASTRAGATE_API_KEY`.
45
+ - Writes runtime `.env`, Docker Compose, webhook server, helper scripts, and the OpeniLink reply plugin.
46
+ - Stores gateway base URL, api key, model, Hermes paths, OpeniLink origin, webhook port, and runtime path.
47
+ - Checks Node.js, local configuration, Hermes files, runtime files, Docker Compose availability, Hermes CLI availability, and WeChat app detection with `doctor`.
48
+ - Starts/stops the local runtime through Docker Compose.
26
49
  - Removes only files created by this CLI with `uninstall --yes`.
27
50
 
28
51
  ## Current non-goals
29
52
 
30
- - Does not install Hermes.
31
- - Does not install or run iLink.
32
- - Does not automate, inject into, read, or control WeChat.
33
- - Does not start a daemon, LaunchAgent, service, or background worker.
53
+ - Does not run a third-party installer unless `--install-hermes` or `install-hermes` is explicitly requested.
54
+ - Does not automate, inject into, read, or control WeChat directly.
55
+ - Does not start a daemon, LaunchAgent, or background worker outside Docker Compose.
56
+ - Does not redeem real CDKs or mutate sub2api accounts; the bundled webhook keeps the existing dry-run POC boundary.
57
+
58
+ ## Runtime wiring
59
+
60
+ After `setup` and `start`, open the OpeniLink Hub shown by the CLI. In the Channel
61
+ Webhook settings, use:
62
+
63
+ ```text
64
+ Webhook URL: http://poc-webhook:9811/webhook
65
+ ```
66
+
67
+ If `--webhook-token` was configured, set the Channel Webhook auth header to:
68
+
69
+ ```text
70
+ Authorization: Bearer <the same token>
71
+ ```
72
+
73
+ The bundled webhook responds to:
74
+
75
+ ```text
76
+ /ping
77
+ /status plus
78
+ /add-plus-dry-run
79
+ ```
80
+
81
+ ## Publish
82
+
83
+ ```sh
84
+ npm run release:check
85
+ npm publish
86
+ ```
34
87
 
35
- The real installer will be released in a later version.
88
+ The package is configured for public npm access. If npm reports `E401`, run
89
+ `npm login` first.
@@ -5,10 +5,16 @@
5
5
  const fs = require("node:fs");
6
6
  const os = require("node:os");
7
7
  const path = require("node:path");
8
+ const childProcess = require("node:child_process");
8
9
 
9
- const VERSION = "0.2";
10
- const DEFAULT_BASE_URL = "https://api.nodus.sbs/v1";
10
+ const VERSION = "0.5.1";
11
+ const DEFAULT_BASE_URL = "https://api.nodus.sbs/";
11
12
  const DEFAULT_MODEL = "gpt-5.5";
13
+ const DEFAULT_OPENILINK_ORIGIN = "http://localhost:9800";
14
+ const DEFAULT_OPENILINK_RP_ID = "localhost";
15
+ const DEFAULT_OPENILINK_PORT = 9800;
16
+ const DEFAULT_WEBHOOK_PORT = 9811;
17
+ const TEMPLATE_DIR = path.join(__dirname, "..", "templates", "wechat-agent-poc");
12
18
 
13
19
  function configHome() {
14
20
  return process.env.NODUS_WECHAT_HOME || path.join(os.homedir(), ".nodus-wechat");
@@ -18,24 +24,41 @@ function configPath() {
18
24
  return path.join(configHome(), "config.json");
19
25
  }
20
26
 
27
+ function hermesHome() {
28
+ return process.env.NODUS_HERMES_HOME || path.join(os.homedir(), ".hermes");
29
+ }
30
+
21
31
  function printHelp() {
22
32
  console.log(`nodus-wechat ${VERSION}
23
33
 
24
- Local CLI skeleton for the upcoming Nodus WeChat Agent installer.
34
+ Local CLI installer for Nodus WeChat, Hermes settings, and the OpeniLink webhook runtime.
25
35
 
26
36
  Usage:
37
+ nodus-wechat [--api-key <key>] [--base-url <url>] [--model <model>]
27
38
  nodus-wechat setup [--api-key <key>] [--base-url <url>] [--model <model>]
39
+ [--runtime-dir <path>] [--openilink-origin <url>]
40
+ [--openilink-rp-id <id>] [--webhook-port <port>]
41
+ [--webhook-token <token>] [--install-hermes]
42
+ nodus-wechat install-hermes
28
43
  nodus-wechat doctor
29
44
  nodus-wechat start
45
+ nodus-wechat status
46
+ nodus-wechat logs
47
+ nodus-wechat stop
30
48
  nodus-wechat uninstall --yes
31
49
 
32
50
  Commands:
33
- setup Create or update local configuration.
34
- doctor Check local prerequisites and configuration.
35
- start Read configuration and show the current stub runtime status.
36
- uninstall Remove files created by this CLI.
37
-
38
- This version does not install Hermes, iLink, or control WeChat.`);
51
+ setup Create or update local configuration and runtime files. This is the default.
52
+ install-hermes Install Hermes Agent CLI with the official installer.
53
+ doctor Check local prerequisites and configuration.
54
+ start Start the local OpeniLink + webhook runtime with Docker Compose.
55
+ status Show Docker Compose service status.
56
+ logs Follow webhook logs.
57
+ stop Stop the local runtime.
58
+ uninstall Remove files created by this CLI.
59
+
60
+ This version installs an OpeniLink webhook POC runtime. It does not inject into,
61
+ read, or control WeChat directly.`);
39
62
  }
40
63
 
41
64
  function parseArgs(argv) {
@@ -49,7 +72,7 @@ function parseArgs(argv) {
49
72
  }
50
73
 
51
74
  const key = item.slice(2);
52
- if (key === "help" || key === "yes") {
75
+ if (key === "help" || key === "yes" || key === "install-hermes") {
53
76
  result[key] = true;
54
77
  continue;
55
78
  }
@@ -77,9 +100,204 @@ function writeConfig(config) {
77
100
  });
78
101
  }
79
102
 
103
+ function parsePositiveInt(value, name) {
104
+ if (value === undefined) {
105
+ return undefined;
106
+ }
107
+
108
+ const parsed = Number.parseInt(value, 10);
109
+ if (!Number.isInteger(parsed) || parsed <= 0) {
110
+ throw new Error(`Invalid value for --${name}: ${value}`);
111
+ }
112
+ return parsed;
113
+ }
114
+
115
+ function parseDotEnv(filePath) {
116
+ if (!fs.existsSync(filePath)) {
117
+ return {};
118
+ }
119
+
120
+ const result = {};
121
+ for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
122
+ if (!line || line.trimStart().startsWith("#") || !line.includes("=")) {
123
+ continue;
124
+ }
125
+ const index = line.indexOf("=");
126
+ result[line.slice(0, index)] = line.slice(index + 1);
127
+ }
128
+ return result;
129
+ }
130
+
131
+ function promptApiKey() {
132
+ if (process.stdin.isTTY && process.platform !== "win32") {
133
+ const result = childProcess.spawnSync(
134
+ "sh",
135
+ [
136
+ "-c",
137
+ [
138
+ 'printf "Paste AstraGate API Key: " > /dev/tty',
139
+ "stty -echo < /dev/tty",
140
+ "IFS= read -r key < /dev/tty",
141
+ "status=$?",
142
+ "stty echo < /dev/tty",
143
+ 'printf "\\n" > /dev/tty',
144
+ 'printf "%s" "$key"',
145
+ "exit $status",
146
+ ].join("; "),
147
+ ],
148
+ { encoding: "utf8" },
149
+ );
150
+ if (!result.error && result.status === 0) {
151
+ return (result.stdout || "").trim();
152
+ }
153
+ }
154
+
155
+ process.stderr.write("Paste AstraGate API Key: ");
156
+ return fs.readFileSync(0, "utf8").split(/\r?\n/)[0].trim();
157
+ }
158
+
159
+ function resolveApiKey(options, existing) {
160
+ const apiKey = options["api-key"] || process.env.NODUS_WECHAT_API_KEY || existing.sub2api?.apiKey || "";
161
+ if (apiKey) {
162
+ return apiKey;
163
+ }
164
+
165
+ const prompted = promptApiKey();
166
+ if (!prompted) {
167
+ throw new Error("AstraGate API Key is required. Rerun setup and paste the key, or pass --api-key <key>.");
168
+ }
169
+ return prompted;
170
+ }
171
+
172
+ function yamlString(value) {
173
+ return JSON.stringify(String(value));
174
+ }
175
+
176
+ function buildHermesConfig(config) {
177
+ return [
178
+ "_config_version: 10",
179
+ "model:",
180
+ ` default: ${yamlString(config.agent.model)}`,
181
+ ' provider: "custom"',
182
+ ` base_url: ${yamlString(config.sub2api.baseUrl)}`,
183
+ ' api_key: "${ASTRAGATE_API_KEY}"',
184
+ "agent:",
185
+ ` reasoning_effort: ${yamlString(config.agent.reasoningEffort)}`,
186
+ "terminal:",
187
+ ' backend: "local"',
188
+ ' cwd: "."',
189
+ "approvals:",
190
+ ' mode: "manual"',
191
+ "toolsets:",
192
+ ' - "all"',
193
+ "display:",
194
+ ' tool_progress: "all"',
195
+ "compression:",
196
+ " enabled: true",
197
+ "",
198
+ ].join("\n");
199
+ }
200
+
201
+ function backupIfExists(filePath) {
202
+ if (!fs.existsSync(filePath)) {
203
+ return;
204
+ }
205
+
206
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
207
+ fs.copyFileSync(filePath, `${filePath}.bak-${stamp}`);
208
+ }
209
+
210
+ function writeHermesEnv(envPath, apiKey) {
211
+ const existingLines = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8").split(/\r?\n/) : [];
212
+ const kept = existingLines.filter((line) => !line.startsWith("ASTRAGATE_API_KEY=") && line.trim() !== "");
213
+ kept.push(`ASTRAGATE_API_KEY=${apiKey}`);
214
+ fs.writeFileSync(envPath, `${kept.join("\n")}\n`, { mode: 0o600 });
215
+ }
216
+
217
+ function installHermesConfig(config) {
218
+ fs.mkdirSync(config.hermes.home, { recursive: true, mode: 0o700 });
219
+ backupIfExists(config.hermes.configPath);
220
+ backupIfExists(config.hermes.envPath);
221
+ fs.writeFileSync(config.hermes.configPath, buildHermesConfig(config), { mode: 0o600 });
222
+ writeHermesEnv(config.hermes.envPath, config.sub2api.apiKey);
223
+ }
224
+
225
+ function shellQuote(value) {
226
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
227
+ }
228
+
229
+ function hermesInstallCommand() {
230
+ return (
231
+ process.env.NODUS_WECHAT_HERMES_INSTALL_COMMAND ||
232
+ "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s --"
233
+ );
234
+ }
235
+
236
+ function runHermesInstaller(hermesDir) {
237
+ const args = ["--skip-setup", "--hermes-home", hermesDir];
238
+ const command = `${hermesInstallCommand()} ${args.map(shellQuote).join(" ")}`;
239
+ const result = childProcess.spawnSync(command, {
240
+ shell: true,
241
+ stdio: "inherit",
242
+ env: { ...process.env, HERMES_HOME: hermesDir },
243
+ });
244
+
245
+ if (result.error) {
246
+ throw result.error;
247
+ }
248
+ if (result.status !== 0) {
249
+ throw new Error(`Hermes installer failed with exit code ${result.status}`);
250
+ }
251
+ }
252
+
253
+ function writeRuntimeEnv(config, options) {
254
+ const envPath = path.join(config.runtime.dir, ".env");
255
+ const existing = parseDotEnv(envPath);
256
+ const webhookToken = options["webhook-token"] ?? existing.POC_WEBHOOK_TOKEN ?? "";
257
+ const lines = [
258
+ "OPENILINK_PORT=" + config.openilink.port,
259
+ "OPENILINK_DATA_DIR=" + path.join(config.runtime.dir, "openilink-hub-data"),
260
+ "POC_WEBHOOK_PORT=" + config.webhook.port,
261
+ "POC_WEBHOOK_BIND=127.0.0.1",
262
+ "",
263
+ "OPENILINK_PUBLIC_ORIGIN=" + config.openilink.publicOrigin,
264
+ "OPENILINK_RP_ID=" + config.openilink.rpId,
265
+ "",
266
+ "POC_WEBHOOK_TOKEN=" + webhookToken,
267
+ "",
268
+ ];
269
+
270
+ fs.writeFileSync(envPath, lines.join("\n"), { mode: 0o600 });
271
+ }
272
+
273
+ function installRuntime(config, options) {
274
+ fs.mkdirSync(config.runtime.dir, { recursive: true, mode: 0o700 });
275
+ fs.cpSync(TEMPLATE_DIR, config.runtime.dir, {
276
+ recursive: true,
277
+ force: true,
278
+ errorOnExist: false,
279
+ });
280
+
281
+ const scriptsDir = path.join(config.runtime.dir, "scripts");
282
+ if (fs.existsSync(scriptsDir)) {
283
+ for (const fileName of fs.readdirSync(scriptsDir)) {
284
+ if (fileName.endsWith(".sh")) {
285
+ fs.chmodSync(path.join(scriptsDir, fileName), 0o755);
286
+ }
287
+ }
288
+ }
289
+
290
+ writeRuntimeEnv(config, options);
291
+ }
292
+
80
293
  function createConfig(options) {
81
294
  const existing = fs.existsSync(configPath()) ? readConfig() : {};
82
295
  const now = new Date().toISOString();
296
+ const runtimeDir = options["runtime-dir"] || existing.runtime?.dir || path.join(configHome(), "runtime");
297
+ const openilinkPort = parsePositiveInt(options["openilink-port"], "openilink-port") || existing.openilink?.port || DEFAULT_OPENILINK_PORT;
298
+ const webhookPort = parsePositiveInt(options["webhook-port"], "webhook-port") || existing.webhook?.port || DEFAULT_WEBHOOK_PORT;
299
+ const hermesDir = options["hermes-home"] || existing.hermes?.home || hermesHome();
300
+ const apiKey = resolveApiKey(options, existing);
83
301
 
84
302
  return {
85
303
  schemaVersion: 1,
@@ -87,7 +305,7 @@ function createConfig(options) {
87
305
  updatedAt: now,
88
306
  sub2api: {
89
307
  baseUrl: options["base-url"] || existing.sub2api?.baseUrl || DEFAULT_BASE_URL,
90
- apiKey: options["api-key"] || process.env.NODUS_WECHAT_API_KEY || existing.sub2api?.apiKey || "",
308
+ apiKey,
91
309
  },
92
310
  agent: {
93
311
  model: options.model || existing.agent?.model || DEFAULT_MODEL,
@@ -95,12 +313,30 @@ function createConfig(options) {
95
313
  approvalMode: existing.agent?.approvalMode || "wechat-confirm",
96
314
  },
97
315
  wechat: {
98
- connector: "ilink",
316
+ connector: "openilink",
99
317
  appPath: existing.wechat?.appPath || null,
100
318
  status: "pending",
101
319
  },
320
+ openilink: {
321
+ publicOrigin: options["openilink-origin"] || existing.openilink?.publicOrigin || DEFAULT_OPENILINK_ORIGIN,
322
+ rpId: options["openilink-rp-id"] || existing.openilink?.rpId || DEFAULT_OPENILINK_RP_ID,
323
+ port: openilinkPort,
324
+ },
325
+ webhook: {
326
+ port: webhookPort,
327
+ bind: "127.0.0.1",
328
+ tokenConfigured: Boolean(options["webhook-token"] || existing.webhook?.tokenConfigured),
329
+ },
330
+ runtime: {
331
+ status: "installed",
332
+ dir: runtimeDir,
333
+ composeFile: path.join(runtimeDir, "docker-compose.yml"),
334
+ },
102
335
  hermes: {
103
- status: "not_installed",
336
+ status: "configured",
337
+ home: hermesDir,
338
+ configPath: path.join(hermesDir, "config.yaml"),
339
+ envPath: path.join(hermesDir, ".env"),
104
340
  },
105
341
  ilink: {
106
342
  status: "not_installed",
@@ -110,15 +346,34 @@ function createConfig(options) {
110
346
 
111
347
  function setup(options) {
112
348
  const config = createConfig(options);
349
+ installRuntime(config, options);
350
+ installHermesConfig(config);
351
+ if (options["install-hermes"]) {
352
+ runHermesInstaller(config.hermes.home);
353
+ }
113
354
  writeConfig(config);
114
355
 
115
356
  console.log(`Config written: ${configPath()}`);
116
- console.log(`Base URL: ${config.sub2api.baseUrl}`);
117
- console.log(`Model: ${config.agent.model}`);
118
- if (!config.sub2api.apiKey) {
119
- console.log("Warning: sub2api api key is empty. Run setup again with --api-key when ready.");
357
+ console.log(`Runtime installed: ${config.runtime.dir}`);
358
+ console.log(`Hermes configured: ${config.hermes.configPath}`);
359
+ if (!options["install-hermes"]) {
360
+ console.log("Hermes CLI install skipped. Run `nodus-wechat install-hermes` if `hermes` is not installed.");
120
361
  }
121
- console.log("Hermes/iLink integration is pending in this CLI version.");
362
+ console.log(`Gateway Base URL: ${config.sub2api.baseUrl}`);
363
+ console.log(`Model: ${config.agent.model}`);
364
+ console.log(`OpeniLink Hub: ${config.openilink.publicOrigin}`);
365
+ console.log(`Webhook URL for OpeniLink: http://poc-webhook:${config.webhook.port}/webhook`);
366
+ console.log("Run `nodus-wechat start` to start the local runtime.");
367
+ }
368
+
369
+ function installHermes() {
370
+ const config = fs.existsSync(configPath())
371
+ ? readConfig()
372
+ : { hermes: { home: hermesHome() } };
373
+ const hermesDir = config.hermes?.home || hermesHome();
374
+ runHermesInstaller(hermesDir);
375
+ console.log(`Hermes installer completed for: ${hermesDir}`);
376
+ return 0;
122
377
  }
123
378
 
124
379
  function doctor() {
@@ -154,7 +409,35 @@ function doctor() {
154
409
  ok = false;
155
410
  console.log("sub2api: failed (api key missing)");
156
411
  }
157
- console.log(`hermes: ${config.hermes?.status || "not_installed"}`);
412
+ if (config.runtime?.dir && fs.existsSync(path.join(config.runtime.dir, "docker-compose.yml"))) {
413
+ console.log(`runtime: installed (${config.runtime.dir})`);
414
+ } else {
415
+ ok = false;
416
+ console.log(`runtime: missing (${config.runtime?.dir || path.join(configHome(), "runtime")})`);
417
+ }
418
+ const docker = dockerComposeAvailable();
419
+ if (docker.ok) {
420
+ console.log(`docker compose: ok (${docker.version})`);
421
+ } else {
422
+ console.log("docker compose: missing (needed for `nodus-wechat start`)");
423
+ }
424
+ console.log(`openilink: ${config.openilink?.publicOrigin || DEFAULT_OPENILINK_ORIGIN}`);
425
+ console.log(`webhook: http://127.0.0.1:${config.webhook?.port || DEFAULT_WEBHOOK_PORT}/health`);
426
+ const hermesConfigPath = config.hermes?.configPath || path.join(hermesHome(), "config.yaml");
427
+ const hermesEnvPath = config.hermes?.envPath || path.join(hermesHome(), ".env");
428
+ const hermesEnv = parseDotEnv(hermesEnvPath);
429
+ if (fs.existsSync(hermesConfigPath) && hermesEnv.ASTRAGATE_API_KEY) {
430
+ console.log(`hermes: configured (${hermesConfigPath})`);
431
+ } else {
432
+ ok = false;
433
+ console.log(`hermes: missing (${hermesConfigPath})`);
434
+ }
435
+ const hermesCli = childProcess.spawnSync("hermes", ["--version"], { encoding: "utf8" });
436
+ if (hermesCli.error || hermesCli.status !== 0) {
437
+ console.log("hermes cli: missing (config is ready; install Hermes before using it)");
438
+ } else {
439
+ console.log(`hermes cli: ok (${(hermesCli.stdout || hermesCli.stderr || "").trim()})`);
440
+ }
158
441
  console.log(`ilink: ${config.ilink?.status || "not_installed"}`);
159
442
  console.log(`wechat: ${findWeChatApp() || "not detected"}`);
160
443
  } catch (error) {
@@ -170,20 +453,96 @@ function findWeChatApp() {
170
453
  return candidates.find((candidate) => fs.existsSync(candidate)) || null;
171
454
  }
172
455
 
173
- function start() {
456
+ function dockerComposeAvailable() {
457
+ const result = childProcess.spawnSync("docker", ["compose", "version"], {
458
+ encoding: "utf8",
459
+ });
460
+ if (result.error || result.status !== 0) {
461
+ return { ok: false };
462
+ }
463
+
464
+ return {
465
+ ok: true,
466
+ version: (result.stdout || result.stderr || "").trim(),
467
+ };
468
+ }
469
+
470
+ function loadRuntimeConfig() {
174
471
  if (!fs.existsSync(configPath())) {
175
472
  console.error(`Config missing: ${configPath()}`);
176
473
  console.error("Run: nodus-wechat setup --api-key <key>");
177
- return 1;
474
+ return null;
178
475
  }
179
476
 
180
477
  const config = readConfig();
181
- console.log("Nodus WeChat runtime stub");
182
- console.log(`Config: ${configPath()}`);
183
- console.log(`Model: ${config.agent?.model || DEFAULT_MODEL}`);
184
- console.log("Hermes/iLink are not integrated in this version.");
185
- console.log("No WeChat automation, login access, or message handling has been started.");
186
- return 0;
478
+ const runtimeDir = config.runtime?.dir || path.join(configHome(), "runtime");
479
+ if (!fs.existsSync(path.join(runtimeDir, "docker-compose.yml"))) {
480
+ console.error(`Runtime missing: ${runtimeDir}`);
481
+ console.error("Run: nodus-wechat setup");
482
+ return null;
483
+ }
484
+
485
+ return { ...config, runtime: { ...config.runtime, dir: runtimeDir } };
486
+ }
487
+
488
+ function runDockerCompose(config, args, stdio = "inherit") {
489
+ const docker = dockerComposeAvailable();
490
+ if (!docker.ok) {
491
+ console.error("Docker Compose is required for this command.");
492
+ console.error("Install Docker Desktop or OrbStack, then rerun `nodus-wechat start`.");
493
+ return 1;
494
+ }
495
+
496
+ const result = childProcess.spawnSync("docker", ["compose", ...args], {
497
+ cwd: config.runtime.dir,
498
+ stdio,
499
+ encoding: stdio === "pipe" ? "utf8" : undefined,
500
+ });
501
+
502
+ if (stdio === "pipe" && result.stdout) {
503
+ process.stdout.write(result.stdout);
504
+ }
505
+ if (stdio === "pipe" && result.stderr) {
506
+ process.stderr.write(result.stderr);
507
+ }
508
+
509
+ return result.status || 0;
510
+ }
511
+
512
+ function start() {
513
+ const config = loadRuntimeConfig();
514
+ if (!config) {
515
+ return 1;
516
+ }
517
+
518
+ return runDockerCompose(config, ["up", "-d"]);
519
+ }
520
+
521
+ function status() {
522
+ const config = loadRuntimeConfig();
523
+ if (!config) {
524
+ return 1;
525
+ }
526
+
527
+ return runDockerCompose(config, ["ps"]);
528
+ }
529
+
530
+ function logs() {
531
+ const config = loadRuntimeConfig();
532
+ if (!config) {
533
+ return 1;
534
+ }
535
+
536
+ return runDockerCompose(config, ["logs", "-f", "poc-webhook"]);
537
+ }
538
+
539
+ function stop() {
540
+ const config = loadRuntimeConfig();
541
+ if (!config) {
542
+ return 1;
543
+ }
544
+
545
+ return runDockerCompose(config, ["down"]);
187
546
  }
188
547
 
189
548
  function uninstall(options) {
@@ -206,27 +565,48 @@ function main() {
206
565
  return 1;
207
566
  }
208
567
 
209
- const command = args._[0];
210
- if (!command || args.help || command === "help") {
568
+ const command = args._[0] || "setup";
569
+ if (args.help || command === "help") {
211
570
  printHelp();
212
571
  return 0;
213
572
  }
214
573
 
215
- if (command === "setup") {
216
- setup(args);
217
- return 0;
218
- }
574
+ try {
575
+ if (command === "setup") {
576
+ setup(args);
577
+ return 0;
578
+ }
219
579
 
220
- if (command === "doctor") {
221
- return doctor();
222
- }
580
+ if (command === "install-hermes") {
581
+ return installHermes();
582
+ }
223
583
 
224
- if (command === "start") {
225
- return start();
226
- }
584
+ if (command === "doctor") {
585
+ return doctor();
586
+ }
587
+
588
+ if (command === "start") {
589
+ return start();
590
+ }
591
+
592
+ if (command === "status") {
593
+ return status();
594
+ }
227
595
 
228
- if (command === "uninstall") {
229
- return uninstall(args);
596
+ if (command === "logs") {
597
+ return logs();
598
+ }
599
+
600
+ if (command === "stop") {
601
+ return stop();
602
+ }
603
+
604
+ if (command === "uninstall") {
605
+ return uninstall(args);
606
+ }
607
+ } catch (error) {
608
+ console.error(error.message);
609
+ return 1;
230
610
  }
231
611
 
232
612
  console.error(`Unknown command: ${command}`);
package/package.json CHANGED
@@ -1,17 +1,23 @@
1
1
  {
2
2
  "name": "nodus-wechat",
3
- "version": "0.2.0",
4
- "description": "CLI skeleton for the upcoming Nodus WeChat local agent installer.",
3
+ "version": "0.5.1",
4
+ "description": "CLI installer for Nodus WeChat, Hermes, and the local OpeniLink webhook runtime.",
5
5
  "license": "MIT",
6
6
  "private": false,
7
7
  "bin": {
8
8
  "nodus-wechat": "bin/nodus-wechat.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test"
11
+ "test": "node --test",
12
+ "release:check": "npm test && npm pack --dry-run",
13
+ "prepublishOnly": "npm test"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
12
17
  },
13
18
  "files": [
14
19
  "bin",
20
+ "templates",
15
21
  "README.md",
16
22
  "LICENSE"
17
23
  ],
@@ -0,0 +1,22 @@
1
+ # Copy to .env before starting the POC.
2
+ #
3
+ # For local verification on this Mac:
4
+ # OPENILINK_PUBLIC_ORIGIN=http://localhost:9800
5
+ # OPENILINK_RP_ID=localhost
6
+ #
7
+ # For the 192 server, replace with the server address you open in the browser:
8
+ # OPENILINK_PUBLIC_ORIGIN=http://192.220.25.138:9800
9
+ # OPENILINK_RP_ID=192.220.25.138
10
+
11
+ OPENILINK_PORT=9800
12
+ OPENILINK_DATA_DIR=/opt/sub2api/openilink-hub-data
13
+ POC_WEBHOOK_PORT=9811
14
+ POC_WEBHOOK_BIND=127.0.0.1
15
+
16
+ # WebAuthn / browser origin. For 192 deployment, use the 192 values below.
17
+ OPENILINK_PUBLIC_ORIGIN=http://192.220.25.138:9800
18
+ OPENILINK_RP_ID=192.220.25.138
19
+
20
+ # Optional. If set, configure the OpeniLink Channel Webhook auth as:
21
+ # Authorization: Bearer <this value>
22
+ POC_WEBHOOK_TOKEN=
@@ -0,0 +1,164 @@
1
+ # WeChat Agent POC
2
+
3
+ 这个 POC 只验证三件事:
4
+
5
+ 1. 能不能扫码绑定微信机器人身份。
6
+ 2. 能不能把机器人/微信号拉进普通微信群。
7
+ 3. 群里发消息后,事件能不能进入 webhook,并能不能回复到群里。
8
+
9
+ 先不要接真实 CDK。这里的 `/add-plus-dry-run` 只返回假任务,确认通道可用后再接 `sub2api` 的真实加号工具。
10
+
11
+ ## Start
12
+
13
+ ```bash
14
+ cd "/Users/zyaire/Documents/API Router/sub2api/deploy/wechat-agent-poc"
15
+ cp .env.example .env
16
+ ./scripts/start.sh
17
+ ```
18
+
19
+ 打开:
20
+
21
+ ```text
22
+ http://192.220.25.138:9800
23
+ ```
24
+
25
+ 本机开发时再把 `.env` 改回:
26
+
27
+ ```dotenv
28
+ OPENILINK_PUBLIC_ORIGIN=http://localhost:9800
29
+ OPENILINK_RP_ID=localhost
30
+ ```
31
+
32
+ ## OpeniLink Setup
33
+
34
+ 当前 192 POC 已经完成基础配置:
35
+
36
+ ```text
37
+ Bot ID: 00612ba4-f96b-4f19-ad75-c3913e92cf75
38
+ Channel: Sub2API POC
39
+ Webhook URL: http://poc-webhook:9811/webhook
40
+ Webhook script: plugins/reply-from-webhook.js
41
+ AI auto-reply: disabled
42
+ Data dir: /opt/sub2api/openilink-hub-data
43
+ ```
44
+
45
+ 如果重新从零部署,在 OpeniLink Hub 后台完成:
46
+
47
+ 1. 注册第一个账号,第一个注册用户会成为管理员。
48
+ 2. 进入 Bot 管理,扫码绑定微信机器人身份。
49
+ 3. 在这个 Bot 下创建一个 Channel。
50
+ 4. 在 Channel 设置中启用 Webhook。
51
+ 5. Webhook URL 填 `http://poc-webhook:9811/webhook`。
52
+ 6. 如果 `.env` 设置了 `POC_WEBHOOK_TOKEN`,认证方式选 Bearer Token,并填入同一个 token。
53
+ 7. 在 Channel 的 Webhook 插件里安装 `plugins/reply-from-webhook.js` 的内容。
54
+ 8. 关闭内置 AI 自动回复,先只测 Webhook 通道。
55
+
56
+ OpeniLink 官方文档里,Webhook 会把消息 POST 到 URL;要把 webhook JSON 响应里的 `reply` 发回微信,需要响应后插件调用 `ctx.reply()`。
57
+
58
+ ## Verification
59
+
60
+ 先私聊机器人:
61
+
62
+ ```text
63
+ /ping
64
+ /status plus
65
+ /add-plus-dry-run
66
+ ```
67
+
68
+ 再拉进普通微信群,群里发:
69
+
70
+ ```text
71
+ @机器人 /ping
72
+ @机器人 /status plus
73
+ @机器人 /add-plus-dry-run
74
+ ```
75
+
76
+ 如果群聊不支持 `@机器人`,也测试直接发:
77
+
78
+ ```text
79
+ /ping
80
+ ```
81
+
82
+ ## Logs
83
+
84
+ 本地探测 webhook:
85
+
86
+ ```bash
87
+ ./scripts/probe-webhook.sh
88
+ ```
89
+
90
+ 查看 webhook 是否收到事件:
91
+
92
+ ```bash
93
+ ./scripts/logs.sh
94
+ ```
95
+
96
+ 查看整体状态:
97
+
98
+ ```bash
99
+ ./scripts/status.sh
100
+ ```
101
+
102
+ ## Deploy To 192
103
+
104
+ 如果本机能 SSH 到 192:
105
+
106
+ ```bash
107
+ ./scripts/deploy-to-192.sh
108
+ ```
109
+
110
+ 默认目标:
111
+
112
+ ```text
113
+ root@192.220.25.138:/opt/sub2api/wechat-agent-poc
114
+ ```
115
+
116
+ 如果服务器没有 Docker,并且是 Ubuntu:
117
+
118
+ ```bash
119
+ ssh root@192.220.25.138
120
+ cd /opt/sub2api/wechat-agent-poc
121
+ ./scripts/install-prereqs-ubuntu.sh
122
+ ./scripts/start.sh
123
+ ```
124
+
125
+ 关键字段:
126
+
127
+ ```text
128
+ payload.type
129
+ payload.content
130
+ payload.sender.user_id
131
+ payload.sessionID
132
+ payload.channel_id
133
+ ```
134
+
135
+ 如果私聊能收、群里收不到,说明 Agent 层没有问题,限制在微信/iLink 群事件投递。不同 OpeniLink 版本的群字段可能叫 `group`、`room` 或只体现在 `sessionID` 里,所以先以日志里的实际 payload 为准。
136
+
137
+ ## Pass Criteria
138
+
139
+ 这几项都通过,再接 Hermes 和真实 sub2api 加号流程:
140
+
141
+ ```text
142
+ 私聊 /ping 有回复
143
+ 群里能看到机器人身份
144
+ 群里 @机器人 /ping 有回复
145
+ webhook 日志里出现 group.id
146
+ webhook 日志里 sender.id 稳定
147
+ 重复发送不会多次触发异常回复
148
+ ```
149
+
150
+ ## Next Step
151
+
152
+ 真实加号流程建议只暴露成受控工具:
153
+
154
+ ```text
155
+ validate_cdk
156
+ redeem_cdk_to_plus_account
157
+ import_account_to_pool
158
+ assign_group("plus")
159
+ healthcheck_account
160
+ enable_account
161
+ report_result_to_wechat
162
+ ```
163
+
164
+ CDK 不建议发在群里。群里只触发任务或查询状态,真实 CDK 走私聊或 sub2api 管理页面。
@@ -0,0 +1,26 @@
1
+ services:
2
+ openilink:
3
+ image: ghcr.io/openilink/openilink-hub:latest
4
+ container_name: sub2api-openilink-poc
5
+ restart: unless-stopped
6
+ working_dir: /data
7
+ ports:
8
+ - "${OPENILINK_PORT:-9800}:9800"
9
+ environment:
10
+ RP_ORIGIN: "${OPENILINK_PUBLIC_ORIGIN:-http://localhost:9800}"
11
+ RP_ID: "${OPENILINK_RP_ID:-localhost}"
12
+ volumes:
13
+ - "${OPENILINK_DATA_DIR:-/opt/sub2api/openilink-hub-data}:/var/lib/openilink-hub"
14
+
15
+ poc-webhook:
16
+ image: python:3.12-alpine
17
+ container_name: sub2api-wechat-poc-webhook
18
+ restart: unless-stopped
19
+ working_dir: /app
20
+ command: ["python", "server.py"]
21
+ ports:
22
+ - "${POC_WEBHOOK_BIND:-127.0.0.1}:${POC_WEBHOOK_PORT:-9811}:9811"
23
+ environment:
24
+ POC_WEBHOOK_TOKEN: "${POC_WEBHOOK_TOKEN:-}"
25
+ volumes:
26
+ - ./poc-webhook:/app:ro
@@ -0,0 +1,27 @@
1
+ // ==UserScript==
2
+ // @name Sub2API POC Reply From Webhook
3
+ // @description Replies to WeChat with the JSON reply field returned by the POC webhook.
4
+ // @version 1.0.0
5
+ // @match *
6
+ // @grant none
7
+ // @timeout 3000
8
+ // ==/UserScript==
9
+
10
+ function onResponse(ctx) {
11
+ if (!ctx.res || ctx.res.status < 200 || ctx.res.status >= 300) {
12
+ ctx.reply("POC webhook failed. Check docker compose logs -f poc-webhook.");
13
+ return;
14
+ }
15
+
16
+ let body = {};
17
+ try {
18
+ body = JSON.parse(ctx.res.body || "{}");
19
+ } catch (err) {
20
+ ctx.reply("POC webhook returned non-JSON response.");
21
+ return;
22
+ }
23
+
24
+ if (body.reply) {
25
+ ctx.reply(String(body.reply));
26
+ }
27
+ }
@@ -0,0 +1,118 @@
1
+ from http.server import BaseHTTPRequestHandler, HTTPServer
2
+ import json
3
+ import os
4
+ import time
5
+
6
+
7
+ def _compact(value):
8
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
9
+
10
+
11
+ def _payload_value(payload, *paths):
12
+ for path in paths:
13
+ cur = payload
14
+ ok = True
15
+ for key in path:
16
+ if isinstance(cur, dict) and key in cur:
17
+ cur = cur[key]
18
+ else:
19
+ ok = False
20
+ break
21
+ if ok and cur not in (None, ""):
22
+ return cur
23
+ return None
24
+
25
+
26
+ class Handler(BaseHTTPRequestHandler):
27
+ def _send_json(self, status, payload):
28
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
29
+ self.send_response(status)
30
+ self.send_header("Content-Type", "application/json; charset=utf-8")
31
+ self.send_header("Content-Length", str(len(body)))
32
+ self.end_headers()
33
+ self.wfile.write(body)
34
+
35
+ def do_GET(self):
36
+ if self.path == "/health":
37
+ self._send_json(200, {"ok": True, "service": "sub2api-wechat-poc-webhook"})
38
+ return
39
+ self._send_json(404, {"ok": False, "error": "not_found"})
40
+
41
+ def do_POST(self):
42
+ expected_token = os.environ.get("POC_WEBHOOK_TOKEN", "")
43
+ if expected_token:
44
+ expected = f"Bearer {expected_token}"
45
+ if self.headers.get("Authorization") != expected:
46
+ self._send_json(401, {"ok": False, "error": "unauthorized"})
47
+ return
48
+
49
+ length = int(self.headers.get("Content-Length", "0"))
50
+ raw = self.rfile.read(length)
51
+ try:
52
+ payload = json.loads(raw.decode("utf-8") or "{}")
53
+ except json.JSONDecodeError:
54
+ self._send_json(400, {"ok": False, "error": "invalid_json"})
55
+ return
56
+
57
+ print(_compact({"ts": int(time.time()), "path": self.path, "payload": payload}), flush=True)
58
+
59
+ if payload.get("type") == "url_verification":
60
+ self._send_json(200, {"challenge": payload.get("challenge")})
61
+ return
62
+
63
+ content = (_payload_value(payload, ("content",), ("text",), ("event", "data", "content"), ("event", "data", "text")) or "").strip()
64
+ sender = _payload_value(payload, ("sender",), ("event", "data", "sender")) or {}
65
+ session_id = _payload_value(payload, ("sessionID",), ("session_id",), ("event", "data", "sessionID"))
66
+ channel_id = _payload_value(payload, ("channel_id",), ("channelID",), ("event", "channel_id"))
67
+ group = _payload_value(payload, ("group",), ("room",), ("event", "data", "group"), ("event", "data", "room"))
68
+
69
+ if content.startswith("/ping") or content == "ping":
70
+ self._send_json(200, {"reply": "pong: sub2api wechat POC webhook is alive"})
71
+ return
72
+
73
+ if content.startswith("/status") or "状态" in content:
74
+ group_name = "plus" if "plus" in content.lower() else "all"
75
+ self._send_json(
76
+ 200,
77
+ {
78
+ "reply": (
79
+ f"POC status group={group_name}: total=3 active=2 disabled=1. "
80
+ "This is mock data; WeChat event delivery is working."
81
+ )
82
+ },
83
+ )
84
+ return
85
+
86
+ if content.startswith("/add-plus-dry-run") or "加号" in content:
87
+ self._send_json(
88
+ 200,
89
+ {
90
+ "reply": (
91
+ "dry-run accepted: job=poc_plus_import_001, group=plus, "
92
+ "no real CDK was redeemed."
93
+ )
94
+ },
95
+ )
96
+ return
97
+
98
+ scope = "group" if group else "dm_or_unknown"
99
+ sender_id = sender.get("user_id") or sender.get("id") or "unknown"
100
+ group_id = None
101
+ if isinstance(group, dict):
102
+ group_id = group.get("id") or group.get("room_id") or group.get("user_id")
103
+ self._send_json(
104
+ 200,
105
+ {
106
+ "reply": (
107
+ f"received {scope} message. sender={sender_id}"
108
+ + (f" group={group_id}" if group_id else "")
109
+ + (f" session={session_id}" if session_id else "")
110
+ + (f" channel={channel_id}" if channel_id else "")
111
+ + ". Try /ping, /status plus, or /add-plus-dry-run."
112
+ )
113
+ },
114
+ )
115
+
116
+
117
+ if __name__ == "__main__":
118
+ HTTPServer(("0.0.0.0", 9811), Handler).serve_forever()
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
5
+ REMOTE="${WECHAT_POC_REMOTE:-root@192.220.25.138}"
6
+ REMOTE_DIR="${WECHAT_POC_REMOTE_DIR:-/opt/sub2api/wechat-agent-poc}"
7
+
8
+ ssh "$REMOTE" "mkdir -p '$REMOTE_DIR'"
9
+ rsync -az --delete \
10
+ --exclude '__pycache__' \
11
+ --exclude '.pytest_cache' \
12
+ "$LOCAL_DIR/" "$REMOTE:$REMOTE_DIR/"
13
+
14
+ ssh "$REMOTE" "cd '$REMOTE_DIR' && chmod +x scripts/*.sh && ./scripts/start.sh"
15
+
16
+ printf "OpeniLink Hub: http://192.220.25.138:9800\n"
17
+ printf "Remote logs: ssh %s 'cd %s && ./scripts/logs.sh'\n" "$REMOTE" "$REMOTE_DIR"
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
5
+ docker compose version
6
+ exit 0
7
+ fi
8
+
9
+ if ! command -v apt-get >/dev/null 2>&1; then
10
+ echo "This helper only supports apt-based Linux hosts. Install Docker manually, then rerun scripts/start.sh." >&2
11
+ exit 1
12
+ fi
13
+
14
+ sudo apt-get update
15
+ sudo apt-get install -y ca-certificates curl gnupg
16
+ sudo install -m 0755 -d /etc/apt/keyrings
17
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
18
+ sudo chmod a+r /etc/apt/keyrings/docker.gpg
19
+
20
+ . /etc/os-release
21
+ echo \
22
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
23
+ ${VERSION_CODENAME} stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
24
+
25
+ sudo apt-get update
26
+ sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
27
+ sudo systemctl enable --now docker
28
+ docker compose version
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+ docker compose logs -f poc-webhook
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ TOKEN=""
7
+ if [ -f .env ]; then
8
+ TOKEN="$(awk -F= '$1=="POC_WEBHOOK_TOKEN"{print $2}' .env | tail -1)"
9
+ fi
10
+
11
+ AUTH_ARGS=()
12
+ if [ -n "$TOKEN" ]; then
13
+ AUTH_ARGS=(-H "Authorization: Bearer $TOKEN")
14
+ fi
15
+
16
+ curl -fsS \
17
+ -H "Content-Type: application/json" \
18
+ "${AUTH_ARGS[@]}" \
19
+ -d '{"type":"message","content":"/ping","sender":{"user_id":"local_probe","user_name":"local probe"},"sessionID":"probe_session","channel_id":"probe_channel"}' \
20
+ "http://127.0.0.1:${POC_WEBHOOK_PORT:-9811}/webhook"
21
+ printf "\n"
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ if [ ! -f .env ]; then
7
+ cp .env.example .env
8
+ fi
9
+
10
+ docker compose up -d
11
+ docker compose ps
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ docker compose ps
7
+ printf "\nWebhook health:\n"
8
+ curl -fsS "http://127.0.0.1:${POC_WEBHOOK_PORT:-9811}/health" || true
9
+ printf "\n\nRecent webhook logs:\n"
10
+ docker compose logs --tail=80 poc-webhook
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+ docker compose down
@@ -0,0 +1,53 @@
1
+ # Sub2API Agent Tools Contract
2
+
3
+ 这个文件定义下一步给 Hermes 或其他 Agent 调用的最小工具边界。当前 POC 只验证微信通道,暂不执行真实 CDK 兑换。
4
+
5
+ ## Tool Boundary
6
+
7
+ Agent 只能调用 HTTP 工具服务,不直接连数据库,不直接读取 CDK 明文日志。
8
+
9
+ 推荐工具:
10
+
11
+ ```text
12
+ GET /agent-tools/pool/status?group=plus
13
+ POST /agent-tools/plus/import-jobs
14
+ GET /agent-tools/plus/import-jobs/{id}
15
+ POST /agent-tools/plus/import-jobs/{id}/retry
16
+ POST /agent-tools/accounts/{id}/disable
17
+ POST /agent-tools/accounts/{id}/enable
18
+ ```
19
+
20
+ ## Import Job
21
+
22
+ ```json
23
+ {
24
+ "requester_wechat_id": "wxid_xxx",
25
+ "group": "plus",
26
+ "cdk": "redacted-at-rest",
27
+ "dry_run": true
28
+ }
29
+ ```
30
+
31
+ 状态机:
32
+
33
+ ```text
34
+ pending
35
+ validating_cdk
36
+ redeeming
37
+ importing_account
38
+ assigning_group
39
+ healthchecking
40
+ enabled
41
+ failed
42
+ ```
43
+
44
+ ## Safety
45
+
46
+ ```text
47
+ 群聊只能查询状态和创建 dry-run job
48
+ 真实 CDK 只允许私聊或 sub2api 管理页提交
49
+ 所有写操作必须校验 requester_wechat_id allowlist
50
+ 所有 CDK 日志必须脱敏
51
+ 同一个 CDK hash 必须幂等
52
+ 失败 job 必须保留 error_code 和 redacted_error
53
+ ```