@mkterswingman/5mghost-wonder 0.0.11 → 0.0.14

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.
@@ -0,0 +1,76 @@
1
+ // src/commands/browser.ts
2
+ // Experimental browser runtime commands.
3
+ import { resolveWonderPaths } from "../platform/paths.js";
4
+ import { parseWecomUrl } from "../wecom/url.js";
5
+ import { runBrowserNoExportProbe } from "../wecom/browser-probe.js";
6
+ export async function runBrowserCommand(argv, context) {
7
+ const [sub, ...rest] = argv;
8
+ switch (sub) {
9
+ case "probe":
10
+ return runBrowserProbe(rest, context);
11
+ default:
12
+ context.io.stderr(`Unknown subcommand: browser ${sub ?? "(none)"}\n` +
13
+ "Usage: wonder browser probe <url> [--headed] [--timeout-ms <ms>] [--save <dir>]");
14
+ return { exitCode: 1 };
15
+ }
16
+ }
17
+ async function runBrowserProbe(args, context) {
18
+ let url;
19
+ let saveDir;
20
+ let timeoutMs;
21
+ let headed = false;
22
+ for (let i = 0; i < args.length; i++) {
23
+ const arg = args[i];
24
+ if (arg === "--save" && args[i + 1]) {
25
+ saveDir = args[++i];
26
+ }
27
+ else if (arg === "--timeout-ms" && args[i + 1]) {
28
+ timeoutMs = Number(args[++i]);
29
+ }
30
+ else if (arg === "--headed") {
31
+ headed = true;
32
+ }
33
+ else if (!arg.startsWith("-")) {
34
+ url = arg;
35
+ }
36
+ }
37
+ if (!url) {
38
+ context.io.stderr(JSON.stringify({
39
+ error: "missing_url",
40
+ message: "用法:wonder browser probe <url> [--headed] [--timeout-ms <ms>] [--save <dir>]",
41
+ }));
42
+ return { exitCode: 1, telemetry: { outcome: "failure", errorKind: "missing_url" } };
43
+ }
44
+ const parsed = parseWecomUrl(url);
45
+ if (!parsed.ok) {
46
+ context.io.stderr(JSON.stringify({
47
+ error: "invalid_url",
48
+ message: "无法识别的 WeCom URL",
49
+ }));
50
+ return { exitCode: 1, telemetry: { outcome: "failure", errorKind: "invalid_url" } };
51
+ }
52
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 60_000)) {
53
+ context.io.stderr(JSON.stringify({
54
+ error: "invalid_timeout",
55
+ message: "--timeout-ms must be between 1000 and 60000",
56
+ }));
57
+ return { exitCode: 1, telemetry: { outcome: "failure", errorKind: "invalid_timeout" } };
58
+ }
59
+ const paths = resolveWonderPaths({ homeDir: context.homeDir });
60
+ const result = await runBrowserNoExportProbe({
61
+ url,
62
+ chromeProfilePath: paths.chromeProfilePath,
63
+ saveDir,
64
+ headed,
65
+ timeoutMs,
66
+ io: context.io,
67
+ });
68
+ context.io.stdout(JSON.stringify(result));
69
+ return {
70
+ exitCode: result.status === "fail" ? 1 : 0,
71
+ telemetry: {
72
+ outcome: result.status === "fail" ? "failure" : "success",
73
+ errorKind: result.status === "fail" ? "browser_probe_failed" : undefined,
74
+ },
75
+ };
76
+ }
@@ -30,6 +30,11 @@ export function renderHelpText() {
30
30
  " doc/slide → download file + path JSON",
31
31
  " read <url> --tab <name> Read specific xlsx tab (cells + merges + images)",
32
32
  " read <url> --save <dir> Output directory (default: ~/Downloads/5mghost-wonder/)",
33
+ "",
34
+ "Experimental browser runtime:",
35
+ " browser probe <url> Open in Wonder browser profile and capture",
36
+ " no-export evidence summaries",
37
+ " browser probe <url> --headed Show the browser while probing",
33
38
  ].join("\n");
34
39
  }
