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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatcc-agent",
3
- "version": "0.3.9",
3
+ "version": "0.5.0",
4
4
  "description": "CCLink Agent - bridges Claude Code CLI with instant messaging",
5
5
  "bin": {
6
6
  "chatcc": "src/cli.js"
@@ -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,
@@ -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 ext = extMatch ? '.' + extMatch[1] : '.png';
23
- const tmpPath = path.join(os.tmpdir(), 'chatcc-' + crypto.randomBytes(8).toString('hex') + ext);
24
- tempFiles.add(tmpPath);
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(tmpPath, () => {});
28
- tempFiles.delete(tmpPath);
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(tmpPath);
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', () => { file.close(); resolve(tmpPath); });
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
- logStream = fs.createWriteStream(getLogFilePath(CHATCC_DIR), { flags: 'a' });
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
- bridge.handleUserText(from, data.content, sid, { ...bridgeOpts, imagePaths: paths.filter(p => p !== null) });
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;
@@ -32,7 +32,10 @@ function rotateLogStream(dir, oldStream) {
32
32
  oldStream.end();
33
33
  }
34
34
  cleanupOldLogs(dir);
35
- const newStream = fs.createWriteStream(getLogFilePath(dir), { flags: 'a' });
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
  }
@@ -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 };
@@ -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
- this.data = parsed;
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 {