@bolloon/bolloon-agent 0.2.4 → 0.2.5
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/dist/cli-entry.js +6 -2
- package/dist/web/input-validator.js +103 -0
- package/dist/web/server.js +56 -0
- package/package.json +1 -1
package/dist/cli-entry.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { spawn } from 'child_process';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import * as fs from 'fs';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
16
17
|
const isWindows = process.platform === 'win32';
|
|
17
18
|
// ANSI 颜色
|
|
18
19
|
const RESET = '\x1b[0m';
|
|
@@ -66,8 +67,11 @@ ${BOLD}环境变量:${RESET}
|
|
|
66
67
|
`);
|
|
67
68
|
}
|
|
68
69
|
function getDistDir() {
|
|
69
|
-
//
|
|
70
|
-
|
|
70
|
+
// 2026-07-01: ESM 模块无 __dirname, 用 import.meta.url + path.dirname 拿当前文件所在目录.
|
|
71
|
+
// 对于打包后的应用, 这是 dist 目录; 对于 tsx 跑 src/index.ts 时, 是 src/ 目录
|
|
72
|
+
// (后续 getMainScript 会优先用 dist/cli-entry.js, 不依赖这里的返回值)
|
|
73
|
+
const __filename_esm = fileURLToPath(import.meta.url);
|
|
74
|
+
return path.dirname(__filename_esm);
|
|
71
75
|
}
|
|
72
76
|
function getMainScript() {
|
|
73
77
|
const distDir = getDistDir();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* input-validator — web 接口输入验证 (v0.2.5)
|
|
3
|
+
*
|
|
4
|
+
* 2026-07-01: 抽到独立模块, claude code / 前端都能 import 验证.
|
|
5
|
+
* - 后端用: POST /api/validate-input 调 validate()
|
|
6
|
+
* - 前端用: 实时 UI 反馈 (发送前预校验, 不阻塞)
|
|
7
|
+
*
|
|
8
|
+
* 验证规则 (bolloon web 当前业务):
|
|
9
|
+
* - text 非空 (trim 后)
|
|
10
|
+
* - text 长度 <= MAX_TEXT_LENGTH
|
|
11
|
+
* - text 不包含 NUL / 不可打印控制字符
|
|
12
|
+
* - channelId 非空
|
|
13
|
+
* - channelName (新建频道) 长度 <= MAX_NAME_LENGTH
|
|
14
|
+
* - 不含 SQL/XSS payload 触发 (sanitize-only: 不是阻止, 是 warn)
|
|
15
|
+
*
|
|
16
|
+
* 设计: 纯函数 + 返回结构化 ValidationResult.
|
|
17
|
+
* { ok: true, reason?, severity? }
|
|
18
|
+
* 严重等级: 'info' | 'warn' | 'block'
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_TEXT_LENGTH = 16_000; // 大约 4K tokens, 单条 user 消息够用
|
|
21
|
+
export const MAX_NAME_LENGTH = 200; // channel name 上限
|
|
22
|
+
export const MAX_AGENT_ID_LENGTH = 200;
|
|
23
|
+
/** 校验单条 /message 请求 (text + channelId) */
|
|
24
|
+
export function validateMessageInput(input) {
|
|
25
|
+
// 1. channelId
|
|
26
|
+
if (typeof input.channelId !== 'string' || input.channelId.trim() === '') {
|
|
27
|
+
return { ok: false, severity: 'block', reason: 'channelId 必填' };
|
|
28
|
+
}
|
|
29
|
+
if (input.channelId.length > 256) {
|
|
30
|
+
return { ok: false, severity: 'block', reason: `channelId 长度超过 256 字符 (${input.channelId.length})` };
|
|
31
|
+
}
|
|
32
|
+
// 2. text
|
|
33
|
+
if (typeof input.text !== 'string') {
|
|
34
|
+
return { ok: false, severity: 'block', reason: 'text 必填且必须是字符串' };
|
|
35
|
+
}
|
|
36
|
+
const trimmed = input.text.trim();
|
|
37
|
+
if (trimmed === '') {
|
|
38
|
+
return { ok: false, severity: 'block', reason: 'text 不能为空 (trim 后)' };
|
|
39
|
+
}
|
|
40
|
+
if (trimmed.length > MAX_TEXT_LENGTH) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
severity: 'block',
|
|
44
|
+
reason: `text 长度 ${trimmed.length} 超过最大 ${MAX_TEXT_LENGTH} 字符`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// 3. 控制字符检测 — 拒绝 NUL + 不可打印 char (< 0x20 除了 \n \r \t)
|
|
48
|
+
// eslint-disable-next-line no-control-regex
|
|
49
|
+
const controlCharRe = /[\x00-\x08\x0B-\x0C\x0E-\x1F]/g;
|
|
50
|
+
const controlMatches = trimmed.match(controlCharRe);
|
|
51
|
+
if (controlMatches && controlMatches.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
severity: 'block',
|
|
55
|
+
reason: `text 含 ${controlMatches.length} 个不可打印控制字符`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// 4. 通用安全 check — 大段 HTML/JS 注入 (sanitize 是另一层, 这里只 warn)
|
|
59
|
+
const htmlRe = /<\/?[a-z][\s\S]*>/i;
|
|
60
|
+
if (htmlRe.test(trimmed) && trimmed.length > 500) {
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
severity: 'warn',
|
|
64
|
+
reason: 'text 包含 HTML 标签 (>500 字符), 确认发送前预览',
|
|
65
|
+
cleaned: trimmed,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, severity: 'info', cleaned: trimmed };
|
|
69
|
+
}
|
|
70
|
+
/** 校验 channel 创建输入 */
|
|
71
|
+
export function validateChannelInput(input) {
|
|
72
|
+
if (typeof input.name !== 'string' || input.name.trim() === '') {
|
|
73
|
+
return { ok: false, severity: 'block', reason: 'name 必填' };
|
|
74
|
+
}
|
|
75
|
+
const trimmedName = input.name.trim();
|
|
76
|
+
if (trimmedName.length > MAX_NAME_LENGTH) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
severity: 'block',
|
|
80
|
+
reason: `name 长度 ${trimmedName.length} 超过最大 ${MAX_NAME_LENGTH}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (typeof input.agentId === 'string' && input.agentId.length > MAX_AGENT_ID_LENGTH) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
severity: 'block',
|
|
87
|
+
reason: `agentId 长度 ${input.agentId.length} 超过最大 ${MAX_AGENT_ID_LENGTH}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, severity: 'info', cleaned: trimmedName };
|
|
91
|
+
}
|
|
92
|
+
const BOOT_TIME = Date.now();
|
|
93
|
+
export function healthCheck(version) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
version,
|
|
97
|
+
uptime_sec: Math.floor((Date.now() - BOOT_TIME) / 1000),
|
|
98
|
+
validators_loaded: [
|
|
99
|
+
'validateMessageInput',
|
|
100
|
+
'validateChannelInput',
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -6,6 +6,32 @@ import * as fs from 'fs/promises';
|
|
|
6
6
|
import * as fsSync from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import * as os from 'os';
|
|
9
|
+
import { validateMessageInput, validateChannelInput, healthCheck, } from './input-validator.js';
|
|
10
|
+
// 读自身 package.json 拿 version (health endpoint 用)
|
|
11
|
+
// 路径: src/web/server.ts → ../../package.json (编译后 dist/web/server.js)
|
|
12
|
+
let cachedVersion = null;
|
|
13
|
+
function getPackageVersion() {
|
|
14
|
+
if (cachedVersion)
|
|
15
|
+
return cachedVersion;
|
|
16
|
+
try {
|
|
17
|
+
const here = fileURLToPath(import.meta.url);
|
|
18
|
+
const hereDir = path.dirname(here);
|
|
19
|
+
// 试 ../package.json (相对 dist/web/) 或 ../../package.json (相对 src/web/)
|
|
20
|
+
let raw = null;
|
|
21
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
22
|
+
try {
|
|
23
|
+
raw = fsSync.readFileSync(path.join(hereDir, rel), 'utf-8');
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
}
|
|
28
|
+
cachedVersion = raw ? (JSON.parse(raw).version ?? '0.0.0') : 'unknown';
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
cachedVersion = 'unknown';
|
|
32
|
+
}
|
|
33
|
+
return cachedVersion;
|
|
34
|
+
}
|
|
9
35
|
// 2026-06-17: 终端静默 — server.ts 高频 spam (LLM 流式每个 token 一次 broadcast,
|
|
10
36
|
// 每次 P2P 收发 [v3]/[v3-meta]/[v3-cross]/[v3-friend] 各一次) 全部走 console.log proxy,
|
|
11
37
|
// 默认 (BOLLOON_VERBOSE != '1') 完全不打;VERBOSE=1 时恢复.
|
|
@@ -1410,6 +1436,36 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1410
1436
|
}
|
|
1411
1437
|
app.get('/', serveStaticHtml('index.html', 'index.html not found; please run `npm run build:web`', 'index'));
|
|
1412
1438
|
app.get('/api-config', serveStaticHtml('api-config.html', 'api-config.html not found; please run `npm run build:web`', 'api-config'));
|
|
1439
|
+
// 2026-07-01 (v0.2.5): 输入验证 + 健康检查 API
|
|
1440
|
+
// - POST /api/validate-input: 前端发送前预校验, 避免后端 reject
|
|
1441
|
+
// - GET /api/health: liveness probe + validators 清单
|
|
1442
|
+
// 这些端点不依赖 LLM / P2P, 永远可以响应.
|
|
1443
|
+
app.post('/api/validate-input', async (req, res) => {
|
|
1444
|
+
try {
|
|
1445
|
+
const body = req.body ?? {};
|
|
1446
|
+
const kind = body.kind || 'message';
|
|
1447
|
+
let result;
|
|
1448
|
+
if (kind === 'channel') {
|
|
1449
|
+
result = validateChannelInput({ name: body.name, agentId: body.agentId });
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
result = validateMessageInput({ text: body.text, channelId: body.channelId });
|
|
1453
|
+
}
|
|
1454
|
+
res.json({
|
|
1455
|
+
ok: result.ok,
|
|
1456
|
+
severity: result.severity ?? (result.ok ? 'info' : 'block'),
|
|
1457
|
+
reason: result.reason,
|
|
1458
|
+
cleaned: result.cleaned,
|
|
1459
|
+
kind,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
catch (e) {
|
|
1463
|
+
res.status(400).json({ ok: false, severity: 'block', reason: e?.message ?? 'parse error' });
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
app.get('/api/health', (_req, res) => {
|
|
1467
|
+
res.json(healthCheck(getPackageVersion()));
|
|
1468
|
+
});
|
|
1413
1469
|
// 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
|
|
1414
1470
|
app.use((err, req, res, _next) => {
|
|
1415
1471
|
console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
|