agim-cli 1.0.9 → 1.0.11

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.
@@ -139,6 +139,16 @@
139
139
  safetyRestartHint: 'Click Save & Restart to apply.',
140
140
  safetySaveRestart: 'Save & Restart',
141
141
  safetySaved: '✓ Setting saved',
142
+ adminListTitle: 'Admin Allowlist',
143
+ adminListHint: 'Only these users can run /restart, /stop, and natural-language equivalents in IM. Edits persist to ~/.agim/env immediately — no restart needed.',
144
+ adminListLoading: 'Loading…',
145
+ adminListEmpty: '(no admins yet — IM service commands are disabled)',
146
+ adminUserIdPlaceholder: 'platform-specific user id (e.g. wxid_… / 123456 / ou_…)',
147
+ adminAdd: 'Add admin',
148
+ adminRemove: 'Remove',
149
+ adminConfirmRemove: 'Remove admin {p}:{u}?',
150
+ adminPublicBindWarn: '⚠️ Web is bound publicly — admin editor disabled to prevent random visitors from granting themselves access. Bind to 127.0.0.1 to enable.',
151
+ adminBootstrapHint: 'Bootstrap token exists at {path}. Run `cat` on the server, then send `/setup admin <token>` in IM to self-onboard.',
142
152
  },
143
153
  zh: {
144
154
  title: 'Agim — 设置',
@@ -264,6 +274,16 @@
264
274
  safetyRestartHint: '点「保存并重启」让改动生效。',
265
275
  safetySaveRestart: '保存并重启',
266
276
  safetySaved: '✓ 设置已保存',
277
+ adminListTitle: '管理员白名单',
278
+ adminListHint: '只有列表里的用户能在 IM 里跑 /restart / /stop 或自然语言"重启服务"。改动会立即写入 ~/.agim/env,无需重启。',
279
+ adminListLoading: '加载中…',
280
+ adminListEmpty: '(还没有管理员 — IM 服务管理命令处于禁用状态)',
281
+ adminUserIdPlaceholder: '该平台的 user id(如 wxid_… / 123456 / ou_…)',
282
+ adminAdd: '添加管理员',
283
+ adminRemove: '移除',
284
+ adminConfirmRemove: '移除管理员 {p}:{u}?',
285
+ adminPublicBindWarn: '⚠️ web 控制台目前监听公开地址 — 为防止陌生访问者擅自给自己加 admin,此编辑器已禁用。绑回 127.0.0.1 后启用。',
286
+ adminBootstrapHint: '检测到一次性 token 文件在 {path}。在服务器上 `cat` 该文件取 token,然后在 IM 里发 /setup admin <token> 即可把自己设为 admin。',
267
287
  },
268
288
  };
269
289
  function t(key) { return T[window.__lang][key] || T.en[key] || key; }
@@ -717,6 +737,8 @@
717
737
  void loadServiceStatus();
718
738
  // Safety toggle reflects the current env value; load once on render.
719
739
  void loadSafetyState();
740
+ // Admin allowlist list — loads via /api/admin-allowlist.
741
+ void loadAdminList();
720
742
  }
721
743
 
722
744
  // ==========================================
@@ -743,6 +765,26 @@
743
765
  <div class="actions">
744
766
  <button type="button" class="btn btn-primary" id="safety-save-restart">${t('safetySaveRestart')}</button>
745
767
  </div>
768
+ <hr class="divider">
769
+ <div style="font-size:13px;font-weight:600;margin-bottom:6px">${t('adminListTitle')}</div>
770
+ <div class="hint" style="margin-bottom:10px">${t('adminListHint')}</div>
771
+ <div id="admin-list" style="margin-bottom:10px">${t('adminListLoading')}</div>
772
+ <div class="row">
773
+ <div>
774
+ <label>Platform</label>
775
+ <input type="text" id="admin-platform" placeholder="wechat-ilink / telegram / feishu …">
776
+ </div>
777
+ <div>
778
+ <label>User ID</label>
779
+ <input type="text" id="admin-userid" placeholder="${t('adminUserIdPlaceholder')}">
780
+ </div>
781
+ </div>
782
+ <div class="actions">
783
+ <button type="button" class="btn btn-primary" id="admin-add">${t('adminAdd')}</button>
784
+ </div>
785
+ <div class="hint" id="admin-public-warn" style="margin-top:8px;display:none;color:var(--yellow)">
786
+ ${t('adminPublicBindWarn')}
787
+ </div>
746
788
  </div>
