aims-workstation 0.1.14 → 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/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');
|
|
@@ -396,6 +421,27 @@ async function downloadAgent(agentId) {
|
|
|
396
421
|
return manifest;
|
|
397
422
|
}
|
|
398
423
|
|
|
424
|
+
// 本地档案写入(离线创建 / 未发布档案编辑):与 downloadAgent 同构的物化结构,source 标记 local。
|
|
425
|
+
// 发布到 ARM(同 id,见 /publish)后即转正为远端档案,本地结构不变——身份全程稳定。
|
|
426
|
+
const bumpPatch = (v) => { const p = String(v || 'v1.0.0').replace(/^v/, '').split('.').map(n => +n || 0); p[2] = (p[2] || 0) + 1; return `v${p[0] || 1}.${p[1] || 0}.${p[2]}`; };
|
|
427
|
+
function writeLocalAgent(agentId, { name, description, prompt }, { bump = false } = {}) {
|
|
428
|
+
const cur = bump ? (localManifest(agentId) || {}) : {};
|
|
429
|
+
const version = bump ? bumpPatch(cur.version) : 'v1.0.0';
|
|
430
|
+
const verDir = path.join(ARM_AGENTS_DIR, agentId, 'versions', version);
|
|
431
|
+
fs.mkdirSync(path.join(verDir, 'knowledges'), { recursive: true });
|
|
432
|
+
const manifest = {
|
|
433
|
+
agent_id: agentId, name, version, description: String(description || '').slice(0, 512),
|
|
434
|
+
downloaded_at: new Date().toISOString(), source: 'local',
|
|
435
|
+
prompt: String(prompt || ''),
|
|
436
|
+
skills: cur.skills || [], knowledges: cur.knowledges || [], ui_bindings: cur.ui_bindings || [], // 未发布档案无 ARM 绑定;编辑时保留既有
|
|
437
|
+
};
|
|
438
|
+
fs.writeFileSync(path.join(verDir, 'prompt.md'), manifest.prompt);
|
|
439
|
+
fs.writeFileSync(path.join(verDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
440
|
+
commitDownload(manifest);
|
|
441
|
+
audit({ actor: 'user', agentId, action: bump ? 'update-local-agent' : 'create-local-agent', version });
|
|
442
|
+
return manifest;
|
|
443
|
+
}
|
|
444
|
+
|
|
399
445
|
// 同步:比对远端目录与本地库,产出 new/update/delisted 事件;顺带冲账离线经验 outbox
|
|
400
446
|
async function armSync() {
|
|
401
447
|
const remote = await armListAgents();
|
|
@@ -524,29 +570,146 @@ function deleteWorkspace(agentId, wsId, purge) {
|
|
|
524
570
|
return reg;
|
|
525
571
|
}
|
|
526
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
|
+
|
|
527
667
|
// ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
|
|
528
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 用例
|
|
529
687
|
function classifyExec(command, autonomy) {
|
|
530
688
|
const cmd = String(command || '').trim();
|
|
531
689
|
const g = guardNow();
|
|
690
|
+
const win = EXEC_WIN;
|
|
532
691
|
// sudo 降权特例(可配置):开启后剥掉 `sudo -u <用户>` 片段再匹配危险清单(嵌套的裸 sudo/毁灭性命令仍会命中),剩余部分按普通命令分级
|
|
533
692
|
const denyCmd = g.sudo_u_l2 ? cmd.replace(/\bsudo\s+(?:-\w+\s+)*-u\s+\S+/g, '') : cmd;
|
|
534
693
|
const denyRes = [];
|
|
535
|
-
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 保持原样
|
|
536
695
|
if (denyRes.some((re) => re.test(denyCmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(清单可在 系统设置·守护栏 维护)' };
|
|
537
696
|
const EXEC_RO = g.ro;
|
|
538
697
|
const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
|
|
539
698
|
const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
|
|
540
|
-
// 复合命令逐段判定:每个由 ; && || |
|
|
699
|
+
// 复合命令逐段判定:每个由 ; && || | 换行(win32 再加 cmd 的无条件分隔符 &)分隔的段都须只读(控制关键字与 for 头部放行)
|
|
541
700
|
const segOk = (seg) => {
|
|
542
|
-
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+/, '');
|
|
543
702
|
if (/^[A-Za-z_]\w*\s+in\b/.test(s)) return true; // for 循环头部(变量 in 值列表)
|
|
544
|
-
if (/^(for|while|until|if|case)\b
|
|
545
|
-
|
|
546
|
-
|
|
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));
|
|
547
709
|
};
|
|
548
|
-
const segments = cmd.
|
|
549
|
-
|
|
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);
|
|
550
713
|
if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
|
|
551
714
|
if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
|
|
552
715
|
return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(${EXEC_RO.slice(0, 8).join('/')}等,完整清单见 系统设置·守护栏)。写操作需 L2 并人工确认` };
|
|
@@ -554,7 +717,7 @@ function classifyExec(command, autonomy) {
|
|
|
554
717
|
const PRIMITIVE_TOOLS = {
|
|
555
718
|
exec: {
|
|
556
719
|
min_level: 0, confirm_required: false, // 确认由 classifyExec 按命令动态判定
|
|
557
|
-
desc:
|
|
720
|
+
desc: `通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力。当前 shell:${SHELL_HINT}`,
|
|
558
721
|
schema: { command: { type: 'string', description: '要执行的 shell 命令' }, timeout_ms: { type: 'number', description: '超时毫秒,默认 15000' } },
|
|
559
722
|
required: ['command'],
|
|
560
723
|
run: async ({ command, timeout_ms = 15000 }, agentId, _wsId, signal) => {
|
|
@@ -562,7 +725,7 @@ const PRIMITIVE_TOOLS = {
|
|
|
562
725
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT; // 凭据不进子进程
|
|
563
726
|
const ws = agentId ? currentWs(agentId) : null; // 文件领地锚定工作区(07 设计 §4.3)
|
|
564
727
|
return await new Promise((resolve) => {
|
|
565
|
-
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) => {
|
|
566
729
|
const out = String(stdout || '').slice(0, 4000);
|
|
567
730
|
const errOut = String(stderr || '').slice(0, 1500);
|
|
568
731
|
audit({ actor: 'assistant', tool: 'exec', command: String(command).slice(0, 200), ok: !err, ws: ws?.id });
|
|
@@ -1320,7 +1483,7 @@ const DEV_TOOLS = {
|
|
|
1320
1483
|
},
|
|
1321
1484
|
},
|
|
1322
1485
|
exec: {
|
|
1323
|
-
desc:
|
|
1486
|
+
desc: `终端命令执行(开发档):读白名单直接放行;写文件/跑脚本/构建调试等开发动作放行(这是你的本职);仅毁灭性命令(sudo/rm -rf //mkfs 等)被永久拒绝。用于探索 AGENT.md、写临时代码、运行测试、验证接口。全部调用入审计。当前 shell:${SHELL_HINT}`,
|
|
1324
1487
|
schema: { command: { type: 'string', description: '只读命令,如 grep -rl "Nexus" <目录>/*/AGENT.md' }, timeout_ms: { type: 'number' } },
|
|
1325
1488
|
required: ['command'],
|
|
1326
1489
|
run: async ({ command, timeout_ms = 30000 }) => {
|
|
@@ -1330,7 +1493,7 @@ const DEV_TOOLS = {
|
|
|
1330
1493
|
const env = { ...process.env };
|
|
1331
1494
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
|
|
1332
1495
|
return await new Promise((resolve) => {
|
|
1333
|
-
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) => {
|
|
1334
1497
|
audit({ actor: 'dev-agent', tool: 'exec', command: String(command).slice(0, 200), ok: !err });
|
|
1335
1498
|
resolve(`$ ${command}\n${String(stdout || '').slice(0, 5000)}${stderr ? '\n[stderr] ' + String(stderr).slice(0, 1000) : ''}${err && !stdout ? '\n[错误] ' + err.message : ''}`);
|
|
1336
1499
|
});
|
|
@@ -1376,7 +1539,9 @@ const ctxCharBudget = () => Math.max(4_000, Math.floor((llmContextTokens() - 16_
|
|
|
1376
1539
|
// 超预算从最旧轮次整轮丢弃;下刀点只选 user 消息边界,剩余前缀不会出现孤儿 toolResult(转换层要求其紧跟 assistant 的 toolCall);当前轮永不裁。
|
|
1377
1540
|
function trimContext(ctx, budget = ctxCharBudget()) {
|
|
1378
1541
|
const msgs = ctx.messages;
|
|
1379
|
-
|
|
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);
|
|
1380
1545
|
if (size() <= budget) return 0;
|
|
1381
1546
|
const lastUser = msgs.length - 1 - [...msgs].reverse().findIndex((m) => m.role === 'user');
|
|
1382
1547
|
let dropped = 0;
|
|
@@ -1718,14 +1883,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1718
1883
|
key_set: !!k, key_masked: k ? `${k.slice(0, 6)}…${k.slice(-4)}` : '',
|
|
1719
1884
|
key_source: cfg.llm_api_key ? 'runtime' : (env.LLM_API_KEY ? 'env' : 'none'),
|
|
1720
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')),
|
|
1721
1887
|
},
|
|
1722
1888
|
scene: { profile: sceneProfile(), length: sceneProfile().length },
|
|
1723
1889
|
auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
|
|
1724
|
-
guard: { ...guardNow(), default_deny:
|
|
1890
|
+
guard: { ...guardNow(), default_deny: guardDefaults().deny, default_ro: guardDefaults().ro },
|
|
1725
1891
|
});
|
|
1726
1892
|
}
|
|
1727
1893
|
if (req.method === 'PUT' && pathname === '/api/settings') {
|
|
1728
|
-
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);
|
|
1729
1895
|
// 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
|
|
1730
1896
|
let guardAudit = null;
|
|
1731
1897
|
if (guard !== undefined) {
|
|
@@ -1777,6 +1943,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1777
1943
|
}
|
|
1778
1944
|
}
|
|
1779
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
|
+
}
|
|
1780
1951
|
// LLM 热切换:先应用验证,失败整体回滚(应用只是构建对象,无网络请求)
|
|
1781
1952
|
let llm_probe = null;
|
|
1782
1953
|
if ([llm_base_url, llm_model, llm_api_key].some((v) => v !== undefined)) {
|
|
@@ -1790,7 +1961,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1790
1961
|
try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
|
|
1791
1962
|
}
|
|
1792
1963
|
saveCfg();
|
|
1793
|
-
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 });
|
|
1794
1965
|
// ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
|
|
1795
1966
|
let probe = { online: false, error: '' };
|
|
1796
1967
|
if ([arm_api_base, arm_token].some((v) => v !== undefined)) {
|
|
@@ -1842,6 +2013,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1842
2013
|
update_available: !!(e.remote_version && cmpVersion(e.remote_version, e.installed_version) > 0),
|
|
1843
2014
|
delisted: !!e.delisted, downloaded_at: e.downloaded_at || null,
|
|
1844
2015
|
created_by_id: null, created_by: null, // 属主身份以远端目录为准(离线/已下架不可编辑,无需属主)
|
|
2016
|
+
local_only: !!e.local_only, // 本地未发布档案(离线创建):大厅显示「未发布」徽章 + 发布入口
|
|
1845
2017
|
});
|
|
1846
2018
|
}
|
|
1847
2019
|
if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)——仅在完整拉取时;
|
|
@@ -1908,24 +2080,36 @@ const server = http.createServer(async (req, res) => {
|
|
|
1908
2080
|
const { name, description, prompt, knowledge_ids } = await body(req);
|
|
1909
2081
|
const nm = String(name || '').trim(), pr = String(prompt || '').trim();
|
|
1910
2082
|
if (!nm || !pr) return json(res, 400, { error: '名称与 System Prompt 必填' });
|
|
2083
|
+
// 身份本地生成并随创建上行(新版 ARM 保持同 id;旧版忽略后以其返回 id 为准,行为不变)
|
|
2084
|
+
const localId = crypto.randomUUID();
|
|
2085
|
+
let newId, bound = 0, local = false;
|
|
1911
2086
|
try {
|
|
1912
|
-
const created = await armRest('/api/v1/agents', { method: 'POST', body: JSON.stringify({ name: nm, description: String(description || '').trim().slice(0, 512), prompt: pr, status: 'active' }) });
|
|
1913
|
-
|
|
2087
|
+
const created = await armRest('/api/v1/agents', { method: 'POST', body: JSON.stringify({ id: localId, name: nm, description: String(description || '').trim().slice(0, 512), prompt: pr, status: 'active' }) });
|
|
2088
|
+
newId = created?.id || created?.agent?.id;
|
|
1914
2089
|
if (!newId) throw new Error('ARM 未返回新助手 id');
|
|
1915
|
-
let bound = 0;
|
|
1916
2090
|
for (const kid of (Array.isArray(knowledge_ids) ? knowledge_ids : [])) { // 绑定幂等(重复绑定返回已有版本)
|
|
1917
2091
|
try { await armRest(`/api/v1/agents/${newId}/knowledges`, { method: 'POST', body: JSON.stringify({ knowledgeId: kid, kind: 'experience' }) }); bound++; }
|
|
1918
2092
|
catch { /* 单条绑定失败不阻断创建 */ }
|
|
1919
2093
|
}
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
return json(res,
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
2094
|
+
} catch (e) {
|
|
2095
|
+
// 离线降级:仅网络不可达(ARM 请求失败/超时)时本地创建(07 离线理念:出生不依赖 ARM);
|
|
2096
|
+
// 业务错误(如名称已存在)照常上抛——那种情况下静默转本地只会掩盖问题。
|
|
2097
|
+
if (!/^ARM 请求失败/.test(String(e.message))) return json(res, 502, { error: `创建失败: ${e.message}` });
|
|
2098
|
+
newId = localId; local = true;
|
|
2099
|
+
}
|
|
2100
|
+
const manifest = local
|
|
2101
|
+
? writeLocalAgent(newId, { name: nm, description: String(description || '').trim(), prompt: pr })
|
|
2102
|
+
: await downloadAgent(newId); // 创建即物化本地(沿用下载链路)
|
|
2103
|
+
const reg = loadRegistry(); const entry = reg.agents.find((x) => x.agent_id === newId);
|
|
2104
|
+
if (entry) { entry.created_via_workstation = true; if (local) entry.local_only = true; } saveRegistry(reg);
|
|
2105
|
+
audit({ actor: 'user', action: local ? 'create-agent-local' : 'create-agent', agentId: newId, name: nm, knowledges: bound });
|
|
2106
|
+
return json(res, 200, { agent_id: newId, manifest, knowledges_bound: bound, local });
|
|
2107
|
+
}
|
|
2108
|
+
// 编辑档案(工作站创建的助手):ARM PUT 后重新物化,本地 prompt 同步更新;未发布档案仅写本地(版本前进)
|
|
1927
2109
|
if (req.method === 'GET' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/profile$/)) {
|
|
1928
2110
|
const id = pathname.split('/')[3];
|
|
2111
|
+
const le = loadRegistry().agents.find((x) => x.agent_id === id);
|
|
2112
|
+
if (le && le.local_only) { const m = localManifest(id) || {}; return json(res, 200, { name: m.name || le.name, description: String(m.description || ''), prompt: m.prompt || '' }); }
|
|
1929
2113
|
try {
|
|
1930
2114
|
const d = await armRest(`/api/v1/agents/${id}`);
|
|
1931
2115
|
return json(res, 200, { name: d.name, description: String(d.description || ''), prompt: d.prompt || '' });
|
|
@@ -1939,6 +2123,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1939
2123
|
if (description !== undefined) patch.description = String(description || '').trim().slice(0, 512);
|
|
1940
2124
|
if (String(prompt || '').trim()) patch.prompt = String(prompt).trim();
|
|
1941
2125
|
if (!Object.keys(patch).length) return json(res, 400, { error: '没有可更新字段' });
|
|
2126
|
+
const le = loadRegistry().agents.find((x) => x.agent_id === id);
|
|
2127
|
+
if (le && le.local_only) { // 未发布档案:仅写本地(版本前进可回滚),发布后编辑将同步 ARM
|
|
2128
|
+
const cur = localManifest(id) || {};
|
|
2129
|
+
const manifest = writeLocalAgent(id, { name: patch.name || cur.name, description: patch.description ?? cur.description, prompt: patch.prompt || cur.prompt }, { bump: true });
|
|
2130
|
+
return json(res, 200, { ok: true, manifest, local: true });
|
|
2131
|
+
}
|
|
1942
2132
|
try {
|
|
1943
2133
|
await armRest(`/api/v1/agents/${id}`, { method: 'PUT', body: JSON.stringify(patch) });
|
|
1944
2134
|
let manifest = null;
|
|
@@ -1957,6 +2147,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1957
2147
|
}
|
|
1958
2148
|
let mBind = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/bindings$/);
|
|
1959
2149
|
if (mBind && req.method === 'GET') {
|
|
2150
|
+
const le = loadRegistry().agents.find((x) => x.agent_id === mBind[1]);
|
|
2151
|
+
if (le && le.local_only) return json(res, 200, { skills: [], knowledges: [], local_only: true }); // 未发布档案无 ARM 绑定
|
|
1960
2152
|
try {
|
|
1961
2153
|
const d = await armRest(`/api/v1/agents/${mBind[1]}`);
|
|
1962
2154
|
return json(res, 200, {
|
|
@@ -2011,18 +2203,48 @@ const server = http.createServer(async (req, res) => {
|
|
|
2011
2203
|
return json(res, 200, { agent_id: newId, name: nm, copied, manifest: { agent_id: manifest.agent_id, name: manifest.name, version: manifest.version } });
|
|
2012
2204
|
} catch (e) { return json(res, 502, { error: `克隆失败: ${e.message}` }); }
|
|
2013
2205
|
}
|
|
2014
|
-
// 从 ARM
|
|
2206
|
+
// 从 ARM 删除(危险操作,前端二次确认):可选同时卸载本地档案;未发布档案只存在于本地,直接卸载
|
|
2015
2207
|
if (req.method === 'POST' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/arm-delete$/)) {
|
|
2016
2208
|
const id = pathname.split('/')[3];
|
|
2017
2209
|
const { also_uninstall, purge_workspaces } = await body(req);
|
|
2018
2210
|
try {
|
|
2019
|
-
|
|
2211
|
+
const le = loadRegistry().agents.find((x) => x.agent_id === id);
|
|
2212
|
+
if (!(le && le.local_only)) await armRest(`/api/v1/agents/${id}`, { method: 'DELETE' });
|
|
2020
2213
|
let uninstalled = false;
|
|
2021
|
-
if (also_uninstall &&
|
|
2022
|
-
audit({ actor: 'user', action: 'arm-delete-agent', agentId: id, also_uninstalled: uninstalled });
|
|
2214
|
+
if (also_uninstall && le) uninstalled = uninstallLocalAgent(id, !!purge_workspaces);
|
|
2215
|
+
audit({ actor: 'user', action: le && le.local_only ? 'delete-local-agent' : 'arm-delete-agent', agentId: id, also_uninstalled: uninstalled });
|
|
2023
2216
|
return json(res, 200, { ok: true, uninstalled });
|
|
2024
2217
|
} catch (e) { return json(res, 502, { error: `ARM 删除失败: ${e.message}` }); }
|
|
2025
2218
|
}
|
|
2219
|
+
// 发布到 ARM(本地未发布档案转正):同 id 上行(新版 ARM),身份不变、本地 deployment/工作台/数据零迁移。
|
|
2220
|
+
// 幂等:同 id 已在 ARM(上次发布部分成功)→ 校验同名即续走转正;旧版 ARM 忽略 id 返回新 id → 明确报错提示升级。
|
|
2221
|
+
if (req.method === 'POST' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/publish$/)) {
|
|
2222
|
+
const id = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/publish$/)[1];
|
|
2223
|
+
const reg = loadRegistry();
|
|
2224
|
+
const le = reg.agents.find((x) => x.agent_id === id);
|
|
2225
|
+
if (!le || !le.local_only) return json(res, 400, { error: '该助手不是本地未发布档案(可能已发布)' });
|
|
2226
|
+
const m = localManifest(id);
|
|
2227
|
+
if (!m) return json(res, 400, { error: '本地档案缺失,请先重新创建' });
|
|
2228
|
+
try {
|
|
2229
|
+
let created;
|
|
2230
|
+
try {
|
|
2231
|
+
created = await armRest('/api/v1/agents', { method: 'POST', body: JSON.stringify({ id, name: m.name, description: String(m.description || '').slice(0, 512), prompt: m.prompt || '', status: 'active' }) });
|
|
2232
|
+
} catch (e) {
|
|
2233
|
+
if (/该 id 已存在/.test(String(e.message))) { // 幂等续走:确认同 id 同名即视为已创建
|
|
2234
|
+
const d = await armRest(`/api/v1/agents/${id}`);
|
|
2235
|
+
if (!d || d.name !== m.name) throw e;
|
|
2236
|
+
created = d;
|
|
2237
|
+
} else throw e;
|
|
2238
|
+
}
|
|
2239
|
+
const finalId = created?.id || created?.agent?.id;
|
|
2240
|
+
if (finalId && finalId !== id) return json(res, 502, { error: '当前 ARM 版本不支持保持助手身份(忽略客户端 id)——请升级 ARM 后再发布' });
|
|
2241
|
+
const manifest = await downloadAgent(id); // 重新物化:source 转正、版本对齐 ARM
|
|
2242
|
+
const reg2 = loadRegistry(); const en = reg2.agents.find((x) => x.agent_id === id);
|
|
2243
|
+
if (en) { en.local_only = false; en.remote_version = manifest.version; } saveRegistry(reg2);
|
|
2244
|
+
audit({ actor: 'user', action: 'publish-agent', agentId: id, name: m.name, version: manifest.version });
|
|
2245
|
+
return json(res, 200, { ok: true, manifest });
|
|
2246
|
+
} catch (e) { return json(res, 502, { error: `发布失败: ${e.message}` }); }
|
|
2247
|
+
}
|
|
2026
2248
|
// ── 本地助手库:下载 / 升级 / 版本历史 / 回滚 / 卸载 ──
|
|
2027
2249
|
let mAgt = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/(download|upgrade|versions|rollback)$/);
|
|
2028
2250
|
if (mAgt && req.method === 'POST' && (mAgt[2] === 'download' || mAgt[2] === 'upgrade')) {
|
|
@@ -2305,7 +2527,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2305
2527
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
|
|
2306
2528
|
audit({ actor: 'panel-action', agentId, panelId, actionId, command: cmd.slice(0, 200) });
|
|
2307
2529
|
return await new Promise((resolve) => {
|
|
2308
|
-
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) => {
|
|
2309
2531
|
const out = String(stdout || '');
|
|
2310
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 }); // 结果入审计:可查证
|
|
2311
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) }));
|
|
@@ -2560,9 +2782,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
2560
2782
|
if (req.method === 'GET' && pathname === '/api/state') {
|
|
2561
2783
|
return json(res, 200, { devices: world.devices, alarms: world.alarms, kpi: world.kpi, work_orders: world.work_orders, inspections: world.inspections });
|
|
2562
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
|
+
}
|
|
2563
2818
|
// ── 对话(SSE 流式;会话绑定工作区:exec cwd / 数据集库 / 上下文随工作区走)──
|
|
2564
2819
|
if (req.method === 'POST' && pathname === '/api/chat') {
|
|
2565
|
-
const { sessionId, agentId, message, autonomyLevel, context, ws_id } = await body(req);
|
|
2820
|
+
const { sessionId, agentId, message, autonomyLevel, context, ws_id, attachments } = await body(req);
|
|
2566
2821
|
const emit = sseInit(req, res);
|
|
2567
2822
|
const ctl = new AbortController();
|
|
2568
2823
|
let finished = false;
|
|
@@ -2577,7 +2832,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2577
2832
|
emit('error', { message: '有一项写操作等待人工确认,请先处理对话中的确认卡(或点取消)' });
|
|
2578
2833
|
return res.end();
|
|
2579
2834
|
}
|
|
2580
|
-
|
|
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);
|
|
2581
2840
|
const dropped = trimContext(s.context); // 主对话 sessionId 同样固定(chat-<agentId>-<wsId>),不裁剪会同样撑死
|
|
2582
2841
|
if (dropped > 0) emit('status', { text: `会话历史过长,已自动裁剪最早 ${dropped} 条消息` });
|
|
2583
2842
|
s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
|
|
@@ -2646,7 +2905,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2646
2905
|
}
|
|
2647
2906
|
const msgs = [];
|
|
2648
2907
|
for (const m of s.context.messages) {
|
|
2649
|
-
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
|
+
}
|
|
2650
2912
|
if (!Array.isArray(m.content)) continue;
|
|
2651
2913
|
const text = m.content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
2652
2914
|
if (text) msgs.push({ kind: 'bot', text });
|
|
@@ -2765,20 +3027,100 @@ if (process.argv.includes('--test-guard')) {
|
|
|
2765
3027
|
['exec', 0, { command: 'sudo -u aims npm install -g x' }, 'blocked'],
|
|
2766
3028
|
['exec', 2, { command: "sudo -u aims bash -c 'sudo reboot'" }, 'blocked'],
|
|
2767
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 行为零变化
|
|
2768
3031
|
);
|
|
2769
3032
|
let pass = true;
|
|
2770
|
-
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2773
|
-
|
|
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
|
+
]);
|
|
2774
3072
|
console.log(pass ? '守护栏单元测试全部通过' : '守护栏单元测试存在失败');
|
|
2775
3073
|
process.exit(pass ? 0 : 1);
|
|
2776
3074
|
}
|
|
2777
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
|
+
|
|
2778
3119
|
applyLLMConfig().then(() => {
|
|
2779
3120
|
server.listen(PORT, HOST, () => {
|
|
2780
3121
|
console.log(`[aims-workstation] http://localhost:${PORT}(绑定 ${HOST}) | 模型: ${model.id} @ ${model.baseUrl}`);
|
|
2781
3122
|
console.log(`[aims-workstation] 数据目录 AIMS_HOME: ${AIMS_HOME}`);
|
|
3123
|
+
console.log(`[aims-workstation] exec shell: ${SHELL}(${SHELL_HINT})`);
|
|
2782
3124
|
console.log(`[aims-workstation] ARM(REST): ${armApiBase()} | PAT: ${ARM_PAT ? '已加载' : '未配置'} | 已上岗助手: ${fs.readdirSync(DEPLOY_DIR).length} | 本地助手库: ${loadRegistry().agents.length}`);
|
|
2783
3125
|
});
|
|
2784
3126
|
// 已上岗助手后台物化为本地档案(best-effort;ARM 不可达时静默跳过,待手动同步)
|