aims-workstation 0.1.15 → 0.1.16
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
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@earendil-works/pi-ai": "^1.2.0"
|
|
14
14
|
},
|
|
15
15
|
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
|
|
16
|
-
"version": "0.1.
|
|
16
|
+
"version": "0.1.16",
|
|
17
17
|
"description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
|
|
18
18
|
"bin": {
|
|
19
19
|
"aims-workstation": "bin/aims-workstation.js"
|
package/server.js
CHANGED
|
@@ -76,19 +76,41 @@ const PORT = +(process.env.PORT || env.PORT || 5600);
|
|
|
76
76
|
const HOST = process.env.AIMS_HOST || '0.0.0.0'; // 默认局域网可达(团队共用);仅本机使用可设 AIMS_HOST=127.0.0.1
|
|
77
77
|
// ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
|
|
78
78
|
const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
|
|
79
|
-
const cfg = { arm_api_base: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', llm_context_tokens: 0, scene_profile: '', access_token: '' };
|
|
79
|
+
const cfg = { arm_api_base: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', llm_context_tokens: 0, llm_vision: '', scene_profile: '', access_token: '' };
|
|
80
80
|
// 守护栏默认策略(可在「系统设置·守护栏」维护,存 platform.json,保存即热生效):deny=正则源串(命中即永久拒绝,任何级别不豁免);
|
|
81
81
|
// ro=只读白名单命令名(免确认直行);sudo_u_l2=true 时 `sudo -u <用户>` 降权形式不再永久拒绝、按写命令走 L2 人工确认(裸 sudo 仍拒绝)
|
|
82
|
-
|
|
82
|
+
// 默认清单按平台合成:POSIX 为主;Windows(cmd/PowerShell 命令与毁灭性命令)仅在 win32 追加——非 Windows 平台不引入 "format" 等词的误杀
|
|
83
|
+
const WIN = process.platform === 'win32';
|
|
84
|
+
const GUARD_POSIX = {
|
|
83
85
|
deny: ['\\bsudo\\b', '\\b(shutdown|reboot|halt|poweroff|mkfs)\\b', '\\bdd\\s+if=', 'rm\\s+-[a-z]*[rf][a-z]*\\s+/(\\s|$)', ':\\(\\)\\s*\\{.*\\};:', '\\bkill\\s+-9\\s+1\\b'],
|
|
84
86
|
ro: ['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'file', 'stat', 'whoami', 'date', 'uname', 'uptime', 'df', 'du', 'ps', 'curl', 'ping', 'nc', 'netstat', 'dig', 'nslookup', 'sqlite3', 'echo', 'which', 'env'],
|
|
85
|
-
sudo_u_l2: false,
|
|
86
87
|
};
|
|
87
|
-
|
|
88
|
+
// Windows 补充:ro 含 cmd 只读命令与 PowerShell 只读 cmdlet/别名(多行为动词 net/route/reg/sc/wmic 不入白名单——首个词无法区分读写);
|
|
89
|
+
// deny 的删除类只拦"直指盘符根",对齐 POSIX `rm -rf /` 只保护根的粒度(`rm -rf /tmp/x` 走 L2 确认);
|
|
90
|
+
// format 只认裸盘符(`format c:` / `format /q d:`),`dotnet format C:\x.sln`、`git log --format=… C:\repo` 这类路径参数不命中
|
|
91
|
+
const GUARD_WIN = {
|
|
92
|
+
deny: [
|
|
93
|
+
'\\bdiskpart\\b',
|
|
94
|
+
'\\bformat(\\.com)?\\b[^|;&]*\\s[c-z]:(\\s|$|/)',
|
|
95
|
+
'\\b(format-volume|clear-disk)\\b',
|
|
96
|
+
'\\b(rd|rmdir|del|erase|remove-item|ri|rm)\\b[^|;&]*([c-z]:\\\\\\s*$|\\s[c-z]:\\\\\\s)',
|
|
97
|
+
'\\b(stop-computer|restart-computer)\\b',
|
|
98
|
+
'%0\\s*\\|\\s*%0',
|
|
99
|
+
],
|
|
100
|
+
ro: ['dir', 'type', 'more', 'find', 'findstr', 'where', 'cd', 'chdir', 'tree', 'fc', 'sort', 'ver', 'vol', 'hostname', 'ipconfig', 'tracert', 'getmac', 'systeminfo', 'tasklist', 'time', 'set', 'help',
|
|
101
|
+
'Get-Content', 'gc', 'Select-String', 'sls', 'Get-ChildItem', 'gci', 'Get-Item', 'gi', 'Get-Process', 'gps', 'Get-Service', 'gsv', 'Get-Command', 'gcm', 'Get-Member', 'gm', 'Get-Help', 'man', 'Get-Location', 'pwd', 'Get-Date', 'Get-NetIPAddress', 'Get-NetTCPConnection', 'Test-Connection', 'Test-NetConnection', 'Get-ComputerInfo', 'Get-Acl'],
|
|
102
|
+
};
|
|
103
|
+
const guardDefaults = (win = WIN) => ({
|
|
104
|
+
deny: [...new Set([...GUARD_POSIX.deny, ...(win ? GUARD_WIN.deny : [])])],
|
|
105
|
+
ro: [...new Set([...(win ? GUARD_WIN.ro : []), ...GUARD_POSIX.ro])], // win32 时 Windows 命令排前:L0 拦截提示的样例对当前平台有意义;去重(find 两边都有)
|
|
106
|
+
sudo_u_l2: false,
|
|
107
|
+
});
|
|
108
|
+
function normalizeGuard(g, win = WIN) {
|
|
88
109
|
g = g && typeof g === 'object' ? g : {};
|
|
110
|
+
const d = guardDefaults(win);
|
|
89
111
|
return {
|
|
90
|
-
deny: Array.isArray(g.deny) ? [...new Set(g.deny.map((s) => String(s).trim()).filter(Boolean))] :
|
|
91
|
-
ro: Array.isArray(g.ro) ? [...new Set(g.ro.map((s) => String(s).trim()).filter(Boolean))] :
|
|
112
|
+
deny: Array.isArray(g.deny) ? [...new Set(g.deny.map((s) => String(s).trim()).filter(Boolean))] : d.deny.slice(),
|
|
113
|
+
ro: Array.isArray(g.ro) ? [...new Set(g.ro.map((s) => String(s).trim()).filter(Boolean))] : d.ro.slice(),
|
|
92
114
|
sudo_u_l2: !!g.sudo_u_l2,
|
|
93
115
|
};
|
|
94
116
|
}
|
|
@@ -141,6 +163,9 @@ const METRICS_LOG = path.join(DATA_DIR, 'metrics.log');
|
|
|
141
163
|
|
|
142
164
|
// ── pi-ai(Agent 层)─────────────────────────────────────
|
|
143
165
|
let models, model, llmCatalog = [];
|
|
166
|
+
// 视觉能力(对话附图是否随消息送给模型):auto 按 pi-ai 模型条目 input 判定(glm-4.6v / glm-5.3-flash 支持,glm-5.3 不支持);
|
|
167
|
+
// 自定义模型名按纯文本模板克隆、能力未知 → 设置页可强制 on/off
|
|
168
|
+
const llmVision = () => cfg.llm_vision === 'on' ? true : cfg.llm_vision === 'off' ? false : !!(model && Array.isArray(model.input) && model.input.includes('image'));
|
|
144
169
|
async function applyLLMConfig() { // 可重复执行:设置页保存后热切换(在途请求沿用旧 model 引用,安全)
|
|
145
170
|
const { createModels, createProvider } = await import('@earendil-works/pi-ai/models');
|
|
146
171
|
const { zaiCodingCnProvider } = await import('@earendil-works/pi-ai/providers/zai-coding-cn');
|
|
@@ -545,29 +570,146 @@ function deleteWorkspace(agentId, wsId, purge) {
|
|
|
545
570
|
return reg;
|
|
546
571
|
}
|
|
547
572
|
|
|
573
|
+
// ── 对话附件:落 <工作区>/attachments/YYYY-MM-DD/,助手 × 工作区天然隔离;exec 的 cwd 就是工作区,助手按相对路径即可读 ──
|
|
574
|
+
const ATTACH_DIR = 'attachments';
|
|
575
|
+
const ATTACH_MAX_BYTES = 25 * 1024 * 1024;
|
|
576
|
+
const ATTACH_INLINE_MAX = 16 * 1024, ATTACH_HEAD = 2 * 1024; // 文本类附件:≤16KB 全文内联进消息,更大只给前 2KB 预览 + exec 提示
|
|
577
|
+
const ATTACH_IMAGES_PER_MSG = 6;
|
|
578
|
+
const ATTACH_TEXT_EXT = new Set(['txt', 'log', 'md', 'markdown', 'json', 'yaml', 'yml', 'csv', 'tsv', 'xml', 'html', 'htm', 'svg', 'ini', 'conf', 'cfg', 'properties', 'env', 'toml', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'py', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'sql', 'diff', 'patch', 'dockerfile', 'gitignore', 'lock', 'pem', 'crt']);
|
|
579
|
+
const ATTACH_MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', pdf: 'application/pdf', txt: 'text/plain', log: 'text/plain', md: 'text/markdown', json: 'application/json', csv: 'text/csv', xml: 'application/xml', yaml: 'text/yaml', yml: 'text/yaml', html: 'text/html', zip: 'application/zip' };
|
|
580
|
+
const fmtBytes = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
581
|
+
// 客户端声明的 MIME 仅在格式合法且不是兜底值时采信,否则按扩展名推断
|
|
582
|
+
const attachMime = (mime, name) => (/^[\w.+-]+\/[\w.+-]+$/.test(String(mime || '')) && mime !== 'application/octet-stream') ? String(mime).toLowerCase() : (ATTACH_MIME_BY_EXT[path.extname(name).slice(1).toLowerCase()] || 'application/octet-stream');
|
|
583
|
+
function attachKind(mime, name, buf) {
|
|
584
|
+
if (/^image\/(png|jpe?g|gif|webp|bmp)$/.test(mime)) return 'image'; // svg 走文本(可内联;也不该作为位图送视觉模型)
|
|
585
|
+
const ext = path.extname(name).slice(1).toLowerCase();
|
|
586
|
+
const textual = /^text\//.test(mime) || /(json|xml|yaml|javascript|x-sh|x-python|svg)/.test(mime) || ATTACH_TEXT_EXT.has(ext);
|
|
587
|
+
return textual && buf && !buf.subarray(0, 8192).includes(0) ? 'text' : 'binary'; // 头部含 NUL 的"文本"按二进制处理
|
|
588
|
+
}
|
|
589
|
+
const safeFileName = (name) => {
|
|
590
|
+
let n = String(name || '').normalize('NFC').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\.{2,}/g, '.').replace(/^\.+/, '').trim() || 'file';
|
|
591
|
+
if (n.length > 80) { const ext = path.extname(n); n = n.slice(0, 80 - ext.length) + ext; }
|
|
592
|
+
return n;
|
|
593
|
+
};
|
|
594
|
+
// rel 为工作区相对 posix 路径,且必须落在 attachments/ 内:防穿越,也不借此暴露工作区其他文件
|
|
595
|
+
function attachAbs(ws, rel) {
|
|
596
|
+
const r = String(rel || '');
|
|
597
|
+
if (!r || path.posix.isAbsolute(r) || /^[a-zA-Z]:/.test(r) || r.split('/').some((seg) => seg === '..' || seg === '')) return null;
|
|
598
|
+
const abs = path.resolve(ws.path, ...r.split('/'));
|
|
599
|
+
return abs.startsWith(path.resolve(ws.path, ATTACH_DIR) + path.sep) ? abs : null;
|
|
600
|
+
}
|
|
601
|
+
function saveAttachment(ws, name, mime, buf) {
|
|
602
|
+
const d = new Date(), p2 = (n) => String(n).padStart(2, '0');
|
|
603
|
+
const day = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
604
|
+
const safe = safeFileName(name);
|
|
605
|
+
const rel = path.posix.join(ATTACH_DIR, day, `${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}-${crypto.randomUUID().slice(0, 4)}-${safe}`);
|
|
606
|
+
const abs = path.join(ws.path, ...rel.split('/'));
|
|
607
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
608
|
+
fs.writeFileSync(abs, buf);
|
|
609
|
+
return { name: safe, rel, size: buf.length, mime, kind: attachKind(mime, safe, buf) };
|
|
610
|
+
}
|
|
611
|
+
// 原始二进制请求体(上传用):声明长度超限直接拒;流式超限立即断开,不等整体读完
|
|
612
|
+
function rawBody(req, max = ATTACH_MAX_BYTES) {
|
|
613
|
+
return new Promise((resolve, reject) => {
|
|
614
|
+
const tooBig = () => Object.assign(new Error(`附件超过上限 ${Math.round(max / 1048576)} MB`), { status: 413 });
|
|
615
|
+
const declared = +req.headers['content-length'];
|
|
616
|
+
if (Number.isFinite(declared) && declared > max) return reject(tooBig());
|
|
617
|
+
const chunks = []; let size = 0, done = false;
|
|
618
|
+
req.on('data', (c) => { if (done) return; size += c.length; if (size > max) { done = true; req.destroy(); return reject(tooBig()); } chunks.push(c); });
|
|
619
|
+
req.on('end', () => { if (!done) { done = true; resolve(Buffer.concat(chunks)); } });
|
|
620
|
+
req.on('error', (e) => { if (!done) { done = true; reject(e); } });
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
// 把附件编进用户消息:图片在视觉可用时作为 ImageContent 随消息提供;文本类内联/预览;其余只给路径。
|
|
624
|
+
// 按 rel 从工作区重读(不信任客户端带回的内容)。无附件时 content 仍是纯字符串——文本对话路径与之前完全一致
|
|
625
|
+
function composeUserMessage(ws, message, rels) {
|
|
626
|
+
const text = String(message || '');
|
|
627
|
+
const list = [];
|
|
628
|
+
for (const r of (Array.isArray(rels) ? rels : []).slice(0, 10)) {
|
|
629
|
+
const rel = String((r && typeof r === 'object' ? r.rel : r) || '');
|
|
630
|
+
const abs = attachAbs(ws, rel);
|
|
631
|
+
if (!abs || !fs.existsSync(abs)) continue;
|
|
632
|
+
const buf = fs.readFileSync(abs), name = path.basename(abs).replace(/^\d{6}-[0-9a-f]{4}-/, ''), mime = attachMime('', abs);
|
|
633
|
+
list.push({ rel, name, size: buf.length, mime, kind: attachKind(mime, name, buf), buf });
|
|
634
|
+
}
|
|
635
|
+
if (!list.length) return { content: text, attachments: [], userText: text };
|
|
636
|
+
const vision = llmVision();
|
|
637
|
+
const images = [];
|
|
638
|
+
const lines = [`【附件 ${list.length} 个 · 已保存到当前工作区(exec 的 cwd),相对路径如下】`];
|
|
639
|
+
list.forEach((a, i) => {
|
|
640
|
+
const head = `${i + 1}. ${a.name}(${a.kind === 'image' ? '图片' : a.kind === 'text' ? '文本' : a.mime},${fmtBytes(a.size)})→ ${a.rel}`;
|
|
641
|
+
if (a.kind === 'image') {
|
|
642
|
+
if (vision && images.length < ATTACH_IMAGES_PER_MSG) { images.push({ type: 'image', data: a.buf.toString('base64'), mimeType: a.mime }); lines.push(`${head}(图片已随本消息提供给你查看)`); }
|
|
643
|
+
else lines.push(`${head}(${vision ? '超出单条消息图片上限,' : '当前模型不支持看图,'}仅作为文件保存,请结合用户描述与文件名处理)`);
|
|
644
|
+
} else if (a.kind === 'text') {
|
|
645
|
+
const s = a.buf.toString('utf8');
|
|
646
|
+
if (a.size <= ATTACH_INLINE_MAX) lines.push(`${head}\n\`\`\`\n${s}\n\`\`\``);
|
|
647
|
+
else lines.push(`${head}\n前 ${fmtBytes(ATTACH_HEAD)} 预览:\n\`\`\`\n${s.slice(0, ATTACH_HEAD)}\n\`\`\`\n(文件较大,完整内容请用 exec 读取,如 tail -n 200 "${a.rel}" / grep -n "ERROR" "${a.rel}")`);
|
|
648
|
+
} else lines.push(head);
|
|
649
|
+
});
|
|
650
|
+
const full = [text, lines.join('\n')].filter(Boolean).join('\n\n');
|
|
651
|
+
const attachments = list.map(({ rel, name, size, mime, kind }) => ({ rel, name, size, mime, kind }));
|
|
652
|
+
return { content: images.length ? [{ type: 'text', text: full }, ...images] : full, attachments, userText: text };
|
|
653
|
+
}
|
|
654
|
+
// 图片 base64 随每轮请求整体重发:只保留最近 keep 条带图用户消息的图片块,更早的替换为文字占位(文件仍在工作区,助手可按路径再看)
|
|
655
|
+
function pruneOldImages(ctx, keep = 3) {
|
|
656
|
+
let seen = 0;
|
|
657
|
+
for (let i = ctx.messages.length - 1; i >= 0; i--) {
|
|
658
|
+
const m = ctx.messages[i];
|
|
659
|
+
if (m.role !== 'user' || !Array.isArray(m.content) || !m.content.some((b) => b.type === 'image')) continue;
|
|
660
|
+
if (++seen <= keep) continue;
|
|
661
|
+
const imgs = (m.attachments || []).filter((a) => a.kind === 'image');
|
|
662
|
+
let k = 0;
|
|
663
|
+
m.content = m.content.map((b) => b.type !== 'image' ? b : { type: 'text', text: `(图片 ${imgs[k]?.name || ''} 已在前文提供,此处省略;文件:${imgs[k++]?.rel || ATTACH_DIR + '/'})` });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
548
667
|
// ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
|
|
549
668
|
const { exec: cpExec } = require('node:child_process');
|
|
669
|
+
// ── exec 用 shell 跨平台解析:/bin/zsh 在 Windows 与无 zsh 的 Linux 上不存在,硬编码会让所有命令 spawn ENOENT ──
|
|
670
|
+
function resolveShell() {
|
|
671
|
+
const exists = (p) => !!p && fs.existsSync(p);
|
|
672
|
+
if (exists(process.env.AIMS_SHELL)) return process.env.AIMS_SHELL; // 显式指定优先(如 Windows 想用 pwsh / 自定义 bash 路径)
|
|
673
|
+
if (process.platform === 'win32') {
|
|
674
|
+
if (exists(process.env.SHELL)) return process.env.SHELL; // Git Bash 内启动时 SHELL=/usr/bin/bash 是 MSYS 伪路径,存在性校验会把它过滤掉
|
|
675
|
+
// 只认 Git for Windows 安装位置,不探测 PATH:System32 的 bash.exe 是 WSL,cwd 为 Windows 路径时行为错乱
|
|
676
|
+
const gitBash = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], path.join(os.homedir(), 'AppData', 'Local', 'Programs')]
|
|
677
|
+
.filter(Boolean).map((base) => path.join(base, 'Git', 'bin', 'bash.exe')).find(exists);
|
|
678
|
+
return gitBash || process.env.ComSpec || 'cmd.exe'; // cmd.exe 由 Node 识别后以 /d /s /c 调用
|
|
679
|
+
}
|
|
680
|
+
return [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].find(exists) || '/bin/sh';
|
|
681
|
+
}
|
|
682
|
+
const SHELL = resolveShell();
|
|
683
|
+
const SHELL_HINT = process.platform === 'win32'
|
|
684
|
+
? (/bash\.exe$/i.test(SHELL) ? 'Windows · Git Bash(POSIX 语法可用)' : `Windows · ${path.basename(SHELL)}(用 Windows 命令与语法:dir/type/findstr,路径用反斜杠)`)
|
|
685
|
+
: `${process.platform} · ${path.basename(SHELL)}`;
|
|
686
|
+
let EXEC_WIN = WIN; // 命令语法判定走 Windows(cmd/PowerShell)分支;模块级可变仅为 --test-guard 在任意平台覆盖 Windows 用例
|
|
550
687
|
function classifyExec(command, autonomy) {
|
|
551
688
|
const cmd = String(command || '').trim();
|
|
552
689
|
const g = guardNow();
|
|
690
|
+
const win = EXEC_WIN;
|
|
553
691
|
// sudo 降权特例(可配置):开启后剥掉 `sudo -u <用户>` 片段再匹配危险清单(嵌套的裸 sudo/毁灭性命令仍会命中),剩余部分按普通命令分级
|
|
554
692
|
const denyCmd = g.sudo_u_l2 ? cmd.replace(/\bsudo\s+(?:-\w+\s+)*-u\s+\S+/g, '') : cmd;
|
|
555
693
|
const denyRes = [];
|
|
556
|
-
for (const src of g.deny) { try { denyRes.push(new RegExp(src)); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } }
|
|
694
|
+
for (const src of g.deny) { try { denyRes.push(new RegExp(src, win ? 'i' : '')); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } } // win32 忽略大小写:cmd/PowerShell 命令名不区分大小写(Stop-Computer / FORMAT C:);POSIX 保持原样
|
|
557
695
|
if (denyRes.some((re) => re.test(denyCmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(清单可在 系统设置·守护栏 维护)' };
|
|
558
696
|
const EXEC_RO = g.ro;
|
|
559
697
|
const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
|
|
560
698
|
const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
|
|
561
|
-
// 复合命令逐段判定:每个由 ; && || |
|
|
699
|
+
// 复合命令逐段判定:每个由 ; && || | 换行(win32 再加 cmd 的无条件分隔符 &)分隔的段都须只读(控制关键字与 for 头部放行)
|
|
562
700
|
const segOk = (seg) => {
|
|
563
|
-
let s = seg.trim().replace(/^(do|then|else|done|fi|esac|\{|\})\s+/, '');
|
|
701
|
+
let s = seg.trim().replace(win ? /^((do|then|else|elseif|done|fi|esac|try|catch|finally|foreach|switch|function|\{|\})\s+)/ : /^(do|then|else|done|fi|esac|\{|\})\s+/, '');
|
|
564
702
|
if (/^[A-Za-z_]\w*\s+in\b/.test(s)) return true; // for 循环头部(变量 in 值列表)
|
|
565
|
-
if (/^(for|while|until|if|case)\b
|
|
566
|
-
|
|
567
|
-
|
|
703
|
+
if ((win ? /^(for|foreach|while|until|if|case|switch|try|function)\b/ : /^(for|while|until|if|case)\b/).test(s)) return true; // 控制关键字本身
|
|
704
|
+
let first = s.split(/\s+/)[0].split(win ? /[\/\\]/ : '/').pop();
|
|
705
|
+
if (win) first = first.replace(/\.(exe|com|bat|cmd|ps1)$/i, ''); // C:\Windows\System32\ipconfig.exe → ipconfig
|
|
706
|
+
const name = win ? first.toLowerCase() : first; // cmd/PowerShell 命令名不区分大小写(Get-Process / get-process)
|
|
707
|
+
const hit = win ? EXEC_RO.some((x) => x.toLowerCase() === name) : EXEC_RO.includes(first);
|
|
708
|
+
return hit && !(name === 'sqlite3' && SQL_WRITE.test(s));
|
|
568
709
|
};
|
|
569
|
-
const segments = cmd.
|
|
570
|
-
|
|
710
|
+
const segments = (win ? cmd.replace(/\d\s*>&\s*\d/g, '') : cmd) // win32:先剥 2>&1 类 fd 复制,& 才能安全作分隔符
|
|
711
|
+
.split(win ? /;|&&|\|\||\|\n?|\n|&/ : /;|&&|\|\||\|\n?|\n/).map((s) => s.trim()).filter(Boolean);
|
|
712
|
+
const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)/.test(noRedirect) && !(win ? /\b(tee|tee-object)\b/i : /\btee\b/).test(noRedirect);
|
|
571
713
|
if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
|
|
572
714
|
if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
|
|
573
715
|
return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(${EXEC_RO.slice(0, 8).join('/')}等,完整清单见 系统设置·守护栏)。写操作需 L2 并人工确认` };
|
|
@@ -575,7 +717,7 @@ function classifyExec(command, autonomy) {
|
|
|
575
717
|
const PRIMITIVE_TOOLS = {
|
|
576
718
|
exec: {
|
|
577
719
|
min_level: 0, confirm_required: false, // 确认由 classifyExec 按命令动态判定
|
|
578
|
-
desc:
|
|
720
|
+
desc: `通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力。当前 shell:${SHELL_HINT}`,
|
|
579
721
|
schema: { command: { type: 'string', description: '要执行的 shell 命令' }, timeout_ms: { type: 'number', description: '超时毫秒,默认 15000' } },
|
|
580
722
|
required: ['command'],
|
|
581
723
|
run: async ({ command, timeout_ms = 15000 }, agentId, _wsId, signal) => {
|
|
@@ -583,7 +725,7 @@ const PRIMITIVE_TOOLS = {
|
|
|
583
725
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT; // 凭据不进子进程
|
|
584
726
|
const ws = agentId ? currentWs(agentId) : null; // 文件领地锚定工作区(07 设计 §4.3)
|
|
585
727
|
return await new Promise((resolve) => {
|
|
586
|
-
const child = cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell:
|
|
728
|
+
const child = cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: SHELL }, (err, stdout, stderr) => {
|
|
587
729
|
const out = String(stdout || '').slice(0, 4000);
|
|
588
730
|
const errOut = String(stderr || '').slice(0, 1500);
|
|
589
731
|
audit({ actor: 'assistant', tool: 'exec', command: String(command).slice(0, 200), ok: !err, ws: ws?.id });
|
|
@@ -1341,7 +1483,7 @@ const DEV_TOOLS = {
|
|
|
1341
1483
|
},
|
|
1342
1484
|
},
|
|
1343
1485
|
exec: {
|
|
1344
|
-
desc:
|
|
1486
|
+
desc: `终端命令执行(开发档):读白名单直接放行;写文件/跑脚本/构建调试等开发动作放行(这是你的本职);仅毁灭性命令(sudo/rm -rf //mkfs 等)被永久拒绝。用于探索 AGENT.md、写临时代码、运行测试、验证接口。全部调用入审计。当前 shell:${SHELL_HINT}`,
|
|
1345
1487
|
schema: { command: { type: 'string', description: '只读命令,如 grep -rl "Nexus" <目录>/*/AGENT.md' }, timeout_ms: { type: 'number' } },
|
|
1346
1488
|
required: ['command'],
|
|
1347
1489
|
run: async ({ command, timeout_ms = 30000 }) => {
|
|
@@ -1351,7 +1493,7 @@ const DEV_TOOLS = {
|
|
|
1351
1493
|
const env = { ...process.env };
|
|
1352
1494
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
|
|
1353
1495
|
return await new Promise((resolve) => {
|
|
1354
|
-
cpExec(command, { cwd: ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell:
|
|
1496
|
+
cpExec(command, { cwd: ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: SHELL }, (err, stdout, stderr) => {
|
|
1355
1497
|
audit({ actor: 'dev-agent', tool: 'exec', command: String(command).slice(0, 200), ok: !err });
|
|
1356
1498
|
resolve(`$ ${command}\n${String(stdout || '').slice(0, 5000)}${stderr ? '\n[stderr] ' + String(stderr).slice(0, 1000) : ''}${err && !stdout ? '\n[错误] ' + err.message : ''}`);
|
|
1357
1499
|
});
|
|
@@ -1397,7 +1539,9 @@ const ctxCharBudget = () => Math.max(4_000, Math.floor((llmContextTokens() - 16_
|
|
|
1397
1539
|
// 超预算从最旧轮次整轮丢弃;下刀点只选 user 消息边界,剩余前缀不会出现孤儿 toolResult(转换层要求其紧跟 assistant 的 toolCall);当前轮永不裁。
|
|
1398
1540
|
function trimContext(ctx, budget = ctxCharBudget()) {
|
|
1399
1541
|
const msgs = ctx.messages;
|
|
1400
|
-
|
|
1542
|
+
// 图片块按固定 4000 字符估算(约 2.5K token):按 base64 长度算会让一张图顶掉整段历史
|
|
1543
|
+
const sizeOf = (c) => Array.isArray(c) ? c.reduce((n, b) => n + (b.type === 'image' ? 4000 : JSON.stringify(b).length), 0) : JSON.stringify(c ?? '').length;
|
|
1544
|
+
const size = () => msgs.reduce((n, m) => n + sizeOf(m.content), 0);
|
|
1401
1545
|
if (size() <= budget) return 0;
|
|
1402
1546
|
const lastUser = msgs.length - 1 - [...msgs].reverse().findIndex((m) => m.role === 'user');
|
|
1403
1547
|
let dropped = 0;
|
|
@@ -1739,14 +1883,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1739
1883
|
key_set: !!k, key_masked: k ? `${k.slice(0, 6)}…${k.slice(-4)}` : '',
|
|
1740
1884
|
key_source: cfg.llm_api_key ? 'runtime' : (env.LLM_API_KEY ? 'env' : 'none'),
|
|
1741
1885
|
context_tokens: llmContextTokens(), context_tokens_runtime: !!cfg.llm_context_tokens, default_context_tokens: LLM_DEFAULT_CONTEXT_TOKENS,
|
|
1886
|
+
vision: llmVision(), vision_mode: cfg.llm_vision || 'auto', vision_auto: !!(model && Array.isArray(model.input) && model.input.includes('image')),
|
|
1742
1887
|
},
|
|
1743
1888
|
scene: { profile: sceneProfile(), length: sceneProfile().length },
|
|
1744
1889
|
auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
|
|
1745
|
-
guard: { ...guardNow(), default_deny:
|
|
1890
|
+
guard: { ...guardNow(), default_deny: guardDefaults().deny, default_ro: guardDefaults().ro },
|
|
1746
1891
|
});
|
|
1747
1892
|
}
|
|
1748
1893
|
if (req.method === 'PUT' && pathname === '/api/settings') {
|
|
1749
|
-
const { arm_api_base, arm_token, llm_base_url, llm_api_key, llm_model, llm_context_tokens, scene_profile, access_token, guard } = await body(req);
|
|
1894
|
+
const { arm_api_base, arm_token, llm_base_url, llm_api_key, llm_model, llm_context_tokens, llm_vision, scene_profile, access_token, guard } = await body(req);
|
|
1750
1895
|
// 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
|
|
1751
1896
|
let guardAudit = null;
|
|
1752
1897
|
if (guard !== undefined) {
|
|
@@ -1798,6 +1943,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1798
1943
|
}
|
|
1799
1944
|
}
|
|
1800
1945
|
if (llm_api_key !== undefined) cfg.llm_api_key = String(llm_api_key || '').trim();
|
|
1946
|
+
if (llm_vision !== undefined) { // 视觉能力:auto=按模型条目 input 判定;on/off 强制(自定义模型名能力未知时按实际选)。不影响 provider,无需热切换
|
|
1947
|
+
const v = String(llm_vision || '').trim();
|
|
1948
|
+
if (v && !['auto', 'on', 'off'].includes(v)) return json(res, 400, { error: `视觉能力须为 auto / on / off:${v}` });
|
|
1949
|
+
cfg.llm_vision = v === 'auto' ? '' : v;
|
|
1950
|
+
}
|
|
1801
1951
|
// LLM 热切换:先应用验证,失败整体回滚(应用只是构建对象,无网络请求)
|
|
1802
1952
|
let llm_probe = null;
|
|
1803
1953
|
if ([llm_base_url, llm_model, llm_api_key].some((v) => v !== undefined)) {
|
|
@@ -1811,7 +1961,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1811
1961
|
try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
|
|
1812
1962
|
}
|
|
1813
1963
|
saveCfg();
|
|
1814
|
-
audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, llm_model: cfg.llm_model, llm_context_tokens: cfg.llm_context_tokens, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
|
|
1964
|
+
audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, llm_model: cfg.llm_model, llm_context_tokens: cfg.llm_context_tokens, llm_vision: cfg.llm_vision || 'auto', llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
|
|
1815
1965
|
// ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
|
|
1816
1966
|
let probe = { online: false, error: '' };
|
|
1817
1967
|
if ([arm_api_base, arm_token].some((v) => v !== undefined)) {
|
|
@@ -2377,7 +2527,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2377
2527
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
|
|
2378
2528
|
audit({ actor: 'panel-action', agentId, panelId, actionId, command: cmd.slice(0, 200) });
|
|
2379
2529
|
return await new Promise((resolve) => {
|
|
2380
|
-
cpExec(cmd, { cwd: ROOT, timeout: +(action.timeout_ms || 120000), maxBuffer: 1024 * 1024, env, shell:
|
|
2530
|
+
cpExec(cmd, { cwd: ROOT, timeout: +(action.timeout_ms || 120000), maxBuffer: 1024 * 1024, env, shell: SHELL }, (err, stdout, stderr) => {
|
|
2381
2531
|
const out = String(stdout || '');
|
|
2382
2532
|
audit({ actor: 'panel-action', agentId, panelId, actionId, command: cmd.slice(0, 200), ok: !err, output_tail: out.slice(-300), stderr_tail: String(stderr || '').slice(-200), err: err ? err.message.slice(0, 150) : undefined }); // 结果入审计:可查证
|
|
2383
2533
|
resolve(json(res, 200, { ok: !err, output: out.slice(0, 8000), error: err ? String(err.message).slice(0, 500) : String(stderr || '').slice(0, 1000) }));
|
|
@@ -2632,9 +2782,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
2632
2782
|
if (req.method === 'GET' && pathname === '/api/state') {
|
|
2633
2783
|
return json(res, 200, { devices: world.devices, alarms: world.alarms, kpi: world.kpi, work_orders: world.work_orders, inspections: world.inspections });
|
|
2634
2784
|
}
|
|
2785
|
+
// ── 对话附件:上传 / 回传 / 删除——只在 <工作区>/attachments/ 内读写(助手 × 工作区隔离;路径困限见 attachAbs)──
|
|
2786
|
+
if (pathname === '/api/attachments' || pathname === '/api/attachments/file') {
|
|
2787
|
+
const agentId = String(url.searchParams.get('agentId') || '');
|
|
2788
|
+
if (!/^[\w.-]{1,80}$/.test(agentId)) return json(res, 400, { error: 'agentId 不合法' });
|
|
2789
|
+
const ws = getWs(agentId, url.searchParams.get('ws_id') || undefined) || currentWs(agentId); // 与 ensureSession 同口径:指定工作区不存在退回当前工作区
|
|
2790
|
+
if (pathname === '/api/attachments/file' && req.method === 'GET') {
|
|
2791
|
+
const rel = url.searchParams.get('rel');
|
|
2792
|
+
const abs = attachAbs(ws, rel);
|
|
2793
|
+
if (!abs || !fs.existsSync(abs)) return json(res, 404, { error: '附件不存在' });
|
|
2794
|
+
const name = path.basename(abs).replace(/^\d{6}-[0-9a-f]{4}-/, '');
|
|
2795
|
+
res.writeHead(200, { 'Content-Type': attachMime('', abs), 'Content-Length': fs.statSync(abs).size, 'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(name)}`, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'private, max-age=3600' });
|
|
2796
|
+
return fs.createReadStream(abs).pipe(res);
|
|
2797
|
+
}
|
|
2798
|
+
if (pathname === '/api/attachments' && req.method === 'DELETE') {
|
|
2799
|
+
const rel = url.searchParams.get('rel');
|
|
2800
|
+
const abs = attachAbs(ws, rel);
|
|
2801
|
+
if (!abs) return json(res, 400, { error: '附件路径不合法(仅允许工作区 attachments/ 内)' });
|
|
2802
|
+
if (fs.existsSync(abs)) fs.unlinkSync(abs);
|
|
2803
|
+
audit({ actor: 'attachment', agentId, ws: ws.id, action: 'delete', rel });
|
|
2804
|
+
return json(res, 200, { ok: true });
|
|
2805
|
+
}
|
|
2806
|
+
if (pathname === '/api/attachments' && req.method === 'POST') {
|
|
2807
|
+
let buf;
|
|
2808
|
+
try { buf = await rawBody(req, ATTACH_MAX_BYTES); } catch (e) { return json(res, e.status || 400, { error: e.message }); }
|
|
2809
|
+
if (!buf.length) return json(res, 400, { error: '空文件' });
|
|
2810
|
+
let name = ''; try { name = decodeURIComponent(String(req.headers['x-file-name'] || '')); } catch { /* 非法编码按无名处理 */ }
|
|
2811
|
+
name = name || `file-${Date.now()}`;
|
|
2812
|
+
const att = saveAttachment(ws, name, attachMime(req.headers['content-type'], name), buf);
|
|
2813
|
+
audit({ actor: 'attachment', agentId, ws: ws.id, action: 'upload', rel: att.rel, size: att.size, mime: att.mime, kind: att.kind });
|
|
2814
|
+
return json(res, 200, { attachment: att, vision: llmVision() }); // vision 供前端提示"当前模型不支持看图"
|
|
2815
|
+
}
|
|
2816
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
2817
|
+
}
|
|
2635
2818
|
// ── 对话(SSE 流式;会话绑定工作区:exec cwd / 数据集库 / 上下文随工作区走)──
|
|
2636
2819
|
if (req.method === 'POST' && pathname === '/api/chat') {
|
|
2637
|
-
const { sessionId, agentId, message, autonomyLevel, context, ws_id } = await body(req);
|
|
2820
|
+
const { sessionId, agentId, message, autonomyLevel, context, ws_id, attachments } = await body(req);
|
|
2638
2821
|
const emit = sseInit(req, res);
|
|
2639
2822
|
const ctl = new AbortController();
|
|
2640
2823
|
let finished = false;
|
|
@@ -2649,7 +2832,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2649
2832
|
emit('error', { message: '有一项写操作等待人工确认,请先处理对话中的确认卡(或点取消)' });
|
|
2650
2833
|
return res.end();
|
|
2651
2834
|
}
|
|
2652
|
-
|
|
2835
|
+
// 附件按 rel 从会话所绑定的工作区重读编进消息(图片进多模态块 / 文本内联 / 其余给路径);userText+attachments 挂在消息上供历史回放
|
|
2836
|
+
const ws = getWs(agentId, s.wsId) || currentWs(agentId);
|
|
2837
|
+
const um = composeUserMessage(ws, context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, attachments);
|
|
2838
|
+
s.context.messages.push({ role: 'user', content: um.content, timestamp: Date.now(), ...(um.attachments.length ? { userText: String(message || ''), attachments: um.attachments } : {}) });
|
|
2839
|
+
pruneOldImages(s.context);
|
|
2653
2840
|
const dropped = trimContext(s.context); // 主对话 sessionId 同样固定(chat-<agentId>-<wsId>),不裁剪会同样撑死
|
|
2654
2841
|
if (dropped > 0) emit('status', { text: `会话历史过长,已自动裁剪最早 ${dropped} 条消息` });
|
|
2655
2842
|
s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
|
|
@@ -2718,7 +2905,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2718
2905
|
}
|
|
2719
2906
|
const msgs = [];
|
|
2720
2907
|
for (const m of s.context.messages) {
|
|
2721
|
-
if (m.role === 'user') {
|
|
2908
|
+
if (m.role === 'user') { // 带附件的消息 content 含附件说明/图片块,展示用 userText 原文 + attachments 元数据
|
|
2909
|
+
const text = m.userText ?? (typeof m.content === 'string' ? m.content : (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join(''));
|
|
2910
|
+
msgs.push({ kind: 'user', text, ...(m.attachments?.length ? { attachments: m.attachments } : {}) }); continue;
|
|
2911
|
+
}
|
|
2722
2912
|
if (!Array.isArray(m.content)) continue;
|
|
2723
2913
|
const text = m.content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
2724
2914
|
if (text) msgs.push({ kind: 'bot', text });
|
|
@@ -2837,20 +3027,100 @@ if (process.argv.includes('--test-guard')) {
|
|
|
2837
3027
|
['exec', 0, { command: 'sudo -u aims npm install -g x' }, 'blocked'],
|
|
2838
3028
|
['exec', 2, { command: "sudo -u aims bash -c 'sudo reboot'" }, 'blocked'],
|
|
2839
3029
|
['exec', 2, { command: 'sudo apt install -y x' }, 'blocked'],
|
|
3030
|
+
['exec', 0, { command: 'grep -i Reboot /var/log/syslog' }, 'ok'], // POSIX deny 仍区分大小写(win32 才忽略):非 Windows 行为零变化
|
|
2840
3031
|
);
|
|
2841
3032
|
let pass = true;
|
|
2842
|
-
|
|
2843
|
-
const
|
|
2844
|
-
|
|
2845
|
-
|
|
3033
|
+
const runCases = (list) => {
|
|
3034
|
+
for (const [tool, lv, args, want] of list) {
|
|
3035
|
+
const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;
|
|
3036
|
+
console.log(`${ok ? '✅' : '❌'} guard(${tool}, L${lv}, ${String(args.command || '').slice(0, 30)}) -> ${g.status}(期望 ${want})`);
|
|
3037
|
+
}
|
|
3038
|
+
};
|
|
3039
|
+
runCases(cases); // POSIX 组:按当前平台语法判定 + POSIX 默认清单(classifyExec 每次现读配置,须在切换 Windows 前跑完)
|
|
3040
|
+
// Windows 组:切到 win32 判定 + win32 默认清单(与真实平台无关,任何平台都跑这组)
|
|
3041
|
+
EXEC_WIN = true; cfg.guard = normalizeGuard({ sudo_u_l2: true }, true);
|
|
3042
|
+
console.log('—— Windows(cmd/PowerShell)语法与清单 ——');
|
|
3043
|
+
runCases([
|
|
3044
|
+
['exec', 0, { command: 'dir /b C:\\work' }, 'ok'],
|
|
3045
|
+
['exec', 0, { command: 'tasklist /v | findstr aims' }, 'ok'],
|
|
3046
|
+
['exec', 0, { command: 'ipconfig /all & netstat -ano' }, 'ok'], // cmd 的 & 无条件分隔:两段都须只读
|
|
3047
|
+
['exec', 0, { command: 'ping -n 1 192.0.2.1 2>&1' }, 'ok'], // fd 复制重定向不被当成 & 分隔
|
|
3048
|
+
['exec', 0, { command: 'C:\\Windows\\System32\\ipconfig.exe /all' }, 'ok'], // 全路径 + .exe 归一为命令名
|
|
3049
|
+
['exec', 0, { command: 'Get-Process | Select-String aims' }, 'ok'], // PowerShell cmdlet,大小写不敏感
|
|
3050
|
+
['exec', 0, { command: 'ls -la && cat package.json' }, 'ok'], // Git Bash 场景:POSIX 清单在 win32 仍有效
|
|
3051
|
+
['exec', 2, { command: 'echo hi > C:\\tmp\\a.txt' }, 'pending'], // 重定向=写
|
|
3052
|
+
['exec', 2, { command: 'dir | Tee-Object out.txt' }, 'pending'], // Tee-Object 视同 tee
|
|
3053
|
+
['exec', 2, { command: 'del /f /s /q C:\\Windows\\Temp\\x' }, 'pending'], // 递归删除但非盘符根:L2 确认(对齐 rm -rf /tmp/x)
|
|
3054
|
+
['exec', 2, { command: 'net user guest /delete' }, 'pending'], // 多行为动词不在白名单 → 写
|
|
3055
|
+
['exec', 0, { command: 'wmic process list brief' }, 'blocked'], // L0 非只读白名单 → 拦截
|
|
3056
|
+
// deny 误杀面:路径参数里的 X:\ 与 format 子命令/参数名不得命中(写命令按 L2 确认即可,不能永拒)
|
|
3057
|
+
['exec', 2, { command: 'dotnet format C:\\proj\\x.sln' }, 'pending'],
|
|
3058
|
+
['exec', 2, { command: 'git log --format=%H C:\\repo' }, 'pending'],
|
|
3059
|
+
['exec', 2, { command: 'Get-Process | Format-Table Name, CPU' }, 'pending'],
|
|
3060
|
+
['exec', 2, { command: 'Remove-Item .\\dist -Recurse -Force' }, 'pending'],
|
|
3061
|
+
['exec', 2, { command: 'rd /s /q "C:\\work\\build"' }, 'pending'],
|
|
3062
|
+
['exec', 0, { command: 'format c: /q' }, 'blocked'],
|
|
3063
|
+
['exec', 0, { command: 'format /q d:' }, 'blocked'], // 裸盘符在 flag 之后
|
|
3064
|
+
['exec', 0, { command: 'FORMAT D:' }, 'blocked'], // deny 忽略大小写
|
|
3065
|
+
['exec', 3, { command: 'Format-Volume -DriveLetter D' }, 'blocked'],
|
|
3066
|
+
['exec', 0, { command: 'rd /s /q C:\\' }, 'blocked'],
|
|
3067
|
+
['exec', 0, { command: 'Remove-Item C:\\ -Recurse -Force' }, 'blocked'],
|
|
3068
|
+
['exec', 0, { command: 'diskpart' }, 'blocked'],
|
|
3069
|
+
['exec', 3, { command: 'Stop-Computer' }, 'blocked'], // deny 任何级别不豁免
|
|
3070
|
+
['exec', 3, { command: 'del x.txt' }, 'ok'], // L3 完全自主:写命令免确认
|
|
3071
|
+
]);
|
|
2846
3072
|
console.log(pass ? '守护栏单元测试全部通过' : '守护栏单元测试存在失败');
|
|
2847
3073
|
process.exit(pass ? 0 : 1);
|
|
2848
3074
|
}
|
|
2849
3075
|
|
|
3076
|
+
// ── 单测: node server.js --test-attachments(附件落盘/路径困限/消息组装/图片裁剪,临时工作区,不碰 LLM)──
|
|
3077
|
+
if (process.argv.includes('--test-attachments')) {
|
|
3078
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aims-att-'));
|
|
3079
|
+
const ws = { id: 'default', path: tmp };
|
|
3080
|
+
let pass = true;
|
|
3081
|
+
const ok = (name, cond) => { if (!cond) pass = false; console.log(`${cond ? '✅' : '❌'} ${name}`); };
|
|
3082
|
+
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
|
|
3083
|
+
const a1 = saveAttachment(ws, '报错截图.png', 'image/png', png);
|
|
3084
|
+
const a2 = saveAttachment(ws, 'app.log', 'text/plain', Buffer.from('line1\nERROR boom\n'));
|
|
3085
|
+
const a3 = saveAttachment(ws, 'big.log', 'text/plain', Buffer.alloc(20 * 1024, 0x61));
|
|
3086
|
+
const a4 = saveAttachment(ws, 'weird.txt', 'text/plain', Buffer.from([0x61, 0x00, 0x62]));
|
|
3087
|
+
const a5 = saveAttachment(ws, '../../evil.sh', 'application/octet-stream', Buffer.from('#!/bin/sh'));
|
|
3088
|
+
ok('图片 kind=image', a1.kind === 'image');
|
|
3089
|
+
ok('文本 kind=text', a2.kind === 'text');
|
|
3090
|
+
ok('头部含 NUL 的 .txt 判为 binary', a4.kind === 'binary');
|
|
3091
|
+
ok('扩展名推断 MIME(客户端给兜底值时)', a5.mime === 'application/octet-stream' && attachMime('application/octet-stream', 'x.png') === 'image/png');
|
|
3092
|
+
ok('文件名 ../ 被清洗、仍落在 attachments/ 内', !a5.rel.includes('..') && !!attachAbs(ws, a5.rel) && fs.existsSync(path.join(tmp, ...a5.rel.split('/'))));
|
|
3093
|
+
ok('落盘位置 = <工作区>/attachments/<日期>/', /^attachments\/\d{4}-\d{2}-\d{2}\/\d{6}-[0-9a-f]{4}-/.test(a1.rel) && fs.existsSync(path.join(tmp, ...a1.rel.split('/'))));
|
|
3094
|
+
ok('rel 穿越/越界一律拒绝', [`../registry.json`, `attachments/../registry.json`, `/etc/passwd`, `C:/x`, `registry.json`, `attachments`, ``, `attachments//x`].every((r) => attachAbs(ws, r) === null));
|
|
3095
|
+
cfg.llm_vision = 'on';
|
|
3096
|
+
const m1 = composeUserMessage(ws, '看下这个报错', [{ rel: a1.rel }, { rel: a2.rel }, { rel: a3.rel }, { rel: '../registry.json' }]);
|
|
3097
|
+
ok('视觉开:content=数组,首块文本 + 1 个 image 块;非法 rel 被忽略', Array.isArray(m1.content) && m1.content[0].type === 'text' && m1.content.filter((b) => b.type === 'image').length === 1 && m1.attachments.length === 3);
|
|
3098
|
+
ok('小文本全文内联', m1.content[0].text.includes('ERROR boom') && m1.content[0].text.includes(a2.rel));
|
|
3099
|
+
ok('大文本只给前 2KB + exec 提示', m1.content[0].text.includes('前 2.0 KB 预览') && m1.content[0].text.includes('tail -n 200') && !m1.content[0].text.includes('a'.repeat(3000)));
|
|
3100
|
+
ok('attachments 元数据不含文件内容,userText 为原文', m1.attachments.every((a) => !('buf' in a) && a.rel && a.name) && m1.userText === '看下这个报错');
|
|
3101
|
+
cfg.llm_vision = 'off';
|
|
3102
|
+
const m2 = composeUserMessage(ws, '', [{ rel: a1.rel }]);
|
|
3103
|
+
ok('视觉关:content=纯字符串且说明不支持看图(空正文直接以附件说明开头)', typeof m2.content === 'string' && m2.content.includes('不支持看图') && m2.content.startsWith('【附件'));
|
|
3104
|
+
const m3 = composeUserMessage(ws, 'hi', []);
|
|
3105
|
+
ok('无附件:content 与原文一致(文本对话路径行为不变)', m3.content === 'hi' && m3.attachments.length === 0);
|
|
3106
|
+
cfg.llm_vision = 'on';
|
|
3107
|
+
const ctx = { messages: [] };
|
|
3108
|
+
for (let i = 0; i < 5; i++) { const u = composeUserMessage(ws, `q${i}`, [{ rel: a1.rel }]); ctx.messages.push({ role: 'user', content: u.content, attachments: u.attachments, userText: u.userText }, { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }); }
|
|
3109
|
+
pruneOldImages(ctx, 3);
|
|
3110
|
+
const withImg = ctx.messages.filter((m) => m.role === 'user' && m.content.some((b) => b.type === 'image')).length;
|
|
3111
|
+
ok('pruneOldImages 只保留最近 3 条带图消息,更早的替换为占位文本', withImg === 3 && ctx.messages[0].content.some((b) => b.type === 'text' && b.text.includes('已在前文提供') && b.text.includes(a1.rel)));
|
|
3112
|
+
ok('trimContext 图片按固定估算(500KB base64 不触发裁剪)', trimContext({ messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }, { type: 'image', data: 'A'.repeat(500000), mimeType: 'image/png' }] }] }, 10_000) === 0);
|
|
3113
|
+
cfg.llm_vision = '';
|
|
3114
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
3115
|
+
console.log(pass ? '附件单元测试全部通过' : '附件单元测试存在失败');
|
|
3116
|
+
process.exit(pass ? 0 : 1);
|
|
3117
|
+
}
|
|
3118
|
+
|
|
2850
3119
|
applyLLMConfig().then(() => {
|
|
2851
3120
|
server.listen(PORT, HOST, () => {
|
|
2852
3121
|
console.log(`[aims-workstation] http://localhost:${PORT}(绑定 ${HOST}) | 模型: ${model.id} @ ${model.baseUrl}`);
|
|
2853
3122
|
console.log(`[aims-workstation] 数据目录 AIMS_HOME: ${AIMS_HOME}`);
|
|
3123
|
+
console.log(`[aims-workstation] exec shell: ${SHELL}(${SHELL_HINT})`);
|
|
2854
3124
|
console.log(`[aims-workstation] ARM(REST): ${armApiBase()} | PAT: ${ARM_PAT ? '已加载' : '未配置'} | 已上岗助手: ${fs.readdirSync(DEPLOY_DIR).length} | 本地助手库: ${loadRegistry().agents.length}`);
|
|
2855
3125
|
});
|
|
2856
3126
|
// 已上岗助手后台物化为本地档案(best-effort;ARM 不可达时静默跳过,待手动同步)
|