35
40
  export async function runHelpCommand(io) {
@@ -11,6 +11,7 @@ import { runSetupCommand } from "./setup.js";
11
11
  import { runAuthCommand } from "./auth.js";
12
12
  import { runWecom } from "./wecom.js";
13
13
  import { runReadCommand } from "./read.js";
14
+ import { runBrowserCommand } from "./browser.js";
14
15
  export async function dispatchWonderCommand(argv, context) {
15
16
  const [cmd, ...rest] = argv;
16
17
  switch (cmd) {
@@ -30,6 +31,8 @@ export async function dispatchWonderCommand(argv, context) {
30
31
  return runWecom(rest, context);
31
32
  case "read":
32
33
  return runRead(rest, context);
34
+ case "browser":
35
+ return runBrowserCommand(rest, context);
33
36
  case "--help":
34
37
  case "-h":
35
38
  case "help":
@@ -2,7 +2,7 @@
2
2
  // Factory for the wonder telemetry runtime.
3
3
  // Returns null on any construction error — telemetry must never crash the CLI.
4
4
  import { DEFAULT_AUTH_URL, TokenManager } from "@mkterswingman/5mghost-auth";
5
- import { TelemetryRuntime, TelemetrySender } from "@mkterswingman/5mghost-telemetry";
5
+ import { TelemetryRuntime, TelemetrySender, } from "@mkterswingman/5mghost-telemetry";
6
6
  import packageJson from "../../package.json" with { type: "json" };
7
7
  export function createWonderTelemetryRuntime(options = {}) {
8
8
  try {
@@ -17,11 +17,12 @@ export function createWonderTelemetryRuntime(options = {}) {
17
17
  authUrl,
18
18
  homeDir: options.homeDir,
19
19
  });
20
+ const sender = new SafeTelemetrySender(new TelemetrySender(tokenManager, ingestUrl));
20
21
  return new TelemetryRuntime({
21
22
  product: "5mghost-wonder",
22
23
  productVersion: packageJson.version,
23
24
  homeDir: options.homeDir,
24
- sender: new TelemetrySender(tokenManager, ingestUrl),
25
+ sender,
25
26
  });
26
27
  }
27
28
  catch {
@@ -29,3 +30,23 @@ export function createWonderTelemetryRuntime(options = {}) {
29
30
  return null;
30
31
  }
31
32
  }
33
+ class SafeTelemetrySender {
34
+ delegate;
35
+ constructor(delegate) {
36
+ this.delegate = delegate;
37
+ }
38
+ async sendBatch(events) {
39
+ try {
40
+ return await this.delegate.sendBatch(events);
41
+ }
42
+ catch (err) {
43
+ const errorName = err instanceof Error ? err.name : typeof err;
44
+ return {
45
+ disposition: {
46
+ retryable: true,
47
+ reason: `transport:${errorName}`,
48
+ },
49
+ };
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,402 @@
1
+ // src/wecom/browser-probe.ts
2
+ // Experimental browser runtime probe for no-export reads. This intentionally
3
+ // records evidence summaries, not raw network bodies or cookies.
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import { createServer } from "node:net";
7
+ import { join, resolve } from "node:path";
8
+ import WebSocket from "ws";
9
+ const CDP_CALL_TIMEOUT_MS = 5000;
10
+ const BROWSER_START_TIMEOUT_MS = 20_000;
11
+ const DEFAULT_CAPTURE_MS = 8_000;
12
+ const MAX_NETWORK_CANDIDATES = 40;
13
+ let cdpNextId = 1;
14
+ export async function runBrowserNoExportProbe(options) {
15
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CAPTURE_MS;
16
+ const browser = findInstalledBrowser(options.executablePath);
17
+ const port = await getFreePort();
18
+ const endpoint = `http://127.0.0.1:${port}`;
19
+ const child = spawnBrowser({
20
+ browser,
21
+ port,
22
+ profilePath: options.chromeProfilePath,
23
+ url: options.url,
24
+ headed: options.headed ?? false,
25
+ });
26
+ try {
27
+ await waitForDebugger(endpoint, BROWSER_START_TIMEOUT_MS);
28
+ const pageWsUrl = await waitForPageWebSocket(endpoint, options.url, BROWSER_START_TIMEOUT_MS);
29
+ const socket = await openDevToolsSocket(pageWsUrl);
30
+ const responses = [];
31
+ try {
32
+ socket.addEventListener("message", (event) => {
33
+ const payload = safeJsonParse(String(event.data));
34
+ if (payload?.method !== "Network.responseReceived")
35
+ return;
36
+ const response = payload.params?.response;
37
+ const requestId = payload.params?.requestId;
38
+ if (!response?.url || !requestId)
39
+ return;
40
+ if (!isInterestingNetworkUrl(response.url, response.mimeType ?? ""))
41
+ return;
42
+ responses.push({
43
+ requestId,
44
+ url: response.url,
45
+ status: response.status ?? 0,
46
+ mimeType: response.mimeType ?? "",
47
+ });
48
+ });
49
+ await sendCdpCommand(socket, "Network.enable");
50
+ await sendCdpCommand(socket, "Page.enable");
51
+ await sendCdpCommand(socket, "Runtime.enable");
52
+ await sendCdpCommand(socket, "Page.navigate", { url: options.url });
53
+ await delay(timeoutMs);
54
+ const pageInfo = await evaluatePageInfo(socket);
55
+ const networkCandidates = await collectNetworkCandidates(socket, responses);
56
+ const evidence = ["browser-page"];
57
+ if (pageInfo.visibleTextSample)
58
+ evidence.push("visible-text");
59
+ if (pageInfo.runtimeGlobals.length > 0)
60
+ evidence.push("runtime-globals");
61
+ if (networkCandidates.length > 0)
62
+ evidence.push("network-response-summary");
63
+ const missing = [
64
+ "raw structured document model",
65
+ "merge ranges",
66
+ "image original resources",
67
+ "image anchors",
68
+ ];
69
+ const result = {
70
+ mode: "browser-no-export-probe",
71
+ status: "partial",
72
+ url: redactUrl(options.url),
73
+ finalUrl: pageInfo.finalUrl ? redactUrl(pageInfo.finalUrl) : undefined,
74
+ title: pageInfo.title,
75
+ loginLikely: pageInfo.loginLikely,
76
+ visibleTextSample: pageInfo.visibleTextSample,
77
+ runtimeGlobals: pageInfo.runtimeGlobals,
78
+ networkCandidates,
79
+ evidence,
80
+ missing,
81
+ };
82
+ if (options.saveDir) {
83
+ mkdirSync(options.saveDir, { recursive: true });
84
+ const savedPath = resolve(options.saveDir, `wonder-browser-probe-${Date.now()}.json`);
85
+ writeFileSync(savedPath, JSON.stringify(result, null, 2), { mode: 0o600 });
86
+ result.savedPath = savedPath;
87
+ }
88
+ return result;
89
+ }
90
+ finally {
91
+ socket.close();
92
+ }
93
+ }
94
+ catch (err) {
95
+ return {
96
+ mode: "browser-no-export-probe",
97
+ status: "fail",
98
+ url: redactUrl(options.url),
99
+ loginLikely: false,
100
+ runtimeGlobals: [],
101
+ networkCandidates: [],
102
+ evidence: [],
103
+ missing: [err instanceof Error ? err.message : String(err)],
104
+ };
105
+ }
106
+ finally {
107
+ await terminateBrowserProcess(child);
108
+ }
109
+ }
110
+ async function evaluatePageInfo(socket) {
111
+ const expression = `(() => {
112
+ const text = (document.body && document.body.innerText || "").replace(/\\s+/g, " ").trim();
113
+ const globals = Object.keys(window)
114
+ .filter((key) => /doc|sheet|wecom|weixin|store|redux|editor|canvas/i.test(key))
115
+ .slice(0, 80);
116
+ const loginLikely = /登录|扫码|二维码|login|sign in/i.test(text);
117
+ return {
118
+ finalUrl: location.href,
119
+ title: document.title,
120
+ loginLikely,
121
+ visibleTextSample: text.slice(0, 2000),
122
+ runtimeGlobals: globals
123
+ };
124
+ })()`;
125
+ const payload = await sendCdpCommand(socket, "Runtime.evaluate", {
126
+ expression,
127
+ returnByValue: true,
128
+ awaitPromise: false,
129
+ });
130
+ const value = payload.result?.value;
131
+ return {
132
+ finalUrl: value?.finalUrl,
133
+ title: value?.title,
134
+ loginLikely: value?.loginLikely ?? false,
135
+ visibleTextSample: value?.visibleTextSample,
136
+ runtimeGlobals: value?.runtimeGlobals ?? [],
137
+ };
138
+ }
139
+ async function collectNetworkCandidates(socket, responses) {
140
+ const unique = new Map();
141
+ for (const response of responses) {
142
+ if (unique.size >= MAX_NETWORK_CANDIDATES)
143
+ break;
144
+ unique.set(response.requestId, response);
145
+ }
146
+ const candidates = [];
147
+ for (const response of unique.values()) {
148
+ let body = "";
149
+ let base64Encoded = false;
150
+ try {
151
+ const payload = await sendCdpCommand(socket, "Network.getResponseBody", { requestId: response.requestId });
152
+ body = payload.body ?? "";
153
+ base64Encoded = payload.base64Encoded ?? false;
154
+ }
155
+ catch {
156
+ // Some responses are streamed, cached, too large, or not retained.
157
+ }
158
+ const signals = detectBodySignals(body, response.mimeType);
159
+ candidates.push({
160
+ url: redactUrl(response.url),
161
+ status: response.status,
162
+ mimeType: response.mimeType,
163
+ bodyLength: body ? body.length : undefined,
164
+ base64Encoded: body ? base64Encoded : undefined,
165
+ signals,
166
+ });
167
+ }
168
+ return candidates;
169
+ }
170
+ function detectBodySignals(body, mimeType) {
171
+ const haystack = `${mimeType}\n${body.slice(0, 200_000)}`;
172
+ const signals = [];
173
+ if (/merge|merged|rowspan|colspan|mergeCell|mergeCells/i.test(haystack))
174
+ signals.push("merge-like");
175
+ if (/image|img|pic|picture|media|download_url|url/i.test(haystack))
176
+ signals.push("image-like");
177
+ if (/cell|row|column|sheet|workbook|table/i.test(haystack))
178
+ signals.push("table-like");
179
+ if (/text|paragraph|content|doc|delta|operation|op/i.test(haystack))
180
+ signals.push("document-like");
181
+ if (/websocket|realtime|sync/i.test(haystack))
182
+ signals.push("realtime-like");
183
+ return signals;
184
+ }
185
+ function spawnBrowser(args) {
186
+ mkdirSync(args.profilePath, { recursive: true });
187
+ const browserArgs = [
188
+ `--remote-debugging-port=${args.port}`,
189
+ `--user-data-dir=${args.profilePath}`,
190
+ "--no-first-run",
191
+ "--no-default-browser-check",
192
+ "--disable-features=DialMediaRouteProvider",
193
+ ];
194
+ if (!args.headed) {
195
+ browserArgs.push("--headless=new");
196
+ browserArgs.push("--disable-gpu");
197
+ }
198
+ browserArgs.push(args.url);
199
+ return spawn(args.browser.executablePath, browserArgs, {
200
+ stdio: "ignore",
201
+ detached: process.platform !== "win32",
202
+ });
203
+ }
204
+ function getKnownBrowserPaths() {
205
+ if (process.platform === "darwin") {
206
+ return [
207
+ {
208
+ label: "Google Chrome",
209
+ executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
210
+ },
211
+ {
212
+ label: "Microsoft Edge",
213
+ executablePath: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
214
+ },
215
+ ];
216
+ }
217
+ if (process.platform === "win32") {
218
+ const lad = process.env["LOCALAPPDATA"] ?? "";
219
+ const pf = process.env["PROGRAMFILES"] ?? "C:\\Program Files";
220
+ const pf86 = process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
221
+ return [
222
+ { label: "Google Chrome", executablePath: join(lad, "Google", "Chrome", "Application", "chrome.exe") },
223
+ { label: "Google Chrome", executablePath: join(pf, "Google", "Chrome", "Application", "chrome.exe") },
224
+ { label: "Google Chrome", executablePath: join(pf86, "Google", "Chrome", "Application", "chrome.exe") },
225
+ { label: "Microsoft Edge", executablePath: join(pf, "Microsoft", "Edge", "Application", "msedge.exe") },
226
+ { label: "Microsoft Edge", executablePath: join(pf86, "Microsoft", "Edge", "Application", "msedge.exe") },
227
+ { label: "Microsoft Edge", executablePath: join(lad, "Microsoft", "Edge", "Application", "msedge.exe") },
228
+ ];
229
+ }
230
+ return [
231
+ { label: "Google Chrome", executablePath: "/usr/bin/google-chrome-stable" },
232
+ { label: "Google Chrome", executablePath: "/usr/bin/google-chrome" },
233
+ { label: "Chromium", executablePath: "/usr/bin/chromium" },
234
+ { label: "Chromium", executablePath: "/usr/bin/chromium-browser" },
235
+ { label: "Microsoft Edge", executablePath: "/usr/bin/microsoft-edge" },
236
+ ];
237
+ }
238
+ function findInstalledBrowser(executablePath) {
239
+ if (executablePath) {
240
+ if (!existsSync(executablePath)) {
241
+ throw new Error(`Configured browser not found: ${executablePath}`);
242
+ }
243
+ return { label: "Configured browser", executablePath };
244
+ }
245
+ for (const candidate of getKnownBrowserPaths()) {
246
+ if (existsSync(candidate.executablePath))
247
+ return candidate;
248
+ }
249
+ throw new Error("Could not find a supported Chrome/Edge installation");
250
+ }
251
+ async function getFreePort() {
252
+ return new Promise((resolve, reject) => {
253
+ const server = createServer();
254
+ server.listen(0, "127.0.0.1", () => {
255
+ const address = server.address();
256
+ if (!address || typeof address === "string") {
257
+ server.close();
258
+ reject(new Error("Failed to allocate a browser debugging port"));
259
+ return;
260
+ }
261
+ const port = address.port;
262
+ server.close((err) => {
263
+ if (err)
264
+ reject(err);
265
+ else
266
+ resolve(port);
267
+ });
268
+ });
269
+ server.on("error", reject);
270
+ });
271
+ }
272
+ async function waitForDebugger(endpoint, timeoutMs) {
273
+ const startedAt = Date.now();
274
+ while (Date.now() - startedAt < timeoutMs) {
275
+ try {
276
+ const res = await fetch(`${endpoint}/json/version`);
277
+ if (res.ok)
278
+ return;
279
+ }
280
+ catch {
281
+ // Startup race.
282
+ }
283
+ await delay(500);
284
+ }
285
+ throw new Error("Browser did not expose the remote debugging endpoint in time");
286
+ }
287
+ async function waitForPageWebSocket(endpoint, expectedUrl, timeoutMs) {
288
+ const startedAt = Date.now();
289
+ while (Date.now() - startedAt < timeoutMs) {
290
+ try {
291
+ const res = await fetch(`${endpoint}/json/list`);
292
+ if (res.ok) {
293
+ const targets = (await res.json());
294
+ const expectedHost = new URL(expectedUrl).host;
295
+ const target = targets.find((t) => t.type === "page" &&
296
+ t.webSocketDebuggerUrl &&
297
+ (t.url?.includes(expectedHost) || targets.length === 1));
298
+ if (target?.webSocketDebuggerUrl)
299
+ return target.webSocketDebuggerUrl;
300
+ }
301
+ }
302
+ catch {
303
+ // Startup race.
304
+ }
305
+ await delay(500);
306
+ }
307
+ throw new Error("Browser did not expose a page DevTools websocket in time");
308
+ }
309
+ async function openDevToolsSocket(url) {
310
+ return new Promise((resolve, reject) => {
311
+ const socket = new WebSocket(url);
312
+ const timer = setTimeout(() => {
313
+ socket.close();
314
+ reject(new Error("Timed out connecting to browser DevTools websocket"));
315
+ }, CDP_CALL_TIMEOUT_MS);
316
+ socket.once("open", () => {
317
+ clearTimeout(timer);
318
+ resolve(socket);
319
+ });
320
+ socket.once("error", () => {
321
+ clearTimeout(timer);
322
+ reject(new Error("Failed to connect to browser DevTools websocket"));
323
+ });
324
+ });
325
+ }
326
+ async function sendCdpCommand(socket, method, params = {}) {
327
+ const id = cdpNextId++;
328
+ return new Promise((resolve, reject) => {
329
+ const timer = setTimeout(() => {
330
+ socket.removeEventListener("message", handleMessage);
331
+ reject(new Error(`CDP timeout: ${method}`));
332
+ }, CDP_CALL_TIMEOUT_MS);
333
+ const handleMessage = (event) => {
334
+ const payload = safeJsonParse(String(event.data));
335
+ if (payload?.id !== id)
336
+ return;
337
+ clearTimeout(timer);
338
+ socket.removeEventListener("message", handleMessage);
339
+ if (payload.error)
340
+ reject(new Error(payload.error.message ?? `CDP error: ${method}`));
341
+ else
342
+ resolve((payload.result ?? {}));
343
+ };
344
+ socket.addEventListener("message", handleMessage);
345
+ socket.send(JSON.stringify({ id, method, params }));
346
+ });
347
+ }
348
+ async function terminateBrowserProcess(child) {
349
+ if (child.exitCode !== null)
350
+ return;
351
+ if (process.platform !== "win32" && child.pid) {
352
+ try {
353
+ process.kill(-child.pid, "SIGTERM");
354
+ }
355
+ catch {
356
+ child.kill();
357
+ }
358
+ }
359
+ else {
360
+ child.kill();
361
+ }
362
+ await delay(500);
363
+ if (child.exitCode === null) {
364
+ try {
365
+ if (process.platform !== "win32" && child.pid)
366
+ process.kill(-child.pid, "SIGKILL");
367
+ else
368
+ child.kill("SIGKILL");
369
+ }
370
+ catch {
371
+ // Already exited.
372
+ }
373
+ }
374
+ }
375
+ function isInterestingNetworkUrl(url, mimeType) {
376
+ if (!/doc\.weixin\.qq\.com|weixin\.qq\.com|wecom/i.test(url))
377
+ return false;
378
+ return /json|text|javascript|octet-stream|protobuf|grpc|xml|html/i.test(mimeType) ||
379
+ /cgi|api|doc|sheet|media|image|file|sync|ws/i.test(url);
380
+ }
381
+ function redactUrl(rawUrl) {
382
+ try {
383
+ const u = new URL(rawUrl);
384
+ u.search = "";
385
+ u.hash = "";
386
+ return u.toString();
387
+ }
388
+ catch {
389
+ return rawUrl.split("?")[0] ?? rawUrl;
390
+ }
391
+ }
392
+ function safeJsonParse(value) {
393
+ try {
394
+ return JSON.parse(value);
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ }
400
+ function delay(ms) {
401
+ return new Promise((resolve) => setTimeout(resolve, ms));
402
+ }
@@ -13,7 +13,9 @@ const LOGIN_URL = "https://doc.weixin.qq.com";
13
13
  const LOGIN_TIMEOUT_MS = 3 * 60 * 1000;
14
14
  const COOKIE_POLL_INTERVAL_MS = 2000;
15
15
  const CDP_CALL_TIMEOUT_MS = 5000;
16
- const MIN_KEY_COOKIES_REQUIRED = 3;
16
+ const PROFILE_FLUSH_DELAY_MS = 1500;
17
+ const GRACEFUL_BROWSER_CLOSE_TIMEOUT_MS = 5000;
18
+ const SIGTERM_BROWSER_CLOSE_TIMEOUT_MS = 3000;
17
19
  const KEY_COOKIES = [
18
20
  "TOK",
19
21
  "wedrive_sid",
@@ -47,7 +49,7 @@ function assertLocalhostUrl(url) {
47
49
  }
48
50
  /**
49
51
  * Spawn Chrome → connect via CDP WebSocket → poll Storage.getCookies until
50
- * MIN_KEY_COOKIES_REQUIRED key cookies are present → return Record<string,string>.
52
+ * all key cookies are present → return Record<string,string>.
51
53
  */
52
54
  export async function collectWecomCookiesViaBrowser(options) {
53
55
  const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
@@ -83,14 +85,24 @@ export async function collectWecomCookiesViaBrowser(options) {
83
85
  const startedAt = Date.now();
84
86
  while (Date.now() - startedAt < timeoutMs) {
85
87
  const cookies = await readWecomCookies(socket);
86
- if (cookies !== null)
88
+ if (cookies !== null) {
89
+ // Give Chrome a short window to flush cookie/profile state before
90
+ // shutdown. Returning immediately made the exported cookies valid
91
+ // while the dedicated browser profile could remain effectively
92
+ // logged out on the next refresh.
93
+ if (child)
94
+ await delay(PROFILE_FLUSH_DELAY_MS);
87
95
  return cookies;
96
+ }
88
97
  await delay(COOKIE_POLL_INTERVAL_MS);
89
98
  }
90
99
  throw new Error(`Timed out waiting for WeCom login after ${Math.round(timeoutMs / 1000)}s. ` +
91
100
  "Sign in to WeCom in the opened browser, then rerun `wonder wecom cookie`.");
92
101
  }
93
102
  finally {
103
+ if (child) {
104
+ await closeBrowserGracefully(socket, child);
105
+ }
94
106
  socket.close();
95
107
  }
96
108
  }
@@ -291,14 +303,14 @@ function isTrustedDomain(domain) {
291
303
  }
292
304
  /**
293
305
  * Poll CDP for WeCom cookies. Returns Record<string,string> when
294
- * MIN_KEY_COOKIES_REQUIRED are present, null otherwise.
306
+ * all key cookies are present, null otherwise.
295
307
  */
296
308
  async function readWecomCookies(socket) {
297
309
  const payload = await sendCdpCommand(socket, "Storage.getCookies");
298
310
  const trusted = (payload.cookies ?? []).filter((c) => isTrustedDomain(c.domain));
299
311
  const cookieNames = new Set(trusted.map((c) => c.name));
300
- const presentKeys = KEY_COOKIES.filter((k) => cookieNames.has(k));
301
- if (presentKeys.length < MIN_KEY_COOKIES_REQUIRED)
312
+ const missingKeys = KEY_COOKIES.filter((k) => !cookieNames.has(k));
313
+ if (missingKeys.length > 0)
302
314
  return null;
303
315
  // Build Record; later entries overwrite earlier ones for the same name.
304
316
  const result = {};
@@ -327,7 +339,9 @@ async function terminateBrowserProcess(child) {
327
339
  else {
328
340
  child.kill();
329
341
  }
330
- await delay(500);
342
+ const exitedAfterSigterm = await waitForBrowserExit(child, SIGTERM_BROWSER_CLOSE_TIMEOUT_MS);
343
+ if (exitedAfterSigterm)
344
+ return;
331
345
  if (child.exitCode === null) {
332
346
  try {
333
347
  if (process.platform !== "win32" && child.pid) {
@@ -342,3 +356,31 @@ async function terminateBrowserProcess(child) {
342
356
  }
343
357
  }
344
358
  }
359
+ async function closeBrowserGracefully(socket, child) {
360
+ if (child.exitCode !== null)
361
+ return;
362
+ try {
363
+ await sendCdpCommand(socket, "Browser.close");
364
+ }
365
+ catch {
366
+ // Browser.close can close the websocket before the response arrives. The
367
+ // process exit is the authoritative signal here.
368
+ }
369
+ await waitForBrowserExit(child, GRACEFUL_BROWSER_CLOSE_TIMEOUT_MS);
370
+ }
371
+ function waitForBrowserExit(child, timeoutMs) {
372
+ if (child.exitCode !== null || child.signalCode !== null) {
373
+ return Promise.resolve(true);
374
+ }
375
+ return new Promise((resolve) => {
376
+ const timer = setTimeout(() => {
377
+ child.removeListener("exit", onExit);
378
+ resolve(false);
379
+ }, timeoutMs);
380
+ const onExit = () => {
381
+ clearTimeout(timer);
382
+ resolve(true);
383
+ };
384
+ child.once("exit", onExit);
385
+ });
386
+ }
@@ -3,6 +3,14 @@
3
3
  // Storage: ~/.wonder/cookies.json (atomic write via tmp+rename, chmod 600)
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
5
5
  import { dirname } from "node:path";
6
+ const REQUIRED_COOKIES_FOR_VALIDATION = [
7
+ "TOK",
8
+ "wedrive_sid",
9
+ "uid",
10
+ "uid_key",
11
+ "wedrive_ticket",
12
+ "wedrive_skey",
13
+ ];
6
14
  // ---------------------------------------------------------------------------
7
15
  // Atomic write helper
8
16
  // ---------------------------------------------------------------------------
@@ -68,6 +76,9 @@ export function removeCookies(cookiesPath) {
68
76
  * - Network error → conservatively return true (avoid false "expired" on offline)
69
77
  */
70
78
  export async function validateCookies(cookies) {
79
+ const missingRequired = REQUIRED_COOKIES_FOR_VALIDATION.some((name) => !cookies[name]);
80
+ if (missingRequired)
81
+ return false;
71
82
  // W-02: strip `;` and `\n` from cookie values to prevent header injection.
72
83
  const cookieHeader = Object.entries(cookies)
73
84
  .map(([k, v]) => `${k}=${String(v).replace(/[;\n]/g, "")}`)