aims-workstation 0.1.1 → 0.1.3

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
+ - **守护栏可维护**:危险命令清单(正则)与只读白名单在「系统设置·守护栏」增删,保存即热生效;`sudo -u <用户>` 降权形式可配置为 L2 确认执行;删减危险规则需二次确认并写审计
12
13
  - **并行会话**:多助手并行、后台运行进度跟踪、确认卡跨切换恢复
13
- - **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像,界面配置即时生效
14
+ - **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像 / 守护栏策略,界面配置即时生效
14
15
 
15
16
  ## 快速开始
16
17
 
@@ -19,7 +20,7 @@ npx aims-workstation
19
20
  # 打开 http://127.0.0.1:5600 → 系统设置里配置 ARM 源与 LLM 即可
20
21
  ```
21
22
 
22
- 要求 **Node ≥ 22.5**(`node:sqlite` 依赖)。服务默认只绑定 `127.0.0.1`(本机工具,勿暴露局域网)。
23
+ 要求 **Node ≥ 22.5**(`node:sqlite` 依赖)。服务默认监听 `0.0.0.0`(局域网可访问,适合团队共用);仅本机使用请设 `AIMS_HOST=127.0.0.1`。
23
24
 
24
25
  ## 配置
25
26
 
@@ -35,6 +36,7 @@ npx aims-workstation
35
36
 
36
37
  ## 安全模型
37
38
 
39
+ - **访问令牌**(可选):设置页「访问安全」启用后,所有 API 需认证——浏览器登录换 HttpOnly Cookie,脚本用 `Authorization: Bearer <token>`;替换令牌即踢掉所有已登录设备
38
40
  - 助手凭据与提示词分离;密钥只存本机(界面永远掩码)
39
41
  - 写操作 L2 挂起确认;毁灭性命令黑名单全级别拒绝
40
42
  - 面板代理出网白名单(仅插件声明过的主机);数据集按助手×工作区物理隔离
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.1",
16
+ "version": "0.1.3",
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');
@@ -69,13 +72,29 @@ for (const envFile of [path.join(PKG_ROOT, '.env'), path.join(AIMS_HOME, '.env')
69
72
  } catch { /* .env 可选 */ }
70
73
  }
71
74
  const PORT = +(process.env.PORT || env.PORT || 5600);
72
- const HOST = process.env.AIMS_HOST || '127.0.0.1'; // 本机工具默认只绑回环(exec/目录浏览/面板动作不宜暴露局域网)
75
+ const HOST = process.env.AIMS_HOST || '0.0.0.0'; // 默认局域网可达(团队共用);仅本机使用可设 AIMS_HOST=127.0.0.1
73
76
  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
- const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '' };
77
- function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } }
79
+ const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '', access_token: '' };
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$/, '');
@@ -90,6 +109,17 @@ const llmModelName = () => cfg.llm_model || env.LLM_MODEL || 'glm-5.3';
90
109
  // 与知识检索互补(常驻事实 vs 按需检索);凭据不进画像(放"凭据在哪、找谁要")。
91
110
  const sceneProfile = () => String(cfg.scene_profile || '').trim();
92
111
  const SCENE_PROFILE_MAX = 4000;
112
+ // 访问令牌:启用后所有 /api/* 需认证(Cookie 或 Bearer),静态页面不拦;局域网开放时的基本防护
113
+ const accessToken = () => String(cfg.access_token || '').trim();
114
+ const AUTH_COOKIE = 'aims_auth';
115
+ const authCookieValue = () => crypto.createHash('sha256').update('aims:' + accessToken()).digest('hex');
116
+ const authOk = (req) => {
117
+ if (!accessToken()) return true; // 未启用=开放
118
+ const cookie = String(req.headers.cookie || '');
119
+ if (cookie.includes(`${AUTH_COOKIE}=${authCookieValue()}`)) return true;
120
+ return req.headers.authorization === `Bearer ${accessToken()}`;
121
+ };
122
+ const safeEqual = (a, b) => String(a).length === String(b).length && crypto.timingSafeEqual(Buffer.from(String(a)), Buffer.from(String(b)));
93
123
  let ARM_PAT = env.ARM_PAT || '';
94
124
  if (!ARM_PAT) {
95
125
  try { ARM_PAT = JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.arm/config.json'), 'utf8')).token || ''; }
@@ -186,6 +216,16 @@ async function armRest(pathname, init = {}, timeout_ms = 20000) {
186
216
  const data = await res.json();
187
217
  if (!res.ok || data.ok === false) throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}`);
