aims-workstation 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,8 +9,9 @@
9
9
  - **工作台**:每个助手一个专属操作面;区块自由布局,**对话式开发面板插件**(描述需求 → 二开助手生成 → 草稿审阅 → 发布)
10
10
  - **工作区**:助手 1:N 工作区(文件现场隔离),`exec` 锚定工作区目录,数据集按工作区分库
11
11
  - **分级自主**:L0 只读 → L2 确认执行(写操作挂起人工确认)→ L3 自主,危险命令永久拒绝;全程审计
12
- - **并行会话**:多助手并行、后台运行进度跟踪、确认卡跨切换恢复
13
- - **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像,界面配置即时生效
12
+ - **守护栏可维护**:危险命令清单(正则)与只读白名单在「系统设置·守护栏」增删,保存即热生效;`sudo -u <用户>` 降权形式可配置为 L2 确认执行;删减危险规则需二次确认并写审计
13
+ - **并行会话**:多助手并行、后台运行进度跟踪、确认卡跨切换恢复;对话可随时中断(停止按钮/状态行中断,执行中的命令一并终止,部分回复保留进上下文可续聊)
14
+ - **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像 / 守护栏策略,界面配置即时生效
14
15
 
15
16
  ## 快速开始
16
17
 
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "@earendil-works/pi-ai": "^1.2.0"
14
14
  },
15
15
  "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
16
- "version": "0.1.2",
16
+ "version": "0.1.4",
17
17
  "description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
18
18
  "bin": {
19
19
  "aims-workstation": "bin/aims-workstation.js"
@@ -31,4 +31,4 @@
31
31
  ".env.example",
32
32
  "README.md"
33
33
  ]
34
- }
34
+ }
package/server.js CHANGED
@@ -2,6 +2,9 @@
2
2
  // 链路: ARM员工库 → 下载物化本地 → 平台实例化(pi-ai + 工具注入 + 分级自主) → 工作台(DSL) → 经验回流ARM
3
3
  // Agent 层基于 @earendil-works/pi-ai(统一 LLM API),前端为 web/ 预构建的 shadcn/ui 应用(web/dist)
4
4
  'use strict';
5
+ // TLS 证书校验关闭(用户决策:内网私有部署自签证书常见,能访问优先)
6
+ // 覆盖所有出站 fetch(ARM REST/MCP、LLM、面板代理)。本工具面向内网/本机使用。
7
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
5
8
  const http = require('http');
6
9
  const fs = require('fs');
7
10
  const path = require('path');
@@ -74,8 +77,24 @@ const ARM_MCP_URL = process.env.ARM_MCP_URL || env.ARM_MCP_URL;
74
77
  // ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
75
78
  const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
76
79
  const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '', access_token: '' };
77
- function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } }
80
+ // 守护栏默认策略(可在「系统设置·守护栏」维护,存 platform.json,保存即热生效):deny=正则源串(命中即永久拒绝,任何级别不豁免);
81
+ // ro=只读白名单命令名(免确认直行);sudo_u_l2=true 时 `sudo -u <用户>` 降权形式不再永久拒绝、按写命令走 L2 人工确认(裸 sudo 仍拒绝)
82
+ const GUARD_DEFAULT = {
83
+ 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
+ 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
+ function normalizeGuard(g) {
88
+ g = g && typeof g === 'object' ? g : {};
89
+ 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(),
92
+ sudo_u_l2: !!g.sudo_u_l2,
93
+ };
94
+ }
95
+ function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } cfg.guard = normalizeGuard(cfg.guard); }
78
96
  loadCfg();
97
+ const guardNow = () => cfg.guard;
79
98
  function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
80
99
  // 站点地址:运行时配置 > ARM_API_BASE(env) > 从 MCP URL 推导;MCP 端点:显式配置 > 跟随站点地址 > env
81
100
  const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || String(env.ARM_MCP_URL || '').replace(/\/api\/mcp$/, '');