747
789
  `;
748
790
  }
@@ -778,6 +820,69 @@
778
820
  status.style.color = safetySkipPending ? 'var(--red)' : 'var(--text-dim)';
779
821
  }
780
822
 
823
+ // ─── Admin allowlist editor ──────────────────────────────────
824
+ async function loadAdminList() {
825
+ const listEl = document.getElementById('admin-list');
826
+ const warnEl = document.getElementById('admin-public-warn');
827
+ if (!listEl) return;
828
+ try {
829
+ const data = await authFetch('/api/admin-allowlist').then(r => r.json());
830
+ const admins = data.admins || [];
831
+ if (admins.length === 0) {
832
+ let inner = `<div class="hint" style="font-style:italic">${esc(t('adminListEmpty'))}</div>`;
833
+ if (data.bootstrapAvailable) {
834
+ inner += `<div class="hint" style="margin-top:6px;color:var(--yellow)">${esc(t('adminBootstrapHint').replace('{path}', data.bootstrapTokenPath || '~/.agim/admin-bootstrap-token'))}</div>`;
835
+ }
836
+ listEl.innerHTML = inner;
837
+ } else {
838
+ listEl.innerHTML = admins.map(a => `
839
+ <div class="agent-row" style="padding:6px 0">
840
+ <div class="left">
841
+ <div>
842
+ <div style="font-family:monospace;font-size:13px">${esc(a.platform)}:${esc(a.userId)}</div>
843
+ </div>
844
+ </div>
845
+ <button type="button" class="btn btn-sm btn-danger" data-admin-remove='${esc(JSON.stringify({platform: a.platform, userId: a.userId}))}'>${esc(t('adminRemove'))}</button>
846
+ </div>
847
+ `).join('');
848
+ // Wire the remove buttons
849
+ listEl.querySelectorAll('[data-admin-remove]').forEach(btn => {
850
+ btn.addEventListener('click', async () => {
851
+ const meta = JSON.parse(btn.getAttribute('data-admin-remove') || '{}');
852
+ if (!confirm(t('adminConfirmRemove').replace('{p}', meta.platform).replace('{u}', meta.userId))) return;
853
+ try {
854
+ const res = await authFetch('/api/admin-allowlist', {
855
+ method: 'DELETE',
856
+ headers: { 'Content-Type': 'application/json' },
857
+ body: JSON.stringify(meta),
858
+ });
859
+ if (!res.ok) {
860
+ const j = await res.json().catch(() => ({}));
861
+ throw new Error(j.error || res.statusText);
862
+ }
863
+ await loadAdminList();
864
+ } catch (err) {
865
+ toast(t('error') + ': ' + (err && err.message ? err.message : err), 'error');
866
+ }
867
+ });
868
+ });
869
+ }
870
+ } catch (err) {
871
+ listEl.textContent = t('error') + ': ' + (err && err.message ? err.message : err);
872
+ }
873
+ // The public-bind warning shows when /api/service/status reports a
874
+ // non-loopback bind. Re-use that endpoint instead of adding another.
875
+ try {
876
+ const status = await authFetch('/api/service/status').then(r => r.json());
877
+ if (warnEl && status.web && status.web.bind && status.web.bind !== '127.0.0.1' && status.web.bind !== 'localhost' && status.web.bind !== '::1') {
878
+ warnEl.style.display = 'block';
879
+ // Also disable the Add button
880
+ const addBtn = document.getElementById('admin-add');
881
+ if (addBtn) addBtn.disabled = true;
882
+ }
883
+ } catch { /* non-fatal */ }
884
+ }
885
+
781
886
  // ==========================================
782
887
  // Service control card
783
888
  // ==========================================
@@ -1336,6 +1441,34 @@
1336
1441
  }
1337
1442
  });
1338
1443
 
1444
+ // Add admin (Safety card → Admin Allowlist).
1445
+ document.getElementById('admin-add')?.addEventListener('click', async () => {
1446
+ const platform = (document.getElementById('admin-platform')?.value || '').trim();
1447
+ const userId = (document.getElementById('admin-userid')?.value || '').trim();
1448
+ if (!platform || !userId) {
1449
+ toast(t('error') + ': platform + userId required', 'error');
1450
+ return;
1451
+ }
1452
+ try {
1453
+ const res = await authFetch('/api/admin-allowlist', {
1454
+ method: 'POST',
1455
+ headers: { 'Content-Type': 'application/json' },
1456
+ body: JSON.stringify({ platform, userId }),
1457
+ });
1458
+ if (!res.ok) {
1459
+ const j = await res.json().catch(() => ({}));
1460
+ throw new Error(j.error || res.statusText);
1461
+ }
1462
+ toast(t('savedMsg'), 'success');
1463
+ // Clear inputs + reload list
1464
+ document.getElementById('admin-platform').value = '';
1465
+ document.getElementById('admin-userid').value = '';
1466
+ await loadAdminList();
1467
+ } catch (err) {
1468
+ toast(`${t('error')}: ${err && err.message ? err.message : err}`, 'error');
1469
+ }
1470
+ });
1471
+
1339
1472
  // Agent toggles — flip config.agents in memory + auto-manage
1340
1473
  // defaultAgent (promote / demote as needed), then re-render so the
1341
1474
  // default-agent dropdown reflects the new eligible set.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAqDA,wBAAgB,iBAAiB,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAOrE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAknB/C"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAqDA,wBAAgB,iBAAiB,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAOrE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAgoB/C"}
@@ -431,6 +431,19 @@ export async function startWebServer(options) {
431
431
  if (url.pathname === '/api/service/restart' && req.method === 'POST') {
432
432
  return handleServiceRestart(res);
433
433
  }
434
+ // Admin allowlist — list + add/remove via the Safety card. Locally
435
+ // gated: only allowed when bound to 127.0.0.1, since the page has
436
+ // no auth and we don't want random LAN visitors granting themselves
437
+ // admin. See `bindHost` upthread.
438
+ if (url.pathname === '/api/admin-allowlist' && req.method === 'GET') {
439
+ return handleAdminAllowlistGet(res);
440
+ }
441
+ if (url.pathname === '/api/admin-allowlist' && req.method === 'POST') {
442
+ return handleAdminAllowlistAdd(req, res, bindHost);
443
+ }
444
+ if (url.pathname === '/api/admin-allowlist' && req.method === 'DELETE') {
445
+ return handleAdminAllowlistRemove(req, res, bindHost);
446
+ }
434
447
  res.writeHead(404);
435
448
  res.end('Not found');
436
449
  });
@@ -1072,6 +1085,93 @@ async function handleServiceRestart(res) {
1072
1085
  }
1073
1086
  sendJson(res, 200, { ok: true, mode: st.mode, restarting: true, message: result.message });
1074
1087
  }
1088
+ // ============================================================
1089
+ // Admin allowlist endpoints (Safety card → Admin Users)
1090
+ // ============================================================
1091
+ //
1092
+ // All three are gated on the server's bindHost being a loopback address.
1093
+ // The web console has no auth, so we MUST not let a LAN visitor add
1094
+ // themselves as admin. When bindHost is 0.0.0.0 we hide the editor on
1095
+ // the frontend AND refuse here as a defense-in-depth.
1096
+ function isLocalBind(bindHost) {
1097
+ return bindHost === '127.0.0.1' || bindHost === '::1' || bindHost === 'localhost';
1098
+ }
1099
+ async function handleAdminAllowlistGet(res) {
1100
+ try {
1101
+ const { listAdmins, isAllowlistConfigured } = await import('../core/admin-allowlist.js');
1102
+ const { BOOTSTRAP_TOKEN_FILE } = await import('../core/admin-bootstrap.js');
1103
+ const hasBootstrap = existsSync(BOOTSTRAP_TOKEN_FILE);
1104
+ sendJson(res, 200, {
1105
+ admins: listAdmins(),
1106
+ configured: isAllowlistConfigured(),
1107
+ bootstrapAvailable: hasBootstrap,
1108
+ bootstrapTokenPath: BOOTSTRAP_TOKEN_FILE,
1109
+ });
1110
+ }
1111
+ catch (err) {
1112
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1113
+ }
1114
+ }
1115
+ async function handleAdminAllowlistAdd(req, res, bindHost) {
1116
+ if (!isLocalBind(bindHost)) {
1117
+ sendJson(res, 403, {
1118
+ error: 'admin editor disabled when web is bound publicly (no auth on this endpoint).',
1119
+ });
1120
+ return;
1121
+ }
1122
+ try {
1123
+ const body = await readBody(req, res);
1124
+ const parsed = JSON.parse(body || '{}');
1125
+ const platform = (parsed.platform || '').trim();
1126
+ const userId = (parsed.userId || '').trim();
1127
+ if (!platform || !userId) {
1128
+ sendJson(res, 400, { error: 'platform and userId required' });
1129
+ return;
1130
+ }
1131
+ const { promoteAdmin, listAdmins } = await import('../core/admin-allowlist.js');
1132
+ const added = await promoteAdmin(platform, userId);
1133
+ sendJson(res, 200, { ok: true, added, admins: listAdmins() });
1134
+ }
1135
+ catch (err) {
1136
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1137
+ }
1138
+ }
1139
+ async function handleAdminAllowlistRemove(req, res, bindHost) {
1140
+ if (!isLocalBind(bindHost)) {
1141
+ sendJson(res, 403, { error: 'admin editor disabled on public binds' });
1142
+ return;
1143
+ }
1144
+ try {
1145
+ const body = await readBody(req, res);
1146
+ const parsed = JSON.parse(body || '{}');
1147
+ const platform = (parsed.platform || '').trim().toLowerCase();
1148
+ const userId = (parsed.userId || '').trim();
1149
+ if (!platform || !userId) {
1150
+ sendJson(res, 400, { error: 'platform and userId required' });
1151
+ return;
1152
+ }
1153
+ const { listAdmins } = await import('../core/admin-allowlist.js');
1154
+ const remaining = listAdmins().filter((a) => !(a.platform === platform && a.userId === userId));
1155
+ const newRaw = remaining.map((a) => `${a.platform}:${a.userId}`).join(',');
1156
+ // Persist to env file. Note this nukes both runtime + env-derived
1157
+ // entries — fine for the editor's use case (it round-trips through
1158
+ // the full list).
1159
+ const { updateEnvFile } = await import('../cli-ui/env-file.js');
1160
+ updateEnvFile({ IMHUB_ADMIN_USERS: newRaw || null });
1161
+ // Update process.env so getEntries() picks it up next call.
1162
+ if (newRaw)
1163
+ process.env.IMHUB_ADMIN_USERS = newRaw;
1164
+ else
1165
+ delete process.env.IMHUB_ADMIN_USERS;
1166
+ // Reset the parse cache so the change is visible immediately.
1167
+ const { _resetCache } = await import('../core/admin-allowlist.js');
1168
+ _resetCache();
1169
+ sendJson(res, 200, { ok: true, admins: remaining });
1170
+ }
1171
+ catch (err) {
1172
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1173
+ }
1174
+ }
1075
1175
  /**
1076
1176
  * POST /api/notify → push a message to an IM thread.
1077
1177
  *