ai-helper-agent 1.0.0 → 1.1.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 CHANGED
@@ -105,6 +105,50 @@ ai-helper-agent start --port 18911
105
105
  | `commandTimeout` | 命令执行超时(毫秒) | 300000(5分钟) |
106
106
  | `fileMaxSize` | 文件读写最大字节数 | 52428800(50MB) |
107
107
 
108
+ ## 审计日志
109
+
110
+ 所有操作自动记录到审计日志中,日志文件位于 `~/.ai-helper-agent/logs/`。
111
+
112
+ ### 日志格式
113
+
114
+ JSON Lines 格式,每行一条记录,文件按日命名 `agent-YYYY-MM-DD.log`。
115
+
116
+ ```json
117
+ {"timestamp":"2026-01-15T10:30:00.123Z","level":"info","category":"fs","action":"read","path":"/home/user/project/src/index.js","size":2048}
118
+ {"timestamp":"2026-01-15T10:30:05.456Z","level":"info","category":"exec","action":"completed","command":"npm test","cwd":"/home/user/project","execId":"a1b2c3d4","exitCode":0,"killed":false,"stdoutLen":1024,"stderrLen":0}
119
+ {"timestamp":"2026-01-15T10:31:00.789Z","level":"warn","category":"security","action":"exec_denied","command":"rm -rf /","reason":"高危命令被拦截"}
120
+ ```
121
+
122
+ ### 日志分类
123
+
124
+ | category | 说明 | 包含的操作 |
125
+ |----------|------|-----------|
126
+ | `auth` | 认证事件 | 配对成功/失败 |
127
+ | `fs` | 文件操作 | read, write, list, delete |
128
+ | `exec` | 命令执行 | started, completed, stopped, error |
129
+ | `security` | 安全事件 | deny, confirm, auth 失败, 路径越权拦截 |
130
+ | `system` | 系统事件 | server_start, server_stop, shutdown |
131
+
132
+ ### 日志查询 API
133
+
134
+ ```
135
+ GET /api/logs?date=2026-01-15&category=security&limit=50&offset=0
136
+ GET /api/logs/dates
137
+ ```
138
+
139
+ - `date` - 日期筛选 (YYYY-MM-DD),不传默认今天
140
+ - `category` - 分类筛选,不传返回全部
141
+ - `limit` - 返回条数上限,默认 200
142
+ - `offset` - 分页偏移
143
+
144
+ 返回按时间倒序排列(最新的在前)。
145
+
146
+ ### 自动清理
147
+
148
+ - 最多保留 30 个日志文件
149
+ - 单文件超过 10MB 自动删除
150
+ - 每次写入日志时触发检查
151
+
108
152
  ## API 端点
109
153
 
110
154
  ### 无需认证
@@ -127,6 +171,8 @@ ai-helper-agent start --port 18911
127
171
  | GET | `/api/exec/running` | 运行中的进程列表 |
128
172
  | GET | `/api/status/detail` | 详细信息(含工作目录) |
129
173
  | POST | `/api/shutdown` | 关闭 Agent 服务 |
174
+ | GET | `/api/logs` | 查询审计日志(支持 date/category/limit/offset 参数) |
175
+ | GET | `/api/logs/dates` | 获取可用日志日期列表 |
130
176
 
131
177
  ### WebSocket