@@ -197,6 +216,16 @@ async function armRest(pathname, init = {}, timeout_ms = 20000) {
197
216
  const data = await res.json();
198
217
  if (!res.ok || data.ok === false) throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}`);
199
218
  return data.data;
219
+ } catch (e) {
220
+ if (e.message === 'fetch failed') { // undici 把网络层错误笼统包装,展开真实原因(DNS/连接/TLS)
221
+ const c = e.cause || {};
222
+ const reason = c.code || c.message || '未知网络错误';
223
+ const hint = /LEAF|CERT|SSL|TLS/i.test(String(reason)) ? '——HTTPS 证书校验失败(自签证书?内网解析应直连 HTTP)'
224
+ : /ENOTFOUND|EAI_AGAIN/.test(String(reason)) ? '——域名解析失败(当前 DNS 无此内网记录)'
225
+ : /ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH/.test(String(reason)) ? '——目标不可达(连接被拒/超时)' : '';
226
+ throw new Error(`ARM 请求失败: ${reason}${hint}`);
227
+ }
228
+ throw e;
200
229
  } finally { clearTimeout(timer); }
201
230
  }
202
231
  const vVer = (v) => (String(v || '').startsWith('v') ? String(v) : `v${v || '0.0.0'}`); // REST 无 v 前缀,统一内部表示
@@ -238,7 +267,7 @@ async function armListAgents(keyword = '') {
238
267
  out.push(...(d.agents || []));
239
268
  if (!out.length || out.length >= +(d.total || 0)) break;
240
269
  }
241
- return out.map(a => ({ agent_id: a.id, name: a.name, version: vVer(a.version), skills: a.skillsCount || 0, knowledges: a.knowledgesCount || 0, description: String(a.description || '').slice(0, 120), status: a.status || 'active' }));
270
+ return out.map(a => ({ agent_id: a.id, name: a.name, version: vVer(a.version), skills: a.skillsCount || 0, knowledges: a.knowledgesCount || 0, description: String(a.description || '').slice(0, 120), status: a.status || 'active', created_by: a.creatorName || a.createdBy || '' }));
242
271
  }
243
272
 
244
273
  // 知识检索(全局经验库):REST 混合检索(RAG 语义 ⊕ 关键词)优先,回落 MCP RAG
@@ -302,6 +331,19 @@ function commitDownload(manifest) {
302
331
  e.versions.sort(cmpVersion);
303
332
  saveRegistry(reg);
304
333
  }
334
+ // 本地卸载(本地 DELETE 与「从 ARM 删除并卸载」共用):上岗配置/工作台/版本目录/注册表,可选清工作区数据
335
+ function uninstallLocalAgent(agentId, purge_workspaces) {
336
+ for (const p of [path.join(DEPLOY_DIR, `${agentId}.json`), path.join(WB_DIR, `${agentId}.json`), draftPath(agentId), path.join(WB_VER_DIR, `${agentId}.json`)]) {
337
+ try { fs.rmSync(p, { force: true }); } catch { }
338
+ }
339
+ try { fs.rmSync(path.join(ARM_AGENTS_DIR, agentId), { recursive: true, force: true }); } catch { }
340
+ const reg = loadRegistry(); reg.agents = reg.agents.filter(a => a.agent_id !== agentId); saveRegistry(reg);
341
+ if (purge_workspaces) {
342
+ try { fs.rmSync(wsDir(agentId), { recursive: true, force: true }); } catch { }
343
+ for (const f of fs.readdirSync(DATA_DIR)) if (f.startsWith(`ws-${agentId}-`)) { try { fs.rmSync(path.join(DATA_DIR, f), { force: true }); } catch { } }
344
+ }
345
+ return true;
346
+ }
305
347
 
306
348
  async function downloadAgentRest(agentId) {
307
349
  const d = await armRest(`/api/v1/agents/${agentId}`);
@@ -503,11 +545,15 @@ function deleteWorkspace(agentId, wsId, purge) {
503
545
 
504
546
  // ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
505
547
  const { exec: cpExec } = require('node:child_process');
506
- const EXEC_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/];
507
- const EXEC_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'];
508
548
  function classifyExec(command, autonomy) {
509
549
  const cmd = String(command || '').trim();
510
- if (EXEC_DENY.some((re) => re.test(cmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(sudo/毁灭性操作等)' };
550
+ const g = guardNow();
551
+ // sudo 降权特例(可配置):开启后剥掉 `sudo -u <用户>` 片段再匹配危险清单(嵌套的裸 sudo/毁灭性命令仍会命中),剩余部分按普通命令分级
552
+ const denyCmd = g.sudo_u_l2 ? cmd.replace(/\bsudo\s+(?:-\w+\s+)*-u\s+\S+/g, '') : cmd;
553
+ const denyRes = [];
554
+ for (const src of g.deny) { try { denyRes.push(new RegExp(src)); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } }
555
+ if (denyRes.some((re) => re.test(denyCmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(清单可在 系统设置·守护栏 维护)' };
556
+ const EXEC_RO = g.ro;
511
557
  const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
512
558
  const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
513
559
  // 复合命令逐段判定:每个由 ; && || | 换行 分隔的段都须只读(控制关键字与 for 头部放行)
@@ -522,7 +568,7 @@ function classifyExec(command, autonomy) {
522
568
  const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)|\btee\b/.test(noRedirect);
523
569
  if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
524
570
  if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
525
- return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(ls/cat/grep/curl/ping/nc/sqlite3只读等)。写操作需 L2 并人工确认` };
571
+ return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(${EXEC_RO.slice(0, 8).join('/')}等,完整清单见 系统设置·守护栏)。写操作需 L2 并人工确认` };
526
572
  }
527
573
  const PRIMITIVE_TOOLS = {
528
574
  exec: {
@@ -530,17 +576,18 @@ const PRIMITIVE_TOOLS = {
530
576
  desc: '通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力',
531
577
  schema: { command: { type: 'string', description: '要执行的 shell 命令' }, timeout_ms: { type: 'number', description: '超时毫秒,默认 15000' } },
532
578
  required: ['command'],
533
- run: async ({ command, timeout_ms = 15000 }, agentId) => {
579
+ run: async ({ command, timeout_ms = 15000 }, agentId, _wsId, signal) => {
534
580
  const env = { ...process.env };
535
581
  delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT; // 凭据不进子进程
536
582
  const ws = agentId ? currentWs(agentId) : null; // 文件领地锚定工作区(07 设计 §4.3)
537
583
  return await new Promise((resolve) => {
538
- cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
584
+ 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) => {
539
585
  const out = String(stdout || '').slice(0, 4000);
540
586
  const errOut = String(stderr || '').slice(0, 1500);
541
587
  audit({ actor: 'assistant', tool: 'exec', command: String(command).slice(0, 200), ok: !err, ws: ws?.id });
542
- resolve(`$ ${command}\n${out}${errOut ? '\n[stderr] ' + errOut : ''}${err && !out && !errOut ? '\n[错误] ' + err.message : ''}`);
588
+ resolve(`$ ${command}\n${out}${errOut ? '\n[stderr] ' + errOut : ''}${err && !out && !errOut ? '\n[错误] ' + err.message : ''}${signal?.aborted ? '\n[中断] 命令已被用户终止' : ''}`);
543
589
  });
590
+ if (signal) signal.addEventListener('abort', () => { try { child.kill('SIGTERM') } catch { /* 已退出 */ } }, { once: true }); // 用户中断 → 终止执行中的命令
544
591
  });
545
592
  },
546
593
  },
@@ -1176,13 +1223,16 @@ function devSystemPrompt() {
1176
1223
  ].join('\n');
1177
1224
  }
1178
1225
 
1179
- async function devChatLoop(session, emit) {
1226
+ async function devChatLoop(session, emit, signal = null) {
1180
1227
  const events = [];
1181
1228
  const tools = Object.entries(DEV_TOOLS).map(([n, t]) => ({ name: n, description: t.desc, parameters: { type: 'object', properties: t.schema, required: t.required || [] } }));
1182
- for (let round = 0; round < 32; round++) {
1183
- emit('status', { text: `第 ${round + 1}/32 轮` });
1184
- const stream = models.stream(model, session.context);
1229
+ const interrupted = () => !!signal?.aborted;
1230
+ for (let round = 0; ; round++) { // 不设轮次上限:自然结束或用户中断(断开连接/停止按钮)
1231
+ if (interrupted()) { emit('interrupted', { at: `第 ${round + 1} 轮` }); return { reply: '(已被用户中断)', events }; }
1232
+ emit('status', { text: `第 ${round + 1} 轮` });
1233
+ const stream = models.stream(model, session.context, signal ? { signal } : undefined);
1185
1234
  for await (const ev of stream) {
1235
+ if (interrupted()) break;
1186
1236
  if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1187
1237
  else if (ev.type === 'toolcall_end') emit('status', { text: `调用工具 ${ev.toolCall?.name || ''}` });
1188
1238
  }
@@ -1192,6 +1242,10 @@ async function devChatLoop(session, emit) {
1192
1242
  const text = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
1193
1243
  if (!calls.length) { emit('done', { reply: text, events }); return { reply: text, events }; }
1194
1244
  for (const tc of calls) {
1245
+ if (interrupted()) { // 余下工具不再执行,补占位结果保证工具调用链完整
1246
+ session.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: '(用户中断,未执行)' }], isError: false, timestamp: Date.now() });
1247
+ continue;
1248
+ }
1195
1249
  const args = tc.arguments || {};
1196
1250
  emit('tool_start', { tool: tc.name, args });
1197
1251
  let result, isErr = false;
@@ -1201,9 +1255,8 @@ async function devChatLoop(session, emit) {
1201
1255
  emit('tool_end', { tool: tc.name, args, result: String(result).slice(0, 600), ok: !isErr });
1202
1256
  session.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1203
1257
  }
1258
+ if (interrupted()) { emit('interrupted', { at: `第 ${round + 1} 轮` }); return { reply: text, events }; }
1204
1259
  }
1205
- emit('done', { reply: '(达到轮次上限——请把结论性需求拆小,或继续对话续跑)', events });
1206
- return { reply: '(达到轮次上限)', events };
1207
1260
  }
1208
1261
 
1209
1262
  // ── 助手运行时(pi-ai Context + 守护栏 + SSE 事件流)────
@@ -1232,7 +1285,7 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
1232
1285
  '- 连通性实测:exec `nc -vz -w 3 <host> <port>`("能否连接"必须实测)',
1233
1286
  '- 平台数据:先 data_describe 看字典,再 data_query 查询(服务端按助手隔离)',
1234
1287
  '- 现场若有已接入的数据面板,以工作台展示为准;没有就说没有,不猜测',
1235
- 'exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。',
1288
+ `exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝${guardNow().sudo_u_l2 ? '(例外:sudo 降权形式 sudo -u <用户> 按写命令处理,L2 确认后可执行;裸 sudo 仍拒绝)' : ''}。`,
1236
1289
  '',
1237
1290
  '【事实与推断分离(硬规则)】',
1238
1291
  '- 连通性结论只能来自实测(nc/curl);记录存在≠可达,设备状态在线≠可达',
@@ -1260,7 +1313,7 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
1260
1313
  if (!deployment) throw new Error('该助手未上岗(无 deployment)');
1261
1314
  const armAgent = await getArmAgent(agentId);
1262
1315
  if (!s || s.agentId !== agentId || (wsId && s.wsId !== wsId)) { // 切工作区 = 新会话上下文(旧会话留在原工作区)
1263
- s = { agentId, wsId: wsId || 'default', autonomy: { L0: 0, L1: 1, L2: 2 }[deployment.default_autonomy] ?? 1, context: null, pending: null };
1316
+ s = { agentId, wsId: wsId || 'default', autonomy: { L0: 0, L1: 1, L2: 2, L3: 3 }[deployment.default_autonomy] ?? 1, context: null, pending: null };
1264
1317
  sessions.set(sessionId, s);
1265
1318
  }
1266
1319
  const ws = getWs(agentId, s.wsId) || currentWs(agentId);
@@ -1274,24 +1327,52 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
1274
1327
  }
1275
1328
 
1276
1329
  // 运行一轮 Agent 循环,事件通过 emit(type, data) 推给 SSE;s.running/s.statusText 供跨页面进度跟踪
1277
- async function agentLoop(s, deployment, emit, maxRounds = 32) {
1278
- for (let round = 0; round < maxRounds; round++) {
1279
- s.statusText = `第 ${round + 1}/${maxRounds} 轮 · 推理中`;
1280
- const stream = models.stream(model, s.context);
1330
+ // 不设轮次上限:跑到无更多工具调用自然结束,失控靠用户中断(signal)
1331
+ // signal:用户中断(停止按钮/断开连接/POST /api/chat/interrupt)——轮次间、流中、每个工具执行前检查;
1332
+ // 中断时保留已流出的部分文本为助手消息并补记"(已被用户中断)",未应答的 toolCall 补占位 toolResult 保证下轮上下文合法
1333
+ async function agentLoop(s, deployment, emit, signal = null) {
1334
+ const interrupted = () => !!signal?.aborted;
1335
+ for (let round = 0; ; round++) {
1336
+ if (interrupted()) return { reply: '(已被用户中断)', interrupted: true, pending: s.pending };
1337
+ s.statusText = `第 ${round + 1} 轮 · 推理中`;
1338
+ const stream = models.stream(model, s.context, signal ? { signal } : undefined);
1281
1339
  emit('status', { text: '推理中…' });
1282
- for await (const ev of stream) {
1283
- if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1284
- else if (ev.type === 'thinking_start') emit('status', { text: '思考中…' });
1285
- else if (ev.type === 'toolcall_end') emit('status', { text: `准备调用工具 ${ev.toolCall?.name || ''}` });
1286
- else if (ev.type === 'error') throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`);
1340
+ let final = null;
1341
+ try {
1342
+ for await (const ev of stream) {
1343
+ if (interrupted()) break;
1344
+ if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1345
+ else if (ev.type === 'thinking_start') emit('status', { text: '思考中…' });
1346
+ else if (ev.type === 'toolcall_end') emit('status', { text: `准备调用工具 ${ev.toolCall?.name || ''}` });
1347
+ else if (ev.type === 'error') {
1348
+ if (interrupted()) break; // 中断引发的流错误按中断处理,不报错
1349
+ throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`);
1350
+ }
1351
+ }
1352
+ final = await stream.result(); // 中止时 pi-ai 以 done(aborted) 收尾,result() 返回已积累的部分消息
1353
+ } catch (e) {
1354
+ if (!interrupted()) throw e; // 真实错误上抛;中断路径用占位消息兜底
1287
1355
  }
1288
- const final = await stream.result();
1356
+ const aborted = interrupted();
1357
+ if (!final) final = { role: 'assistant', content: [], timestamp: Date.now() };
1358
+ if (aborted) (final.content = final.content || []).push({ type: 'text', text: (final.content.some((b) => b.type === 'text' && b.text) ? '\n\n' : '') + '(已被用户中断)' });
1289
1359
  s.context.messages.push(final);
1290
1360
  const textNow = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
1291
1361
  const calls = (final.content || []).filter(b => b.type === 'toolCall');
1292
- if (!calls.length) return { reply: textNow, pending: null };
1362
+ if (!calls.length || aborted) {
1363
+ if (aborted) {
1364
+ emit('interrupted', { at: `第 ${round + 1} 轮` });
1365
+ audit({ actor: 'user', agent: deployment.agent_id, action: 'interrupt-chat', round: round + 1 });
1366
+ return { reply: textNow, interrupted: true, pending: s.pending };
1367
+ }
1368
+ return { reply: textNow, pending: null };
1369
+ }
1293
1370
 
1294
1371
  for (const tc of calls) {
1372
+ if (interrupted()) { // 后续工具不再执行,补占位结果保证工具调用链完整
1373
+ s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: '(用户中断,未执行)' }], isError: false, timestamp: Date.now() });
1374
+ continue;
1375
+ }
1295
1376
  const args = tc.arguments || {};
1296
1377
  emit('tool_start', { tool: tc.name, args });
1297
1378
  s.statusText = `调用工具 ${tc.name}`;
@@ -1311,14 +1392,18 @@ async function agentLoop(s, deployment, emit, maxRounds = 32) {
1311
1392
  if (s.pending && s.pending.tool_call_id === tc.id) s.pending = null;
1312
1393
  let result, isErr = false;
1313
1394
  s.statusText = `执行工具 ${tc.name}`;
1314
- try { result = await getTool(tc.name).run(args, s.agentId, s.wsId || 'default'); }
1395
+ try { result = await getTool(tc.name).run(args, s.agentId, s.wsId || 'default', signal); }
1315
1396
  catch (e) { result = `工具执行失败: ${e.message}`; isErr = true; }
1316
1397
  s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1317
1398
  emit('tool_end', { tool: tc.name, args, result: String(result), status: 'ok' });
1318
1399
  audit({ actor: 'assistant', agent: deployment.agent_id, tool: tc.name, autonomy: s.autonomy, ok: !isErr });
1319
1400
  }
1401
+ if (interrupted()) {
1402
+ emit('interrupted', { at: `第 ${round + 1} 轮` });
1403
+ audit({ actor: 'user', agent: deployment.agent_id, action: 'interrupt-chat', round: round + 1 });
1404
+ return { reply: textNow, interrupted: true, pending: s.pending };
1405
+ }
1320
1406
  }
1321
- return { reply: '(达到工具调用轮次上限——回复「继续」可在完整上下文上续跑;也可在工作台 DSL 提高 max_rounds,上限 128)', pending: null };
1322
1407
  }
1323
1408
 
1324
1409
  // ── HTTP ────────────────────────────────────────────────
@@ -1326,7 +1411,7 @@ function json(res, code, data) { res.writeHead(code, { 'Content-Type': 'applicat
1326
1411
  async function body(req) { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; }
1327
1412
  function sseInit(req, res) {
1328
1413
  res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
1329
- return (type, data) => res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
1414
+ return (type, data) => { if (res.writableEnded || res.destroyed) return; res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); }; // 客户端断开后不再写
1330
1415
  }
1331
1416
 
1332
1417
  const server = http.createServer(async (req, res) => {
@@ -1377,10 +1462,27 @@ const server = http.createServer(async (req, res) => {
1377
1462
  },
1378
1463
  scene: { profile: sceneProfile(), length: sceneProfile().length },
1379
1464
  auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
1465
+ guard: { ...guardNow(), default_deny: GUARD_DEFAULT.deny, default_ro: GUARD_DEFAULT.ro },
1380
1466
  });
1381
1467
  }
1382
1468
  if (req.method === 'PUT' && pathname === '/api/settings') {
1383
- const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token } = await body(req);
1469
+ const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token, guard } = await body(req);
1470
+ // 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
1471
+ let guardAudit = null;
1472
+ if (guard !== undefined) {
1473
+ if (!guard || typeof guard !== 'object') return json(res, 400, { error: 'guard 须为对象 { deny, ro, sudo_u_l2 }' });
1474
+ const next = normalizeGuard(guard);
1475
+ for (const src of next.deny) { try { new RegExp(src); } catch (e) { return json(res, 400, { error: `危险清单正则不合法:${src}(${e.message})` }); } }
1476
+ for (const c of next.ro) { if (!/^[A-Za-z0-9_.-]{1,32}$/.test(c)) return json(res, 400, { error: `只读白名单项须为单个命令名(字母数字._-):${c}` }); }
1477
+ const cur = guardNow();
1478
+ guardAudit = {
1479
+ removed_deny: cur.deny.filter((d) => !next.deny.includes(d)),
1480
+ added_deny: next.deny.filter((d) => !cur.deny.includes(d)),
1481
+ removed_ro: cur.ro.filter((c) => !next.ro.includes(c)),
1482
+ sudo_u_l2: next.sudo_u_l2,
1483
+ };
1484
+ cfg.guard = next;
1485
+ }
1384
1486
  let authChanged = false;
1385
1487
  if (access_token !== undefined) { const t = String(access_token || '').trim(); authChanged = t !== accessToken(); cfg.access_token = t; } // 置空=关闭认证
1386
1488
  const normUrl = (u) => { const s = String(u || '').trim().replace(/\/+$/, ''); return s; };
@@ -1425,7 +1527,7 @@ const server = http.createServer(async (req, res) => {
1425
1527
  try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
1426
1528
  }
1427
1529
  saveCfg();
1428
- audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, mcp_explicit: !!cfg.arm_mcp_url, llm_model: cfg.llm_model, 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 });
1530
+ audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, mcp_explicit: !!cfg.arm_mcp_url, llm_model: cfg.llm_model, 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 });
1429
1531
  // ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
1430
1532
  let probe = { online: false, error: '' };
1431
1533
  if ([arm_api_base, arm_mcp_url, arm_token].some((v) => v !== undefined)) {
@@ -1462,15 +1564,18 @@ const server = http.createServer(async (req, res) => {
1462
1564
 
1463
1565
  // ── 助手目录(本地库 ∪ 远端目录;离线时仅本地)──
1464
1566
  if (req.method === 'GET' && pathname === '/api/agents') {
1465
- const keyword = url.searchParams.get('keyword') || '';
1567
+ const kw = (url.searchParams.get('keyword') || '').trim().toLowerCase();
1466
1568
  const reg = loadRegistry();
1467
1569
  let online = true, remoteCards = [];
1468
- try { remoteCards = await armListAgents(keyword); } // REST 全量优先,旧版回落 MCP
1570
+ try { remoteCards = await armListAgents(url.searchParams.get('keyword') || ''); } // REST 全量优先,旧版回落 MCP
1469
1571
  catch { online = false; }
1470
1572
  const byId = new Map();
1471
1573
  for (const e of reg.agents) {
1472
1574
  const lm = localManifest(e.agent_id) || {};
1575
+ // 搜索时本地库同样按关键词过滤(名称/描述),与远端搜索结果口径一致
1576
+ if (kw && !(String(e.name || '').toLowerCase().includes(kw) || String(e.description || lm.description || '').toLowerCase().includes(kw))) continue;
1473
1577
  byId.set(e.agent_id, {
1578
+ created_via_workstation: !!e.created_via_workstation, // 工作站新建的助手:大厅提供编辑档案/从 ARM 删除
1474
1579
  agent_id: e.agent_id, name: e.name, version: e.installed_version,
1475
1580
  skills: (lm.skills || []).length, knowledges: (lm.knowledges || []).length,
1476
1581
  description: e.description || lm.description || '',
@@ -1480,7 +1585,8 @@ const server = http.createServer(async (req, res) => {
1480
1585
  delisted: !!e.delisted, downloaded_at: e.downloaded_at || null,
1481
1586
  });
1482
1587
  }
1483
- if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)
1588
+ if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)——仅在完整拉取时;
1589
+ // 搜索结果是全量的子集,若据此刷新会把未命中的本地助手误标"已下架"
1484
1590
  const ids = new Set(remoteCards.map(c => c.agent_id));
1485
1591
  for (const c of remoteCards) {
1486
1592
  const cur = byId.get(c.agent_id) || { agent_id: c.agent_id, installed: false, installed_version: null, downloaded_at: null };
@@ -1491,15 +1597,98 @@ const server = http.createServer(async (req, res) => {
1491
1597
  update_available: cur.installed ? cmpVersion(c.version, cur.installed_version) > 0 : false,
1492
1598
  });
1493
1599
  }
1494
- for (const e of reg.agents) {
1495
- if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
1496
- else e.delisted = true;
1600
+ if (!kw) {
1601
+ for (const e of reg.agents) {
1602
+ if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
1603
+ else e.delisted = true;
1604
+ }
1605
+ reg.last_sync_at = new Date().toISOString();
1606
+ saveRegistry(reg);
1497
1607
  }
1498
- reg.last_sync_at = new Date().toISOString();
1499
- saveRegistry(reg);
1500
1608
  }
1501
1609
  return json(res, 200, { online, last_sync_at: reg.last_sync_at || null, agents: [...byId.values()] });
1502
1610
  }
1611
+ // ── 新建助手:本地起草(AI 生成 prompt)→ ARM JSON 创建(创建即发布)→ 可选知识绑定(幂等)→ 下载物化 ──
1612
+ if (req.method === 'POST' && pathname === '/api/agents/draft-prompt') {
1613
+ const { description, name } = await body(req);
1614
+ if (!String(description || '').trim()) return json(res, 400, { error: '请先填写助手职责描述' });
1615
+ try {
1616
+ const ctx = {
1617
+ systemPrompt: '你是助手档案起草器。根据用户对助手职责的描述写出可直接使用的中文 System Prompt:第一句角色定位;随后 3-6 条职责要点(每条一个短句);最后 1-2 条工作方式或能力边界约束。只输出 Prompt 正文,不要标题、不要解释,200 字以内。',
1618
+ messages: [{ role: 'user', content: `助手名称:${String(name || '').trim() || '(待定)'}\n职责描述:${String(description).trim()}\n\n请生成 System Prompt:`, timestamp: Date.now() }],
1619
+ tools: [],
1620
+ };
1621
+ const stream = models.stream(model, ctx);
1622
+ let out = '';
1623
+ for await (const ev of stream) { if (ev.type === 'text_delta') out += ev.delta; }
1624
+ const final = await stream.result();
1625
+ const text = ((final.content || []).filter(b => b.type === 'text').map(b => b.text).join('') || out).trim();
1626
+ return json(res, 200, { prompt: text });
1627
+ } catch (e) { return json(res, 502, { error: `起草失败: ${e.message}` }); }
1628
+ }
1629
+ if (req.method === 'GET' && pathname === '/api/agents/knowledges') { // 新建向导的知识选择器:ARM 关键词检索
1630
+ const kwd = url.searchParams.get('keyword') || '';
1631
+ try {
1632
+ const d = await armRest(`/api/v1/knowledges?search=${encodeURIComponent(kwd)}&page=1&pageSize=20`);
1633
+ const items = (Array.isArray(d) ? d : (d.items || d.knowledges || [])).map((k) => ({ id: k.id, name: k.name, description: String(k.description || '').slice(0, 80) }));
1634
+ return json(res, 200, { items });
1635
+ } catch (e) { return json(res, 200, { items: [], error: String(e.message).slice(0, 100) }); }
1636
+ }
1637
+ if (req.method === 'POST' && pathname === '/api/agents/create') {
1638
+ const { name, description, prompt, knowledge_ids } = await body(req);
1639
+ const nm = String(name || '').trim(), pr = String(prompt || '').trim();
1640
+ if (!nm || !pr) return json(res, 400, { error: '名称与 System Prompt 必填' });
1641
+ try {
1642
+ 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' }) });
1643
+ const newId = created?.id || created?.agent?.id;
1644
+ if (!newId) throw new Error('ARM 未返回新助手 id');
1645
+ let bound = 0;
1646
+ for (const kid of (Array.isArray(knowledge_ids) ? knowledge_ids : [])) { // 绑定幂等(重复绑定返回已有版本)
1647
+ try { await armRest(`/api/v1/agents/${newId}/knowledges`, { method: 'POST', body: JSON.stringify({ knowledgeId: kid, kind: 'experience' }) }); bound++; }
1648
+ catch { /* 单条绑定失败不阻断创建 */ }
1649
+ }
1650
+ const manifest = await downloadAgent(newId); // 创建即物化本地(沿用下载链路)
1651
+ const reg = loadRegistry(); const entry = reg.agents.find((x) => x.agent_id === newId); if (entry) entry.created_via_workstation = true; saveRegistry(reg); // 大厅据此显示编辑/删除入口
1652
+ audit({ actor: 'user', action: 'create-agent', agentId: newId, name: nm, knowledges: bound });
1653
+ return json(res, 200, { agent_id: newId, manifest, knowledges_bound: bound });
1654
+ } catch (e) { return json(res, 502, { error: `创建失败: ${e.message}` }); }
1655
+ }
1656
+ // 编辑档案(工作站创建的助手):ARM PUT 后重新物化,本地 prompt 同步更新
1657
+ if (req.method === 'GET' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/profile$/)) {
1658
+ const id = pathname.split('/')[3];
1659
+ try {
1660
+ const d = await armRest(`/api/v1/agents/${id}`);
1661
+ return json(res, 200, { name: d.name, description: String(d.description || ''), prompt: d.prompt || '' });
1662
+ } catch (e) { return json(res, 502, { error: `档案读取失败: ${e.message}` }); }
1663
+ }
1664
+ if (req.method === 'PUT' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}$/)) {
1665
+ const id = pathname.split('/')[3];
1666
+ const { name, description, prompt } = await body(req);
1667
+ const patch = {};
1668
+ if (String(name || '').trim()) patch.name = String(name).trim();
1669
+ if (description !== undefined) patch.description = String(description || '').trim().slice(0, 512);
1670
+ if (String(prompt || '').trim()) patch.prompt = String(prompt).trim();
1671
+ if (!Object.keys(patch).length) return json(res, 400, { error: '没有可更新字段' });
1672
+ try {
1673
+ await armRest(`/api/v1/agents/${id}`, { method: 'PUT', body: JSON.stringify(patch) });
1674
+ let manifest = null;
1675
+ try { manifest = await downloadAgent(id); } catch { /* 本地未安装时无需物化 */ }
1676
+ audit({ actor: 'user', action: 'update-agent', agentId: id, fields: Object.keys(patch) });
1677
+ return json(res, 200, { ok: true, manifest });
1678
+ } catch (e) { return json(res, 502, { error: `更新失败: ${e.message}` }); }
1679
+ }
1680
+ // 从 ARM 删除(危险操作,前端二次确认):可选同时卸载本地档案
1681
+ if (req.method === 'POST' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/arm-delete$/)) {
1682
+ const id = pathname.split('/')[3];
1683
+ const { also_uninstall, purge_workspaces } = await body(req);
1684
+ try {
1685
+ await armRest(`/api/v1/agents/${id}`, { method: 'DELETE' });
1686
+ let uninstalled = false;
1687
+ if (also_uninstall && loadRegistry().agents.some((a) => a.agent_id === id)) uninstalled = uninstallLocalAgent(id, !!purge_workspaces);
1688
+ audit({ actor: 'user', action: 'arm-delete-agent', agentId: id, also_uninstalled: uninstalled });
1689
+ return json(res, 200, { ok: true, uninstalled });
1690
+ } catch (e) { return json(res, 502, { error: `ARM 删除失败: ${e.message}` }); }
1691
+ }
1503
1692
  // ── 本地助手库:下载 / 升级 / 版本历史 / 回滚 / 卸载 ──
1504
1693
  let mAgt = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/(download|upgrade|versions|rollback)$/);
1505
1694
  if (mAgt && req.method === 'POST' && (mAgt[2] === 'download' || mAgt[2] === 'upgrade')) {
@@ -1538,15 +1727,7 @@ const server = http.createServer(async (req, res) => {
1538
1727
  if (loadDeployment(agentId) && !force) {
1539
1728
  return json(res, 409, { error: '该助手在岗:卸载将同时删除上岗配置与工作台定义', hint: '确认卸载请带 force=true(可另带 purge_workspaces=true 清除工作区数据)' });
1540
1729
  }
1541
- for (const p of [path.join(DEPLOY_DIR, `${agentId}.json`), path.join(WB_DIR, `${agentId}.json`), draftPath(agentId), path.join(WB_VER_DIR, `${agentId}.json`)]) {
1542
- try { fs.rmSync(p, { force: true }); } catch { }
1543
- }
1544
- try { fs.rmSync(path.join(ARM_AGENTS_DIR, agentId), { recursive: true, force: true }); } catch { }
1545
- const reg = loadRegistry(); reg.agents = reg.agents.filter(a => a.agent_id !== agentId); saveRegistry(reg);
1546
- if (purge_workspaces) {
1547
- try { fs.rmSync(wsDir(agentId), { recursive: true, force: true }); } catch { }
1548
- for (const f of fs.readdirSync(DATA_DIR)) if (f.startsWith(`ws-${agentId}-`)) { try { fs.rmSync(path.join(DATA_DIR, f), { force: true }); } catch { } }
1549
- }
1730
+ uninstallLocalAgent(agentId, !!purge_workspaces);
1550
1731
  audit({ actor: 'arm-library', agentId, action: 'uninstall', purged_workspaces: !!purge_workspaces });
1551
1732
  return json(res, 200, { ok: true });
1552
1733
  }
@@ -1873,6 +2054,9 @@ const server = http.createServer(async (req, res) => {
1873
2054
  if (req.method === 'POST' && pathname === '/api/dev/chat') {
1874
2055
  const { sessionId, agentId, message } = await body(req);
1875
2056
  const emit = sseInit(req, res);
2057
+ const ctl = new AbortController();
2058
+ let finished = false;
2059
+ req.on('close', () => { if (!finished) ctl.abort(); }); // 断开即中断定制循环(无轮次上限后的失控出口)
1876
2060
  try {
1877
2061
  devCtx.agentId = agentId;
1878
2062
  if (!loadWorkbench(agentId)) return emit('error', { message: '该助手尚无工作台' }), res.end();
@@ -1884,9 +2068,10 @@ const server = http.createServer(async (req, res) => {
1884
2068
  s.context.systemPrompt = devSystemPrompt();
1885
2069
  }
1886
2070
  s.context.messages.push({ role: 'user', content: message, timestamp: Date.now() });
1887
- await devChatLoop(s, emit);
2071
+ await devChatLoop(s, emit, ctl.signal);
1888
2072
  emit('meta', { hasDraft: !!loadDraft(agentId) });
1889
2073
  } catch (e) { emit('error', { message: e.message }); }
2074
+ finally { finished = true; }
1890
2075
  return res.end();
1891
2076
  }
1892
2077
 
@@ -1898,10 +2083,13 @@ const server = http.createServer(async (req, res) => {
1898
2083
  if (req.method === 'POST' && pathname === '/api/chat') {
1899
2084
  const { sessionId, agentId, message, autonomyLevel, context, ws_id } = await body(req);
1900
2085
  const emit = sseInit(req, res);
2086
+ const ctl = new AbortController();
2087
+ let finished = false;
2088
+ req.on('close', () => { if (!finished) ctl.abort(); }); // 客户端断开(停止按钮/关页面)→ 中断后台循环
1901
2089
  try {
1902
2090
  const { s, deployment, armAgent } = await ensureSession(sessionId, agentId, autonomyLevel, ws_id);
1903
2091
  if (s.running) { // 会话在后台仍在处理上一条消息(跨页面进度跟踪)
1904
- emit('error', { message: `该会话正在后台处理(${s.statusText || '进行中'})——可先切换其他助手,稍后回来进度会自动恢复` });
2092
+ emit('error', { message: `该会话正在后台处理(${s.statusText || '进行中'})——可先切换其他助手,稍后回来进度会自动恢复;也可在状态行点「中断」` });
1905
2093
  return res.end();
1906
2094
  }
1907
2095
  if (s.pending) {
@@ -1909,19 +2097,28 @@ const server = http.createServer(async (req, res) => {
1909
2097
  return res.end();
1910
2098
  }
1911
2099
  s.context.messages.push({ role: 'user', content: context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, timestamp: Date.now() });
1912
- const wb = loadDraft(agentId) || loadWorkbench(agentId) || {};
1913
- s.running = true; s.sinceTs = Date.now();
1914
- const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
2100
+ s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
2101
+ const out = await agentLoop(s, deployment, emit, ctl.signal);
1915
2102
  emit('done', { reply: out.reply, pending: out.pending });
1916
2103
  } catch (e) { emit('error', { message: e.message }); }
1917
- finally { const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; } }
2104
+ finally { finished = true; const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; s.abortCtl = null; } }
1918
2105
  return res.end();
1919
2106
  }
2107
+ if (req.method === 'POST' && pathname === '/api/chat/interrupt') {
2108
+ const { sessionId } = await body(req);
2109
+ const s = sessions.get(sessionId);
2110
+ if (s?.running && s.abortCtl) { s.abortCtl.abort(); return json(res, 200, { ok: true, interrupted: true }); } // 幂等:循环在下一个检查点退出
2111
+ return json(res, 200, { ok: true, interrupted: false, note: '当前没有进行中的对话' });
2112
+ }
1920
2113
  if (req.method === 'POST' && pathname === '/api/chat/confirm') {
1921
2114
  const { sessionId, approved } = await body(req);
1922
2115
  const emit = sseInit(req, res);
2116
+ const ctl = new AbortController();
2117
+ let finished = false;
2118
+ let s = null;
2119
+ req.on('close', () => { if (!finished) ctl.abort(); });
1923
2120
  try {
1924
- const s = sessions.get(sessionId);
2121
+ s = sessions.get(sessionId);
1925
2122
  if (!s || !s.pending) return emit('error', { message: '无挂起操作' }), res.end();
1926
2123
  const deployment = loadDeployment(s.agentId);
1927
2124
  const { tool_call_id, tool, args } = s.pending; s.pending = null;
@@ -1931,17 +2128,16 @@ const server = http.createServer(async (req, res) => {
1931
2128
  } else {
1932
2129
  emit('tool_start', { tool, args });
1933
2130
  let result, isErr = false;
1934
- try { result = await getTool(tool).run(args, s.agentId, s.wsId || 'default'); } catch (e) { result = `执行失败: ${e.message}`; isErr = true; }
2131
+ try { result = await getTool(tool).run(args, s.agentId, s.wsId || 'default', ctl.signal); } catch (e) { result = `执行失败: ${e.message}`; isErr = true; }
1935
2132
  s.context.messages.push({ role: 'toolResult', toolCallId: tool_call_id, toolName: tool, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1936
2133
  emit('tool_end', { tool, args, result: String(result), status: 'executed' });
1937
2134
  audit({ actor: 'human-confirm', agent: s.agentId, tool, autonomy: s.autonomy, ok: !isErr });
1938
2135
  }
1939
- const wb = loadDraft(s.agentId) || loadWorkbench(s.agentId) || {};
1940
- s.running = true; s.sinceTs = Date.now();
1941
- const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
2136
+ s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
2137
+ const out = await agentLoop(s, deployment, emit, ctl.signal);
1942
2138
  emit('done', { reply: out.reply, pending: out.pending });
1943
2139
  } catch (e) { emit('error', { message: e.message }); }
1944
- finally { if (s) { s.running = false; s.statusText = ''; } }
2140
+ finally { finished = true; if (s) { s.running = false; s.statusText = ''; s.abortCtl = null; } }
1945
2141
  return res.end();
1946
2142
  }
1947
2143
  // ── 会话历史与进度(切换助手后回来:上下文回放 + 后台运行状态;服务端为唯一事实源)──
@@ -2065,6 +2261,14 @@ if (process.argv.includes('--test-guard')) {
2065
2261
  ['exec', 3, { command: 'echo hi > /tmp/a.txt' }, 'ok'],
2066
2262
  ['exec', 3, { command: 'sudo reboot' }, 'blocked'],
2067
2263
  ];
2264
+ // 守护栏可配置(系统设置·守护栏):sudo_u_l2 开启后,`sudo -u <用户>` 降权按写命令走 L2 确认;裸 sudo 与嵌套危险命令仍拒绝
2265
+ cfg.guard = normalizeGuard({ ...guardNow(), sudo_u_l2: true });
2266
+ cases.push(
2267
+ ['exec', 2, { command: "sshpass -p x ssh -o StrictHostKeyChecking=no root@192.0.2.1 'sudo -u aims bash -c \"npm install -g aims-workstation\"' 2>&1 | tail -40" }, 'pending'],
2268
+ ['exec', 0, { command: 'sudo -u aims npm install -g x' }, 'blocked'],
2269
+ ['exec', 2, { command: "sudo -u aims bash -c 'sudo reboot'" }, 'blocked'],
2270
+ ['exec', 2, { command: 'sudo apt install -y x' }, 'blocked'],
2271
+ );
2068
2272
  let pass = true;
2069
2273
  for (const [tool, lv, args, want] of cases) {
2070
2274
  const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;