ai-helper-agent 1.1.0 → 1.2.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/README.md +79 -15
- package/bin/agent.js +295 -54
- package/package.json +1 -1
- package/src/auth.js +24 -53
- package/src/config.js +75 -24
- package/src/executor.js +87 -30
- package/src/logger.js +79 -24
- package/src/search.js +375 -0
- package/src/security.js +34 -24
- package/src/server.js +177 -29
package/src/auth.js
CHANGED
|
@@ -7,38 +7,10 @@ let currentPairCode = null;
|
|
|
7
7
|
let pairCodeTimer = null;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* 生成随机配对码(4
|
|
10
|
+
* 生成随机配对码(4位数字,使用密码学安全的随机数)
|
|
11
11
|
*/
|
|
12
12
|
function generatePairCode() {
|
|
13
|
-
return String(
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 生成认证 token(HMAC-SHA256)
|
|
18
|
-
*/
|
|
19
|
-
function generateToken(extensionId) {
|
|
20
|
-
const secret = crypto.randomBytes(32).toString('hex');
|
|
21
|
-
const pairings = loadPairings();
|
|
22
|
-
pairings[extensionId] = {
|
|
23
|
-
token: secret,
|
|
24
|
-
pairedAt: Date.now()
|
|
25
|
-
};
|
|
26
|
-
// 直接用 secret 作为 token
|
|
27
|
-
return secret;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 验证 token 是否有效
|
|
32
|
-
*/
|
|
33
|
-
function verifyToken(token) {
|
|
34
|
-
if (!token) return null;
|
|
35
|
-
const pairings = loadPairings();
|
|
36
|
-
for (const [extId, pairing] of Object.entries(pairings)) {
|
|
37
|
-
if (pairing.token === token) {
|
|
38
|
-
return extId;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return null;
|
|
13
|
+
return String(crypto.randomInt(1000, 10000));
|
|
42
14
|
}
|
|
43
15
|
|
|
44
16
|
/**
|
|
@@ -76,9 +48,23 @@ function stopPairCodeRotation() {
|
|
|
76
48
|
}
|
|
77
49
|
|
|
78
50
|
/**
|
|
79
|
-
*
|
|
51
|
+
* 验证 token 是否有效
|
|
52
|
+
*/
|
|
53
|
+
function verifyToken(token) {
|
|
54
|
+
if (!token || typeof token !== 'string') return null;
|
|
55
|
+
const pairings = loadPairings();
|
|
56
|
+
for (const [extId, pairing] of Object.entries(pairings)) {
|
|
57
|
+
if (pairing.token === token) {
|
|
58
|
+
return extId;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 处理配对请求(异步,支持写入锁)
|
|
80
66
|
*/
|
|
81
|
-
function handlePairRequest(code, extensionId) {
|
|
67
|
+
async function handlePairRequest(code, extensionId) {
|
|
82
68
|
if (!code || !extensionId) {
|
|
83
69
|
return { success: false, error: '缺少配对码或扩展ID' };
|
|
84
70
|
}
|
|
@@ -92,33 +78,18 @@ function handlePairRequest(code, extensionId) {
|
|
|
92
78
|
}
|
|
93
79
|
// 生成新 token 并保存
|
|
94
80
|
const token = crypto.randomBytes(32).toString('hex');
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Bearer Token 认证中间件(Express)
|
|
101
|
-
*/
|
|
102
|
-
function authMiddleware(req, res, next) {
|
|
103
|
-
const authHeader = req.headers.authorization;
|
|
104
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
105
|
-
return res.status(401).json({ success: false, error: '未提供认证 token' });
|
|
81
|
+
try {
|
|
82
|
+
await savePairing(extensionId, token);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { success: false, error: `配对保存失败: ${err.message}` };
|
|
106
85
|
}
|
|
107
|
-
|
|
108
|
-
const extId = verifyToken(token);
|
|
109
|
-
if (!extId) {
|
|
110
|
-
return res.status(403).json({ success: false, error: '认证 token 无效' });
|
|
111
|
-
}
|
|
112
|
-
req.extensionId = extId;
|
|
113
|
-
next();
|
|
86
|
+
return { success: true, token, message: '配对成功' };
|
|
114
87
|
}
|
|
115
88
|
|
|
116
89
|
export {
|
|
117
|
-
generateToken,
|
|
118
90
|
verifyToken,
|
|
119
91
|
getCurrentPairCode,
|
|
120
92
|
startPairCodeRotation,
|
|
121
93
|
stopPairCodeRotation,
|
|
122
|
-
handlePairRequest
|
|
123
|
-
authMiddleware
|
|
94
|
+
handlePairRequest
|
|
124
95
|
};
|
package/src/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// agent/src/config.js -
|
|
1
|
+
// agent/src/config.js - 配置管理(带内存缓存 + 写入互斥锁)
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
|
|
@@ -17,6 +17,8 @@ const DEFAULTS = {
|
|
|
17
17
|
fileMaxSize: 50 * 1024 * 1024
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
const VALID_CONFIG_KEYS = Object.keys(DEFAULTS);
|
|
21
|
+
|
|
20
22
|
// 内存缓存
|
|
21
23
|
let configCache = null;
|
|
22
24
|
let configCacheMtime = 0;
|
|
@@ -24,9 +26,24 @@ let configCacheMtime = 0;
|
|
|
24
26
|
let pairingsCache = null;
|
|
25
27
|
let pairingsCacheMtime = 0;
|
|
26
28
|
|
|
29
|
+
// 写入互斥锁(防止并发写互相覆盖)
|
|
30
|
+
let writeLock = Promise.resolve();
|
|
31
|
+
|
|
32
|
+
function withWriteLock(fn) {
|
|
33
|
+
const prev = writeLock;
|
|
34
|
+
let release;
|
|
35
|
+
writeLock = new Promise(resolve => { release = resolve; });
|
|
36
|
+
return prev.then(() => fn().finally(release));
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
function ensureAgentDir() {
|
|
28
40
|
if (!existsSync(AGENT_DIR)) {
|
|
29
|
-
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(AGENT_DIR, { recursive: true });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('[Config] 无法创建 Agent 目录:', err.message);
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
30
47
|
}
|
|
31
48
|
}
|
|
32
49
|
|
|
@@ -49,7 +66,7 @@ export function loadConfig() {
|
|
|
49
66
|
|
|
50
67
|
if (!existsSync(CONFIG_FILE)) {
|
|
51
68
|
const defaults = { ...DEFAULTS, allowedPaths: [DEFAULTS.workdir] };
|
|
52
|
-
|
|
69
|
+
saveConfigSync(defaults);
|
|
53
70
|
configCache = defaults;
|
|
54
71
|
configCacheMtime = getMtime(CONFIG_FILE);
|
|
55
72
|
return defaults;
|
|
@@ -66,7 +83,8 @@ export function loadConfig() {
|
|
|
66
83
|
configCache = { ...DEFAULTS, ...userConfig };
|
|
67
84
|
configCacheMtime = mtime;
|
|
68
85
|
return configCache;
|
|
69
|
-
} catch {
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error('[Config] 配置文件解析失败:', err.message);
|
|
70
88
|
configCache = { ...DEFAULTS, allowedPaths: [DEFAULTS.workdir] };
|
|
71
89
|
configCacheMtime = 0;
|
|
72
90
|
return configCache;
|
|
@@ -74,23 +92,48 @@ export function loadConfig() {
|
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
/**
|
|
77
|
-
*
|
|
95
|
+
* 同步写入磁盘(内部方法,不加锁)
|
|
78
96
|
*/
|
|
79
|
-
|
|
97
|
+
function saveConfigSync(config) {
|
|
80
98
|
ensureAgentDir();
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
99
|
+
const safe = {};
|
|
100
|
+
for (const key of VALID_CONFIG_KEYS) {
|
|
101
|
+
if (config[key] !== undefined) safe[key] = config[key];
|
|
102
|
+
}
|
|
103
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(safe, null, 2), 'utf-8');
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
/**
|
|
87
|
-
*
|
|
107
|
+
* 保存配置(写入磁盘并更新缓存,加写锁防并发覆盖)
|
|
88
108
|
*/
|
|
89
|
-
export function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
export async function saveConfig(config) {
|
|
110
|
+
return withWriteLock(async () => {
|
|
111
|
+
try {
|
|
112
|
+
saveConfigSync(config);
|
|
113
|
+
// 从磁盘重新加载确保缓存与磁盘一致
|
|
114
|
+
const mtime = getMtime(CONFIG_FILE);
|
|
115
|
+
const raw = readFileSync(CONFIG_FILE, 'utf-8');
|
|
116
|
+
configCache = { ...DEFAULTS, ...JSON.parse(raw) };
|
|
117
|
+
configCacheMtime = mtime;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error('[Config] 保存配置失败:', err.message);
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 更新单字段配置(加写锁防 TOCTOU)
|
|
127
|
+
*/
|
|
128
|
+
export async function updateConfigField(key, value) {
|
|
129
|
+
if (!VALID_CONFIG_KEYS.includes(key)) {
|
|
130
|
+
throw new Error(`无效配置项: ${key}`);
|
|
131
|
+
}
|
|
132
|
+
return withWriteLock(async () => {
|
|
133
|
+
const config = loadConfig();
|
|
134
|
+
config[key] = value;
|
|
135
|
+
return saveConfig(config).then(() => config);
|
|
136
|
+
});
|
|
94
137
|
}
|
|
95
138
|
|
|
96
139
|
/**
|
|
@@ -114,7 +157,8 @@ export function loadPairings() {
|
|
|
114
157
|
pairingsCache = JSON.parse(readFileSync(PAIRINGS_FILE, 'utf-8'));
|
|
115
158
|
pairingsCacheMtime = mtime;
|
|
116
159
|
return pairingsCache;
|
|
117
|
-
} catch {
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('[Config] 配对文件解析失败:', err.message);
|
|
118
162
|
pairingsCache = {};
|
|
119
163
|
pairingsCacheMtime = 0;
|
|
120
164
|
return {};
|
|
@@ -122,15 +166,22 @@ export function loadPairings() {
|
|
|
122
166
|
}
|
|
123
167
|
|
|
124
168
|
/**
|
|
125
|
-
*
|
|
169
|
+
* 保存配对(加写锁防并发覆盖)
|
|
126
170
|
*/
|
|
127
|
-
export function savePairing(extensionId, token) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
export async function savePairing(extensionId, token) {
|
|
172
|
+
return withWriteLock(async () => {
|
|
173
|
+
const pairings = loadPairings();
|
|
174
|
+
pairings[extensionId] = { token, pairedAt: Date.now() };
|
|
175
|
+
ensureAgentDir();
|
|
176
|
+
try {
|
|
177
|
+
writeFileSync(PAIRINGS_FILE, JSON.stringify(pairings, null, 2), 'utf-8');
|
|
178
|
+
pairingsCache = pairings;
|
|
179
|
+
pairingsCacheMtime = getMtime(PAIRINGS_FILE);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.error('[Config] 保存配对失败:', err.message);
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
134
185
|
}
|
|
135
186
|
|
|
136
187
|
export { AGENT_DIR, CONFIG_FILE };
|
package/src/executor.js
CHANGED
|
@@ -6,6 +6,40 @@ import { loadConfig } from './config.js';
|
|
|
6
6
|
// 运行中的进程映射:execId → { process, wsClients: Set, timeoutId, forceKillId }
|
|
7
7
|
const runningProcesses = new Map();
|
|
8
8
|
|
|
9
|
+
// 安全的子进程环境变量白名单(不泄露宿主敏感信息)
|
|
10
|
+
const SAFE_ENV_KEYS = [
|
|
11
|
+
'PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
12
|
+
'PWD', 'OLDPWD', 'NODE_PATH', 'MANPATH', 'INFOPATH',
|
|
13
|
+
'XDG_SESSION_TYPE', 'DISPLAY', 'SSH_AUTH_SOCK', 'COLORTERM',
|
|
14
|
+
'EDITOR', 'VISUAL', 'PAGER', 'BROWSER',
|
|
15
|
+
'GIT_EDITOR', 'GIT_PAGER', 'GIT_SSH_COMMAND',
|
|
16
|
+
// 平台相关
|
|
17
|
+
'TMPDIR', 'TEMPDIR', 'TEMP', 'TMP',
|
|
18
|
+
// 常用工具
|
|
19
|
+
'NVM_DIR', 'NVM_BIN', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'CARGO_HOME',
|
|
20
|
+
'RUSTUP_HOME', 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_PREFIX',
|
|
21
|
+
'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'GRADLE_HOME',
|
|
22
|
+
'PKG_CONFIG_PATH', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH',
|
|
23
|
+
// 颜色/格式化
|
|
24
|
+
'NO_COLOR', 'CLICOLOR', 'CLICOLOR_FORCE', 'FORCE_COLOR'
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 构建安全的子进程环境变量
|
|
29
|
+
*/
|
|
30
|
+
function buildSafeEnv() {
|
|
31
|
+
const env = {};
|
|
32
|
+
for (const key of SAFE_ENV_KEYS) {
|
|
33
|
+
if (process.env[key] !== undefined) {
|
|
34
|
+
env[key] = process.env[key];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// 强制覆盖
|
|
38
|
+
env.TERM = 'dumb';
|
|
39
|
+
env.FORCE_COLOR = '0';
|
|
40
|
+
return env;
|
|
41
|
+
}
|
|
42
|
+
|
|
9
43
|
/**
|
|
10
44
|
* 生成唯一执行 ID
|
|
11
45
|
*/
|
|
@@ -30,7 +64,7 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
30
64
|
|
|
31
65
|
const proc = spawn('sh', ['-c', command], {
|
|
32
66
|
cwd: workdir,
|
|
33
|
-
env:
|
|
67
|
+
env: buildSafeEnv(),
|
|
34
68
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
35
69
|
});
|
|
36
70
|
|
|
@@ -40,12 +74,14 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
40
74
|
let stdoutCollected = '';
|
|
41
75
|
let stderrCollected = '';
|
|
42
76
|
let killed = false;
|
|
43
|
-
let
|
|
77
|
+
let finished = false; // 防止 close/error 重复处理
|
|
44
78
|
|
|
45
|
-
|
|
79
|
+
// 存储到 entry 中供 clearTimers 读取
|
|
80
|
+
const entry = { process: proc, wsClients };
|
|
81
|
+
runningProcesses.set(execId, entry);
|
|
46
82
|
|
|
47
83
|
// 超时控制
|
|
48
|
-
|
|
84
|
+
entry.timeoutId = setTimeout(() => {
|
|
49
85
|
killed = true;
|
|
50
86
|
killProcess(execId);
|
|
51
87
|
}, timeout);
|
|
@@ -60,6 +96,16 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
60
96
|
}
|
|
61
97
|
}
|
|
62
98
|
|
|
99
|
+
// 清理所有定时器
|
|
100
|
+
function clearTimers() {
|
|
101
|
+
clearTimeout(entry.timeoutId);
|
|
102
|
+
entry.timeoutId = null;
|
|
103
|
+
if (entry.forceKillId !== null && entry.forceKillId !== undefined) {
|
|
104
|
+
clearTimeout(entry.forceKillId);
|
|
105
|
+
entry.forceKillId = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
63
109
|
// stdout 流式输出
|
|
64
110
|
proc.stdout.on('data', (chunk) => {
|
|
65
111
|
const str = chunk.toString();
|
|
@@ -74,23 +120,18 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
74
120
|
broadcast({ type: 'stderr', data: str, execId });
|
|
75
121
|
});
|
|
76
122
|
|
|
77
|
-
// 清理超时计时器
|
|
78
|
-
function clearTimers() {
|
|
79
|
-
clearTimeout(timeoutId);
|
|
80
|
-
if (forceKillId !== null) {
|
|
81
|
-
clearTimeout(forceKillId);
|
|
82
|
-
forceKillId = null;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
123
|
// 进程结束
|
|
87
124
|
proc.on('close', (exitCode) => {
|
|
125
|
+
if (finished) return;
|
|
126
|
+
finished = true;
|
|
88
127
|
clearTimers();
|
|
89
|
-
broadcast({ type: 'exit', exitCode, execId, killed });
|
|
128
|
+
broadcast({ type: 'exit', exitCode: typeof exitCode === 'number' ? exitCode : -1, execId, killed });
|
|
90
129
|
runningProcesses.delete(execId);
|
|
91
130
|
if (onComplete) {
|
|
92
131
|
onComplete({
|
|
93
|
-
execId,
|
|
132
|
+
execId,
|
|
133
|
+
exitCode: typeof exitCode === 'number' ? exitCode : -1,
|
|
134
|
+
killed,
|
|
94
135
|
stdout: collectOutput ? stdoutCollected : undefined,
|
|
95
136
|
stderr: collectOutput ? stderrCollected : undefined
|
|
96
137
|
});
|
|
@@ -99,6 +140,8 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
99
140
|
|
|
100
141
|
// 进程错误
|
|
101
142
|
proc.on('error', (err) => {
|
|
143
|
+
if (finished) return;
|
|
144
|
+
finished = true;
|
|
102
145
|
clearTimers();
|
|
103
146
|
broadcast({ type: 'error', error: err.message, execId });
|
|
104
147
|
runningProcesses.delete(execId);
|
|
@@ -120,21 +163,23 @@ function executeCommand(command, cwd, wsClient, onComplete, collectOutput = fals
|
|
|
120
163
|
* @returns {Promise<{execId, exitCode, stdout, stderr, killed, error?}>}
|
|
121
164
|
*/
|
|
122
165
|
function executeCommandSync(command, cwd) {
|
|
123
|
-
return new Promise((resolve
|
|
166
|
+
return new Promise((resolve) => {
|
|
124
167
|
const config = loadConfig();
|
|
125
168
|
const timeout = config.commandTimeout || 300000;
|
|
126
169
|
|
|
127
170
|
let resolved = false;
|
|
171
|
+
let syncTimeoutId = null;
|
|
128
172
|
|
|
129
173
|
const execId = executeCommand(command, cwd, null, (result) => {
|
|
174
|
+
clearTimeout(syncTimeoutId);
|
|
130
175
|
if (!resolved) {
|
|
131
176
|
resolved = true;
|
|
132
177
|
resolve(result);
|
|
133
178
|
}
|
|
134
179
|
}, true);
|
|
135
180
|
|
|
136
|
-
//
|
|
137
|
-
setTimeout(() => {
|
|
181
|
+
// 超时兜底(超时后额外 5s 用于 kill 完成)
|
|
182
|
+
syncTimeoutId = setTimeout(() => {
|
|
138
183
|
if (!resolved) {
|
|
139
184
|
resolved = true;
|
|
140
185
|
killProcess(execId);
|
|
@@ -161,20 +206,32 @@ function addWsClient(execId, wsClient) {
|
|
|
161
206
|
*/
|
|
162
207
|
function killProcess(execId) {
|
|
163
208
|
const entry = runningProcesses.get(execId);
|
|
164
|
-
if (entry)
|
|
209
|
+
if (!entry) return false;
|
|
210
|
+
|
|
211
|
+
if (entry.process.exitCode !== null) return false; // 已退出
|
|
212
|
+
|
|
213
|
+
try {
|
|
165
214
|
entry.process.kill('SIGTERM');
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
} catch {}
|
|
173
|
-
}, 5000);
|
|
174
|
-
entry.forceKillId = forceKillId;
|
|
175
|
-
return true;
|
|
215
|
+
} catch {}
|
|
216
|
+
|
|
217
|
+
// 5秒后强制 kill(绑定到 entry 对象防止闭包引用旧变量)
|
|
218
|
+
if (entry.timeoutId) {
|
|
219
|
+
clearTimeout(entry.timeoutId);
|
|
220
|
+
entry.timeoutId = null;
|
|
176
221
|
}
|
|
177
|
-
|
|
222
|
+
if (entry.forceKillId) {
|
|
223
|
+
clearTimeout(entry.forceKillId);
|
|
224
|
+
}
|
|
225
|
+
entry.forceKillId = setTimeout(() => {
|
|
226
|
+
try {
|
|
227
|
+
if (entry.process.exitCode === null) {
|
|
228
|
+
entry.process.kill('SIGKILL');
|
|
229
|
+
}
|
|
230
|
+
} catch {}
|
|
231
|
+
entry.forceKillId = null;
|
|
232
|
+
}, 5000);
|
|
233
|
+
|
|
234
|
+
return true;
|
|
178
235
|
}
|
|
179
236
|
|
|
180
237
|
/**
|
package/src/logger.js
CHANGED
|
@@ -6,6 +6,43 @@ import { homedir } from 'os';
|
|
|
6
6
|
const LOG_DIR = join(homedir(), '.ai-helper-agent', 'logs');
|
|
7
7
|
const MAX_LOG_FILES = 30; // 最多保留 30 个日志文件
|
|
8
8
|
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 单文件最大 10MB
|
|
9
|
+
const CLEAN_INTERVAL_MS = 60_000; // 清理间隔(60 秒)
|
|
10
|
+
const QUERY_MAX_LIMIT = 1000; // 单次查询最大条数
|
|
11
|
+
|
|
12
|
+
// 是否输出到终端 stderr
|
|
13
|
+
let consoleOutput = false;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 设置是否在终端输出日志(前台运行时开启)
|
|
17
|
+
*/
|
|
18
|
+
function setConsoleOutput(enabled) {
|
|
19
|
+
consoleOutput = enabled;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CATEGORY_LABELS = {
|
|
23
|
+
auth: '认证', fs: '文件', exec: '命令', security: '安全', system: '系统'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const LEVEL_LABELS = { info: 'INFO', warn: 'WARN', error: 'ERROR' };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 格式化详情为可读字符串
|
|
30
|
+
*/
|
|
31
|
+
function formatDetails(details) {
|
|
32
|
+
if (!details || Object.keys(details).length === 0) return '';
|
|
33
|
+
const parts = [];
|
|
34
|
+
for (const [key, val] of Object.entries(details)) {
|
|
35
|
+
if (val !== undefined && val !== null) {
|
|
36
|
+
const str = typeof val === 'string' ? val : JSON.stringify(val);
|
|
37
|
+
if (str.length > 120) {
|
|
38
|
+
parts.push(`${key}=${str.slice(0, 120)}...`);
|
|
39
|
+
} else {
|
|
40
|
+
parts.push(`${key}=${str}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return parts.join(' ');
|
|
45
|
+
}
|
|
9
46
|
|
|
10
47
|
function ensureLogDir() {
|
|
11
48
|
if (!existsSync(LOG_DIR)) {
|
|
@@ -16,17 +53,27 @@ function ensureLogDir() {
|
|
|
16
53
|
/**
|
|
17
54
|
* 获取当天日志文件路径
|
|
18
55
|
*/
|
|
19
|
-
function getLogFile() {
|
|
56
|
+
function getLogFile(date) {
|
|
57
|
+
if (date) return join(LOG_DIR, `agent-${date}.log`);
|
|
20
58
|
const now = new Date();
|
|
21
|
-
const
|
|
22
|
-
return join(LOG_DIR, `agent-${
|
|
59
|
+
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
|
60
|
+
return join(LOG_DIR, `agent-${dateStr}.log`);
|
|
23
61
|
}
|
|
24
62
|
|
|
63
|
+
// 清理节流:记录上次清理时间
|
|
64
|
+
let lastCleanTime = 0;
|
|
65
|
+
|
|
25
66
|
/**
|
|
26
|
-
* 清理旧日志文件(保留最近 MAX_LOG_FILES
|
|
67
|
+
* 清理旧日志文件(保留最近 MAX_LOG_FILES 个文件 + 删除超大文件)
|
|
68
|
+
* 节流执行:至少间隔 CLEAN_INTERVAL_MS
|
|
27
69
|
*/
|
|
28
70
|
function cleanOldLogs() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (now - lastCleanTime < CLEAN_INTERVAL_MS) return;
|
|
73
|
+
lastCleanTime = now;
|
|
74
|
+
|
|
29
75
|
try {
|
|
76
|
+
ensureLogDir();
|
|
30
77
|
const files = readdirSync(LOG_DIR)
|
|
31
78
|
.filter(f => f.endsWith('.log'))
|
|
32
79
|
.map(f => {
|
|
@@ -45,7 +92,7 @@ function cleanOldLogs() {
|
|
|
45
92
|
}
|
|
46
93
|
}
|
|
47
94
|
if (deleted > 0) {
|
|
48
|
-
console.
|
|
95
|
+
console.error(`[Logger] 清理了 ${deleted} 个旧日志文件`);
|
|
49
96
|
}
|
|
50
97
|
} catch {}
|
|
51
98
|
}
|
|
@@ -58,18 +105,30 @@ function cleanOldLogs() {
|
|
|
58
105
|
* @param {Object} details - 详细信息
|
|
59
106
|
*/
|
|
60
107
|
function log(level, category, action, details = {}) {
|
|
108
|
+
const entry = {
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
level,
|
|
111
|
+
category,
|
|
112
|
+
action,
|
|
113
|
+
...details
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// 输出到终端 stderr(前台运行时可见)
|
|
117
|
+
if (consoleOutput) {
|
|
118
|
+
const time = entry.timestamp.slice(11, 19); // HH:MM:SS
|
|
119
|
+
const levelTag = LEVEL_LABELS[level] || level.toUpperCase();
|
|
120
|
+
const catTag = CATEGORY_LABELS[category] || category;
|
|
121
|
+
const detailStr = formatDetails(details);
|
|
122
|
+
const msg = detailStr
|
|
123
|
+
? `[${time}] [${levelTag}] [${catTag}:${action}] ${detailStr}`
|
|
124
|
+
: `[${time}] [${levelTag}] [${catTag}:${action}]`;
|
|
125
|
+
process.stderr.write(msg + '\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 写入文件
|
|
61
129
|
try {
|
|
62
130
|
ensureLogDir();
|
|
63
|
-
cleanOldLogs();
|
|
64
|
-
|
|
65
|
-
const entry = {
|
|
66
|
-
timestamp: new Date().toISOString(),
|
|
67
|
-
level,
|
|
68
|
-
category,
|
|
69
|
-
action,
|
|
70
|
-
...details
|
|
71
|
-
};
|
|
72
|
-
|
|
131
|
+
cleanOldLogs();
|
|
73
132
|
appendFileSync(getLogFile(), JSON.stringify(entry) + '\n', 'utf-8');
|
|
74
133
|
} catch (err) {
|
|
75
134
|
// 日志写入失败不应影响主流程
|
|
@@ -96,14 +155,10 @@ function logError(category, action, details) { log('error', category, action, de
|
|
|
96
155
|
* @returns {{ entries: Array, total: number }}
|
|
97
156
|
*/
|
|
98
157
|
function queryLogs(options = {}) {
|
|
99
|
-
const { date, category, limit = 200, offset = 0 } = options;
|
|
158
|
+
const { date, category, limit: rawLimit = 200, offset = 0 } = options;
|
|
159
|
+
const limit = Math.min(rawLimit, QUERY_MAX_LIMIT);
|
|
100
160
|
|
|
101
|
-
|
|
102
|
-
if (date) {
|
|
103
|
-
filePath = join(LOG_DIR, `agent-${date}.log`);
|
|
104
|
-
} else {
|
|
105
|
-
filePath = getLogFile();
|
|
106
|
-
}
|
|
161
|
+
const filePath = getLogFile(date);
|
|
107
162
|
|
|
108
163
|
let entries = [];
|
|
109
164
|
try {
|
|
@@ -121,7 +176,7 @@ function queryLogs(options = {}) {
|
|
|
121
176
|
} catch {}
|
|
122
177
|
|
|
123
178
|
// 按时间倒序(最新的在前)
|
|
124
|
-
entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
179
|
+
entries.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
|
125
180
|
|
|
126
181
|
const total = entries.length;
|
|
127
182
|
entries = entries.slice(offset, offset + limit);
|
|
@@ -146,4 +201,4 @@ function getLogDates() {
|
|
|
146
201
|
}
|
|
147
202
|
}
|
|
148
203
|
|
|
149
|
-
export { log, logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates };
|
|
204
|
+
export { setConsoleOutput, log, logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates };
|