agim-cli 1.2.143 → 1.2.144

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.
@@ -75,6 +75,29 @@ function isLoopbackPeer(req) {
75
75
  const ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
76
76
  return ip === '127.0.0.1' || ip === '::1';
77
77
  }
78
+ function isEnvOff(name) {
79
+ const v = (process.env[name] || '').trim().toLowerCase();
80
+ return v === 'off' || v === '0' || v === 'false' || v === 'no';
81
+ }
82
+ function hasForwardedPeerHeaders(req) {
83
+ return req.headers.forwarded !== undefined
84
+ || req.headers['x-forwarded-for'] !== undefined
85
+ || req.headers['x-forwarded-host'] !== undefined
86
+ || req.headers['x-real-ip'] !== undefined
87
+ || req.headers['cf-connecting-ip'] !== undefined;
88
+ }
89
+ function isTrustedLoopbackPeer(req) {
90
+ if (!isLoopbackPeer(req))
91
+ return false;
92
+ if (isEnvOff('IMHUB_TRUST_LOOPBACK'))
93
+ return false;
94
+ // A reverse proxy on the same host makes remote users appear as
95
+ // 127.0.0.1. Treat forwarded requests as network traffic and require
96
+ // the normal token path instead of granting the local bootstrap bypass.
97
+ if (hasForwardedPeerHeaders(req))
98
+ return false;
99
+ return true;
100
+ }
78
101
  /** R13 A5 — track once-per-process whether the deprecated `?token=`
79
102
  * URL fallback has been used, so we warn at most once per service
80
103
  * lifetime instead of spamming the journal. Cleared by tests via
@@ -142,8 +165,8 @@ function checkAuth(req, res, url) {
142
165
  // 1. Disabled by env → pass through.
143
166
  if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
144
167
  return true;
145
- // 2. Loopback → pass through (local CLI / browser-on-same-host).
146
- if (isLoopbackPeer(req))
168
+ // 2. Trusted loopback → pass through (local CLI / browser-on-same-host).
169
+ if (isTrustedLoopbackPeer(req))
147
170
  return true;
148
171
  // 3. Public-by-design path → pass through.
149
172
  if (isPublicPath(url.pathname, req.method || 'GET'))
@@ -201,7 +224,7 @@ function verifyTokenSync(raw) {
201
224
  function getRequestActor(req) {
202
225
  if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
203
226
  return 'web:auth-off';
204
- if (isLoopbackPeer(req))
227
+ if (isTrustedLoopbackPeer(req))
205
228
  return 'web:loopback';
206
229
  let url;
207
230
  try {
@@ -224,17 +247,18 @@ function getRequestActor(req) {
224
247
  *
225
248
  * Trust order:
226
249
  * 1. IMHUB_WEB_AUTH=off → admin (operator explicitly disabled auth)
227
- * 2. Loopback peer → admin (operator on the host)
250
+ * 2. Trusted loopback → admin (operator on the host)
228
251
  * 3. Bearer token → token.role === 'admin'
229
252
  * 4. Otherwise → not admin
230
253
  *
231
254
  * Note: when no token has been created yet (pre-bootstrap), the
232
- * loopback-peer branch still grants admin so the CLI bootstrap flow
233
- * works. */
255
+ * trusted-loopback branch still grants admin so the CLI bootstrap flow
256
+ * works. Disable it with IMHUB_TRUST_LOOPBACK=off. Reverse-proxied
257
+ * requests with Forwarded / X-Forwarded-* peer headers never qualify. */
234
258
  function isRequestAdmin(req) {
235
259
  if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
236
260
  return true;
237
- if (isLoopbackPeer(req))
261
+ if (isTrustedLoopbackPeer(req))
238
262
  return true;
239
263
  let url;
240
264
  try {
@@ -265,6 +289,13 @@ function requireAdmin(req, res) {
265
289
  res.end(JSON.stringify({ error: 'forbidden', message: 'admin role required' }));
266
290
  return false;
267
291
  }
292
+ export const __webAuthForTesting = {
293
+ isTrustedLoopbackPeer,
294
+ isRequestAdmin,
295
+ setTokenModule(mod) {
296
+ _tokenModule = mod;
297
+ },
298
+ };
268
299
  export function createSerialQueue() {
269
300
  let queue = Promise.resolve();
270
301
  return (fn) => {
@@ -357,6 +388,7 @@ export async function startWebServer(options) {
357
388
  event: 'web.auth_mode',
358
389
  bind: bindHost,
359
390
  enabled: isAuthEnabled(),
391
+ trustLoopback: !isEnvOff('IMHUB_TRUST_LOOPBACK'),
360
392
  }, `Web console auth: ${isAuthEnabled() ? 'token-gated' : 'disabled (IMHUB_WEB_AUTH=off)'}`);
361
393
  // HTTP request handler — static files + REST API
362
394
  const httpServer = createServer(async (req, res) => {
@@ -643,21 +675,31 @@ export async function startWebServer(options) {
643
675
  }
644
676
  // Jobs
645
677
  if (url.pathname === '/api/jobs' && req.method === 'GET') {
678
+ if (!requireAdmin(req, res))
679
+ return;
646
680
  return handleListJobs(req, res, url);
647
681
  }
648
682
  const jobIdMatch = url.pathname.match(/^\/api\/jobs\/(\d+)$/);
649
683
  if (jobIdMatch && req.method === 'GET') {
684
+ if (!requireAdmin(req, res))
685
+ return;
650
686
  return handleGetJob(req, res, parseInt(jobIdMatch[1], 10));
651
687
  }
652
688
  const jobCancelMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
653
689
  if (jobCancelMatch && req.method === 'POST') {
690
+ if (!requireAdmin(req, res))
691
+ return;
654
692
  return handleCancelJob(req, res, parseInt(jobCancelMatch[1], 10));
655
693
  }
656
694
  const jobRunMatch = url.pathname.match(/^\/api\/jobs\/(\d+)\/run$/);
657
695
  if (jobRunMatch && req.method === 'POST') {
696
+ if (!requireAdmin(req, res))
697
+ return;
658
698
  return handleRunJob(req, res, parseInt(jobRunMatch[1], 10));
659
699
  }
660
700
  if (url.pathname === '/api/jobs' && req.method === 'POST') {
701
+ if (!requireAdmin(req, res))
702
+ return;
661
703
  return handleCreateJob(req, res);
662
704
  }
663
705
  // bgjobs (read-only view of ~/.claude/bgjobs, ~/.config/opencode/bgjobs, ~/.codex/bgjobs)
@@ -677,34 +719,46 @@ export async function startWebServer(options) {
677
719
  return handleListSchedules(req, res, url);
678
720
  }
679
721
  // Reminders — list / cancel / snooze. Web-only path (the IM-side path
680
- // is /remind slash command). Auth via the same web session cookie as
681
- // every other /api/* endpoint above; no per-user filtering yet, so
682
- // single-operator deployments only.
722
+ // is /remind slash command). This exposes global reminders, so it is
723
+ // admin-only until a per-user mobile scope exists.
683
724
  if (url.pathname === '/api/reminders' && req.method === 'GET') {
725
+ if (!requireAdmin(req, res))
726
+ return;
684
727
  return handleListReminders(req, res, url);
685
728
  }
686
729
  const reminderCancelMatch = url.pathname.match(/^\/api\/reminders\/(\d+)\/cancel$/);
687
730
  if (reminderCancelMatch && req.method === 'POST') {
731
+ if (!requireAdmin(req, res))
732
+ return;
688
733
  return handleCancelReminderApi(req, res, Number.parseInt(reminderCancelMatch[1], 10));
689
734
  }
690
735
  const reminderSnoozeMatch = url.pathname.match(/^\/api\/reminders\/(\d+)\/snooze$/);
691
736
  if (reminderSnoozeMatch && req.method === 'POST') {
737
+ if (!requireAdmin(req, res))
738
+ return;
692
739
  return handleSnoozeReminderApi(req, res, Number.parseInt(reminderSnoozeMatch[1], 10));
693
740
  }
694
741
  // /api/memos — search / list / delete. List uses the same searchMemos
695
742
  // function the MCP tool exposes; query/who/what/has_location/limit
696
743
  // come through as URL params.
697
744
  if (url.pathname === '/api/memos' && req.method === 'GET') {
745
+ if (!requireAdmin(req, res))
746
+ return;
698
747
  return handleListMemos(req, res, url);
699
748
  }
700
749
  const memoIdMatch = url.pathname.match(/^\/api\/memos\/(\d+)$/);
701
750
  if (memoIdMatch && req.method === 'DELETE') {
751
+ if (!requireAdmin(req, res))
752
+ return;
702
753
  return handleDeleteMemo(req, res, Number.parseInt(memoIdMatch[1], 10));
703
754
  }
704
755
  // /api/env — read/write SMTP + Baidu AK + IMHUB_WEB_BIND. Values
705
756
  // sensitive enough that GET returns them masked (only the last 4 chars
706
- // visible) unless an explicit ?reveal=1 is passed (still auth-gated).
757
+ // visible) unless an explicit ?reveal=1 is passed. Keep the settings
758
+ // surface admin-only: even masked values disclose configured providers.
707
759
  if (url.pathname === '/api/env' && req.method === 'GET') {
760
+ if (!requireAdmin(req, res))
761
+ return;
708
762
  return handleGetEnv(req, res, url);
709
763
  }
710
764
  if (url.pathname === '/api/env' && req.method === 'PUT') {
@@ -866,9 +920,13 @@ export async function startWebServer(options) {
866
920
  // v1.5 — Memory admin: enumerate users, list / delete facts, view /
867
921
  // edit / delete persona, export. Backs the Memory tab in /tasks.
868
922
  if (url.pathname === '/api/memory/users' && req.method === 'GET') {
923
+ if (!requireAdmin(req, res))
924
+ return;
869
925
  return handleMemoryUsers(req, res);
870
926
  }
871
927
  if (url.pathname === '/api/memory/facts' && req.method === 'GET') {
928
+ if (!requireAdmin(req, res))
929
+ return;
872
930
  return handleMemoryFacts(req, res, url);
873
931
  }
874
932
  if (url.pathname === '/api/memory/facts' && req.method === 'DELETE') {
@@ -878,9 +936,13 @@ export async function startWebServer(options) {
878
936
  }
879
937
  const memFactIdMatch = url.pathname.match(/^\/api\/memory\/facts\/(\d+)$/);
880
938
  if (memFactIdMatch && req.method === 'DELETE') {
939
+ if (!requireAdmin(req, res))
940
+ return;
881
941
  return handleMemoryDeleteOne(req, res, url, parseInt(memFactIdMatch[1], 10));
882
942
  }
883
943
  if (url.pathname === '/api/memory/persona' && req.method === 'GET') {
944
+ if (!requireAdmin(req, res))
945
+ return;
884
946
  return handleMemoryPersona(req, res, url);
885
947
  }
886
948
  if (url.pathname === '/api/memory/persona' && req.method === 'PUT') {
@@ -894,6 +956,8 @@ export async function startWebServer(options) {
894
956
  return handleMemoryPersonaDelete(req, res, url);
895
957
  }
896
958
  if (url.pathname === '/api/memory/export' && req.method === 'GET') {
959
+ if (!requireAdmin(req, res))
960
+ return;
897
961
  return handleMemoryExport(req, res, url);
898
962
  }
899
963
  // v1.6 — vector backend control + index ops.
@@ -928,6 +992,8 @@ export async function startWebServer(options) {
928
992
  return handleMemoryConsolidate(req, res);
929
993
  }
930
994
  if (url.pathname === '/api/memory/consolidate/status' && req.method === 'GET') {
995
+ if (!requireAdmin(req, res))
996
+ return;
931
997
  return handleMemoryConsolidateStatus(req, res);
932
998
  }
933
999
  // v1.2.3 — Skills browser. Lists locally-installed claude/opencode
@@ -944,10 +1010,14 @@ export async function startWebServer(options) {
944
1010
  }
945
1011
  // PR-B: HITL approvals — global pending list + per-reqId resolve.
946
1012
  if (url.pathname === '/api/approvals' && req.method === 'GET') {
1013
+ if (!requireAdmin(req, res))
1014
+ return;
947
1015
  return handleListApprovals(req, res);
948
1016
  }
949
1017
  const approvalResolveMatch = url.pathname.match(/^\/api\/approvals\/([^/]+)\/resolve$/);
950
1018
  if (approvalResolveMatch && req.method === 'POST') {
1019
+ if (!requireAdmin(req, res))
1020
+ return;
951
1021
  return handleResolveApproval(req, res, approvalResolveMatch[1]);
952
1022
  }
953
1023
  // PR-D: Agent workspace file browser. Read-only inspection of
@@ -955,6 +1025,8 @@ export async function startWebServer(options) {
955
1025
  // text files. PUT path supports inline editing (annotate CLAUDE.md,
956
1026
  // AGENTS.md, etc.) — same traversal/size guards as GET.
957
1027
  if (url.pathname === '/api/workspace-files' && req.method === 'GET') {
1028
+ if (!requireAdmin(req, res))
1029
+ return;
958
1030
  return handleWorkspaceFiles(req, res, url);
959
1031
  }
960
1032
  if (url.pathname === '/api/workspace-files' && req.method === 'PUT') {
@@ -976,9 +1048,12 @@ export async function startWebServer(options) {
976
1048
  return handleBatchJob(req, res, 'run', getDefaultAgent(options.defaultAgent));
977
1049
  }
978
1050
  // PR-C: SSE event stream — audit / approval / job / metrics events
979
- // pushed real-time so the dashboard stops polling. Open access; same
980
- // trust model as the REST API.
1051
+ // pushed real-time so the dashboard stops polling. Global control-plane
1052
+ // telemetry is admin-only; user/mobile-scoped streams should use a
1053
+ // separate endpoint when added.
981
1054
  if (url.pathname === '/events' && req.method === 'GET') {
1055
+ if (!requireAdmin(req, res))
1056
+ return;
982
1057
  return handleEventsSSE(req, res);
983
1058
  }
984
1059
  if (url.pathname === '/api/notify' && req.method === 'POST') {
@@ -1110,11 +1185,11 @@ export async function startWebServer(options) {
1110
1185
  }, 'WS upgrade refused (per-IP rate limit)');
1111
1186
  return cb(false, 429, 'rate limited');
1112
1187
  }
1113
- // Auth-off / loopback bypass — mirror checkAuth's two short-circuits
1188
+ // Auth-off / trusted-loopback bypass — mirror checkAuth's two short-circuits
1114
1189
  // so dev / local CLI sessions still work without a token.
1115
1190
  if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
1116
1191
  return cb(true);
1117
- if (isLoopbackPeer(info.req))
1192
+ if (isTrustedLoopbackPeer(info.req))
1118
1193
  return cb(true);
1119
1194
  // Origin check: cookie SameSite=Lax handles most of the CSWSH
1120
1195
  // surface, but defence-in-depth — reject when Origin's host
@@ -1168,8 +1243,9 @@ export async function startWebServer(options) {
1168
1243
  // IMHUB_WS_MAX_PER_IP active connections per IP (default 20)
1169
1244
  // IMHUB_WS_MAX_NEW_PER_IP_PER_MIN new connections per IP per minute (default 30)
1170
1245
  //
1171
- // Loopback bypasses both — local dev / CLI tooling makes many
1172
- // short connections legitimately.
1246
+ // Trusted loopback bypasses both — local dev / CLI tooling makes many
1247
+ // short connections legitimately. Reverse-proxied loopback traffic is
1248
+ // still counted as network traffic.
1173
1249
  const wsMaxPerIp = (() => {
1174
1250
  const raw = process.env.IMHUB_WS_MAX_PER_IP;
1175
1251
  if (raw) {
@@ -1198,7 +1274,7 @@ export async function startWebServer(options) {
1198
1274
  }
1199
1275
  /** Returns {ok:true} when the IP may open a new WS, else {ok:false, reason}. */
1200
1276
  function checkWsIpRateLimit(req) {
1201
- if (isLoopbackPeer(req))
1277
+ if (isTrustedLoopbackPeer(req))
1202
1278
  return { ok: true };
1203
1279
  const ip = peerIp(req);
1204
1280
  if (!ip)
@@ -1216,7 +1292,7 @@ export async function startWebServer(options) {
1216
1292
  return { ok: true };
1217
1293
  }
1218
1294
  function recordWsIpOpen(req) {
1219
- if (isLoopbackPeer(req))
1295
+ if (isTrustedLoopbackPeer(req))
1220
1296
  return;
1221
1297
  const ip = peerIp(req);
1222
1298
  if (!ip)
@@ -1227,7 +1303,7 @@ export async function startWebServer(options) {
1227
1303
  wsPerIp.set(ip, slot);
1228
1304
  }
1229
1305
  function recordWsIpClose(req) {
1230
- if (isLoopbackPeer(req))
1306
+ if (isTrustedLoopbackPeer(req))
1231
1307
  return;
1232
1308
  const ip = peerIp(req);
1233
1309
  if (!ip)
@@ -4655,6 +4731,13 @@ function readBody(req, res) {
4655
4731
  aborted = true;
4656
4732
  if (res && !res.headersSent) {
4657
4733
  sendJson(res, 413, { error: 'Request body too large' });
4734
+ res.once('finish', () => {
4735
+ if (!req.destroyed)
4736
+ req.destroy();
4737
+ });
4738
+ }
4739
+ else if (!req.destroyed) {
4740
+ req.destroy();
4658
4741
  }
4659
4742
  const err = new Error('Request body too large');
4660
4743
  err.statusCode = 413;