@cjhyy/code-shell-chat 0.8.9 → 0.8.10

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/dist/config.js CHANGED
@@ -1,7 +1,10 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, statSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { defaultWechatDataDirectory, FileWechatCredentialStore } from "./wechat-storage.js";
5
+ const MAX_CONFIG_BYTES = 4 * 1024 * 1024;
6
+ const MAX_CONFIG_STRING_CHARS = 32_768;
7
+ const MAX_CONFIG_LIST_ITEMS = 10_000;
5
8
  export function defaultGatewayConfigPath(env = process.env) {
6
9
  return join(resolveHome(env), ".code-shell", "im-gateway", "config.json");
7
10
  }
@@ -131,12 +134,12 @@ export function loadGatewayConfig(opts = {}) {
131
134
  autoLaunch: raw.desktop?.autoLaunch !== false,
132
135
  command: configuredCommand ?? defaultLaunch.command,
133
136
  args: configuredArgs ?? (configuredCommand ? [] : defaultLaunch.args),
134
- startupTimeoutMs: readPositiveNumber(raw.desktop?.startupTimeoutMs) ?? 30_000,
137
+ startupTimeoutMs: readPositiveNumber(raw.desktop?.startupTimeoutMs, 5 * 60_000) ?? 30_000,
135
138
  },
136
139
  webhook: {
137
140
  host: readNonEmptyString(raw.webhook?.host) ?? "127.0.0.1",
138
141
  port: readPort(raw.webhook?.port) ?? 8787,
139
- maxBodyBytes: readPositiveNumber(raw.webhook?.maxBodyBytes) ?? 1_048_576,
142
+ maxBodyBytes: readPositiveNumber(raw.webhook?.maxBodyBytes, 16 * 1024 * 1024) ?? 1_048_576,
140
143
  },
141
144
  runtime: {
142
145
  lockPath: readNonEmptyString(raw.runtime?.lockPath) ??
@@ -145,12 +148,12 @@ export function loadGatewayConfig(opts = {}) {
145
148
  join(resolveHome(env), ".code-shell", "im-gateway", "inbox.json"),
146
149
  eventCursorPath: readNonEmptyString(raw.runtime?.eventCursorPath) ??
147
150
  join(resolveHome(env), ".code-shell", "im-gateway", "desktop-events.json"),
148
- maxPending: readPositiveInteger(raw.runtime?.maxPending) ?? 1_000,
149
- maxConcurrent: readPositiveInteger(raw.runtime?.maxConcurrent) ?? 4,
150
- maxPerTarget: readPositiveInteger(raw.runtime?.maxPerTarget) ?? 1,
151
- maxMessagesPerUserPerMinute: readPositiveInteger(raw.runtime?.maxMessagesPerUserPerMinute) ?? 20,
152
- adapterRestartBaseMs: readPositiveInteger(raw.runtime?.adapterRestartBaseMs) ?? 1_000,
153
- adapterRestartMaxMs: readPositiveInteger(raw.runtime?.adapterRestartMaxMs) ?? 30_000,
151
+ maxPending: readPositiveInteger(raw.runtime?.maxPending, 100_000) ?? 1_000,
152
+ maxConcurrent: readPositiveInteger(raw.runtime?.maxConcurrent, 64) ?? 4,
153
+ maxPerTarget: readPositiveInteger(raw.runtime?.maxPerTarget, 16) ?? 1,
154
+ maxMessagesPerUserPerMinute: readPositiveInteger(raw.runtime?.maxMessagesPerUserPerMinute, 100_000) ?? 20,
155
+ adapterRestartBaseMs: readPositiveInteger(raw.runtime?.adapterRestartBaseMs, 24 * 60 * 60_000) ?? 1_000,
156
+ adapterRestartMaxMs: readPositiveInteger(raw.runtime?.adapterRestartMaxMs, 24 * 60 * 60_000) ?? 30_000,
154
157
  },
155
158
  notifications: [],
156
159
  };
@@ -398,18 +401,32 @@ function loadAllowlist(name, raw, env, targetEnv, userEnv, rawTargetKey) {
398
401
  return { allowedTargetIds, allowedUserIds };
399
402
  }
400
403
  function readRawConfig(configPath) {
401
- if (!existsSync(configPath))
402
- return {};
404
+ let fd;
403
405
  try {
404
- const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
406
+ const entry = lstatSync(configPath);
407
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_CONFIG_BYTES) {
408
+ throw new Error(`config must be a regular file no larger than ${MAX_CONFIG_BYTES} bytes`);
409
+ }
410
+ fd = openSync(configPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
411
+ const opened = fstatSync(fd);
412
+ if (!opened.isFile() || opened.size > MAX_CONFIG_BYTES) {
413
+ throw new Error(`config must be a regular file no larger than ${MAX_CONFIG_BYTES} bytes`);
414
+ }
415
+ const parsed = JSON.parse(readFileSync(fd, "utf-8"));
405
416
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
406
417
  throw new Error("top-level value must be an object");
407
418
  }
408
419
  return parsed;
409
420
  }
410
421
  catch (error) {
422
+ if (error.code === "ENOENT")
423
+ return {};
411
424
  throw new Error(`无法读取 IM gateway 配置 ${configPath}:${error instanceof Error ? error.message : String(error)}`, { cause: error });
412
425
  }
426
+ finally {
427
+ if (fd !== undefined)
428
+ closeSync(fd);
429
+ }
413
430
  }
