chatcc-agent 0.4.0 → 0.5.1
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 +53 -0
- package/src/config.js +10 -5
- package/src/im-client.js +3 -0
- package/src/image-downloader.js +8 -1
- package/src/index.js +27 -2
- package/src/log-uploader.js +91 -0
- package/src/trace.js +30 -0
package/package.json
CHANGED
package/src/claude-bridge.js
CHANGED
|
@@ -64,6 +64,7 @@ class ClaudeBridge {
|
|
|
64
64
|
this._pendingPermissionRequests = new Map();
|
|
65
65
|
this._toolUseBlockQueue = new Map();
|
|
66
66
|
this._askedQuestionIds = new Map(); // msgID → Set<tool_use_id>
|
|
67
|
+
this._pendingQuestionRequests = new Map(); // tool_use_id → { requestId, msgID, replyTo, sessionID, questions }
|
|
67
68
|
this._toolMeta = new Map(); // tool_use_id → { toolName, input }
|
|
68
69
|
this._queue = new ProcessQueue({
|
|
69
70
|
maxConcurrency: options.maxConcurrency || 5,
|
|
@@ -155,6 +156,45 @@ class ClaudeBridge {
|
|
|
155
156
|
return true;
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
resolveQuestionAnswer(toolUseID, answers, from) {
|
|
160
|
+
const req = this._pendingQuestionRequests.get(toolUseID);
|
|
161
|
+
if (!req) {
|
|
162
|
+
console.warn(`[Bridge] resolveQuestionAnswer: no pending question for tool_use_id=${toolUseID}`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// Only the client who owns this session may answer
|
|
166
|
+
if (from && req.replyTo && req.replyTo !== from) {
|
|
167
|
+
console.warn(`[Security] question_answer rejected: ${from} is not the session owner (${req.replyTo})`);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
this._pendingQuestionRequests.delete(toolUseID);
|
|
171
|
+
|
|
172
|
+
const entry = this._activeProcesses.get(req.msgID);
|
|
173
|
+
if (!entry) {
|
|
174
|
+
console.warn(`[Bridge] resolveQuestionAnswer: process gone for tool_use_id=${toolUseID}`);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Write the answer back to the waiting Claude process as a control_response.
|
|
179
|
+
// The SDK maps updatedInput.answers (keyed by question text) into the tool_result.
|
|
180
|
+
this._writeToStdin(entry.proc, {
|
|
181
|
+
type: 'control_response',
|
|
182
|
+
response: {
|
|
183
|
+
subtype: 'success',
|
|
184
|
+
request_id: req.requestId,
|
|
185
|
+
response: {
|
|
186
|
+
behavior: 'allow',
|
|
187
|
+
updatedInput: {
|
|
188
|
+
questions: req.questions,
|
|
189
|
+
answers: answers || {},
|
|
190
|
+
annotations: {},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
158
198
|
_handlePermissionRequest(event, replyTo, msgID, sessionID, cwd, workspaceRestricted, permissions) {
|
|
159
199
|
const req = event.request;
|
|
160
200
|
const requestId = req.request_id;
|
|
@@ -599,6 +639,19 @@ class ClaudeBridge {
|
|
|
599
639
|
const req = event.request;
|
|
600
640
|
if (req?.subtype === 'permission') {
|
|
601
641
|
this._handlePermissionRequest(event, replyTo, msgID, sessionID, cwd, workspaceRestricted, permissions);
|
|
642
|
+
} else if (req?.subtype === 'can_use_tool' && req.tool_name === 'AskUserQuestion') {
|
|
643
|
+
// AskUserQuestion asks for the user's selection via the permission-prompt-tool
|
|
644
|
+
// stdio protocol. The assistant tool_use already pushed a user_question to the
|
|
645
|
+
// client (see _handleAssistantEvent); here we only record the control_request's
|
|
646
|
+
// request_id so the client's answer can be written back as a control_response.
|
|
647
|
+
const toolUseID = req.tool_use_id;
|
|
648
|
+
if (toolUseID) {
|
|
649
|
+
this._pendingQuestionRequests.set(toolUseID, {
|
|
650
|
+
requestId: event.request_id,
|
|
651
|
+
msgID, replyTo, sessionID,
|
|
652
|
+
questions: req.input?.questions || [],
|
|
653
|
+
});
|
|
654
|
+
}
|
|
602
655
|
}
|
|
603
656
|
break;
|
|
604
657
|
}
|
package/src/config.js
CHANGED
|
@@ -48,15 +48,20 @@ function callCloudFunction(action, params, env) {
|
|
|
48
48
|
const postData = JSON.stringify({ type: action, ...params });
|
|
49
49
|
const url = new URL(httpTrigger + '/index');
|
|
50
50
|
|
|
51
|
+
// traceId 贯穿到云函数:有当前请求上下文就带 X-Trace-Id 头,便于跨层关联
|
|
52
|
+
const traceId = require('./trace').getTraceId();
|
|
53
|
+
const headers = {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
56
|
+
};
|
|
57
|
+
if (traceId) headers['X-Trace-Id'] = traceId;
|
|
58
|
+
|
|
51
59
|
const options = {
|
|
52
60
|
hostname: url.hostname,
|
|
53
61
|
port: 443,
|
|
54
62
|
path: url.pathname,
|
|
55
63
|
method: 'POST',
|
|
56
|
-
headers
|
|
57
|
-
'Content-Type': 'application/json',
|
|
58
|
-
'Content-Length': Buffer.byteLength(postData),
|
|
59
|
-
},
|
|
64
|
+
headers,
|
|
60
65
|
};
|
|
61
66
|
|
|
62
67
|
const req = require('https').request(options, (res) => {
|
|
@@ -399,5 +404,5 @@ module.exports = {
|
|
|
399
404
|
CREDENTIALS_FILE, startRenewalTimer, renewAgentNow,
|
|
400
405
|
readPairedClients, addPairedClient, removePairedClient,
|
|
401
406
|
tryRenewFromRaw, startClientValidationTimer,
|
|
402
|
-
validateAgentExists, clearLocalState,
|
|
407
|
+
validateAgentExists, clearLocalState, readCachedCredentials,
|
|
403
408
|
};
|
package/src/im-client.js
CHANGED
|
@@ -149,7 +149,10 @@ class IMClient {
|
|
|
149
149
|
// Stamp a monotonic seq onto every outgoing message (including chunks).
|
|
150
150
|
// The iOS client uses it as the authoritative sort key. Placed here so it
|
|
151
151
|
// is impossible to send without one — this is the sole send path.
|
|
152
|
+
// traceId:当前请求上下文有就带上,跨 hop 续链。
|
|
153
|
+
const traceId = require('./trace').getTraceId();
|
|
152
154
|
payload = { ...payload, seq: this._seqStore.next() };
|
|
155
|
+
if (traceId) payload.trace_id = traceId;
|
|
153
156
|
const message = this.tim.createCustomMessage({
|
|
154
157
|
to,
|
|
155
158
|
conversationType: this.TIM.TYPES.CONV_C2C,
|
package/src/image-downloader.js
CHANGED
|
@@ -70,10 +70,13 @@ function downloadImage(url, tempFiles) {
|
|
|
70
70
|
// we later declare to Claude, so it must match the actual bytes.
|
|
71
71
|
const realExt = CONTENT_TYPE_EXT[contentType] || urlExt;
|
|
72
72
|
if (realExt !== urlExt) {
|
|
73
|
+
console.log(`[Image] Realign ext: url=${urlExt} -> ${realExt} (content-type=${contentType})`);
|
|
73
74
|
tempFiles.delete(writePath);
|
|
74
75
|
fs.unlink(writePath, () => {});
|
|
75
76
|
writePath = makePath(realExt);
|
|
76
77
|
tempFiles.add(writePath);
|
|
78
|
+
} else {
|
|
79
|
+
console.log(`[Image] ext=${realExt} (content-type=${contentType})`);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
const contentLength = parseInt(res.headers['content-length'], 10);
|
|
@@ -90,7 +93,11 @@ function downloadImage(url, tempFiles) {
|
|
|
90
93
|
}
|
|
91
94
|
});
|
|
92
95
|
res.pipe(file);
|
|
93
|
-
file.on('finish', () => {
|
|
96
|
+
file.on('finish', () => {
|
|
97
|
+
file.close();
|
|
98
|
+
console.log(`[Image] Downloaded ${downloaded} bytes -> ${path.basename(writePath)}`);
|
|
99
|
+
resolve(writePath);
|
|
100
|
+
});
|
|
94
101
|
}).on('error', fail);
|
|
95
102
|
}).catch(fail);
|
|
96
103
|
}
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const os = require('os');
|
|
17
17
|
const crypto = require('crypto');
|
|
18
|
+
const logUploader = require('./log-uploader');
|
|
18
19
|
|
|
19
20
|
const isDaemon = process.argv.includes('--daemon');
|
|
20
21
|
const CHATCC_DIR = path.join(os.homedir(), '.chatcc');
|
|
@@ -32,8 +33,14 @@ if (isDaemon) {
|
|
|
32
33
|
const ts = () => new Date().toISOString().slice(0, 19);
|
|
33
34
|
const bindStream = (stream) => {
|
|
34
35
|
console.log = (...args) => { origLog(...args); stream.write(`[${ts()}] ` + args.join(' ') + '\n'); };
|
|
35
|
-
console.warn = (...args) => {
|
|
36
|
-
|
|
36
|
+
console.warn = (...args) => {
|
|
37
|
+
origWarn(...args); stream.write(`[${ts()}] WARN ` + args.join(' ') + '\n');
|
|
38
|
+
logUploader.collect('WARN', args.join(' ')); // 采样上报到 CLS(未 start 时为空操作)
|
|
39
|
+
};
|
|
40
|
+
console.error = (...args) => {
|
|
41
|
+
origErr(...args); stream.write(`[${ts()}] ERROR ` + args.join(' ') + '\n');
|
|
42
|
+
logUploader.collect('ERROR', args.join(' '));
|
|
43
|
+
};
|
|
37
44
|
};
|
|
38
45
|
bindStream(logStream);
|
|
39
46
|
cleanupOldLogs(CHATCC_DIR);
|
|
@@ -127,6 +134,7 @@ async function shutdown(signal) {
|
|
|
127
134
|
if (termBridge) await termBridge.killAll();
|
|
128
135
|
cleanupTempFiles();
|
|
129
136
|
sessions.flush();
|
|
137
|
+
await logUploader.stop(); // 退出前把缓冲日志 flush 到云函数
|
|
130
138
|
if (imClient) {
|
|
131
139
|
try { await imClient.logout(); } catch {}
|
|
132
140
|
}
|
|
@@ -171,6 +179,9 @@ async function main() {
|
|
|
171
179
|
|
|
172
180
|
const config = await loadConfig();
|
|
173
181
|
|
|
182
|
+
// 启动日志上报(采样 error/warn → 云函数 → CLS);仅 daemon 模式上报,前台调试只落本地
|
|
183
|
+
if (isDaemon) logUploader.start({ agentUserID: config.agentUserID, tcbEnv: config.tcbEnv });
|
|
184
|
+
|
|
174
185
|
console.log('╔══════════════════════════════════════╗');
|
|
175
186
|
console.log('║ CCLink Agent v' + getVersion() + ' ║');
|
|
176
187
|
console.log('╚══════════════════════════════════════╝');
|
|
@@ -282,6 +293,9 @@ async function main() {
|
|
|
282
293
|
|
|
283
294
|
// Route incoming messages
|
|
284
295
|
imClient.onMessage(async (from, data) => {
|
|
296
|
+
// 建立请求级 traceId 上下文:沿用入站 trace_id(跨 hop 续链),没有则生成。
|
|
297
|
+
// 后续所有出站 IM/HTTP 调用、日志都会自动带上。
|
|
298
|
+
require('./trace').enterWithTrace(data && data.trace_id);
|
|
285
299
|
const qs = bridge.getQueueStatus();
|
|
286
300
|
console.log(`[Message] from=${from} cc_type=${data.cc_type} queue_active=${qs.activeCount} queue_waiting=${qs.queuedCount}`);
|
|
287
301
|
|
|
@@ -377,6 +391,7 @@ async function main() {
|
|
|
377
391
|
// If every image failed, there's nothing to analyze — tell the client instead
|
|
378
392
|
// of silently feeding an empty (or text-only) prompt to Claude.
|
|
379
393
|
if (ok.length === 0) {
|
|
394
|
+
console.warn(`[Image] All ${paths.length} image(s) failed to download in session ${sid}; notifying client`);
|
|
380
395
|
imClient.sendCustomMessage(from, 'error', {
|
|
381
396
|
type: 'image_download',
|
|
382
397
|
session_id: sid,
|
|
@@ -424,6 +439,16 @@ async function main() {
|
|
|
424
439
|
break;
|
|
425
440
|
}
|
|
426
441
|
|
|
442
|
+
case 'question_answer': {
|
|
443
|
+
// Structured answer to an AskUserQuestion. data.answers is keyed by question text;
|
|
444
|
+
// value is the chosen label (single-select) or array of labels (multi-select).
|
|
445
|
+
console.log(`[Question] tool_use_id=${data.tool_use_id} from=${from} answers=${JSON.stringify(data.answers)}`);
|
|
446
|
+
if (bridge) {
|
|
447
|
+
bridge.resolveQuestionAnswer(data.tool_use_id, data.answers, from);
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
|
|
427
452
|
case 'file_tree_request': {
|
|
428
453
|
if (fileSessionDenied(from, data)) {
|
|
429
454
|
imClient.sendCustomMessage(from, 'file_tree_response', { request_id: data.request_id, error: 'Access denied' }).catch(() => {});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Agent 日志上报:采集本进程 error/warn 日志 → 采样 + 脱敏 → 批量转发到云函数 agentLog handler。
|
|
2
|
+
// 安全:CLS 写密钥留在服务端,本模块只把日志交给云函数,不直接碰 CLS(密钥不下发到用户机器)。
|
|
3
|
+
// 防风暴:每分钟采集上限,单批上限;flush 失败静默且绝不再打日志(防递归)。
|
|
4
|
+
const config = require('./config');
|
|
5
|
+
const { getTraceId } = require('./trace');
|
|
6
|
+
|
|
7
|
+
let AGENT_VERSION = 'unknown';
|
|
8
|
+
try { AGENT_VERSION = require('../package.json').version; } catch (e) {}
|
|
9
|
+
|
|
10
|
+
const MAX_BATCH = 50;
|
|
11
|
+
const FLUSH_INTERVAL_MS = 60 * 1000; // 每 60s 定时 flush 一次
|
|
12
|
+
const PER_MINUTE_CAP = 30; // 每分钟最多采集 30 条(采样上限,控成本)
|
|
13
|
+
|
|
14
|
+
let _buffer = [];
|
|
15
|
+
let _sentThisMinute = 0;
|
|
16
|
+
let _minuteStart = 0;
|
|
17
|
+
let _timer = null;
|
|
18
|
+
let _cfg = null; // { agentUserID, tcbEnv }
|
|
19
|
+
|
|
20
|
+
// 脱敏:抹掉用户名路径、token/密钥片段,避免隐私/凭证进 CLS
|
|
21
|
+
function sanitize(input) {
|
|
22
|
+
let s = (typeof input === 'string') ? input : String(input);
|
|
23
|
+
s = s.replace(/\/Users\/[^/\s"']+/g, '/Users/***');
|
|
24
|
+
s = s.replace(/\/home\/[^/\s"']+/g, '/home/***');
|
|
25
|
+
s = s.replace(/C:\\Users\\[^\\\s"']+/g, 'C:\\Users\\***');
|
|
26
|
+
s = s.replace(/(token|secret|password|passwd|apikey|api_key|authorization)["']?\s*[:=]\s*"?[^\s"',}]+/gi, '$1=***');
|
|
27
|
+
if (s.length > 4000) s = s.slice(0, 4000) + '…[truncated]';
|
|
28
|
+
return s;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 采集一条 error/warn 日志。绝不调用 console.*(否则与 console patch 互相递归)。
|
|
32
|
+
function collect(level, msg, extra) {
|
|
33
|
+
if (!_cfg) return; // 未 start,不采集
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (_minuteStart === 0) _minuteStart = now;
|
|
36
|
+
if (now - _minuteStart > 60000) { _minuteStart = now; _sentThisMinute = 0; }
|
|
37
|
+
if (_sentThisMinute >= PER_MINUTE_CAP) return; // 触顶采样,丢弃
|
|
38
|
+
_sentThisMinute++;
|
|
39
|
+
|
|
40
|
+
const entry = {
|
|
41
|
+
ts: new Date(now).toISOString(),
|
|
42
|
+
level,
|
|
43
|
+
msg: sanitize(msg),
|
|
44
|
+
traceId: getTraceId() || '',
|
|
45
|
+
};
|
|
46
|
+
if (extra && typeof extra === 'object') {
|
|
47
|
+
for (const k of Object.keys(extra)) {
|
|
48
|
+
if (['msg', 'level', 'traceId'].includes(k)) continue;
|
|
49
|
+
const v = extra[k];
|
|
50
|
+
if (v != null) entry[k] = sanitize(v);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
_buffer.push(entry);
|
|
54
|
+
if (_buffer.length >= MAX_BATCH) {
|
|
55
|
+
flush().catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 把当前缓冲区推送到云函数。失败静默(不打日志,防递归/防风暴)。
|
|
60
|
+
async function flush() {
|
|
61
|
+
if (!_cfg || _buffer.length === 0) return;
|
|
62
|
+
const batch = _buffer.splice(0, Math.min(_buffer.length, MAX_BATCH));
|
|
63
|
+
try {
|
|
64
|
+
const cached = config.readCachedCredentials();
|
|
65
|
+
const renew_token = cached && cached.renew_token;
|
|
66
|
+
if (!renew_token) return; // 没凭证就不报,等配对后自然有
|
|
67
|
+
await config.callCloudFunction('agentLog', {
|
|
68
|
+
agent_im_user_id: _cfg.agentUserID,
|
|
69
|
+
renew_token,
|
|
70
|
+
agent_version: AGENT_VERSION,
|
|
71
|
+
logs: batch,
|
|
72
|
+
}, _cfg.tcbEnv);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// 上报失败:静默丢弃,避免日志上报自身的错误再被采集(递归)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function start(cfg) {
|
|
79
|
+
_cfg = cfg;
|
|
80
|
+
_minuteStart = Date.now();
|
|
81
|
+
if (_timer) clearInterval(_timer);
|
|
82
|
+
_timer = setInterval(() => { flush().catch(() => {}); }, FLUSH_INTERVAL_MS);
|
|
83
|
+
_timer.unref(); // 不阻止进程退出
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function stop() {
|
|
87
|
+
if (_timer) { clearInterval(_timer); _timer = null; }
|
|
88
|
+
await flush().catch(() => {});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { start, stop, collect, flush };
|
package/src/trace.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 请求级 traceId:贯穿一次入站请求的所有出站 IM/HTTP 调用 + 日志。
|
|
2
|
+
// 用 AsyncLocalStorage,业务代码不用层层传参;跨 hop 时 sendCustomMessage /
|
|
3
|
+
// callCloudFunction 会把当前 traceId 带上,云函数侧也生成/回传,形成跨层链路。
|
|
4
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const als = new AsyncLocalStorage();
|
|
8
|
+
|
|
9
|
+
// 在入站请求边界调用:data 里有 trace_id 就沿用(跨 hop 续链),没有则生成。
|
|
10
|
+
// 两种用法:
|
|
11
|
+
// - startTrace(id, fn):把整段逻辑包进闭包,上下文随 fn 自动传播
|
|
12
|
+
// - enterWithTrace(id):在 handler 顶部同步设上下文,后续 await/出站都带上
|
|
13
|
+
// (适合含多个 early return 的 handler,无需重排主体)
|
|
14
|
+
function startTrace(traceId, fn) {
|
|
15
|
+
const id = (traceId && typeof traceId === 'string') ? traceId : crypto.randomUUID();
|
|
16
|
+
return als.run({ traceId: id }, fn);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function enterWithTrace(traceId) {
|
|
20
|
+
const id = (traceId && typeof traceId === 'string') ? traceId : crypto.randomUUID();
|
|
21
|
+
als.enterWith({ traceId: id });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 业务代码/出站调用读取当前 traceId;无上下文时返回 undefined(不强制)
|
|
25
|
+
function getTraceId() {
|
|
26
|
+
const store = als.getStore();
|
|
27
|
+
return store ? store.traceId : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { startTrace, enterWithTrace, getTraceId };
|