132
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-helper-agent",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "AI Helper 本地代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
package/src/logger.js ADDED
@@ -0,0 +1,149 @@
1
+ // agent/src/logger.js - 审计日志模块(JSON Lines 格式,按日轮转,自动清理)
2
+ import { appendFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+
6
+ const LOG_DIR = join(homedir(), '.ai-helper-agent', 'logs');
7
+ const MAX_LOG_FILES = 30; // 最多保留 30 个日志文件
8
+ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 单文件最大 10MB
9
+
10
+ function ensureLogDir() {
11
+ if (!existsSync(LOG_DIR)) {
12
+ mkdirSync(LOG_DIR, { recursive: true });
13
+ }
14
+ }
15
+
16
+ /**
17
+ * 获取当天日志文件路径
18
+ */
19
+ function getLogFile() {
20
+ const now = new Date();
21
+ const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
22
+ return join(LOG_DIR, `agent-${date}.log`);
23
+ }
24
+
25
+ /**
26
+ * 清理旧日志文件(保留最近 MAX_LOG_FILES 个,删除超大文件)
27
+ */
28
+ function cleanOldLogs() {
29
+ try {
30
+ const files = readdirSync(LOG_DIR)
31
+ .filter(f => f.endsWith('.log'))
32
+ .map(f => {
33
+ const path = join(LOG_DIR, f);
34
+ const stat = statSync(path);
35
+ return { name: f, path, mtime: stat.mtimeMs, size: stat.size };
36
+ })
37
+ .sort((a, b) => b.mtime - a.mtime); // 最新的在前面
38
+
39
+ let deleted = 0;
40
+ for (let i = 0; i < files.length; i++) {
41
+ const f = files[i];
42
+ // 超过保留数量,或单文件超过大小限制
43
+ if (i >= MAX_LOG_FILES || f.size > MAX_LOG_SIZE) {
44
+ try { unlinkSync(f.path); deleted++; } catch {}
45
+ }
46
+ }
47
+ if (deleted > 0) {
48
+ console.log(`[Logger] 清理了 ${deleted} 个旧日志文件`);
49
+ }
50
+ } catch {}
51
+ }
52
+
53
+ /**
54
+ * 写入一条日志
55
+ * @param {'info'|'warn'|'error'} level - 日志级别
56
+ * @param {'auth'|'fs'|'exec'|'security'|'system'} category - 日志分类
57
+ * @param {string} action - 操作名称
58
+ * @param {Object} details - 详细信息
59
+ */
60
+ function log(level, category, action, details = {}) {
61
+ try {
62
+ ensureLogDir();
63
+ cleanOldLogs(); // 每次写入时顺便清理(频率不高,影响可忽略)
64
+
65
+ const entry = {
66
+ timestamp: new Date().toISOString(),
67
+ level,
68
+ category,
69
+ action,
70
+ ...details
71
+ };
72
+
73
+ appendFileSync(getLogFile(), JSON.stringify(entry) + '\n', 'utf-8');
74
+ } catch (err) {
75
+ // 日志写入失败不应影响主流程
76
+ console.error('[Logger] 日志写入失败:', err.message);
77
+ }
78
+ }
79
+
80
+ // ==================== 便捷方法 ====================
81
+
82
+ function logAuth(action, details) { log('info', 'auth', action, details); }
83
+ function logFs(action, details) { log('info', 'fs', action, details); }
84
+ function logExec(action, details) { log('info', 'exec', action, details); }
85
+ function logSecurity(action, details) { log('warn', 'security', action, details); }
86
+ function logSystem(action, details) { log('info', 'system', action, details); }
87
+ function logError(category, action, details) { log('error', category, action, details); }
88
+
89
+ /**
90
+ * 查询日志(用于 /api/logs 端点)
91
+ * @param {Object} options - 查询选项
92
+ * @param {string} [options.date] - 日期 (YYYY-MM-DD)
93
+ * @param {string} [options.category] - 分类过滤
94
+ * @param {number} [options.limit=200] - 返回条数上限
95
+ * @param {number} [options.offset=0] - 偏移量
96
+ * @returns {{ entries: Array, total: number }}
97
+ */
98
+ function queryLogs(options = {}) {
99
+ const { date, category, limit = 200, offset = 0 } = options;
100
+
101
+ let filePath;
102
+ if (date) {
103
+ filePath = join(LOG_DIR, `agent-${date}.log`);
104
+ } else {
105
+ filePath = getLogFile();
106
+ }
107
+
108
+ let entries = [];
109
+ try {
110
+ if (existsSync(filePath)) {
111
+ const raw = readFileSync(filePath, 'utf-8');
112
+ const lines = raw.trim().split('\n');
113
+ for (const line of lines) {
114
+ try {
115
+ const entry = JSON.parse(line);
116
+ if (category && entry.category !== category) continue;
117
+ entries.push(entry);
118
+ } catch {}
119
+ }
120
+ }
121
+ } catch {}
122
+
123
+ // 按时间倒序(最新的在前)
124
+ entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
125
+
126
+ const total = entries.length;
127
+ entries = entries.slice(offset, offset + limit);
128
+
129
+ return { entries, total };
130
+ }
131
+
132
+ /**
133
+ * 获取可用日志日期列表
134
+ */
135
+ function getLogDates() {
136
+ try {
137
+ ensureLogDir();
138
+ const files = readdirSync(LOG_DIR)
139
+ .filter(f => f.match(/^agent-\d{4}-\d{2}-\d{2}\.log$/))
140
+ .map(f => f.replace('agent-', '').replace('.log', ''))
141
+ .sort()
142
+ .reverse();
143
+ return files;
144
+ } catch {
145
+ return [];
146
+ }
147
+ }
148
+
149
+ export { log, logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates };
package/src/server.js CHANGED
@@ -7,6 +7,7 @@ import { loadConfig } from './config.js';
7
7
  import { verifyToken, getCurrentPairCode, startPairCodeRotation, stopPairCodeRotation, handlePairRequest } from './auth.js';
8
8
  import { checkPath, checkCommand } from './security.js';
9
9
  import { executeCommand, executeCommandSync, addWsClient, killProcess, getRunningProcesses } from './executor.js';
10
+ import { logAuth, logFs, logExec, logSecurity, logSystem, logError, queryLogs, getLogDates } from './logger.js';
10
11
 
11
12
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
12
13
 
@@ -64,6 +65,8 @@ export function startServer() {
64
65
  const config = loadConfig();
65
66
  const { port, host } = config;
66
67
 
68
+ logSystem('server_start', { port, host, workdir: config.workdir });
69
+
67
70
  // ==================== HTTP Server ====================
68
71
  const server = http.createServer(async (req, res) => {
69
72
  // CORS 预检
@@ -87,10 +90,15 @@ export function startServer() {
87
90
  try { body = await parseBody(req); }
88
91
  catch (err) { return jsonResponse(res, 400, { success: false, error: err.message }); }
89
92
  const result = handlePairRequest(body.code, body.extensionId);
93
+ if (result.success) {
94
+ logAuth('pair_success', { extensionId: body.extensionId });
95
+ } else {
96
+ logAuth('pair_failed', { extensionId: body.extensionId, reason: result.error });
97
+ }
90
98
  return jsonResponse(res, result.success ? 200 : 400, result);
91
99
  }
92
100
 
93
- // 健康检查(不含敏感信息)
101
+ // 健康检查
94
102
  if (req.method === 'GET' && pathname === '/api/status') {
95
103
  return jsonResponse(res, 200, {
96
104
  success: true,
@@ -102,17 +110,19 @@ export function startServer() {
102
110
  // ---------- 需要认证的接口 ----------
103
111
  const authHeader = req.headers.authorization;
104
112
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
113
+ logSecurity('auth_missing', { path: pathname });
105
114
  return jsonResponse(res, 401, { success: false, error: '未提供认证 token' });
106
115
  }
107
116
  const token = authHeader.slice(7);
108
117
  const extId = verifyToken(token);
109
118
  if (!extId) {
119
+ logSecurity('auth_invalid', { path: pathname });
110
120
  return jsonResponse(res, 403, { success: false, error: '认证 token 无效' });
111
121
  }
112
122
 
113
123
  const maxSize = config.fileMaxSize;
114
124
 
115
- // 认证后的状态信息(含敏感信息)
125
+ // 认证后的状态信息
116
126
  if (req.method === 'GET' && pathname === '/api/status/detail') {
117
127
  return jsonResponse(res, 200, {
118
128
  success: true,
@@ -133,32 +143,44 @@ export function startServer() {
133
143
  // 读取文件
134
144
  if (pathname === '/api/fs/read') {
135
145
  const check = checkPath(body.path);
136
- if (!check.allowed) return jsonResponse(res, 403, { success: false, error: check.reason });
146
+ if (!check.allowed) {
147
+ logSecurity('fs_read_blocked', { path: body.path, reason: check.reason });
148
+ return jsonResponse(res, 403, { success: false, error: check.reason });
149
+ }
137
150
  if (!existsSync(check.resolved)) return jsonResponse(res, 404, { success: false, error: '文件不存在' });
138
- const stat = statSync(check.resolved);
139
- if (stat.isDirectory()) return jsonResponse(res, 400, { success: false, error: '路径是目录' });
140
- if (stat.size > maxSize) return jsonResponse(res, 400, { success: false, error: `文件过大 (${stat.size} > ${maxSize})` });
151
+ const fstat = statSync(check.resolved);
152
+ if (fstat.isDirectory()) return jsonResponse(res, 400, { success: false, error: '路径是目录' });
153
+ if (fstat.size > maxSize) return jsonResponse(res, 400, { success: false, error: `文件过大 (${fstat.size} > ${maxSize})` });
141
154
  const content = readFileSync(check.resolved, 'utf-8');
142
- return jsonResponse(res, 200, { success: true, content, size: stat.size, path: check.resolved });
155
+ logFs('read', { path: check.resolved, size: fstat.size });
156
+ return jsonResponse(res, 200, { success: true, content, size: fstat.size, path: check.resolved });
143
157
  }
144
158
 
145
159
  // 写入文件
146
160
  if (pathname === '/api/fs/write') {
147
161
  const check = checkPath(body.path);
148
- if (!check.allowed) return jsonResponse(res, 403, { success: false, error: check.reason });
162
+ if (!check.allowed) {
163
+ logSecurity('fs_write_blocked', { path: body.path, reason: check.reason });
164
+ return jsonResponse(res, 403, { success: false, error: check.reason });
165
+ }
149
166
  const buf = Buffer.from(body.content || '', 'utf-8');
150
167
  if (buf.length > maxSize) return jsonResponse(res, 400, { success: false, error: `内容过大 (${buf.length} > ${maxSize})` });
151
168
  writeFileSync(check.resolved, body.content || '', 'utf-8');
169
+ logFs('write', { path: check.resolved, size: buf.length });
152
170
  return jsonResponse(res, 200, { success: true, size: buf.length, path: check.resolved });
153
171
  }
154
172
 
155
173
  // 列出目录
156
174
  if (pathname === '/api/fs/list') {
157
- const check = checkPath(body.path || '.');
158
- if (!check.allowed) return jsonResponse(res, 403, { success: false, error: check.reason });
175
+ const dirPath = body.path || '.';
176
+ const check = checkPath(dirPath);
177
+ if (!check.allowed) {
178
+ logSecurity('fs_list_blocked', { path: dirPath, reason: check.reason });
179
+ return jsonResponse(res, 403, { success: false, error: check.reason });
180
+ }
159
181
  if (!existsSync(check.resolved)) return jsonResponse(res, 404, { success: false, error: '目录不存在' });
160
- const stat = statSync(check.resolved);
161
- if (!stat.isDirectory()) return jsonResponse(res, 400, { success: false, error: '路径不是目录' });
182
+ const dstat = statSync(check.resolved);
183
+ if (!dstat.isDirectory()) return jsonResponse(res, 400, { success: false, error: '路径不是目录' });
162
184
  const entries = readdirSync(check.resolved).map(name => {
163
185
  const fullPath = join(check.resolved, name);
164
186
  try {
@@ -166,26 +188,31 @@ export function startServer() {
166
188
  return { name, type: s.isDirectory() ? 'directory' : 'file', size: s.size, mtime: s.mtimeMs };
167
189
  } catch { return { name, type: 'unknown', size: 0, mtime: 0 }; }
168
190
  });
191
+ logFs('list', { path: check.resolved, entryCount: entries.length });
169
192
  return jsonResponse(res, 200, { success: true, entries, path: check.resolved });
170
193
  }
171
194
 
172
195
  // 删除文件/目录
173
196
  if (pathname === '/api/fs/delete') {
174
197
  const check = checkPath(body.path);
175
- if (!check.allowed) return jsonResponse(res, 403, { success: false, error: check.reason });
198
+ if (!check.allowed) {
199
+ logSecurity('fs_delete_blocked', { path: body.path, reason: check.reason });
200
+ return jsonResponse(res, 403, { success: false, error: check.reason });
201
+ }
176
202
  if (!existsSync(check.resolved)) return jsonResponse(res, 404, { success: false, error: '文件/目录不存在' });
177
- const stat = statSync(check.resolved);
178
- if (stat.isDirectory()) {
203
+ const fstat2 = statSync(check.resolved);
204
+ const isDir = fstat2.isDirectory();
205
+ if (isDir) {
179
206
  rmdirSync(check.resolved, { recursive: true });
180
207
  } else {
181
208
  unlinkSync(check.resolved);
182
209
  }
210
+ logFs('delete', { path: check.resolved, type: isDir ? 'directory' : 'file', size: fstat2.size });
183
211
  return jsonResponse(res, 200, { success: true, path: check.resolved });
184
212
  }
185
213
 
186
214
  // === 命令执行 ===
187
215
 
188
- // 发起命令执行
189
216
  if (pathname === '/api/exec') {
190
217
  const { command, cwd, wait, force } = body;
191
218
  if (!command) return jsonResponse(res, 400, { success: false, error: '缺少 command 参数' });
@@ -194,22 +221,34 @@ export function startServer() {
194
221
  let resolvedCwd = cwd || config.workdir;
195
222
  const cwdCheck = checkPath(resolvedCwd);
196
223
  if (!cwdCheck.allowed) {
224
+ logSecurity('exec_cwd_blocked', { command, cwd: resolvedCwd, reason: cwdCheck.reason });
197
225
  return jsonResponse(res, 403, { success: false, error: '执行目录校验失败: ' + cwdCheck.reason });
198
226
  }
199
227
 
200
228
  // 安全检查
201
229
  const cmdCheck = checkCommand(command, force);
202
230
  if (cmdCheck.level === 'deny') {
231
+ logSecurity('exec_denied', { command, reason: cmdCheck.reason });
203
232
  return jsonResponse(res, 403, { success: false, error: cmdCheck.reason, level: 'deny' });
204
233
  }
205
234
  if (cmdCheck.level === 'confirm') {
235
+ logSecurity('exec_confirm_required', { command, reason: cmdCheck.reason });
206
236
  return jsonResponse(res, 200, { success: true, level: 'confirm', reason: cmdCheck.reason, command, cwd });
207
237
  }
208
238
 
209
- // 同步等待模式(用于扩展端获取完整输出)
239
+ // 同步等待模式
210
240
  if (wait) {
211
241
  try {
212
242
  const result = await executeCommandSync(command, resolvedCwd);
243
+ logExec('completed', {
244
+ command,
245
+ cwd: resolvedCwd,
246
+ execId: result.execId,
247
+ exitCode: result.exitCode,
248
+ killed: result.killed,
249
+ stdoutLen: (result.stdout || '').length,
250
+ stderrLen: (result.stderr || '').length
251
+ });
213
252
  return jsonResponse(res, 200, {
214
253
  success: true,
215
254
  level: 'allow',
@@ -221,12 +260,22 @@ export function startServer() {
221
260
  error: result.error
222
261
  });
223
262
  } catch (err) {
263
+ logError('exec', 'error', { command, error: err.message });
224
264
  return jsonResponse(res, 500, { success: false, error: `命令执行异常: ${err.message}` });
225
265
  }
226
266
  }
227
267
 
228
- // 异步模式(返回 execId,适用于 WebSocket 场景)
229
- const execId = executeCommand(command, resolvedCwd, null, null);
268
+ // 异步模式
269
+ const execId = executeCommand(command, resolvedCwd, null, (result) => {
270
+ logExec('completed', {
271
+ command,
272
+ cwd: resolvedCwd,
273
+ execId: result.execId,
274
+ exitCode: result.exitCode,
275
+ killed: result.killed
276
+ });
277
+ });
278
+ logExec('started', { command, cwd: resolvedCwd, execId });
230
279
  return jsonResponse(res, 200, {
231
280
  success: true,
232
281
  level: 'allow',
@@ -238,6 +287,7 @@ export function startServer() {
238
287
  // 停止命令
239
288
  if (pathname === '/api/exec/stop') {
240
289
  const stopped = killProcess(body.execId);
290
+ logExec('stopped', { execId: body.execId, success: stopped });
241
291
  return jsonResponse(res, 200, { success: stopped, execId: body.execId });
242
292
  }
243
293
  }
@@ -247,8 +297,24 @@ export function startServer() {
247
297
  return jsonResponse(res, 200, { success: true, processes: getRunningProcesses() });
248
298
  }
249
299
 
250
- // Agent 关闭(需认证,POST 方法)
300
+ // 日志查询(需认证)
301
+ if (req.method === 'GET' && pathname === '/api/logs') {
302
+ const date = url.searchParams.get('date') || undefined;
303
+ const category = url.searchParams.get('category') || undefined;
304
+ const limit = parseInt(url.searchParams.get('limit') || '200', 10);
305
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10);
306
+ const result = queryLogs({ date, category, limit, offset });
307
+ return jsonResponse(res, 200, { success: true, ...result });
308
+ }
309
+
310
+ // 日志日期列表(需认证)
311
+ if (req.method === 'GET' && pathname === '/api/logs/dates') {
312
+ return jsonResponse(res, 200, { success: true, dates: getLogDates() });
313
+ }
314
+
315
+ // Agent 关闭
251
316
  if (req.method === 'POST' && pathname === '/api/shutdown') {
317
+ logSystem('shutdown', { reason: 'api_request' });
252
318
  jsonResponse(res, 200, { success: true, message: 'Agent 正在关闭...' });
253
319
  shutdown();
254
320
  return;
@@ -268,7 +334,6 @@ export function startServer() {
268
334
  if (pathParts[1] === 'ws' && pathParts[2] === 'exec') {
269
335
  const execId = pathParts[3];
270
336
 
271
- // Token 验证(从 Authorization header 或 query param)
272
337
  const authHeader = request.headers.authorization;
273
338
  let token = null;
274
339
  if (authHeader && authHeader.startsWith('Bearer ')) {
@@ -277,6 +342,7 @@ export function startServer() {
277
342
  token = url.searchParams.get('token');
278
343
  }
279
344
  if (!verifyToken(token)) {
345
+ logSecurity('ws_auth_invalid', { execId });
280
346
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
281
347
  socket.destroy();
282
348
  return;
@@ -309,6 +375,7 @@ export function startServer() {
309
375
  }
310
376
  wss.close();
311
377
  server.close();
378
+ logSystem('server_stop', { reason: 'shutdown' });
312
379
  process.exit(0);
313
380
  }
314
381