188
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;
189
229
  } finally { clearTimeout(timer); }
190
230
  }
191
231
  const vVer = (v) => (String(v || '').startsWith('v') ? String(v) : `v${v || '0.0.0'}`); // REST 无 v 前缀,统一内部表示
@@ -492,11 +532,15 @@ function deleteWorkspace(agentId, wsId, purge) {
492
532
 
493
533
  // ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
494
534
  const { exec: cpExec } = require('node:child_process');
495
- 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/];
496
- 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'];
497
535
  function classifyExec(command, autonomy) {
498
536
  const cmd = String(command || '').trim();
499
- if (EXEC_DENY.some((re) => re.test(cmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(sudo/毁灭性操作等)' };
537
+ const g = guardNow();
538
+ // sudo 降权特例(可配置):开启后剥掉 `sudo -u <用户>` 片段再匹配危险清单(嵌套的裸 sudo/毁灭性命令仍会命中),剩余部分按普通命令分级
539
+ const denyCmd = g.sudo_u_l2 ? cmd.replace(/\bsudo\s+(?:-\w+\s+)*-u\s+\S+/g, '') : cmd;
540
+ const denyRes = [];
541
+ for (const src of g.deny) { try { denyRes.push(new RegExp(src)); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } }
542
+ if (denyRes.some((re) => re.test(denyCmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(清单可在 系统设置·守护栏 维护)' };
543
+ const EXEC_RO = g.ro;
500
544
  const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
501
545
  const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
502
546
  // 复合命令逐段判定:每个由 ; && || | 换行 分隔的段都须只读(控制关键字与 for 头部放行)
@@ -511,7 +555,7 @@ function classifyExec(command, autonomy) {
511
555
  const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)|\btee\b/.test(noRedirect);
512
556
  if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
513
557
  if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
514
- return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(ls/cat/grep/curl/ping/nc/sqlite3只读等)。写操作需 L2 并人工确认` };
558
+ return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(${EXEC_RO.slice(0, 8).join('/')}等,完整清单见 系统设置·守护栏)。写操作需 L2 并人工确认` };
515
559
  }
516
560
  const PRIMITIVE_TOOLS = {
517
561
  exec: {
@@ -1168,8 +1212,8 @@ function devSystemPrompt() {
1168
1212
  async function devChatLoop(session, emit) {
1169
1213
  const events = [];
1170
1214
  const tools = Object.entries(DEV_TOOLS).map(([n, t]) => ({ name: n, description: t.desc, parameters: { type: 'object', properties: t.schema, required: t.required || [] } }));
1171
- for (let round = 0; round < 24; round++) {
1172
- emit('status', { text: `第 ${round + 1}/24 轮` });
1215
+ for (let round = 0; round < 32; round++) {
1216
+ emit('status', { text: `第 ${round + 1}/32 轮` });
1173
1217
  const stream = models.stream(model, session.context);
1174
1218
  for await (const ev of stream) {
1175
1219
  if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
@@ -1221,7 +1265,7 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
1221
1265
  '- 连通性实测:exec `nc -vz -w 3 <host> <port>`("能否连接"必须实测)',
1222
1266
  '- 平台数据:先 data_describe 看字典,再 data_query 查询(服务端按助手隔离)',
1223
1267
  '- 现场若有已接入的数据面板,以工作台展示为准;没有就说没有,不猜测',
1224
- 'exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。',
1268
+ `exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝${guardNow().sudo_u_l2 ? '(例外:sudo 降权形式 sudo -u <用户> 按写命令处理,L2 确认后可执行;裸 sudo 仍拒绝)' : ''}。`,
1225
1269
  '',
1226
1270
  '【事实与推断分离(硬规则)】',
1227
1271
  '- 连通性结论只能来自实测(nc/curl);记录存在≠可达,设备状态在线≠可达',
@@ -1263,7 +1307,7 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
1263
1307
  }
1264
1308
 
1265
1309
  // 运行一轮 Agent 循环,事件通过 emit(type, data) 推给 SSE;s.running/s.statusText 供跨页面进度跟踪
1266
- async function agentLoop(s, deployment, emit, maxRounds = 16) {
1310
+ async function agentLoop(s, deployment, emit, maxRounds = 32) {
1267
1311
  for (let round = 0; round < maxRounds; round++) {
1268
1312
  s.statusText = `第 ${round + 1}/${maxRounds} 轮 · 推理中`;
1269
1313
  const stream = models.stream(model, s.context);
@@ -1307,7 +1351,7 @@ async function agentLoop(s, deployment, emit, maxRounds = 16) {
1307
1351
  audit({ actor: 'assistant', agent: deployment.agent_id, tool: tc.name, autonomy: s.autonomy, ok: !isErr });
1308
1352
  }
1309
1353
  }
1310
- return { reply: '(达到工具调用轮次上限)', pending: null };
1354
+ return { reply: '(达到工具调用轮次上限——回复「继续」可在完整上下文上续跑;也可在工作台 DSL 提高 max_rounds,上限 128)', pending: null };
1311
1355
  }
1312
1356
 
1313
1357
  // ── HTTP ────────────────────────────────────────────────
@@ -1333,6 +1377,18 @@ const server = http.createServer(async (req, res) => {
1333
1377
  }
1334
1378
  return json(res, 404, { error: '前端未构建:cd web && pnpm build' });
1335
1379
  }
1380
+ // ── 访问认证(启用令牌后:API 需 Cookie/Bearer;静态页面开放;health 化身只报 auth_required)──
1381
+ if (req.method === 'POST' && pathname === '/api/auth/login') {
1382
+ const { token } = await body(req);
1383
+ if (!accessToken()) return json(res, 200, { ok: true, auth_required: false });
1384
+ if (!token || !safeEqual(token, accessToken())) return json(res, 401, { error: '访问令牌不正确' });
1385
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Set-Cookie': `${AUTH_COOKIE}=${authCookieValue()}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000` });
1386
+ return res.end(JSON.stringify({ ok: true }));
1387
+ }
1388
+ if (!authOk(req) && pathname.startsWith('/api')) {
1389
+ if (pathname === '/api/health') return json(res, 200, { ok: true, auth_required: true }); // 前端据此弹登录门
1390
+ return json(res, 401, { error: '需要访问令牌', code: 'AUTH_REQUIRED' });
1391
+ }
1336
1392
  if (req.method === 'GET' && pathname === '/api/health') return json(res, 200, { ok: true, model: model?.id, arm: armMcpUrl(), pat: !!ARM_PAT, local_agents: loadRegistry().agents.length });
1337
1393
 
1338
1394
  // ── ARM 连接与同步(07:大厅双视图 = 本地助手库 ∪ 远端目录)──
@@ -1353,10 +1409,30 @@ const server = http.createServer(async (req, res) => {
1353
1409
  key_source: cfg.llm_api_key ? 'runtime' : (env.LLM_API_KEY ? 'env' : 'none'),
1354
1410
  },
1355
1411
  scene: { profile: sceneProfile(), length: sceneProfile().length },
1412
+ auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
1413
+ guard: { ...guardNow(), default_deny: GUARD_DEFAULT.deny, default_ro: GUARD_DEFAULT.ro },
1356
1414
  });
1357
1415
  }
1358
1416
  if (req.method === 'PUT' && pathname === '/api/settings') {
1359
- const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile } = await body(req);
1417
+ 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);
1418
+ // 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
1419
+ let guardAudit = null;
1420
+ if (guard !== undefined) {
1421
+ if (!guard || typeof guard !== 'object') return json(res, 400, { error: 'guard 须为对象 { deny, ro, sudo_u_l2 }' });
1422
+ const next = normalizeGuard(guard);
1423
+ for (const src of next.deny) { try { new RegExp(src); } catch (e) { return json(res, 400, { error: `危险清单正则不合法:${src}(${e.message})` }); } }
1424
+ for (const c of next.ro) { if (!/^[A-Za-z0-9_.-]{1,32}$/.test(c)) return json(res, 400, { error: `只读白名单项须为单个命令名(字母数字._-):${c}` }); }
1425
+ const cur = guardNow();
1426
+ guardAudit = {
1427
+ removed_deny: cur.deny.filter((d) => !next.deny.includes(d)),
1428
+ added_deny: next.deny.filter((d) => !cur.deny.includes(d)),
1429
+ removed_ro: cur.ro.filter((c) => !next.ro.includes(c)),
1430
+ sudo_u_l2: next.sudo_u_l2,
1431
+ };
1432
+ cfg.guard = next;
1433
+ }
1434
+ let authChanged = false;
1435
+ if (access_token !== undefined) { const t = String(access_token || '').trim(); authChanged = t !== accessToken(); cfg.access_token = t; } // 置空=关闭认证
1360
1436
  const normUrl = (u) => { const s = String(u || '').trim().replace(/\/+$/, ''); return s; };
1361
1437
  let sceneChanged = false;
1362
1438
  if (scene_profile !== undefined) {
@@ -1399,7 +1475,7 @@ const server = http.createServer(async (req, res) => {
1399
1475
  try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
1400
1476
  }
1401
1477
  saveCfg();
1402
- 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 });
1478
+ 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 });
1403
1479
  // ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
1404
1480
  let probe = { online: false, error: '' };
1405
1481
  if ([arm_api_base, arm_mcp_url, arm_token].some((v) => v !== undefined)) {
@@ -1885,7 +1961,7 @@ const server = http.createServer(async (req, res) => {
1885
1961
  s.context.messages.push({ role: 'user', content: context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, timestamp: Date.now() });
1886
1962
  const wb = loadDraft(agentId) || loadWorkbench(agentId) || {};
1887
1963
  s.running = true; s.sinceTs = Date.now();
1888
- const out = await agentLoop(s, deployment, emit, Math.max(1, +(wb.max_rounds) || 16));
1964
+ const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
1889
1965
  emit('done', { reply: out.reply, pending: out.pending });
1890
1966
  } catch (e) { emit('error', { message: e.message }); }
1891
1967
  finally { const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; } }
@@ -1912,7 +1988,7 @@ const server = http.createServer(async (req, res) => {
1912
1988
  }
1913
1989
  const wb = loadDraft(s.agentId) || loadWorkbench(s.agentId) || {};
1914
1990
  s.running = true; s.sinceTs = Date.now();
1915
- const out = await agentLoop(s, deployment, emit, Math.max(1, +(wb.max_rounds) || 16));
1991
+ const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
1916
1992
  emit('done', { reply: out.reply, pending: out.pending });
1917
1993
  } catch (e) { emit('error', { message: e.message }); }
1918
1994
  finally { if (s) { s.running = false; s.statusText = ''; } }
@@ -2039,6 +2115,14 @@ if (process.argv.includes('--test-guard')) {
2039
2115
  ['exec', 3, { command: 'echo hi > /tmp/a.txt' }, 'ok'],
2040
2116
  ['exec', 3, { command: 'sudo reboot' }, 'blocked'],
2041
2117
  ];
2118
+ // 守护栏可配置(系统设置·守护栏):sudo_u_l2 开启后,`sudo -u <用户>` 降权按写命令走 L2 确认;裸 sudo 与嵌套危险命令仍拒绝
2119
+ cfg.guard = normalizeGuard({ ...guardNow(), sudo_u_l2: true });
2120
+ cases.push(
2121
+ ['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'],
2122
+ ['exec', 0, { command: 'sudo -u aims npm install -g x' }, 'blocked'],
2123
+ ['exec', 2, { command: "sudo -u aims bash -c 'sudo reboot'" }, 'blocked'],
2124
+ ['exec', 2, { command: 'sudo apt install -y x' }, 'blocked'],
2125
+ );
2042
2126
  let pass = true;
2043
2127
  for (const [tool, lv, args, want] of cases) {
2044
2128
  const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;