@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 +50 -18
- package/dist/delivery-queue.d.ts +2 -0
- package/dist/delivery-queue.js +298 -75
- package/dist/instance-lock.js +34 -3
- package/dist/service-manager.js +40 -8
- package/dist/teams.js +91 -14
- package/dist/wechat-login.js +90 -18
- package/dist/wechat.d.ts +2 -0
- package/dist/wechat.js +10 -2
- package/package.json +1 -1
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
|
-
|
|
402
|
-
return {};
|
|
404
|
+
let fd;
|
|
403
405
|
try {
|
|
404
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
package/dist/delivery-queue.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/delivery-queue.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
this.targetInFlight.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
this.pending.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
+
throw new Error("attachment exceeds spool limit");
|
|
203
267
|
const file = `${recordId}.${index}`;
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
264
|
-
.map((
|
|
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
|
-
|
|
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
|
|
278
|
-
if (parsed.version !== 1 ||
|
|
279
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
typeof value.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
}
|
package/dist/instance-lock.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
package/dist/service-manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile as nodeExecFile } from "node:child_process";
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
196
|
-
await
|
|
197
|
-
|
|
198
|
-
await
|
|
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
|
|
203
|
-
return
|
|
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("&", "&")
|
package/dist/teams.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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,
|
|
143
|
-
if (target
|
|
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()]
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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;
|
package/dist/wechat-login.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync,
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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;
|