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
- const GUARD_DEFAULT = {
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
- function normalizeGuard(g) {
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))] : GUARD_DEFAULT.deny.slice(),
91
- ro: Array.isArray(g.ro) ? [...new Set(g.ro.map((s) => String(s).trim()).filter(Boolean))] : GUARD_DEFAULT.ro.slice(),
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
- // 复合命令逐段判定:每个由 ; && || | 换行 分隔的段都须只读(控制关键字与 for 头部放行)
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/.test(s)) return true; // 控制关键字本身
545
- const first = s.split(/\s+/)[0].split('/').pop();
546
- return EXEC_RO.includes(first) && !(first === 'sqlite3' && SQL_WRITE.test(s));
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.split(/;|&&|\|\||\|\n?|\n/).map((s) => s.trim()).filter(Boolean);
549
- const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)|\btee\b/.test(noRedirect);
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: '通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力',
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: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
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: '终端命令执行(开发档):读白名单直接放行;写文件/跑脚本/构建调试等开发动作放行(这是你的本职);仅毁灭性命令(sudo/rm -rf //mkfs 等)被永久拒绝。用于探索 AGENT.md、写临时代码、运行测试、验证接口。全部调用入审计',
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: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
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
- const size = () => msgs.reduce((n, m) => n + JSON.stringify(m.content ?? '').length, 0);
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: GUARD_DEFAULT.deny, default_ro: GUARD_DEFAULT.ro },
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
- const newId = created?.id || created?.agent?.id;
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
- const manifest = await downloadAgent(newId); // 创建即物化本地(沿用下载链路)
1921
- const reg = loadRegistry(); const entry = reg.agents.find((x) => x.agent_id === newId); if (entry) entry.created_via_workstation = true; saveRegistry(reg); // 大厅据此显示编辑/删除入口
1922
- audit({ actor: 'user', action: 'create-agent', agentId: newId, name: nm, knowledges: bound });
1923
- return json(res, 200, { agent_id: newId, manifest, knowledges_bound: bound });
1924
- } catch (e) { return json(res, 502, { error: `创建失败: ${e.message}` }); }
1925
- }
1926
- // 编辑档案(工作站创建的助手):ARM PUT 后重新物化,本地 prompt 同步更新
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
- await armRest(`/api/v1/agents/${id}`, { method: 'DELETE' });
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 && loadRegistry().agents.some((a) => a.agent_id === id)) uninstalled = uninstallLocalAgent(id, !!purge_workspaces);
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: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
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
- s.context.messages.push({ role: 'user', content: context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, timestamp: Date.now() });
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') { msgs.push({ kind: 'user', text: String(m.content) }); continue; }
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
- for (const [tool, lv, args, want] of cases) {
2771
- const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;
2772
- console.log(`${ok ? '✅' : '❌'} guard(${tool}, L${lv}, ${String(args.command || '').slice(0, 30)}) -> ${g.status}(期望 ${want})`);
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 不可达时静默跳过,待手动同步)