414
431
  function sectionEnabled(raw, ...values) {
415
432
  if (raw?.enabled === false)
@@ -425,13 +442,18 @@ function envString(env, key) {
425
442
  return readNonEmptyString(env[key]);
426
443
  }
427
444
  function readNonEmptyString(value) {
428
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
445
+ return typeof value === "string" && value.length <= MAX_CONFIG_STRING_CHARS && value.trim()
446
+ ? value.trim()
447
+ : undefined;
429
448
  }
430
449
  function readStringList(value, fallbackToEmpty = true) {
431
450
  if (value === undefined && !fallbackToEmpty)
432
451
  return undefined;
433
452
  if (!Array.isArray(value))
434
453
  return [];
454
+ if (value.length > MAX_CONFIG_LIST_ITEMS) {
455
+ throw new Error(`配置列表最多包含 ${MAX_CONFIG_LIST_ITEMS} 项`);
456
+ }
435
457
  return [
436
458
  ...new Set(value.map(readNonEmptyString).filter((item) => Boolean(item))),
437
459
  ];
@@ -439,18 +461,28 @@ function readStringList(value, fallbackToEmpty = true) {
439
461
  function readCsvOverride(value) {
440
462
  if (value === undefined)
441
463
  return undefined;
442
- return [
464
+ if (value.length > MAX_CONFIG_BYTES)
465
+ throw new Error("环境变量白名单过长");
466
+ const result = [
443
467
  ...new Set(value
444
468
  .split(",")
445
469
  .map((item) => item.trim())
446
470
  .filter(Boolean)),
447
471
  ];
472
+ if (result.length > MAX_CONFIG_LIST_ITEMS) {
473
+ throw new Error(`环境变量白名单最多包含 ${MAX_CONFIG_LIST_ITEMS} 项`);
474
+ }
475
+ return result;
448
476
  }
449
- function readPositiveNumber(value) {
450
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
477
+ function readPositiveNumber(value, max) {
478
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= max
479
+ ? value
480
+ : undefined;
451
481
  }
452
- function readPositiveInteger(value) {
453
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
482
+ function readPositiveInteger(value, max) {
483
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= max
484
+ ? value
485
+ : undefined;
454
486
  }
455
487
  function readPort(value) {
456
488
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65_535
@@ -52,6 +52,7 @@ export declare class DeliveryQueue {
52
52
  private readonly targetInFlight;
53
53
  private mutation;
54
54
  private retryTimer?;
55
+ private persistRetryTimer?;
55
56
  private stopped;
56
57
  constructor(config: DeliveryQueueConfig, deliver: (adapterId: string, message: ChannelMessage) => Promise<void>, onError: (error: unknown, message: ChannelMessage) => void);
57
58
  start(): Promise<void>;
@@ -60,6 +61,7 @@ export declare class DeliveryQueue {
60
61
  enqueue(adapterId: string, message: ChannelMessage): Promise<"queued" | "duplicate">;
61
62
  private pump;
62
63
  private process;
64
+ private schedulePersistRetry;
63
65
  /** Directory holding spooled attachment bytes, or undefined when not durable. */
64
66
  private spoolDir;
65
67
  /**
@@ -1,6 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmod, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, readdir, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { dirname } from "node:path";
5
+ const MAX_DELIVERY_STATE_BYTES = 64 * 1024 * 1024;
6
+ const MAX_COMPLETED_KEYS = 100_000;
7
+ const MAX_SPOOLED_ATTACHMENTS = 32;
8
+ const MAX_SPOOL_SCAN_ENTRIES = 100_000;
9
+ const SPOOL_FILE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.\d{1,3}$/iu;
4
10
  export class DeliveryBackpressureError extends Error {
5
11
  constructor(limit) {
6
12
  super(`Chat Gateway inbox 已满(上限 ${limit})`);
@@ -33,6 +39,7 @@ export class DeliveryQueue {
33
39
  targetInFlight = new Map();
34
40
  mutation = Promise.resolve();
35
41
  retryTimer;
42
+ persistRetryTimer;
36
43
  stopped = true;
37
44
  constructor(config, deliver, onError) {
38
45
  this.config = config;
@@ -53,6 +60,9 @@ export class DeliveryQueue {
53
60
  if (this.retryTimer)
54
61
  clearTimeout(this.retryTimer);
55
62
  this.retryTimer = undefined;
63
+ if (this.persistRetryTimer)
64
+ clearTimeout(this.persistRetryTimer);
65
+ this.persistRetryTimer = undefined;
56
66
  }
57
67
  status() {
58
68
  const now = Date.now();
@@ -84,20 +94,44 @@ export class DeliveryQueue {
84
94
  // the adapter had already acked it. Spool the bytes instead: the record
85
95
  // then persists like any other, carrying file paths in place of closures.
86
96
  const spooled = await this.spoolAttachments(id, message);
87
- const persistent = Boolean(this.config.path && (!message.attachments?.length || spooled !== undefined));
88
- const safeMessage = persistent ? serializableMessage(message, spooled) : message;
89
- this.pending.push({
90
- id,
91
- ...(dedupeKey ? { dedupeKey } : {}),
92
- adapterId,
93
- message: safeMessage,
94
- attempts: 0,
95
- nextAttemptAt: Date.now(),
96
- persistent,
97
- ...(spooled ? { spooled } : {}),
98
- });
99
- if (persistent)
100
- await this.persist();
97
+ let record;
98
+ try {
99
+ const persistent = Boolean(this.config.path && (!message.attachments?.length || spooled !== undefined));
100
+ const safeMessage = persistent ? serializableMessage(message, spooled) : message;
101
+ record = {
102
+ id,
103
+ ...(dedupeKey ? { dedupeKey } : {}),
104
+ adapterId,
105
+ message: safeMessage,
106
+ attempts: 0,
107
+ nextAttemptAt: Date.now(),
108
+ persistent,
109
+ ...(spooled ? { spooled } : {}),
110
+ };
111
+ this.pending.push(record);
112
+ if (persistent)
113
+ await this.persist();
114
+ }
115
+ catch (error) {
116
+ if (record) {
117
+ const index = this.pending.findIndex((candidate) => candidate.id === record?.id);
118
+ if (index >= 0)
119
+ this.pending.splice(index, 1);
120
+ await this.discardSpool(record);
121
+ }
122
+ else if (spooled) {
123
+ await this.discardSpool({
124
+ id,
125
+ adapterId,
126
+ message,
127
+ attempts: 0,
128
+ nextAttemptAt: 0,
129
+ persistent: false,
130
+ spooled,
131
+ });
132
+ }
133
+ throw error;
134
+ }
101
135
  });
102
136
  this.pump();
103
137
  return result;
@@ -139,39 +173,66 @@ export class DeliveryQueue {
139
173
  error = caught;
140
174
  this.onError(caught, entry.message);
141
175
  }
142
- await this.withMutation(async () => {
143
- this.inFlight.delete(entry.id);
144
- const key = targetKey(entry.message);
145
- const targetCount = (this.targetInFlight.get(key) ?? 1) - 1;
146
- if (targetCount <= 0)
147
- this.targetInFlight.delete(key);
148
- else
149
- this.targetInFlight.set(key, targetCount);
150
- if (error === undefined || error instanceof UnroutableDeliveryError) {
151
- // Success, or a permanently unroutable record: drop it so a message
152
- // that can never be delivered does not retry (and re-log) forever.
153
- const index = this.pending.findIndex((candidate) => candidate.id === entry.id);
154
- if (index >= 0)
155
- this.pending.splice(index, 1);
156
- if (error === undefined && entry.dedupeKey)
157
- this.completed.set(entry.dedupeKey, Date.now());
158
- // Terminal outcome — the bytes will never be needed again. Cleaning up
159
- // here (rather than on a TTL sweep) keeps the spool bounded by what is
160
- // actually pending.
161
- await this.discardSpool(entry);
162
- }
163
- else {
164
- entry.attempts += 1;
165
- entry.nextAttemptAt =
166
- Date.now() +
167
- Math.min(this.config.retryMaxMs, this.config.retryBaseMs * 2 ** Math.min(entry.attempts - 1, 10));
168
- }
169
- this.pruneCompleted();
170
- if (entry.persistent)
171
- await this.persist();
172
- });
176
+ const terminal = error === undefined || error instanceof UnroutableDeliveryError;
177
+ try {
178
+ await this.withMutation(async () => {
179
+ this.inFlight.delete(entry.id);
180
+ const key = targetKey(entry.message);
181
+ const targetCount = (this.targetInFlight.get(key) ?? 1) - 1;
182
+ if (targetCount <= 0)
183
+ this.targetInFlight.delete(key);
184
+ else
185
+ this.targetInFlight.set(key, targetCount);
186
+ if (terminal) {
187
+ // Success, or a permanently unroutable record: drop it so a message
188
+ // that can never be delivered does not retry (and re-log) forever.
189
+ const index = this.pending.findIndex((candidate) => candidate.id === entry.id);
190
+ if (index >= 0)
191
+ this.pending.splice(index, 1);
192
+ if (error === undefined && entry.dedupeKey)
193
+ this.completed.set(entry.dedupeKey, Date.now());
194
+ }
195
+ else {
196
+ entry.attempts += 1;
197
+ entry.nextAttemptAt =
198
+ Date.now() +
199
+ Math.min(this.config.retryMaxMs, this.config.retryBaseMs * 2 ** Math.min(entry.attempts - 1, 10));
200
+ }
201
+ this.pruneCompleted();
202
+ // Persist the terminal removal BEFORE deleting attachment bytes. If the
203
+ // state write fails, the durable record still has everything needed for
204
+ // at-least-once recovery after a crash.
205
+ if (entry.persistent)
206
+ await this.persist();
207
+ if (terminal)
208
+ await this.discardSpool(entry);
209
+ });
210
+ }
211
+ catch (persistError) {
212
+ this.onError(persistError, entry.message);
213
+ this.schedulePersistRetry(entry.message, terminal ? entry : undefined);
214
+ }
173
215
  this.pump();
174
216
  }
217
+ schedulePersistRetry(message, cleanup) {
218
+ if (this.stopped || !this.config.path || this.persistRetryTimer)
219
+ return;
220
+ const delay = Math.max(10, Math.min(this.config.retryBaseMs, this.config.retryMaxMs));
221
+ this.persistRetryTimer = setTimeout(() => {
222
+ this.persistRetryTimer = undefined;
223
+ if (this.stopped)
224
+ return;
225
+ void this.withMutation(async () => {
226
+ await this.persist();
227
+ if (cleanup)
228
+ await this.discardSpool(cleanup);
229
+ }).catch((error) => {
230
+ this.onError(error, message);
231
+ this.schedulePersistRetry(message, cleanup);
232
+ });
233
+ }, delay);
234
+ this.persistRetryTimer.unref?.();
235
+ }
175
236
  /** Directory holding spooled attachment bytes, or undefined when not durable. */
176
237
  spoolDir() {
177
238
  if (!this.config.path)
@@ -191,17 +252,22 @@ export class DeliveryQueue {
191
252
  if (!attachments?.length || !dir)
192
253
  return undefined;
193
254
  const limit = this.config.maxSpooledAttachmentBytes ?? 16 * 1024 * 1024;
255
+ const written = [];
194
256
  try {
195
- await mkdir(dir, { recursive: true, mode: 0o700 });
257
+ if (attachments.length > MAX_SPOOLED_ATTACHMENTS)
258
+ return undefined;
259
+ await ensureRealDirectory(dir);
196
260
  const spooled = [];
197
261
  for (const [index, attachment] of attachments.entries()) {
198
262
  const bytes = await attachment.load();
199
263
  // Oversized payloads stay in memory: spooling them would trade a lost
200
264
  // message for unbounded disk use.
201
265
  if (bytes.byteLength > limit)
202
- return undefined;
266
+ throw new Error("attachment exceeds spool limit");
203
267
  const file = `${recordId}.${index}`;
204
- await writeFile(`${dir}/${file}`, bytes, { mode: 0o600 });
268
+ const target = `${dir}/${file}`;
269
+ await writeFile(target, bytes, { mode: 0o600, flag: "wx" });
270
+ written.push(target);
205
271
  spooled.push({
206
272
  id: attachment.id,
207
273
  kind: attachment.kind,
@@ -215,6 +281,7 @@ export class DeliveryQueue {
215
281
  }
216
282
  catch {
217
283
  // Spooling is an availability improvement, never a new failure mode.
284
+ await Promise.all(written.map((target) => rm(target, { force: true }).catch(() => undefined)));
218
285
  return undefined;
219
286
  }
220
287
  }
@@ -229,7 +296,7 @@ export class DeliveryQueue {
229
296
  ...(entry.name ? { name: entry.name } : {}),
230
297
  ...(entry.mimeType ? { mimeType: entry.mimeType } : {}),
231
298
  size: entry.size,
232
- load: async () => new Uint8Array(await readFile(`${dir}/${entry.file}`)),
299
+ load: async () => new Uint8Array(await readBoundedSpoolFile(dir, entry.file, entry.size, this.config.maxSpooledAttachmentBytes ?? 16 * 1024 * 1024)),
233
300
  }));
234
301
  }
235
302
  /**
@@ -254,32 +321,66 @@ export class DeliveryQueue {
254
321
  }
255
322
  let files;
256
323
  try {
257
- files = await readdir(dir);
324
+ await assertRealDirectory(dir);
325
+ files = await readdir(dir, { withFileTypes: true });
258
326
  }
259
- catch {
327
+ catch (error) {
328
+ if (error.code !== "ENOENT")
329
+ throw error;
260
330
  return; // No spool directory yet — nothing to sweep.
261
331
  }
332
+ if (files.length > MAX_SPOOL_SCAN_ENTRIES) {
333
+ throw new Error("Chat Gateway attachment spool has too many entries");
334
+ }
262
335
  await Promise.all(files
263
- .filter((file) => !referenced.has(file))
264
- .map((file) => rm(`${dir}/${file}`, { force: true }).catch(() => undefined)));
336
+ .filter((entry) => entry.isFile() && SPOOL_FILE_RE.test(entry.name) && !referenced.has(entry.name))
337
+ .map((entry) => rm(`${dir}/${entry.name}`, { force: true }).catch(() => undefined)));
265
338
  }
266
339
  /** Delete a record's spooled files once it will never be delivered again. */
267
340
  async discardSpool(entry) {
268
341
  const dir = this.spoolDir();
269
342
  if (!entry.spooled?.length || !dir)
270
343
  return;
271
- await Promise.all(entry.spooled.map((attachment) => rm(`${dir}/${attachment.file}`, { force: true }).catch(() => undefined)));
344
+ try {
345
+ await assertRealDirectory(dir);
346
+ }
347
+ catch (error) {
348
+ if (error.code === "ENOENT")
349
+ return;
350
+ throw error;
351
+ }
352
+ await Promise.all(entry.spooled
353
+ .filter((attachment) => SPOOL_FILE_RE.test(attachment.file))
354
+ .map(async (attachment) => {
355
+ const target = `${dir}/${attachment.file}`;
356
+ try {
357
+ const info = await lstat(target);
358
+ if (!info.isSymbolicLink() && info.isFile())
359
+ await rm(target, { force: true });
360
+ }
361
+ catch (error) {
362
+ if (error.code !== "ENOENT")
363
+ throw error;
364
+ }
365
+ }));
272
366
  }
273
367
  async load() {
274
368
  if (!this.config.path)
275
369
  return;
276
370
  try {
277
- const parsed = JSON.parse(await readFile(this.config.path, "utf-8"));
278
- if (parsed.version !== 1 || !Array.isArray(parsed.pending) || !parsed.completed)
279
- return;
371
+ const parsed = JSON.parse(await readBoundedRegularFile(this.config.path, MAX_DELIVERY_STATE_BYTES, "Chat Gateway inbox"));
372
+ if (parsed.version !== 1 ||
373
+ !Array.isArray(parsed.pending) ||
374
+ parsed.pending.length > this.config.maxPending ||
375
+ !parsed.completed ||
376
+ typeof parsed.completed !== "object" ||
377
+ Array.isArray(parsed.completed)) {
378
+ throw new Error("invalid delivery state root");
379
+ }
280
380
  for (const entry of parsed.pending) {
281
- if (!validStoredEntry(entry))
381
+ if (!validStoredEntry(entry, this.config.maxSpooledAttachmentBytes ?? 16 * 1024 * 1024)) {
282
382
  continue;
383
+ }
283
384
  // Turn spooled metadata back into real attachments whose load() reads
284
385
  // the file. This is the whole point of the spool: after a restart the
285
386
  // upstream will not resend, so the bytes have to come from disk.
@@ -291,9 +392,16 @@ export class DeliveryQueue {
291
392
  nextAttemptAt: Date.now(),
292
393
  });
293
394
  }
294
- for (const [key, timestamp] of Object.entries(parsed.completed)) {
295
- if (typeof timestamp === "number")
395
+ const completed = Object.entries(parsed.completed);
396
+ if (completed.length > MAX_COMPLETED_KEYS)
397
+ throw new Error("too many completed deliveries");
398
+ for (const [key, timestamp] of completed) {
399
+ if (key.length <= 16_384 &&
400
+ typeof timestamp === "number" &&
401
+ Number.isSafeInteger(timestamp) &&
402
+ timestamp >= 0) {
296
403
  this.completed.set(key, timestamp);
404
+ }
297
405
  }
298
406
  this.pruneCompleted();
299
407
  }
@@ -324,10 +432,15 @@ export class DeliveryQueue {
324
432
  completed: Object.fromEntries(this.completed),
325
433
  };
326
434
  const serialized = `${JSON.stringify(state)}\n`;
327
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
435
+ if (Buffer.byteLength(serialized, "utf8") > MAX_DELIVERY_STATE_BYTES) {
436
+ throw new DeliveryBackpressureError(this.config.maxPending);
437
+ }
438
+ const parent = dirname(path);
439
+ await ensureRealDirectory(parent);
440
+ await assertSafeRegularTarget(path);
328
441
  const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
329
442
  try {
330
- await writeFile(temporary, serialized, { encoding: "utf-8", mode: 0o600 });
443
+ await writeFile(temporary, serialized, { encoding: "utf-8", mode: 0o600, flag: "wx" });
331
444
  await rename(temporary, path);
332
445
  await chmod(path, 0o600).catch(() => undefined);
333
446
  }
@@ -384,18 +497,128 @@ function serializableMessage(message, spooled) {
384
497
  }
385
498
  return parsed;
386
499
  }
387
- function validStoredEntry(entry) {
388
- if (!entry || typeof entry !== "object")
500
+ function validStoredEntry(entry, maxAttachmentBytes) {
501
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
389
502
  return false;
390
503
  const value = entry;
391
- return (typeof value.id === "string" &&
392
- typeof value.adapterId === "string" &&
393
- typeof value.attempts === "number" &&
394
- typeof value.nextAttemptAt === "number" &&
395
- Boolean(value.message) &&
396
- typeof value.message?.channel === "string" &&
397
- typeof value.message?.target === "string" &&
398
- typeof value.message?.senderId === "string" &&
399
- typeof value.message?.text === "string" &&
400
- !value.message?.attachments);
504
+ if (typeof value.id !== "string" ||
505
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value.id) ||
506
+ typeof value.adapterId !== "string" ||
507
+ value.adapterId.length === 0 ||
508
+ value.adapterId.length > 256 ||
509
+ !Number.isSafeInteger(value.attempts) ||
510
+ (value.attempts ?? -1) < 0 ||
511
+ (value.attempts ?? 0) > 10_000 ||
512
+ !Number.isSafeInteger(value.nextAttemptAt) ||
513
+ (value.nextAttemptAt ?? -1) < 0 ||
514
+ !value.message ||
515
+ typeof value.message !== "object" ||
516
+ typeof value.message.channel !== "string" ||
517
+ value.message.channel.length === 0 ||
518
+ value.message.channel.length > 64 ||
519
+ typeof value.message.target !== "string" ||
520
+ value.message.target.length === 0 ||
521
+ value.message.target.length > 4_096 ||
522
+ typeof value.message.senderId !== "string" ||
523
+ value.message.senderId.length === 0 ||
524
+ value.message.senderId.length > 4_096 ||
525
+ typeof value.message.text !== "string" ||
526
+ value.message.text.length > 1_048_576 ||
527
+ value.message.attachments) {
528
+ return false;
529
+ }
530
+ if (value.dedupeKey !== undefined && (typeof value.dedupeKey !== "string" || value.dedupeKey.length > 16_384)) {
531
+ return false;
532
+ }
533
+ if (value.spooled === undefined)
534
+ return true;
535
+ if (!Array.isArray(value.spooled) || value.spooled.length > MAX_SPOOLED_ATTACHMENTS)
536
+ return false;
537
+ const files = new Set();
538
+ for (const attachment of value.spooled) {
539
+ if (!attachment ||
540
+ typeof attachment !== "object" ||
541
+ typeof attachment.id !== "string" ||
542
+ attachment.id.length === 0 ||
543
+ attachment.id.length > 4_096 ||
544
+ typeof attachment.kind !== "string" ||
545
+ attachment.kind.length === 0 ||
546
+ attachment.kind.length > 64 ||
547
+ (attachment.name !== undefined &&
548
+ (typeof attachment.name !== "string" || attachment.name.length > 4_096)) ||
549
+ (attachment.mimeType !== undefined &&
550
+ (typeof attachment.mimeType !== "string" || attachment.mimeType.length > 1_024)) ||
551
+ !Number.isSafeInteger(attachment.size) ||
552
+ attachment.size < 0 ||
553
+ attachment.size > maxAttachmentBytes ||
554
+ typeof attachment.file !== "string" ||
555
+ !SPOOL_FILE_RE.test(attachment.file) ||
556
+ !attachment.file.startsWith(`${value.id}.`) ||
557
+ files.has(attachment.file)) {
558
+ return false;
559
+ }
560
+ files.add(attachment.file);
561
+ }
562
+ return true;
563
+ }
564
+ async function ensureRealDirectory(path) {
565
+ await mkdir(path, { recursive: true, mode: 0o700 });
566
+ await assertRealDirectory(path);
567
+ }
568
+ async function assertRealDirectory(path) {
569
+ const info = await lstat(path);
570
+ if (info.isSymbolicLink() || !info.isDirectory()) {
571
+ throw new Error(`Chat Gateway storage path is not a real directory: ${path}`);
572
+ }
573
+ }
574
+ async function assertSafeRegularTarget(path) {
575
+ try {
576
+ const info = await lstat(path);
577
+ if (info.isSymbolicLink() || !info.isFile()) {
578
+ throw new Error(`Chat Gateway storage target is not a regular file: ${path}`);
579
+ }
580
+ }
581
+ catch (error) {
582
+ if (error.code !== "ENOENT")
583
+ throw error;
584
+ }
585
+ }
586
+ async function readBoundedRegularFile(path, maxBytes, label) {
587
+ const entry = await lstat(path);
588
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > maxBytes) {
589
+ throw new Error(`${label} is not a bounded regular file`);
590
+ }
591
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
592
+ try {
593
+ const opened = await handle.stat();
594
+ if (!opened.isFile() || opened.size > maxBytes) {
595
+ throw new Error(`${label} is not a bounded regular file`);
596
+ }
597
+ return await handle.readFile("utf8");
598
+ }
599
+ finally {
600
+ await handle.close();
601
+ }
602
+ }
603
+ async function readBoundedSpoolFile(dir, file, expectedBytes, maxBytes) {
604
+ if (!SPOOL_FILE_RE.test(file) || expectedBytes < 0 || expectedBytes > maxBytes) {
605
+ throw new Error("invalid Chat Gateway attachment spool reference");
606
+ }
607
+ await assertRealDirectory(dir);
608
+ const target = `${dir}/${file}`;
609
+ const entry = await lstat(target);
610
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size !== expectedBytes) {
611
+ throw new Error("Chat Gateway attachment spool file is invalid");
612
+ }
613
+ const handle = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
614
+ try {
615
+ const opened = await handle.stat();
616
+ if (!opened.isFile() || opened.size !== expectedBytes || opened.size > maxBytes) {
617
+ throw new Error("Chat Gateway attachment spool file is invalid");
618
+ }
619
+ return await handle.readFile();
620
+ }
621
+ finally {
622
+ await handle.close();
623
+ }
401
624
  }
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ const MAX_LOCK_FILE_BYTES = 64 * 1024;
5
+ const MAX_LOCK_OWNER_CHARS = 256;
4
6
  export class GatewayAlreadyRunningError extends Error {
5
7
  path;
6
8
  current;
@@ -21,7 +23,15 @@ export class GatewayAlreadyRunningError extends Error {
21
23
  export function acquireGatewayInstanceLock(path, owner) {
22
24
  if (!path || !owner)
23
25
  throw new Error("gateway lock path and owner are required");
24
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
26
+ if (owner.length > MAX_LOCK_OWNER_CHARS || /[\0\r\n]/u.test(owner)) {
27
+ throw new Error("gateway lock owner is invalid");
28
+ }
29
+ const parent = dirname(path);
30
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
31
+ const parentInfo = lstatSync(parent);
32
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
33
+ throw new Error("gateway lock parent must be a real directory");
34
+ }
25
35
  const record = {
26
36
  version: 1,
27
37
  pid: process.pid,
@@ -31,8 +41,10 @@ export function acquireGatewayInstanceLock(path, owner) {
31
41
  };
32
42
  for (let attempt = 0; attempt < 3; attempt++) {
33
43
  let fd;
44
+ let created = false;
34
45
  try {
35
46
  fd = openSync(path, "wx", 0o600);
47
+ created = true;
36
48
  writeFileSync(fd, `${JSON.stringify(record)}\n`, "utf-8");
37
49
  closeSync(fd);
38
50
  fd = undefined;
@@ -53,6 +65,8 @@ export function acquireGatewayInstanceLock(path, owner) {
53
65
  catch (error) {
54
66
  if (fd !== undefined)
55
67
  closeSync(fd);
68
+ if (created)
69
+ rmSync(path, { force: true });
56
70
  if (error.code !== "EEXIST")
57
71
  throw error;
58
72
  const existing = readLock(path);
@@ -68,13 +82,26 @@ export function acquireGatewayInstanceLock(path, owner) {
68
82
  throw new GatewayAlreadyRunningError(path);
69
83
  }
70
84
  function readLock(path) {
85
+ let fd;
71
86
  try {
72
- const value = JSON.parse(readFileSync(path, "utf-8"));
87
+ const entry = lstatSync(path);
88
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_LOCK_FILE_BYTES) {
89
+ return undefined;
90
+ }
91
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
92
+ const opened = fstatSync(fd);
93
+ if (!opened.isFile() || opened.size > MAX_LOCK_FILE_BYTES)
94
+ return undefined;
95
+ const value = JSON.parse(readFileSync(fd, "utf-8"));
73
96
  if (value.version !== 1 ||
74
97
  !Number.isSafeInteger(value.pid) ||
75
98
  (value.pid ?? 0) <= 0 ||
76
99
  typeof value.owner !== "string" ||
100
+ value.owner.length === 0 ||
101
+ value.owner.length > MAX_LOCK_OWNER_CHARS ||
102
+ /[\0\r\n]/u.test(value.owner) ||
77
103
  typeof value.token !== "string" ||
104
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value.token) ||
78
105
  typeof value.startedAt !== "number") {
79
106
  return undefined;
80
107
  }
@@ -83,6 +110,10 @@ function readLock(path) {
83
110
  catch {
84
111
  return undefined;
85
112
  }
113
+ finally {
114
+ if (fd !== undefined)
115
+ closeSync(fd);
116
+ }
86
117
  }
87
118
  function isProcessAlive(pid) {
88
119
  try {
@@ -1,5 +1,6 @@
1
1
  import { execFile as nodeExecFile } from "node:child_process";
2
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmod, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
5
6
  import { promisify } from "node:util";
@@ -96,7 +97,7 @@ export class GatewayServiceManager {
96
97
  async installLaunchd() {
97
98
  const path = this.launchdPath();
98
99
  const logDir = join(this.home, ".code-shell", "im-gateway", "logs");
99
- await mkdir(logDir, { recursive: true, mode: 0o700 });
100
+ await ensureRealDirectory(logDir);
100
101
  await writeOwnerFile(path, renderLaunchdService({
101
102
  executable: this.executable,
102
103
  cliPath: this.cliPath,
@@ -192,20 +193,51 @@ WantedBy=default.target
192
193
  `;
193
194
  }
194
195
  async function writeOwnerFile(path, contents) {
195
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
196
- await writeFile(path, contents, { encoding: "utf-8", mode: 0o600 });
197
- if (process.platform !== "win32")
198
- await chmod(path, 0o600);
196
+ const parent = dirname(path);
197
+ await ensureRealDirectory(parent);
198
+ try {
199
+ const targetInfo = await lstat(path);
200
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) {
201
+ throw new Error(`service definition must be a regular file: ${path}`);
202
+ }
203
+ }
204
+ catch (error) {
205
+ if (error.code !== "ENOENT")
206
+ throw error;
207
+ }
208
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
209
+ try {
210
+ await writeFile(temporary, contents, {
211
+ encoding: "utf-8",
212
+ mode: 0o600,
213
+ flag: "wx",
214
+ });
215
+ await rename(temporary, path);
216
+ if (process.platform !== "win32")
217
+ await chmod(path, 0o600);
218
+ }
219
+ finally {
220
+ await rm(temporary, { force: true }).catch(() => undefined);
221
+ }
199
222
  }
200
223
  async function fileExists(path) {
201
224
  try {
202
- await readFile(path);
203
- return true;
225
+ const info = await lstat(path);
226
+ return !info.isSymbolicLink() && info.isFile();
204
227
  }
205
228
  catch {
206
229
  return false;
207
230
  }
208
231
  }
232
+ async function ensureRealDirectory(path) {
233
+ await mkdir(path, { recursive: true, mode: 0o700 });
234
+ const info = await lstat(path);
235
+ if (info.isSymbolicLink() || !info.isDirectory()) {
236
+ throw new Error(`service directory must be a real directory: ${path}`);
237
+ }
238
+ if (process.platform !== "win32")
239
+ await chmod(path, 0o700);
240
+ }
209
241
  function escapeXml(value) {
210
242
  return value
211
243
  .replaceAll("&", "&amp;")
package/dist/teams.js CHANGED
@@ -1,10 +1,14 @@
1
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
3
  import { dirname } from "node:path";
3
4
  import { ActivityTypes, CloudAdapter, ConfigurationBotFrameworkAuthentication, TurnContext, } from "botbuilder";
4
5
  import { BUILTIN_CHANNEL_CAPABILITIES } from "./channel.js";
5
6
  import { formatOutgoingMarkdown, waitForAbort } from "./lifecycle.js";
6
7
  import { mediaKind, outgoingAttachments, remoteAttachment, safeAttachmentName } from "./media.js";
7
8
  import { readRequestBody, sendResponse } from "./webhook.js";
9
+ const MAX_TEAMS_STATE_BYTES = 4 * 1024 * 1024;
10
+ const MAX_TEAMS_REFERENCES = 1_000;
11
+ const MAX_TEAMS_REFERENCE_BYTES = 64 * 1024;
8
12
  export class TeamsAdapter {
9
13
  channel = "teams";
10
14
  capabilities = BUILTIN_CHANNEL_CAPABILITIES.teams;
@@ -135,12 +139,20 @@ export class TeamsAdapter {
135
139
  loadReferences() {
136
140
  if (!this.statePath)
137
141
  return;
142
+ let fd;
138
143
  try {
139
- const parsed = JSON.parse(readFileSync(this.statePath, "utf-8"));
144
+ const entry = lstatSync(this.statePath);
145
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_TEAMS_STATE_BYTES)
146
+ return;
147
+ fd = openSync(this.statePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
148
+ const opened = fstatSync(fd);
149
+ if (!opened.isFile() || opened.size > MAX_TEAMS_STATE_BYTES)
150
+ return;
151
+ const parsed = JSON.parse(readFileSync(fd, "utf-8"));
140
152
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
141
153
  return;
142
- for (const [target, reference] of Object.entries(parsed).slice(0, 1_000)) {
143
- if (target && reference && typeof reference === "object" && !Array.isArray(reference)) {
154
+ for (const [target, reference] of Object.entries(parsed).slice(0, MAX_TEAMS_REFERENCES)) {
155
+ if (validTeamsConversationReference(target, reference)) {
144
156
  this.references.set(target, reference);
145
157
  }
146
158
  }
@@ -148,21 +160,86 @@ export class TeamsAdapter {
148
160
  catch {
149
161
  // First run, malformed legacy state, or an unavailable home directory.
150
162
  }
163
+ finally {
164
+ if (fd !== undefined)
165
+ closeSync(fd);
166
+ }
151
167
  }
152
168
  saveReferences() {
153
169
  if (!this.statePath)
154
170
  return;
155
- const entries = [...this.references.entries()].slice(-1_000);
156
- mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
157
- const temporary = `${this.statePath}.${process.pid}.tmp`;
158
- writeFileSync(temporary, `${JSON.stringify(Object.fromEntries(entries))}\n`, {
159
- encoding: "utf-8",
160
- mode: 0o600,
161
- });
162
- renameSync(temporary, this.statePath);
163
- if (process.platform !== "win32")
164
- chmodSync(this.statePath, 0o600);
171
+ const entries = [...this.references.entries()]
172
+ .filter(([target, reference]) => validTeamsConversationReference(target, reference))
173
+ .slice(-MAX_TEAMS_REFERENCES);
174
+ const serialized = `${JSON.stringify(Object.fromEntries(entries))}\n`;
175
+ if (Buffer.byteLength(serialized, "utf8") > MAX_TEAMS_STATE_BYTES) {
176
+ throw new Error("Teams conversation state is too large");
177
+ }
178
+ const parent = dirname(this.statePath);
179
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
180
+ const parentInfo = lstatSync(parent);
181
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
182
+ throw new Error("Teams conversation state parent must be a real directory");
183
+ }
184
+ try {
185
+ const targetInfo = lstatSync(this.statePath);
186
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) {
187
+ throw new Error("Teams conversation state must be a regular file");
188
+ }
189
+ }
190
+ catch (error) {
191
+ if (error.code !== "ENOENT")
192
+ throw error;
193
+ }
194
+ const temporary = `${this.statePath}.${process.pid}.${randomUUID()}.tmp`;
195
+ try {
196
+ writeFileSync(temporary, serialized, { encoding: "utf-8", mode: 0o600, flag: "wx" });
197
+ renameSync(temporary, this.statePath);
198
+ if (process.platform !== "win32")
199
+ chmodSync(this.statePath, 0o600);
200
+ }
201
+ finally {
202
+ rmSync(temporary, { force: true });
203
+ }
204
+ }
205
+ }
206
+ function validTeamsConversationReference(target, value) {
207
+ if (!target || target.length > 4_096 || /[\0\r\n]/u.test(target))
208
+ return false;
209
+ if (!value || typeof value !== "object" || Array.isArray(value))
210
+ return false;
211
+ let serialized;
212
+ try {
213
+ serialized = JSON.stringify(value);
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ if (Buffer.byteLength(serialized, "utf8") > MAX_TEAMS_REFERENCE_BYTES)
219
+ return false;
220
+ const reference = value;
221
+ if (reference.channelId !== "msteams" || typeof reference.serviceUrl !== "string")
222
+ return false;
223
+ if (reference.serviceUrl.length > 2_048)
224
+ return false;
225
+ try {
226
+ const url = new URL(reference.serviceUrl);
227
+ if (url.protocol !== "https:" || url.username || url.password || !url.hostname)
228
+ return false;
229
+ if (url.hostname === "localhost" || url.hostname.endsWith(".localhost"))
230
+ return false;
231
+ if (/^(?:127\.|0\.|10\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/u.test(url.hostname)) {
232
+ return false;
233
+ }
234
+ }
235
+ catch {
236
+ return false;
165
237
  }
238
+ const conversation = reference.conversation;
239
+ return (Boolean(conversation) &&
240
+ typeof conversation === "object" &&
241
+ !Array.isArray(conversation) &&
242
+ conversation.id === target);
166
243
  }
167
244
  class BotFrameworkResponse {
168
245
  response;
@@ -1,8 +1,10 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from "node:fs";
1
+ import { chmodSync, closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { dirname, resolve } from "node:path";
3
4
  import { defaultGatewayConfigPath } from "./config.js";
4
5
  import { loginWechatWithQr, } from "./wechat.js";
5
6
  import { defaultWechatDataDirectory, FileWechatCredentialStore, wechatCredentialFingerprint, } from "./wechat-storage.js";
7
+ const MAX_GATEWAY_CONFIG_BYTES = 4 * 1024 * 1024;
6
8
  /**
7
9
  * Complete the personal-WeChat QR flow and persist both the owner-only token
8
10
  * and the matching CodeShell gateway configuration. Shared by the CLI and the
@@ -11,6 +13,10 @@ import { defaultWechatDataDirectory, FileWechatCredentialStore, wechatCredential
11
13
  export async function loginCodeShellWechat(options = {}) {
12
14
  const configPath = resolve(options.configPath ?? defaultGatewayConfigPath());
13
15
  const credentialsDir = resolve(options.credentialsDir ?? defaultWechatDataDirectory());
16
+ // Validate the destination before starting a remote login or mutating local
17
+ // credentials/state. A linked, oversized, malformed, or insecure config must
18
+ // not leave a half-applied successful QR login behind.
19
+ readGatewayConfigObject(configPath);
14
20
  const store = new FileWechatCredentialStore(credentialsDir);
15
21
  const login = options.login ?? loginWechatWithQr;
16
22
  const result = await login({
@@ -65,17 +71,7 @@ export async function loginCodeShellWechat(options = {}) {
65
71
  return { accountId: credentials.accountId, configPath };
66
72
  }
67
73
  function updateWechatConfig(options) {
68
- let raw = {};
69
- if (existsSync(options.configPath)) {
70
- if (process.platform !== "win32" && (statSync(options.configPath).mode & 0o077) !== 0) {
71
- throw new Error(`Chat gateway 配置权限必须为 0600:${options.configPath}`);
72
- }
73
- const parsed = JSON.parse(readFileSync(options.configPath, "utf8"));
74
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
75
- throw new Error(`Chat gateway 配置不是 JSON object:${options.configPath}`);
76
- }
77
- raw = parsed;
78
- }
74
+ const raw = readGatewayConfigObject(options.configPath);
79
75
  const existing = raw.wechat && typeof raw.wechat === "object" && !Array.isArray(raw.wechat)
80
76
  ? raw.wechat
81
77
  : {};
@@ -88,10 +84,86 @@ function updateWechatConfig(options) {
88
84
  ...(options.credentialsDir ? { credentialsDir: options.credentialsDir } : {}),
89
85
  },
90
86
  };
91
- mkdirSync(dirname(options.configPath), { recursive: true, mode: 0o700 });
92
- const temporary = `${options.configPath}.${process.pid}.tmp`;
93
- writeFileSync(temporary, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
94
- renameSync(temporary, options.configPath);
95
- if (process.platform !== "win32")
96
- chmodSync(options.configPath, 0o600);
87
+ const serialized = `${JSON.stringify(updated, null, 2)}\n`;
88
+ if (Buffer.byteLength(serialized, "utf8") > MAX_GATEWAY_CONFIG_BYTES) {
89
+ throw new Error("Chat gateway 配置过大");
90
+ }
91
+ assertSafeConfigTarget(options.configPath);
92
+ const temporary = `${options.configPath}.${process.pid}.${randomUUID()}.tmp`;
93
+ try {
94
+ writeFileSync(temporary, serialized, { mode: 0o600, flag: "wx" });
95
+ renameSync(temporary, options.configPath);
96
+ if (process.platform !== "win32")
97
+ chmodSync(options.configPath, 0o600);
98
+ }
99
+ finally {
100
+ rmSync(temporary, { force: true });
101
+ }
102
+ }
103
+ function readGatewayConfigObject(configPath) {
104
+ const parent = dirname(configPath);
105
+ try {
106
+ const parentInfo = lstatSync(parent);
107
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
108
+ throw new Error("Chat gateway 配置目录必须是普通目录");
109
+ }
110
+ }
111
+ catch (error) {
112
+ if (error.code !== "ENOENT")
113
+ throw error;
114
+ }
115
+ if (!existsSync(configPath)) {
116
+ try {
117
+ const linked = lstatSync(configPath);
118
+ if (linked.isSymbolicLink())
119
+ throw new Error("linked config is not allowed");
120
+ }
121
+ catch (error) {
122
+ if (error.code !== "ENOENT")
123
+ throw error;
124
+ }
125
+ return {};
126
+ }
127
+ let fd;
128
+ try {
129
+ const entry = lstatSync(configPath);
130
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_GATEWAY_CONFIG_BYTES) {
131
+ throw new Error(`Chat gateway 配置必须是有限大小的普通文件:${configPath}`);
132
+ }
133
+ if (process.platform !== "win32" && (entry.mode & 0o077) !== 0) {
134
+ throw new Error(`Chat gateway 配置权限必须为 0600:${configPath}`);
135
+ }
136
+ fd = openSync(configPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
137
+ const opened = fstatSync(fd);
138
+ if (!opened.isFile() || opened.size > MAX_GATEWAY_CONFIG_BYTES) {
139
+ throw new Error(`Chat gateway 配置必须是有限大小的普通文件:${configPath}`);
140
+ }
141
+ const parsed = JSON.parse(readFileSync(fd, "utf8"));
142
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
143
+ throw new Error(`Chat gateway 配置不是 JSON object:${configPath}`);
144
+ }
145
+ return parsed;
146
+ }
147
+ finally {
148
+ if (fd !== undefined)
149
+ closeSync(fd);
150
+ }
151
+ }
152
+ function assertSafeConfigTarget(configPath) {
153
+ const parent = dirname(configPath);
154
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
155
+ const parentInfo = lstatSync(parent);
156
+ if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) {
157
+ throw new Error("Chat gateway 配置目录必须是普通目录");
158
+ }
159
+ try {
160
+ const targetInfo = lstatSync(configPath);
161
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) {
162
+ throw new Error("Chat gateway 配置必须是普通文件");
163
+ }
164
+ }
165
+ catch (error) {
166
+ if (error.code !== "ENOENT")
167
+ throw error;
168
+ }
97
169
  }
package/dist/wechat.d.ts CHANGED
@@ -70,6 +70,8 @@ export declare class WechatAdapter implements ChannelAdapter {
70
70
  /** Preserve visible ordering and keep context/media state mutations single-writer. */
71
71
  private outboundQueue;
72
72
  private readonly seenMessageIds;
73
+ /** Cursor whose batch was rejected by the handler and therefore must retry even after max age. */
74
+ private heldCursor?;
73
75
  private readonly delivery;
74
76
  constructor(config: WechatAdapterConfig, options?: WechatAdapterOptions);
75
77
  run(handler: ChannelMessageHandler, signal: AbortSignal): Promise<void>;
package/dist/wechat.js CHANGED
@@ -66,6 +66,8 @@ export class WechatAdapter {
66
66
  /** Preserve visible ordering and keep context/media state mutations single-writer. */
67
67
  outboundQueue = Promise.resolve();
68
68
  seenMessageIds = new Set();
69
+ /** Cursor whose batch was rejected by the handler and therefore must retry even after max age. */
70
+ heldCursor;
69
71
  delivery = new OutgoingDeliveryTracker();
70
72
  constructor(config, options = {}) {
71
73
  this.config = config;
@@ -106,6 +108,8 @@ export class WechatAdapter {
106
108
  throw new Error("微信状态文件已被新的扫码绑定接管,长轮询停止;请重启 Gateway 使用新凭据");
107
109
  }
108
110
  try {
111
+ const requestedCursor = this.state.cursor ?? "";
112
+ const retryingHeldBatch = this.heldCursor === requestedCursor;
109
113
  const response = await this.post("ilink/bot/getupdates", {
110
114
  get_updates_buf: this.state.cursor ?? "",
111
115
  base_info: this.baseInfo(),
@@ -123,7 +127,7 @@ export class WechatAdapter {
123
127
  }
124
128
  let batchAccepted = true;
125
129
  for (const raw of response.msgs ?? []) {
126
- const message = this.normalizeInbound(raw);
130
+ const message = this.normalizeInbound(raw, retryingHeldBatch);
127
131
  if (!message || this.isDuplicate(message.messageId))
128
132
  continue;
129
133
  try {
@@ -141,11 +145,14 @@ export class WechatAdapter {
141
145
  }
142
146
  }
143
147
  if (!batchAccepted) {
148
+ this.heldCursor = requestedCursor;
144
149
  throw new Error("微信消息未被上层接收,保留游标等待重投");
145
150
  }
146
151
  if (response.get_updates_buf) {
147
152
  await this.commitCursor(response.get_updates_buf);
148
153
  }
154
+ if (this.heldCursor === requestedCursor)
155
+ this.heldCursor = undefined;
149
156
  }
150
157
  catch (error) {
151
158
  if (signal.aborted)
@@ -428,7 +435,7 @@ export class WechatAdapter {
428
435
  }
429
436
  throw lastError instanceof Error ? lastError : new Error("微信附件上传失败");
430
437
  }
431
- normalizeInbound(raw) {
438
+ normalizeInbound(raw, allowExpiredRetry = false) {
432
439
  if (raw.message_type !== undefined && raw.message_type !== MESSAGE_TYPE_USER)
433
440
  return undefined;
434
441
  if (raw.message_state === MESSAGE_STATE_GENERATING)
@@ -437,6 +444,7 @@ export class WechatAdapter {
437
444
  if (!senderId)
438
445
  return undefined;
439
446
  if (raw.create_time_ms !== undefined &&
447
+ !allowExpiredRetry &&
440
448
  this.maxMessageAgeMs >= 0 &&
441
449
  this.now() - raw.create_time_ms > this.maxMessageAgeMs) {
442
450
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-chat",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "description": "A standalone, transport-agnostic multi-channel chat gateway for Node.js and Bun.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",