chatcc-agent 0.3.9 → 0.5.0
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/package.json +1 -1
- package/src/claude-bridge.js +12 -1
- package/src/config.js +6 -0
- package/src/im-client.js +14 -0
- package/src/image-downloader.js +38 -7
- package/src/index.js +73 -5
- package/src/log-rotation.js +4 -1
- package/src/seq-store.js +85 -0
- package/src/session-store.js +5 -2
package/package.json
CHANGED
package/src/claude-bridge.js
CHANGED
|
@@ -105,10 +105,15 @@ class ClaudeBridge {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
resolveToolApproval(toolUseID, approved) {
|
|
108
|
+
resolveToolApproval(toolUseID, approved, from) {
|
|
109
109
|
// Control protocol (new flow — respond via stdin)
|
|
110
110
|
const permReq = this._pendingPermissionRequests.get(toolUseID);
|
|
111
111
|
if (permReq) {
|
|
112
|
+
// Only the client who owns this session may approve/deny
|
|
113
|
+
if (from && permReq.replyTo && permReq.replyTo !== from) {
|
|
114
|
+
console.warn(`[Security] tool_approval rejected: ${from} is not the session owner (${permReq.replyTo})`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
112
117
|
this._pendingPermissionRequests.delete(toolUseID);
|
|
113
118
|
const entry = this._activeProcesses.get(permReq.msgID);
|
|
114
119
|
if (entry) {
|
|
@@ -130,6 +135,12 @@ class ClaudeBridge {
|
|
|
130
135
|
const pending = this._pendingToolApprovals.get(toolUseID);
|
|
131
136
|
if (!pending) return false;
|
|
132
137
|
|
|
138
|
+
// Only the client who owns this session may approve/deny
|
|
139
|
+
if (from && pending.replyTo && pending.replyTo !== from) {
|
|
140
|
+
console.warn(`[Security] tool_approval rejected (legacy): ${from} is not the session owner (${pending.replyTo})`);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
this._pendingToolApprovals.delete(toolUseID);
|
|
134
145
|
|
|
135
146
|
if (!approved) {
|
package/src/config.js
CHANGED
|
@@ -113,6 +113,12 @@ async function promptSetupCode() {
|
|
|
113
113
|
const setupCode = input.slice(0, atIdx);
|
|
114
114
|
const tcbEnv = input.slice(atIdx + 1);
|
|
115
115
|
|
|
116
|
+
// L3: validate cloud env id before building the trigger URL
|
|
117
|
+
if (!/^[a-z0-9-]+$/.test(tcbEnv)) {
|
|
118
|
+
console.log(' ✗ 无效格式,环境信息只能包含小写字母、数字和连字符');
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
116
122
|
console.log(' 验证中...');
|
|
117
123
|
try {
|
|
118
124
|
const result = await callCloudFunction('validateSetupCode', {
|
package/src/im-client.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const WebSocket = require('ws');
|
|
2
2
|
global.WebSocket = WebSocket;
|
|
3
3
|
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { SeqStore } = require('./seq-store');
|
|
7
|
+
|
|
4
8
|
const { CURRENT_PROTOCOL_VERSION, MIN_PROTOCOL_VERSION } = require('./constants');
|
|
5
9
|
const PROTOCOL_VERSION = CURRENT_PROTOCOL_VERSION;
|
|
6
10
|
|
|
@@ -13,6 +17,12 @@ class IMClient {
|
|
|
13
17
|
this._onFatal = options.onFatal || (() => process.exit(1));
|
|
14
18
|
this._onUserSigExpired = options.onUserSigExpired || null;
|
|
15
19
|
|
|
20
|
+
// Persistent monotonic sequence stamped onto every outgoing message as
|
|
21
|
+
// `seq`, so the iOS client can order messages by real send order instead
|
|
22
|
+
// of unreliable arrival order. Falls back to ~/.chatcc/seq.json when the
|
|
23
|
+
// caller (tests) doesn't pass a path.
|
|
24
|
+
this._seqStore = new SeqStore(options.seqFilePath || path.join(os.homedir(), '.chatcc', 'seq.json'));
|
|
25
|
+
|
|
16
26
|
const TIM = require('@tencentcloud/chat');
|
|
17
27
|
this.TIM = TIM;
|
|
18
28
|
this.tim = TIM.create({ SDKAppID: sdkAppID });
|
|
@@ -136,6 +146,10 @@ class IMClient {
|
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
async sendCustomMessage(to, ccType, payload = {}, pushOptions = null) {
|
|
149
|
+
// Stamp a monotonic seq onto every outgoing message (including chunks).
|
|
150
|
+
// The iOS client uses it as the authoritative sort key. Placed here so it
|
|
151
|
+
// is impossible to send without one — this is the sole send path.
|
|
152
|
+
payload = { ...payload, seq: this._seqStore.next() };
|
|
139
153
|
const message = this.tim.createCustomMessage({
|
|
140
154
|
to,
|
|
141
155
|
conversationType: this.TIM.TYPES.CONV_C2C,
|
package/src/image-downloader.js
CHANGED
|
@@ -10,6 +10,17 @@ const DOWNLOAD_MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
|
|
10
10
|
const IMAGE_CONTENT_TYPES = new Set([
|
|
11
11
|
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml',
|
|
12
12
|
]);
|
|
13
|
+
// Map Content-Type -> file extension so the saved temp file's extension (and thus the
|
|
14
|
+
// downstream media_type) matches the actual bytes, instead of trusting the URL path.
|
|
15
|
+
const CONTENT_TYPE_EXT = {
|
|
16
|
+
'image/png': 'png',
|
|
17
|
+
'image/jpeg': 'jpg',
|
|
18
|
+
'image/jpg': 'jpg',
|
|
19
|
+
'image/gif': 'gif',
|
|
20
|
+
'image/webp': 'webp',
|
|
21
|
+
'image/bmp': 'bmp',
|
|
22
|
+
'image/svg+xml': 'svg',
|
|
23
|
+
};
|
|
13
24
|
|
|
14
25
|
function downloadImage(url, tempFiles) {
|
|
15
26
|
return new Promise((resolve, reject) => {
|
|
@@ -19,13 +30,14 @@ function downloadImage(url, tempFiles) {
|
|
|
19
30
|
const initialProtocol = parsed.protocol;
|
|
20
31
|
|
|
21
32
|
const extMatch = parsed.pathname.match(/\.(png|jpg|jpeg|gif|webp|bmp)/i);
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
33
|
+
const urlExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
|
34
|
+
const makePath = (e) => path.join(os.tmpdir(), 'chatcc-' + crypto.randomBytes(8).toString('hex') + '.' + e);
|
|
35
|
+
let writePath = makePath(urlExt);
|
|
36
|
+
tempFiles.add(writePath);
|
|
25
37
|
|
|
26
38
|
function fail(err) {
|
|
27
|
-
fs.unlink(
|
|
28
|
-
tempFiles.delete(
|
|
39
|
+
fs.unlink(writePath, () => {});
|
|
40
|
+
tempFiles.delete(writePath);
|
|
29
41
|
reject(err);
|
|
30
42
|
}
|
|
31
43
|
|
|
@@ -52,11 +64,26 @@ function downloadImage(url, tempFiles) {
|
|
|
52
64
|
return fail(new Error('Invalid content type: ' + contentType));
|
|
53
65
|
}
|
|
54
66
|
|
|
67
|
+
// Realign the temp file extension to the real Content-Type: COS / IM image
|
|
68
|
+
// URLs are often bare UUIDs with no extension, so the URL-derived ext (which
|
|
69
|
+
// defaults to 'png') is unreliable. The file extension drives the media_type
|
|
70
|
+
// we later declare to Claude, so it must match the actual bytes.
|
|
71
|
+
const realExt = CONTENT_TYPE_EXT[contentType] || urlExt;
|
|
72
|
+
if (realExt !== urlExt) {
|
|
73
|
+
console.log(`[Image] Realign ext: url=${urlExt} -> ${realExt} (content-type=${contentType})`);
|
|
74
|
+
tempFiles.delete(writePath);
|
|
75
|
+
fs.unlink(writePath, () => {});
|
|
76
|
+
writePath = makePath(realExt);
|
|
77
|
+
tempFiles.add(writePath);
|
|
78
|
+
} else {
|
|
79
|
+
console.log(`[Image] ext=${realExt} (content-type=${contentType})`);
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
const contentLength = parseInt(res.headers['content-length'], 10);
|
|
56
83
|
if (contentLength && contentLength > DOWNLOAD_MAX_SIZE) return fail(new Error('File too large'));
|
|
57
84
|
|
|
58
85
|
let downloaded = 0;
|
|
59
|
-
const file = fs.createWriteStream(
|
|
86
|
+
const file = fs.createWriteStream(writePath);
|
|
60
87
|
res.on('data', (chunk) => {
|
|
61
88
|
downloaded += chunk.length;
|
|
62
89
|
if (downloaded > DOWNLOAD_MAX_SIZE) {
|
|
@@ -66,7 +93,11 @@ function downloadImage(url, tempFiles) {
|
|
|
66
93
|
}
|
|
67
94
|
});
|
|
68
95
|
res.pipe(file);
|
|
69
|
-
file.on('finish', () => {
|
|
96
|
+
file.on('finish', () => {
|
|
97
|
+
file.close();
|
|
98
|
+
console.log(`[Image] Downloaded ${downloaded} bytes -> ${path.basename(writePath)}`);
|
|
99
|
+
resolve(writePath);
|
|
100
|
+
});
|
|
70
101
|
}).on('error', fail);
|
|
71
102
|
}).catch(fail);
|
|
72
103
|
}
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,7 @@ const { FileService } = require('./file-service');
|
|
|
7
7
|
const { collectServerMeta } = require('./server-info');
|
|
8
8
|
const { getLogFilePath, cleanupOldLogs, rotateLogStream } = require('./log-rotation');
|
|
9
9
|
const { SessionStore } = require('./session-store');
|
|
10
|
+
const { CURRENT_PROTOCOL_VERSION, MIN_PROTOCOL_VERSION } = require('./constants');
|
|
10
11
|
const { handleSessionCreate, handleSessionUpdateSettings, handleClaudeSessionList, handlePermissionGrant, handleSessionSync } = require('./session-handler');
|
|
11
12
|
const { handleClientList, handleClientRemove } = require('./client-handler');
|
|
12
13
|
const { downloadImage } = require('./image-downloader');
|
|
@@ -22,7 +23,9 @@ const CHATCC_DIR = path.join(os.homedir(), '.chatcc');
|
|
|
22
23
|
let logStream = null;
|
|
23
24
|
if (isDaemon) {
|
|
24
25
|
if (!fs.existsSync(CHATCC_DIR)) fs.mkdirSync(CHATCC_DIR, { recursive: true });
|
|
25
|
-
|
|
26
|
+
const _logPath = getLogFilePath(CHATCC_DIR);
|
|
27
|
+
try { if (fs.existsSync(_logPath)) fs.chmodSync(_logPath, 0o600); } catch {}
|
|
28
|
+
logStream = fs.createWriteStream(_logPath, { flags: 'a', mode: 0o600 });
|
|
26
29
|
const origLog = console.log;
|
|
27
30
|
const origWarn = console.warn;
|
|
28
31
|
const origErr = console.error;
|
|
@@ -185,6 +188,7 @@ async function main() {
|
|
|
185
188
|
|
|
186
189
|
// Create IM client and login
|
|
187
190
|
imClient = new IMClient(config.sdkAppID, config.agentUserID, config.userSig, {
|
|
191
|
+
seqFilePath: path.join(CHATCC_DIR, 'seq.json'),
|
|
188
192
|
onFatal: () => shutdown('IM_FATAL'),
|
|
189
193
|
onUserSigExpired: async () => {
|
|
190
194
|
console.log('[UserSig] Refreshing from CloudBase...');
|
|
@@ -290,6 +294,30 @@ async function main() {
|
|
|
290
294
|
return;
|
|
291
295
|
}
|
|
292
296
|
|
|
297
|
+
// M3: reject messages from incompatible protocol versions.
|
|
298
|
+
// data.v = sender's version; data.min_v = lowest version the sender can talk to.
|
|
299
|
+
if (data.v !== undefined) {
|
|
300
|
+
if (data.v < MIN_PROTOCOL_VERSION) {
|
|
301
|
+
console.warn(`[Security] Rejected ${from}: protocol v${data.v} < min v${MIN_PROTOCOL_VERSION}`);
|
|
302
|
+
imClient.sendCustomMessage(from, 'version_error', {
|
|
303
|
+
agent_version: CURRENT_PROTOCOL_VERSION,
|
|
304
|
+
min_version: MIN_PROTOCOL_VERSION,
|
|
305
|
+
received_version: data.v,
|
|
306
|
+
message: `Protocol version ${data.v} is no longer supported`,
|
|
307
|
+
}).catch(() => {});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (data.min_v !== undefined && data.min_v > CURRENT_PROTOCOL_VERSION) {
|
|
311
|
+
console.warn(`[Security] Rejected ${from}: client requires v${data.min_v} > agent v${CURRENT_PROTOCOL_VERSION}`);
|
|
312
|
+
imClient.sendCustomMessage(from, 'version_error', {
|
|
313
|
+
agent_version: CURRENT_PROTOCOL_VERSION,
|
|
314
|
+
required_version: data.min_v,
|
|
315
|
+
message: `Agent too old (client requires v${data.min_v}), please upgrade`,
|
|
316
|
+
}).catch(() => {});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
293
321
|
switch (data.cc_type) {
|
|
294
322
|
case 'user_text': {
|
|
295
323
|
const sid = data.session_id;
|
|
@@ -341,10 +369,27 @@ async function main() {
|
|
|
341
369
|
const bridgeOpts = { cwd, claudeSessionId: claudeSid, onClaudeSessionId, onResumeFailed, onSessionTitle, workspaceRestricted: isRestricted, permissions: sessionPermissions };
|
|
342
370
|
if (imageUrls.length > 0) {
|
|
343
371
|
Promise.all(imageUrls.map(u => downloadImage(u, tempFiles).catch(e => {
|
|
344
|
-
console.error('[Image] Download failed:', e.message, e.stack);
|
|
372
|
+
console.error('[Image] Download failed for', u, ':', e.message, e.stack);
|
|
345
373
|
return null;
|
|
346
374
|
}))).then((paths) => {
|
|
347
|
-
|
|
375
|
+
const ok = paths.filter(p => p !== null);
|
|
376
|
+
const failed = paths.length - ok.length;
|
|
377
|
+
// If every image failed, there's nothing to analyze — tell the client instead
|
|
378
|
+
// of silently feeding an empty (or text-only) prompt to Claude.
|
|
379
|
+
if (ok.length === 0) {
|
|
380
|
+
console.warn(`[Image] All ${paths.length} image(s) failed to download in session ${sid}; notifying client`);
|
|
381
|
+
imClient.sendCustomMessage(from, 'error', {
|
|
382
|
+
type: 'image_download',
|
|
383
|
+
session_id: sid,
|
|
384
|
+
message: `图片加载失败(${failed}/${paths.length}),请重试或换一张图`,
|
|
385
|
+
}).catch(() => {});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// Partial failure: still proceed, but warn how many dropped so behavior is visible.
|
|
389
|
+
if (failed > 0) {
|
|
390
|
+
console.warn(`[Image] Partial failure: ${failed}/${paths.length} images dropped in session ${sid}`);
|
|
391
|
+
}
|
|
392
|
+
bridge.handleUserText(from, data.content, sid, { ...bridgeOpts, imagePaths: ok });
|
|
348
393
|
}).catch(e => console.error('[Image] Processing failed:', e.message, e.stack));
|
|
349
394
|
} else {
|
|
350
395
|
bridge.handleUserText(from, data.content, sid, { ...bridgeOpts, imagePaths: [] });
|
|
@@ -373,32 +418,48 @@ async function main() {
|
|
|
373
418
|
|
|
374
419
|
case 'tool_approval_response': {
|
|
375
420
|
const approved = data.approved === true;
|
|
376
|
-
console.log(`[Approval] tool_use_id=${data.msg_id} approved=${approved}`);
|
|
421
|
+
console.log(`[Approval] tool_use_id=${data.msg_id} approved=${approved} from=${from}`);
|
|
377
422
|
if (bridge) {
|
|
378
|
-
bridge.resolveToolApproval(data.msg_id, approved);
|
|
423
|
+
bridge.resolveToolApproval(data.msg_id, approved, from);
|
|
379
424
|
}
|
|
380
425
|
break;
|
|
381
426
|
}
|
|
382
427
|
|
|
383
428
|
case 'file_tree_request': {
|
|
429
|
+
if (fileSessionDenied(from, data)) {
|
|
430
|
+
imClient.sendCustomMessage(from, 'file_tree_response', { request_id: data.request_id, error: 'Access denied' }).catch(() => {});
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
384
433
|
const fsConstraint = resolveFileConstraint(data);
|
|
385
434
|
fileService.handleTreeRequest(from, data, fsConstraint);
|
|
386
435
|
break;
|
|
387
436
|
}
|
|
388
437
|
|
|
389
438
|
case 'file_read_request': {
|
|
439
|
+
if (fileSessionDenied(from, data)) {
|
|
440
|
+
imClient.sendCustomMessage(from, 'file_read_response', { request_id: data.request_id, error: 'Access denied' }).catch(() => {});
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
390
443
|
const fsConstraint = resolveFileConstraint(data);
|
|
391
444
|
fileService.handleReadRequest(from, data, fsConstraint);
|
|
392
445
|
break;
|
|
393
446
|
}
|
|
394
447
|
|
|
395
448
|
case 'file_search_request': {
|
|
449
|
+
if (fileSessionDenied(from, data)) {
|
|
450
|
+
imClient.sendCustomMessage(from, 'file_search_response', { request_id: data.request_id, error: 'Access denied' }).catch(() => {});
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
396
453
|
const fsConstraint = resolveFileConstraint(data);
|
|
397
454
|
fileService.handleSearchRequest(from, data, fsConstraint);
|
|
398
455
|
break;
|
|
399
456
|
}
|
|
400
457
|
|
|
401
458
|
case 'file_mkdir_request': {
|
|
459
|
+
if (fileSessionDenied(from, data)) {
|
|
460
|
+
imClient.sendCustomMessage(from, 'file_mkdir_response', { request_id: data.request_id, error: 'Access denied' }).catch(() => {});
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
402
463
|
const fsConstraint = resolveFileConstraint(data);
|
|
403
464
|
fileService.handleMkdirRequest(from, data, fsConstraint);
|
|
404
465
|
break;
|
|
@@ -552,6 +613,13 @@ async function main() {
|
|
|
552
613
|
|
|
553
614
|
const MAX_SESSIONS = 50;
|
|
554
615
|
|
|
616
|
+
// Reject file operations that reference another client's session.
|
|
617
|
+
function fileSessionDenied(from, data) {
|
|
618
|
+
if (!data.session_id) return false;
|
|
619
|
+
const sess = sessions ? sessions.get(data.session_id) : null;
|
|
620
|
+
return !!sess && sess.clientID !== from;
|
|
621
|
+
}
|
|
622
|
+
|
|
555
623
|
function resolveFileConstraint(data) {
|
|
556
624
|
if (!data.session_id) return null;
|
|
557
625
|
const sess = sessions ? sessions.get(data.session_id) : null;
|
package/src/log-rotation.js
CHANGED
|
@@ -32,7 +32,10 @@ function rotateLogStream(dir, oldStream) {
|
|
|
32
32
|
oldStream.end();
|
|
33
33
|
}
|
|
34
34
|
cleanupOldLogs(dir);
|
|
35
|
-
|
|
35
|
+
// L2: tighten log file permission to 0o600
|
|
36
|
+
const newPath = getLogFilePath(dir);
|
|
37
|
+
try { if (fs.existsSync(newPath)) fs.chmodSync(newPath, 0o600); } catch {}
|
|
38
|
+
const newStream = fs.createWriteStream(newPath, { flags: 'a', mode: 0o600 });
|
|
36
39
|
newStream.on('error', (e) => console.error(`[LogRotation] Write error: ${e.message}`));
|
|
37
40
|
return newStream;
|
|
38
41
|
}
|
package/src/seq-store.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persistent, monotonically increasing sequence counter.
|
|
6
|
+
*
|
|
7
|
+
* Every outgoing IM message carries the result of `next()` as a `seq` field,
|
|
8
|
+
* which the iOS client uses as the authoritative sort order (instead of its
|
|
9
|
+
* local arrival-order counter). Two hard requirements shape this design:
|
|
10
|
+
*
|
|
11
|
+
* 1. Never reuse a number. If the agent crashes and restarts, the counter
|
|
12
|
+
* MUST resume past the last value it handed out — otherwise a freshly
|
|
13
|
+
* sent `seq=3` collides with a message the client already stored as
|
|
14
|
+
* `seq=3`, and pagination/sorting breaks.
|
|
15
|
+
* 2. Keep up with streaming throughput (many chunks per second).
|
|
16
|
+
*
|
|
17
|
+
* (1) forces a synchronous, atomic write on every `next()` — a 2s debounced
|
|
18
|
+
* flush (like SessionStore uses) would lose the tail on crash and let the
|
|
19
|
+
* counter roll back. A single integer file with tmp+rename is cheap enough
|
|
20
|
+
* that we accept the per-message fsync.
|
|
21
|
+
*/
|
|
22
|
+
class SeqStore {
|
|
23
|
+
constructor(filePath) {
|
|
24
|
+
this.filePath = filePath;
|
|
25
|
+
this.seq = 0;
|
|
26
|
+
this._load();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_load() {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(this.filePath)) {
|
|
32
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (typeof parsed.seq === 'number' && parsed.seq >= 0 && Number.isFinite(parsed.seq)) {
|
|
35
|
+
this.seq = Math.floor(parsed.seq);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// A half-written file is renamed aside (like SessionStore) so the next
|
|
40
|
+
// boot starts clean from 0 rather than crashing on bad JSON.
|
|
41
|
+
try { fs.renameSync(this.filePath, this.filePath + '.broken'); } catch {}
|
|
42
|
+
console.warn('[SeqStore] Load failed, starting from 0:', e.message);
|
|
43
|
+
}
|
|
44
|
+
// Normalize to an odd high-water mark. Old builds incremented by 1 and may
|
|
45
|
+
// have persisted an even value; resume on the next odd number so we never
|
|
46
|
+
// emit an even seq (those belong to the client).
|
|
47
|
+
if (this.seq % 2 === 0) this.seq += 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns the next ODD sequence number (1, 3, 5, …). Monotonic, never reused
|
|
52
|
+
* across restarts. Odd-only so agent seqs and client-minted (even) sort
|
|
53
|
+
* orders never collide.
|
|
54
|
+
*/
|
|
55
|
+
next() {
|
|
56
|
+
this.seq += 2;
|
|
57
|
+
this._writeToDisk();
|
|
58
|
+
return this.seq;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
flush() {
|
|
62
|
+
this._writeToDisk();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_writeToDisk() {
|
|
66
|
+
try {
|
|
67
|
+
const dir = path.dirname(this.filePath);
|
|
68
|
+
if (!fs.existsSync(dir)) {
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
70
|
+
}
|
|
71
|
+
const tmp = this.filePath + '.tmp';
|
|
72
|
+
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
73
|
+
fs.writeFileSync(fd, JSON.stringify({ seq: this.seq }), 'utf-8');
|
|
74
|
+
fs.closeSync(fd);
|
|
75
|
+
fs.renameSync(tmp, this.filePath);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Best-effort: if the write fails we DO NOT throw, because a throw here
|
|
78
|
+
// would abort message dispatch. The in-memory counter is still correct
|
|
79
|
+
// for this process; only a crash within this window risks rollback.
|
|
80
|
+
console.warn('[SeqStore] Write failed:', e.message);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { SeqStore };
|
package/src/session-store.js
CHANGED
|
@@ -6,7 +6,7 @@ class SessionStore {
|
|
|
6
6
|
this.filePath = filePath;
|
|
7
7
|
this.maxSessions = options.maxSessions || 50;
|
|
8
8
|
this.ttlDays = options.ttlDays || 30;
|
|
9
|
-
this.data =
|
|
9
|
+
this.data = Object.create(null);
|
|
10
10
|
this._flushTimer = null;
|
|
11
11
|
|
|
12
12
|
this._load();
|
|
@@ -19,7 +19,10 @@ class SessionStore {
|
|
|
19
19
|
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
20
20
|
const parsed = JSON.parse(raw);
|
|
21
21
|
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
22
|
-
|
|
22
|
+
// L1: use a prototype-less object so session_id like '__proto__' is harmless
|
|
23
|
+
const safe = Object.create(null);
|
|
24
|
+
for (const key of Object.keys(parsed)) safe[key] = parsed[key];
|
|
25
|
+
this.data = safe;
|
|
23
26
|
}
|
|
24
27
|
} catch {
|
|
25
28
|
try {
|