agim-cli 1.2.1 → 1.2.17

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.
Files changed (199) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +18 -5
  3. package/README.zh-CN.md +20 -7
  4. package/dist/cli-ui/entry-menu.d.ts +2 -0
  5. package/dist/cli-ui/entry-menu.d.ts.map +1 -1
  6. package/dist/cli-ui/entry-menu.js +1 -0
  7. package/dist/cli-ui/entry-menu.js.map +1 -1
  8. package/dist/cli-ui/i18n.d.ts +5 -0
  9. package/dist/cli-ui/i18n.d.ts.map +1 -1
  10. package/dist/cli-ui/i18n.js +10 -0
  11. package/dist/cli-ui/i18n.js.map +1 -1
  12. package/dist/cli.js +117 -0
  13. package/dist/cli.js.map +1 -1
  14. package/dist/core/admin-bootstrap.d.ts +14 -0
  15. package/dist/core/admin-bootstrap.d.ts.map +1 -1
  16. package/dist/core/admin-bootstrap.js +21 -0
  17. package/dist/core/admin-bootstrap.js.map +1 -1
  18. package/dist/core/commands/builtin.d.ts.map +1 -1
  19. package/dist/core/commands/builtin.js +2 -0
  20. package/dist/core/commands/builtin.js.map +1 -1
  21. package/dist/core/commands/router.js +1 -1
  22. package/dist/core/commands/router.js.map +1 -1
  23. package/dist/core/commands/web.d.ts +3 -0
  24. package/dist/core/commands/web.d.ts.map +1 -0
  25. package/dist/core/commands/web.js +28 -0
  26. package/dist/core/commands/web.js.map +1 -0
  27. package/dist/core/intent.d.ts +11 -2
  28. package/dist/core/intent.d.ts.map +1 -1
  29. package/dist/core/intent.js +26 -4
  30. package/dist/core/intent.js.map +1 -1
  31. package/dist/core/memory.js.map +1 -1
  32. package/dist/core/render-router.d.ts.map +1 -1
  33. package/dist/core/render-router.js +3 -2
  34. package/dist/core/render-router.js.map +1 -1
  35. package/dist/core/router.d.ts.map +1 -1
  36. package/dist/core/router.js +8 -1
  37. package/dist/core/router.js.map +1 -1
  38. package/dist/core/types.d.ts +3 -0
  39. package/dist/core/types.d.ts.map +1 -1
  40. package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -1
  41. package/dist/plugins/agents/acp/acp-client.js +7 -0
  42. package/dist/plugins/agents/acp/acp-client.js.map +1 -1
  43. package/dist/plugins/agents/acp/discovery.d.ts.map +1 -1
  44. package/dist/plugins/agents/acp/discovery.js +4 -0
  45. package/dist/plugins/agents/acp/discovery.js.map +1 -1
  46. package/dist/plugins/agents/acp/url-guard.d.ts +44 -0
  47. package/dist/plugins/agents/acp/url-guard.d.ts.map +1 -0
  48. package/dist/plugins/agents/acp/url-guard.js +109 -0
  49. package/dist/plugins/agents/acp/url-guard.js.map +1 -0
  50. package/dist/web/env-mask.d.ts +21 -0
  51. package/dist/web/env-mask.d.ts.map +1 -0
  52. package/dist/web/env-mask.js +44 -0
  53. package/dist/web/env-mask.js.map +1 -0
  54. package/dist/web/public/assets/a2a-Dk2fSs33.js +7 -0
  55. package/dist/web/public/assets/a2a-Dk2fSs33.js.map +1 -0
  56. package/dist/web/public/assets/activity-eiIPshcV.js +7 -0
  57. package/dist/web/public/assets/activity-eiIPshcV.js.map +1 -0
  58. package/dist/web/public/assets/admins-DlbQYdW_.js +12 -0
  59. package/dist/web/public/assets/admins-DlbQYdW_.js.map +1 -0
  60. package/dist/web/public/assets/agents-BMI1WbZj.js +12 -0
  61. package/dist/web/public/assets/agents-BMI1WbZj.js.map +1 -0
  62. package/dist/web/public/assets/approvals-DlXS_sKD.js +10 -0
  63. package/dist/web/public/assets/approvals-DlXS_sKD.js.map +1 -0
  64. package/dist/web/public/assets/audit-C8I8xC_6.js +2 -0
  65. package/dist/web/public/assets/audit-C8I8xC_6.js.map +1 -0
  66. package/dist/web/public/assets/bgjobs-PFYinH7D.js +7 -0
  67. package/dist/web/public/assets/bgjobs-PFYinH7D.js.map +1 -0
  68. package/dist/web/public/assets/brain-DEEJttEL.js +7 -0
  69. package/dist/web/public/assets/brain-DEEJttEL.js.map +1 -0
  70. package/dist/web/public/assets/briefcase-BlMy8gI6.js +7 -0
  71. package/dist/web/public/assets/briefcase-BlMy8gI6.js.map +1 -0
  72. package/dist/web/public/assets/browser-ponyfill-BOcGq8h9.js +3 -0
  73. package/dist/web/public/assets/browser-ponyfill-BOcGq8h9.js.map +1 -0
  74. package/dist/web/public/assets/chevron-right-DmABPvoA.js +7 -0
  75. package/dist/web/public/assets/chevron-right-DmABPvoA.js.map +1 -0
  76. package/dist/web/public/assets/circle-check-C0Qpg1vL.js +7 -0
  77. package/dist/web/public/assets/circle-check-C0Qpg1vL.js.map +1 -0
  78. package/dist/web/public/assets/circle-check-big-C8LG3beV.js +7 -0
  79. package/dist/web/public/assets/circle-check-big-C8LG3beV.js.map +1 -0
  80. package/dist/web/public/assets/circle-x-D_cRHcHK.js +7 -0
  81. package/dist/web/public/assets/circle-x-D_cRHcHK.js.map +1 -0
  82. package/dist/web/public/assets/confirm-dialog-Baz_xFle.js +2 -0
  83. package/dist/web/public/assets/confirm-dialog-Baz_xFle.js.map +1 -0
  84. package/dist/web/public/assets/data-table--I_ktDF4.js +17 -0
  85. package/dist/web/public/assets/data-table--I_ktDF4.js.map +1 -0
  86. package/dist/web/public/assets/dialog-DZpoEskO.js +6 -0
  87. package/dist/web/public/assets/dialog-DZpoEskO.js.map +1 -0
  88. package/dist/web/public/assets/download-DbFGHwZ5.js +7 -0
  89. package/dist/web/public/assets/download-DbFGHwZ5.js.map +1 -0
  90. package/dist/web/public/assets/email-BB1Hq8eE.js +7 -0
  91. package/dist/web/public/assets/email-BB1Hq8eE.js.map +1 -0
  92. package/dist/web/public/assets/empty-state-DXNa90pP.js +2 -0
  93. package/dist/web/public/assets/empty-state-DXNa90pP.js.map +1 -0
  94. package/dist/web/public/assets/env-Bqrb9XkC.js +2 -0
  95. package/dist/web/public/assets/env-Bqrb9XkC.js.map +1 -0
  96. package/dist/web/public/assets/external-link-nhnJN0qg.js +7 -0
  97. package/dist/web/public/assets/external-link-nhnJN0qg.js.map +1 -0
  98. package/dist/web/public/assets/eye-IKkn_oUo.js +12 -0
  99. package/dist/web/public/assets/eye-IKkn_oUo.js.map +1 -0
  100. package/dist/web/public/assets/facts-C7Qy9vTw.js +2 -0
  101. package/dist/web/public/assets/facts-C7Qy9vTw.js.map +1 -0
  102. package/dist/web/public/assets/health-CMRdeNEW.js +2 -0
  103. package/dist/web/public/assets/health-CMRdeNEW.js.map +1 -0
  104. package/dist/web/public/assets/hot-Bh5Nrc7i.js +17 -0
  105. package/dist/web/public/assets/hot-Bh5Nrc7i.js.map +1 -0
  106. package/dist/web/public/assets/index-CpGWCLE5.js +166 -0
  107. package/dist/web/public/assets/index-CpGWCLE5.js.map +1 -0
  108. package/dist/web/public/assets/index-GpceOxum.css +1 -0
  109. package/dist/web/public/assets/installed-FYLkPij2.js +7 -0
  110. package/dist/web/public/assets/installed-FYLkPij2.js.map +1 -0
  111. package/dist/web/public/assets/jobs-BmqLUzHp.js +2 -0
  112. package/dist/web/public/assets/jobs-BmqLUzHp.js.map +1 -0
  113. package/dist/web/public/assets/layout-9Gp_myEd.js +2 -0
  114. package/dist/web/public/assets/layout-9Gp_myEd.js.map +1 -0
  115. package/dist/web/public/assets/layout-BZaHqf69.js +2 -0
  116. package/dist/web/public/assets/layout-BZaHqf69.js.map +1 -0
  117. package/dist/web/public/assets/layout-CXsUyEpG.js +2 -0
  118. package/dist/web/public/assets/layout-CXsUyEpG.js.map +1 -0
  119. package/dist/web/public/assets/layout-DFxtpNut.js +2 -0
  120. package/dist/web/public/assets/layout-DFxtpNut.js.map +1 -0
  121. package/dist/web/public/assets/layout-d8qxPKQk.js +2 -0
  122. package/dist/web/public/assets/layout-d8qxPKQk.js.map +1 -0
  123. package/dist/web/public/assets/loader-circle-JaKY-xMt.js +7 -0
  124. package/dist/web/public/assets/loader-circle-JaKY-xMt.js.map +1 -0
  125. package/dist/web/public/assets/map-pin-hFFSWZ3B.js +7 -0
  126. package/dist/web/public/assets/map-pin-hFFSWZ3B.js.map +1 -0
  127. package/dist/web/public/assets/memos-EhjMUvVZ.js +12 -0
  128. package/dist/web/public/assets/memos-EhjMUvVZ.js.map +1 -0
  129. package/dist/web/public/assets/messengers-BRV1IVGX.js +7 -0
  130. package/dist/web/public/assets/messengers-BRV1IVGX.js.map +1 -0
  131. package/dist/web/public/assets/network-DtCI2ZUU.js +7 -0
  132. package/dist/web/public/assets/network-DtCI2ZUU.js.map +1 -0
  133. package/dist/web/public/assets/outbox-CxUbMp6o.js +7 -0
  134. package/dist/web/public/assets/outbox-CxUbMp6o.js.map +1 -0
  135. package/dist/web/public/assets/pagination-CkZY8YNa.js +17 -0
  136. package/dist/web/public/assets/pagination-CkZY8YNa.js.map +1 -0
  137. package/dist/web/public/assets/persona-B6TFMSnI.js +2 -0
  138. package/dist/web/public/assets/persona-B6TFMSnI.js.map +1 -0
  139. package/dist/web/public/assets/play-BxRcWaH5.js +7 -0
  140. package/dist/web/public/assets/play-BxRcWaH5.js.map +1 -0
  141. package/dist/web/public/assets/policy-ndE1Y8zD.js +2 -0
  142. package/dist/web/public/assets/policy-ndE1Y8zD.js.map +1 -0
  143. package/dist/web/public/assets/react-C9F3QeMB.js +33 -0
  144. package/dist/web/public/assets/react-C9F3QeMB.js.map +1 -0
  145. package/dist/web/public/assets/refresh-ccw-Bx817_KW.js +7 -0
  146. package/dist/web/public/assets/refresh-ccw-Bx817_KW.js.map +1 -0
  147. package/dist/web/public/assets/reminders-XynkGQc5.js +17 -0
  148. package/dist/web/public/assets/reminders-XynkGQc5.js.map +1 -0
  149. package/dist/web/public/assets/save-CqMcATrh.js +7 -0
  150. package/dist/web/public/assets/save-CqMcATrh.js.map +1 -0
  151. package/dist/web/public/assets/schedules-VM02w_Om.js +7 -0
  152. package/dist/web/public/assets/schedules-VM02w_Om.js.map +1 -0
  153. package/dist/web/public/assets/search-Ba-e1t1P.js +7 -0
  154. package/dist/web/public/assets/search-Ba-e1t1P.js.map +1 -0
  155. package/dist/web/public/assets/service-C-wnwJ-b.js +7 -0
  156. package/dist/web/public/assets/service-C-wnwJ-b.js.map +1 -0
  157. package/dist/web/public/assets/status-badge-CsdJ6k8Q.js +2 -0
  158. package/dist/web/public/assets/status-badge-CsdJ6k8Q.js.map +1 -0
  159. package/dist/web/public/assets/subtasks-mGRKpF0G.js +7 -0
  160. package/dist/web/public/assets/subtasks-mGRKpF0G.js.map +1 -0
  161. package/dist/web/public/assets/table-vmLMgj6_.js +2 -0
  162. package/dist/web/public/assets/table-vmLMgj6_.js.map +1 -0
  163. package/dist/web/public/assets/topn-nu66Fotx.js +7 -0
  164. package/dist/web/public/assets/topn-nu66Fotx.js.map +1 -0
  165. package/dist/web/public/assets/trash-2-ZIitN_U3.js +7 -0
  166. package/dist/web/public/assets/trash-2-ZIitN_U3.js.map +1 -0
  167. package/dist/web/public/assets/use-event-stream-BGeFcayX.js +2 -0
  168. package/dist/web/public/assets/use-event-stream-BGeFcayX.js.map +1 -0
  169. package/dist/web/public/assets/use-memory-DgEqHEca.js +2 -0
  170. package/dist/web/public/assets/use-memory-DgEqHEca.js.map +1 -0
  171. package/dist/web/public/assets/use-observability-CQev_A8e.js +2 -0
  172. package/dist/web/public/assets/use-observability-CQev_A8e.js.map +1 -0
  173. package/dist/web/public/assets/use-settings-CU-UcrVD.js +2 -0
  174. package/dist/web/public/assets/use-settings-CU-UcrVD.js.map +1 -0
  175. package/dist/web/public/assets/use-skills-Dr77CXLA.js +2 -0
  176. package/dist/web/public/assets/use-skills-Dr77CXLA.js.map +1 -0
  177. package/dist/web/public/assets/use-workspace-PNv9Z4de.js +2 -0
  178. package/dist/web/public/assets/use-workspace-PNv9Z4de.js.map +1 -0
  179. package/dist/web/public/assets/useQuery-BTyugXYV.js +2 -0
  180. package/dist/web/public/assets/useQuery-BTyugXYV.js.map +1 -0
  181. package/dist/web/public/assets/vector-w-Ea3pg6.js +2 -0
  182. package/dist/web/public/assets/vector-w-Ea3pg6.js.map +1 -0
  183. package/dist/web/public/assets/viewer-DKA7QP9U.js +12 -0
  184. package/dist/web/public/assets/viewer-DKA7QP9U.js.map +1 -0
  185. package/dist/web/public/assets/workspace-DVLZca7t.js +17 -0
  186. package/dist/web/public/assets/workspace-DVLZca7t.js.map +1 -0
  187. package/dist/web/public/assets/workspaces-DYZsMmY-.js +7 -0
  188. package/dist/web/public/assets/workspaces-DYZsMmY-.js.map +1 -0
  189. package/dist/web/public/assets/x-Ru3rHT82.js +7 -0
  190. package/dist/web/public/assets/x-Ru3rHT82.js.map +1 -0
  191. package/dist/web/public/favicon.svg +4 -0
  192. package/dist/web/public/index.html +37 -928
  193. package/dist/web/public/manifest.webmanifest +19 -0
  194. package/dist/web/public/tasks.html +362 -6
  195. package/dist/web/public/vendor/chart.umd.min.js +20 -0
  196. package/dist/web/server.d.ts.map +1 -1
  197. package/dist/web/server.js +694 -60
  198. package/dist/web/server.js.map +1 -1
  199. package/package.json +4 -4
@@ -1,6 +1,6 @@
1
1
  // Web chat server — HTTP + WebSocket for browser-based agent interaction
2
2
  import { createServer } from 'node:http';
3
- import { readFileSync, existsSync } from 'node:fs';
3
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
4
4
  import { readdir, stat, readFile, writeFile, rename, unlink, realpath, open } from 'node:fs/promises';
5
5
  import { join, dirname, resolve as resolvePath, sep as pathSep, relative as relativePath } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -12,6 +12,7 @@ import { registry } from '../core/registry.js';
12
12
  import { sink, resolveMessenger } from '../core/message-sink.js';
13
13
  import { generateTraceId, createLogger, logger as rootLogger } from '../core/logger.js';
14
14
  import { validateConfig } from '../core/config-schema.js';
15
+ import { isMasked, maskSecret } from './env-mask.js';
15
16
  import { consumeToken, peekToken } from '../core/location-token.js';
16
17
  import { createMemo, updateMemo, getMemo, mapUrls } from '../core/memos.js';
17
18
  import { normalizeIncomingCoords } from '../core/coord-systems.js';
@@ -30,11 +31,6 @@ import { isAgentAvailableCached, loadConfig, saveConfig, } from '../core/onboard
30
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
31
32
  const PUBLIC_DIR = join(__dirname, 'public');
32
33
  const DEFAULT_PORT = 3000;
33
- function isMasked(value) {
34
- if (!value)
35
- return false;
36
- return /^.{0,2}\*{2,}.{0,2}$/.test(value);
37
- }
38
34
  /** v1.1.10 — paths that never require a web token. */
39
35
  const PUBLIC_EXACT_PATHS = new Set([
40
36
  '/login', '/login.html',
@@ -48,7 +44,7 @@ const PUBLIC_EXACT_PATHS = new Set([
48
44
  '/api/loc',
49
45
  '/favicon.ico', '/favicon.svg',
50
46
  ]);
51
- const PUBLIC_PREFIX_PATHS = ['/l/', '/v/', '/api/loc/'];
47
+ const PUBLIC_PREFIX_PATHS = ['/l/', '/v/', '/api/loc/', '/vendor/'];
52
48
  const PUBLIC_API_VIEWER_RE = /^\/api\/viewer\/[0-9a-f-]{8,}$/i;
53
49
  function isPublicPath(pathname, method) {
54
50
  if (PUBLIC_EXACT_PATHS.has(pathname))
@@ -63,8 +59,12 @@ function isPublicPath(pathname, method) {
63
59
  return false;
64
60
  }
65
61
  function isLoopbackPeer(req) {
62
+ // Treat an empty / missing remoteAddress as untrusted: under exotic
63
+ // transports or post-close sockets the field can read empty, which
64
+ // pre-R9 would silently pass auth. Tighten to exact loopback IPs
65
+ // (with the IPv4-mapped IPv6 form Node uses on dual-stack servers).
66
66
  const ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
67
- return ip === '127.0.0.1' || ip === '::1' || ip === '';
67
+ return ip === '127.0.0.1' || ip === '::1';
68
68
  }
69
69
  function extractToken(req, url) {
70
70
  // 1. Authorization: Bearer <token>
@@ -158,6 +158,13 @@ export async function startWebServer(options) {
158
158
  const port = options.port || DEFAULT_PORT;
159
159
  const bindHost = process.env.IMHUB_WEB_BIND || '127.0.0.1';
160
160
  const clients = new Map();
161
+ // R9: threadId → tokenId ownership. A WS peer claims a threadId on
162
+ // first connection (verifyClient already verified the token); later
163
+ // `get-history { threadId }` rekey requests must come from a peer
164
+ // holding the same tokenId, otherwise an authenticated operator could
165
+ // adopt and wipe another operator's session. Survives reconnects
166
+ // within the process lifetime so a page refresh still works.
167
+ const threadOwners = new Map();
161
168
  // v1.1.10+: token-based auth for the web console.
162
169
  // ─ loopback peers (127.0.0.1 / ::1) bypass auth (local CLI / curl)
163
170
  // ─ public viewer pages (/v/:id, /loc, /l/<token>) remain public
@@ -193,7 +200,49 @@ export async function startWebServer(options) {
193
200
  // Loopback peers + the public paths below bypass.
194
201
  if (!checkAuth(req, res, url))
195
202
  return;
196
- // Static pagesdirect serve, no auth gate.
203
+ // v2 SPA fallback opt-in via IMHUB_WEB_V2=1. When enabled, any
204
+ // GET that doesn't match an API / WS / SSE / static-asset path
205
+ // and isn't a public SSR page falls through to serving the v2
206
+ // index.html. The client-side router (react-router) then resolves
207
+ // the path. Anything served from src/web-app/dist/* is built in
208
+ // the M5 PR; until then, set IMHUB_WEB_V2=1 only if you've run
209
+ // `npm --prefix src/web-app run build` and copied dist/* into
210
+ // src/web/public/`.
211
+ //
212
+ // Default IMHUB_WEB_V2=0 → existing behavior unchanged: tasks.html
213
+ // / reminders.html / memos.html / settings.html / index.html are
214
+ // served as before. Zero impact on production deployments.
215
+ if (process.env.IMHUB_WEB_V2 === '1' && req.method === 'GET') {
216
+ const p = url.pathname;
217
+ const isApi = p.startsWith('/api/');
218
+ const isWs = p.startsWith('/ws');
219
+ const isSse = p === '/events';
220
+ const isVendor = p.startsWith('/vendor/');
221
+ const isLegacyShared = p === '/_app.js' || p === '/_shell.css';
222
+ const isPublicSsr = p === '/loc' || p === '/loc.html' || p.startsWith('/l/') || p.startsWith('/v/');
223
+ if (!isApi && !isWs && !isSse && !isVendor && !isLegacyShared && !isPublicSsr) {
224
+ // SPA static asset (assets/index-xxx.js, /manifest.webmanifest,
225
+ // /favicon.svg) → return the file if it exists in PUBLIC_DIR.
226
+ // Otherwise fall back to /index.html so the router handles it.
227
+ const trimmed = p.replace(/^\//, '');
228
+ const candidate = join(PUBLIC_DIR, trimmed);
229
+ if (trimmed && existsSync(candidate) && !candidate.endsWith('.html')) {
230
+ // Best-effort content-type guess; serveStatic handles binary
231
+ // + sets cache headers.
232
+ const ct = candidate.endsWith('.js') ? 'application/javascript; charset=utf-8' :
233
+ candidate.endsWith('.css') ? 'text/css; charset=utf-8' :
234
+ candidate.endsWith('.svg') ? 'image/svg+xml' :
235
+ candidate.endsWith('.webmanifest') ? 'application/manifest+json' :
236
+ candidate.endsWith('.json') ? 'application/json; charset=utf-8' :
237
+ 'application/octet-stream';
238
+ return serveStatic(res, candidate, ct);
239
+ }
240
+ return servePageHtml(res, join(PUBLIC_DIR, 'index.html'));
241
+ }
242
+ // Falls through to API / WS / SSR-page handlers below.
243
+ }
244
+ // Static pages — direct serve, no auth gate. This is the v1
245
+ // routing path; v2 takes precedence when IMHUB_WEB_V2=1 (above).
197
246
  if (url.pathname === '/' || url.pathname === '/index.html' ||
198
247
  url.pathname === '/settings' || url.pathname === '/settings.html' ||
199
248
  url.pathname === '/tasks' || url.pathname === '/tasks.html' ||
@@ -232,6 +281,22 @@ export async function startWebServer(options) {
232
281
  if (url.pathname === '/_app.js' && req.method === 'GET') {
233
282
  return serveStatic(res, join(PUBLIC_DIR, '_app.js'), 'application/javascript; charset=utf-8');
234
283
  }
284
+ // v1.2.3 — vendored third-party assets (Chart.js etc). Whitelist by
285
+ // filename pattern to keep the static surface tight. Public on
286
+ // purpose — no auth gate (third-party libs aren't secrets).
287
+ if (req.method === 'GET' && url.pathname.startsWith('/vendor/')) {
288
+ const fname = url.pathname.slice('/vendor/'.length);
289
+ // Only allow plain filenames (no traversal, no nested dirs).
290
+ if (!/^[A-Za-z0-9._-]+\.(js|css|map)$/.test(fname)) {
291
+ res.statusCode = 404;
292
+ res.end('vendor file not whitelisted');
293
+ return;
294
+ }
295
+ const ct = fname.endsWith('.css') ? 'text/css; charset=utf-8'
296
+ : fname.endsWith('.map') ? 'application/json; charset=utf-8'
297
+ : 'application/javascript; charset=utf-8';
298
+ return serveStatic(res, join(PUBLIC_DIR, 'vendor', fname), ct);
299
+ }
235
300
  // ─── Location capture endpoints (public; auth via single-use token) ───
236
301
  // Public on purpose — the user reaches these via a token-bearing link
237
302
  // sent into their IM thread. Token (32 hex chars, 10-min TTL, single
@@ -487,6 +552,9 @@ export async function startWebServer(options) {
487
552
  if (url.pathname === '/api/env' && req.method === 'PUT') {
488
553
  return handlePutEnv(req, res);
489
554
  }
555
+ if (url.pathname === '/api/messengers/email/test' && req.method === 'POST') {
556
+ return handleEmailTest(req, res);
557
+ }
490
558
  if (url.pathname === '/api/workspaces' && req.method === 'GET') {
491
559
  return handleListWorkspaces(req, res, url);
492
560
  }
@@ -615,6 +683,26 @@ export async function startWebServer(options) {
615
683
  if (url.pathname === '/api/memory/vector/clear' && req.method === 'POST') {
616
684
  return handleVectorClear(req, res, url);
617
685
  }
686
+ // v1.2.2 — manual trigger for the daily consolidation. Useful when the
687
+ // user just wants a persona summary now without waiting 24h.
688
+ if (url.pathname === '/api/memory/consolidate' && req.method === 'POST') {
689
+ return handleMemoryConsolidate(req, res);
690
+ }
691
+ if (url.pathname === '/api/memory/consolidate/status' && req.method === 'GET') {
692
+ return handleMemoryConsolidateStatus(req, res);
693
+ }
694
+ // v1.2.3 — Skills browser. Lists locally-installed claude/opencode
695
+ // skills with frontmatter; modal renders the full SKILL.md markdown.
696
+ if (url.pathname === '/api/skills' && req.method === 'GET') {
697
+ return handleSkillsList(req, res);
698
+ }
699
+ if (url.pathname === '/api/skills/remote/hot' && req.method === 'GET') {
700
+ return handleSkillsRemoteHot(req, res);
701
+ }
702
+ const skillDetailMatch = url.pathname.match(/^\/api\/skills\/([A-Za-z0-9._-]+)$/);
703
+ if (skillDetailMatch && req.method === 'GET') {
704
+ return handleSkillDetail(req, res, skillDetailMatch[1]);
705
+ }
618
706
  // PR-B: HITL approvals — global pending list + per-reqId resolve.
619
707
  if (url.pathname === '/api/approvals' && req.method === 'GET') {
620
708
  return handleListApprovals(req, res);
@@ -699,8 +787,52 @@ export async function startWebServer(options) {
699
787
  res.writeHead(404);
700
788
  res.end('Not found');
701
789
  });
702
- // WebSocket server
703
- const wss = new WebSocketServer({ server: httpServer });
790
+ // WebSocket server with auth + same-origin gate. Before R9 the WS
791
+ // upgrade event bypassed checkAuth entirely (the upgrade path isn't
792
+ // a normal HTTP request handler), leaving every chat / approval-card
793
+ // socket open to anyone reachable on the port. verifyClient runs the
794
+ // same token check as the REST path and locks Origin to the request's
795
+ // host so a third-party page can't open a cross-site WS.
796
+ const wss = new WebSocketServer({
797
+ server: httpServer,
798
+ verifyClient: (info, cb) => {
799
+ // Auth-off / loopback bypass — mirror checkAuth's two short-circuits
800
+ // so dev / local CLI sessions still work without a token.
801
+ if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
802
+ return cb(true);
803
+ if (isLoopbackPeer(info.req))
804
+ return cb(true);
805
+ // Origin check: cookie SameSite=Lax handles most of the CSWSH
806
+ // surface, but defence-in-depth — reject when Origin's host
807
+ // doesn't match the request Host header. Origin can be absent
808
+ // (non-browser clients); we allow that since they can't carry a
809
+ // browser cookie anyway and must supply Bearer.
810
+ const origin = info.origin || info.req.headers.origin;
811
+ if (typeof origin === 'string' && origin) {
812
+ try {
813
+ if (new URL(origin).host !== info.req.headers.host) {
814
+ return cb(false, 403, 'origin mismatch');
815
+ }
816
+ }
817
+ catch {
818
+ return cb(false, 400, 'invalid origin');
819
+ }
820
+ }
821
+ // Auth: token from Authorization / Cookie / ?token=.
822
+ try {
823
+ const url = new URL(info.req.url || '/ws', `http://${info.req.headers.host}`);
824
+ const tok = extractToken(info.req, url);
825
+ const tokenId = tok ? verifyTokenSync(tok) : null;
826
+ if (!tokenId)
827
+ return cb(false, 401, 'auth required');
828
+ info.req._agimTokenId = tokenId;
829
+ cb(true);
830
+ }
831
+ catch {
832
+ cb(false, 500, 'auth error');
833
+ }
834
+ },
835
+ });
704
836
  // M3: cap concurrent WS clients so a leaked / shared web token can't OOM
705
837
  // the host by opening unbounded connections. Default 100 is generous for
706
838
  // a single-user / small-team setup; production multi-tenant should set
@@ -726,10 +858,24 @@ export async function startWebServer(options) {
726
858
  ws.close(1013, 'Server too busy');
727
859
  return;
728
860
  }
729
- // No auth gate access control is upstream (reverse proxy / firewall).
730
- const clientId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
861
+ // R9: token verified at upgrade time by verifyClient; we read the
862
+ // tokenId here so get-history can enforce ownership when the client
863
+ // wants to rekey to a persisted threadId. 'anon' is the loopback /
864
+ // auth-off path — still safe because those paths trust the peer.
865
+ const tokenId = req._agimTokenId ?? 'anon';
866
+ // R9: crypto.randomBytes(12) → 96 bits of base64url entropy
867
+ // (~16 chars). The pre-R9 `Date.now() + Math.random().slice(2,8)`
868
+ // gave ~30 bits and a publicly enumerable timestamp prefix — enough
869
+ // for an authenticated peer to brute-force another live session's
870
+ // id. With H2 ownership-by-tokenId the impact is bounded to
871
+ // same-tokenId tabs (which is fine), but the stronger id closes the
872
+ // pre-rekey race window.
873
+ const clientId = `web-${randomBytes(12).toString('base64url')}`;
731
874
  const client = { ws, id: clientId, agent: options.defaultAgent };
732
875
  clients.set(clientId, client);
876
+ // First-claim ownership: this connection's tokenId owns this
877
+ // brand-new threadId until disconnect.
878
+ threadOwners.set(clientId, tokenId);
733
879
  webLog.info({ clientId }, 'Client connected');
734
880
  // Send available agents list
735
881
  sendToClient(ws, {
@@ -753,9 +899,16 @@ export async function startWebServer(options) {
753
899
  if (msg && msg.type === 'approval-action') {
754
900
  const actionData = String(msg.data || '');
755
901
  const messageId = String(msg.messageId || '');
902
+ // Use `client.id` (mutable; updated on session rekey) — not
903
+ // the original `clientId` const — so the threadId we hand
904
+ // to the bus matches the threadId the bus stamped on the
905
+ // pending approval request. Without this, a session that
906
+ // was rekeyed via `get-history` reports the stale id and
907
+ // the bus can't find the open req to resolve.
908
+ const liveId = client.id;
756
909
  webLog.info({
757
910
  event: 'approval.web.click_received',
758
- clientId, data: actionData, messageId,
911
+ clientId: liveId, data: actionData, messageId,
759
912
  handlerBound: !!webButtonHandler,
760
913
  });
761
914
  if (!actionData || !messageId) {
@@ -767,7 +920,7 @@ export async function startWebServer(options) {
767
920
  // failure mode that PR-A's fix patches. Tell the user and the
768
921
  // operator (via log) instead of dropping the click.
769
922
  const why = 'approval handler not bound (router not installed?). Restart agim to rebind.';
770
- webLog.warn({ event: 'approval.web.no_handler', clientId, data: actionData, messageId }, why);
923
+ webLog.warn({ event: 'approval.web.no_handler', clientId: liveId, data: actionData, messageId }, why);
771
924
  sendToClient(ws, { type: 'error', message: why });
772
925
  return;
773
926
  }
@@ -777,33 +930,67 @@ export async function startWebServer(options) {
777
930
  // ack is a no-op resolving to the in-page status the page itself
778
931
  // chose to render after click.
779
932
  await webButtonHandler({
780
- data: actionData, threadId: clientId, userId: `web:${clientId}`,
933
+ data: actionData, threadId: liveId, userId: `web:${liveId}`,
781
934
  userDisplay: 'Web', messageId, ack: async () => { },
782
935
  });
783
- webLog.info({ event: 'approval.web.click_resolved', clientId, data: actionData });
936
+ webLog.info({ event: 'approval.web.click_resolved', clientId: liveId, data: actionData });
784
937
  }
785
938
  catch (err) {
786
939
  const errMsg = err instanceof Error ? err.message : String(err);
787
- webLog.error({ event: 'approval.web.click_failed', clientId, data: actionData, err: errMsg });
940
+ webLog.error({ event: 'approval.web.click_failed', clientId: liveId, data: actionData, err: errMsg });
788
941
  sendToClient(ws, { type: 'error', message: `Approval click failed: ${errMsg}` });
789
942
  }
790
943
  return;
791
944
  }
792
- await handleClientMessage(client, msg, options.defaultAgent);
945
+ // Pass an onRekey callback so handleClientMessage can keep
946
+ // the `clients` map in sync when a browser refresh resumes
947
+ // with its persisted threadId. Otherwise the map stays keyed
948
+ // by the WS-assigned id, and the web messenger's outbound
949
+ // approval-card lookup misses the live client.
950
+ await handleClientMessage(client, msg, options.defaultAgent, (oldId, newId) => {
951
+ // Only reassign if we actually owned `oldId` — defensive
952
+ // against the (impossible-but-cheap-to-guard) case of two
953
+ // clients colliding on the rekey path.
954
+ if (clients.get(oldId) === client)
955
+ clients.delete(oldId);
956
+ clients.set(newId, client);
957
+ // R9: keep the ownership map aligned with the live id so
958
+ // a subsequent rekey from another connection (same token,
959
+ // e.g. a second tab) succeeds with the new threadId.
960
+ const owner = threadOwners.get(oldId);
961
+ if (owner !== undefined) {
962
+ threadOwners.delete(oldId);
963
+ threadOwners.set(newId, owner);
964
+ }
965
+ }, tokenId, threadOwners);
793
966
  }
794
967
  catch (err) {
795
- webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Error parsing client message');
968
+ webLog.error({ clientId: client.id, err: err instanceof Error ? err.message : String(err) }, 'Error parsing client message');
796
969
  sendToClient(ws, { type: 'error', message: 'Invalid message format' });
797
970
  }
798
971
  });
799
972
  });
800
973
  ws.on('close', () => {
801
- webLog.info({ clientId }, 'Client disconnected');
802
- clients.delete(clientId);
974
+ // Use `client.id` rather than the closure-captured `clientId` so
975
+ // we delete whichever key the map is actually using after a
976
+ // potential rekey. The map maintenance on rekey deletes the old
977
+ // key, so this just covers the live one.
978
+ const liveId = client.id;
979
+ webLog.info({ clientId: liveId }, 'Client disconnected');
980
+ clients.delete(liveId);
981
+ // R9: drop the ownership entry only for server-generated ids that
982
+ // were never rekeyed to a persistent client-side threadId. Rekeyed
983
+ // ids stay registered so the operator's next reconnect with the
984
+ // same localStorage id finds its previous owner.
985
+ if (liveId === clientId)
986
+ threadOwners.delete(liveId);
803
987
  });
804
988
  ws.on('error', (err) => {
805
- webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
806
- clients.delete(clientId);
989
+ const liveId = client.id;
990
+ webLog.error({ clientId: liveId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
991
+ clients.delete(liveId);
992
+ if (liveId === clientId)
993
+ threadOwners.delete(liveId);
807
994
  });
808
995
  });
809
996
  // Default to loopback; operators can opt into LAN/public exposure with
@@ -1022,7 +1209,10 @@ async function handlePutConfig(req, res) {
1022
1209
  }
1023
1210
  const result = validateConfig(merged);
1024
1211
  if (!result.ok) {
1025
- sendJson(res, 400, { error: 'Config validation failed', details: result.errors });
1212
+ sendError(res, 400, 'CONFIG_INVALID', {
1213
+ message: 'Config validation failed',
1214
+ extra: { details: result.errors },
1215
+ });
1026
1216
  return;
1027
1217
  }
1028
1218
  await saveConfig(result.config);
@@ -1030,7 +1220,7 @@ async function handlePutConfig(req, res) {
1030
1220
  }
1031
1221
  catch (err) {
1032
1222
  const msg = err instanceof Error ? err.message : String(err);
1033
- sendJson(res, 400, { error: msg });
1223
+ sendError(res, 400, 'CONFIG_SAVE_FAILED', { detail: msg });
1034
1224
  }
1035
1225
  }
1036
1226
  async function handleAgentsStatus(_req, res) {
@@ -1865,13 +2055,8 @@ const ENV_EDITABLE_KEYS = [
1865
2055
  'IMHUB_MEMORY_VECTOR_HYBRID_WEIGHT',
1866
2056
  ];
1867
2057
  const SECRET_KEYS = new Set(['IMHUB_SMTP_PASS', 'IMHUB_BAIDU_MAP_AK', 'IMHUB_MEMORY_VECTOR_OPENAI_API_KEY']);
1868
- function maskSecret(v) {
1869
- if (!v)
1870
- return '';
1871
- if (v.length <= 8)
1872
- return '*'.repeat(v.length);
1873
- return v.slice(0, 4) + '*'.repeat(Math.max(0, v.length - 8)) + v.slice(-4);
1874
- }
2058
+ // maskSecret moved to ./env-mask.ts (imported at the top of this file
2059
+ // alongside isMasked).
1875
2060
  async function handleGetEnv(_req, res, url) {
1876
2061
  try {
1877
2062
  const { readEnvFile } = await import('../cli-ui/env-file.js');
@@ -1896,7 +2081,7 @@ async function handlePutEnv(req, res) {
1896
2081
  const parsed = JSON.parse(body || '{}');
1897
2082
  const updates = parsed.updates;
1898
2083
  if (!updates || typeof updates !== 'object') {
1899
- sendJson(res, 400, { error: 'updates object required' });
2084
+ sendError(res, 400, 'ENV_UPDATES_REQUIRED', { message: 'updates object required' });
1900
2085
  return;
1901
2086
  }
1902
2087
  // Filter to whitelist — never let arbitrary keys through.
@@ -1914,7 +2099,7 @@ async function handlePutEnv(req, res) {
1914
2099
  }
1915
2100
  }
1916
2101
  if (Object.keys(safe).length === 0) {
1917
- sendJson(res, 400, { error: 'no editable keys in updates' });
2102
+ sendError(res, 400, 'ENV_NO_EDITABLE_KEYS', { message: 'no editable keys in updates' });
1918
2103
  return;
1919
2104
  }
1920
2105
  const { updateEnvFile } = await import('../cli-ui/env-file.js');
@@ -1936,6 +2121,59 @@ async function handlePutEnv(req, res) {
1936
2121
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1937
2122
  }
1938
2123
  }
2124
+ // POST /api/messengers/email/test — verify SMTP credentials currently in
2125
+ // process.env by opening a fresh nodemailer transporter and calling
2126
+ // `verify()`. Doesn't actually send mail. Returns { ok, message } on
2127
+ // success or { ok: false, error } with a clean error string. Operators
2128
+ // save first, then click "Test" — same UX as v1's settings page.
2129
+ async function handleEmailTest(req, res) {
2130
+ try {
2131
+ // Drain body even though we don't use it, so the connection doesn't
2132
+ // stall under keep-alive.
2133
+ await readBody(req, res);
2134
+ const host = process.env.IMHUB_SMTP_HOST?.trim();
2135
+ const user = process.env.IMHUB_SMTP_USER?.trim();
2136
+ const pass = process.env.IMHUB_SMTP_PASS;
2137
+ if (!host || !user || !pass) {
2138
+ sendError(res, 400, 'EMAIL_NOT_CONFIGURED', {
2139
+ message: 'SMTP not configured (host / user / pass required)',
2140
+ extra: { ok: false },
2141
+ });
2142
+ return;
2143
+ }
2144
+ const portRaw = process.env.IMHUB_SMTP_PORT?.trim();
2145
+ const portNum = portRaw ? Number.parseInt(portRaw, 10) : 465;
2146
+ const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 465;
2147
+ const secureRaw = process.env.IMHUB_SMTP_SECURE?.trim().toLowerCase();
2148
+ const secure = secureRaw !== undefined
2149
+ ? (secureRaw === '1' || secureRaw === 'true' || secureRaw === 'yes')
2150
+ : port === 465;
2151
+ const nodemailer = (await import('nodemailer')).default;
2152
+ const tx = nodemailer.createTransport({
2153
+ host, port, secure,
2154
+ auth: { user, pass },
2155
+ // Short timeouts — the UI should see a result within ~10s rather
2156
+ // than hanging on an unreachable host.
2157
+ connectionTimeout: 8000,
2158
+ greetingTimeout: 6000,
2159
+ socketTimeout: 8000,
2160
+ });
2161
+ try {
2162
+ await tx.verify();
2163
+ sendJson(res, 200, { ok: true, message: `Connected to ${host}:${port}` });
2164
+ }
2165
+ finally {
2166
+ tx.close();
2167
+ }
2168
+ }
2169
+ catch (err) {
2170
+ const msg = err instanceof Error ? err.message : String(err);
2171
+ sendError(res, 400, 'EMAIL_VERIFY_FAILED', {
2172
+ detail: msg,
2173
+ extra: { ok: false },
2174
+ });
2175
+ }
2176
+ }
1939
2177
  async function handleListBgjobs(_req, res, url) {
1940
2178
  try {
1941
2179
  const { resolveRoots, listJobsForRoot, listAllJobs } = await import('../core/bgjob-reader.js');
@@ -2091,7 +2329,7 @@ async function handleMemoryFacts(_req, res, url) {
2091
2329
  try {
2092
2330
  const user_key = readUserKey(url);
2093
2331
  if (!user_key) {
2094
- sendJson(res, 400, { error: 'user_key required (platform:userId)' });
2332
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required (platform:userId)' });
2095
2333
  return;
2096
2334
  }
2097
2335
  const { listFacts } = await import('../core/memory.js');
@@ -2111,7 +2349,7 @@ async function handleMemoryDeleteOne(req, res, url, id) {
2111
2349
  try {
2112
2350
  const user_key = readUserKey(url);
2113
2351
  if (!user_key) {
2114
- sendJson(res, 400, { error: 'user_key required' });
2352
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2115
2353
  return;
2116
2354
  }
2117
2355
  const { deleteFact } = await import('../core/memory.js');
@@ -2127,7 +2365,7 @@ async function handleMemoryBulkDelete(req, res, url) {
2127
2365
  try {
2128
2366
  const user_key = readUserKey(url);
2129
2367
  if (!user_key) {
2130
- sendJson(res, 400, { error: 'user_key required' });
2368
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2131
2369
  return;
2132
2370
  }
2133
2371
  const body = await readBody(req, res);
@@ -2156,7 +2394,7 @@ async function handleMemoryPersona(_req, res, url) {
2156
2394
  try {
2157
2395
  const user_key = readUserKey(url);
2158
2396
  if (!user_key) {
2159
- sendJson(res, 400, { error: 'user_key required' });
2397
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2160
2398
  return;
2161
2399
  }
2162
2400
  const { getPersona } = await import('../core/memory.js');
@@ -2175,7 +2413,7 @@ async function handleMemoryPersonaPut(req, res, url) {
2175
2413
  try {
2176
2414
  const user_key = readUserKey(url);
2177
2415
  if (!user_key) {
2178
- sendJson(res, 400, { error: 'user_key required' });
2416
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2179
2417
  return;
2180
2418
  }
2181
2419
  const body = await readBody(req, res);
@@ -2197,7 +2435,7 @@ async function handleMemoryPersonaDelete(_req, res, url) {
2197
2435
  try {
2198
2436
  const user_key = readUserKey(url);
2199
2437
  if (!user_key) {
2200
- sendJson(res, 400, { error: 'user_key required' });
2438
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2201
2439
  return;
2202
2440
  }
2203
2441
  const { deletePersona } = await import('../core/memory.js');
@@ -2212,7 +2450,7 @@ async function handleMemoryExport(_req, res, url) {
2212
2450
  try {
2213
2451
  const user_key = readUserKey(url);
2214
2452
  if (!user_key) {
2215
- sendJson(res, 400, { error: 'user_key required' });
2453
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2216
2454
  return;
2217
2455
  }
2218
2456
  const { exportUserMemory } = await import('../core/memory.js');
@@ -2370,6 +2608,250 @@ async function handleVectorBackfill(req, res, url) {
2370
2608
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2371
2609
  }
2372
2610
  }
2611
+ const consolidateJobs = new Map();
2612
+ function pickRecentConsolidate() {
2613
+ return Array.from(consolidateJobs.values())
2614
+ .sort((a, b) => b.startedAt - a.startedAt)
2615
+ .slice(0, 5);
2616
+ }
2617
+ async function handleMemoryConsolidate(_req, res) {
2618
+ try {
2619
+ // Dedupe: if a consolidate is already running, return that jobId.
2620
+ for (const j of consolidateJobs.values()) {
2621
+ if (j.phase === 'running') {
2622
+ sendJson(res, 202, { ok: true, jobId: j.id, alreadyRunning: true });
2623
+ return;
2624
+ }
2625
+ }
2626
+ const id = `consolidate-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
2627
+ const job = { id, startedAt: Date.now(), finishedAt: null, phase: 'running', result: {} };
2628
+ consolidateJobs.set(id, job);
2629
+ if (consolidateJobs.size > 50) {
2630
+ const oldest = Array.from(consolidateJobs.values()).sort((a, b) => a.startedAt - b.startedAt)[0];
2631
+ if (oldest)
2632
+ consolidateJobs.delete(oldest.id);
2633
+ }
2634
+ void (async () => {
2635
+ try {
2636
+ const { runConsolidationOnce } = await import('../core/memory-consolidate.js');
2637
+ const r = await runConsolidationOnce();
2638
+ job.finishedAt = Date.now();
2639
+ job.phase = 'done';
2640
+ job.result = r;
2641
+ }
2642
+ catch (err) {
2643
+ job.finishedAt = Date.now();
2644
+ job.phase = 'failed';
2645
+ job.result = { error: err instanceof Error ? err.message : String(err) };
2646
+ }
2647
+ })();
2648
+ sendJson(res, 202, { ok: true, jobId: id, hint: 'poll /api/memory/consolidate/status for progress' });
2649
+ }
2650
+ catch (err) {
2651
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2652
+ }
2653
+ }
2654
+ async function handleMemoryConsolidateStatus(_req, res) {
2655
+ sendJson(res, 200, { jobs: pickRecentConsolidate() });
2656
+ }
2657
+ function inferCategory(slug) {
2658
+ if (slug.startsWith('fin-data-') || slug.startsWith('mx_') || slug.startsWith('ttfund-')
2659
+ || slug.startsWith('ash-') || slug.startsWith('low-position-pick')
2660
+ || slug.startsWith('overnight-') || slug.startsWith('garp-')
2661
+ || slug.startsWith('gold') || slug === 'precious-metals-report')
2662
+ return '金融数据 / 行情';
2663
+ if (slug.startsWith('white-pig-') || slug.startsWith('add-white-pig')
2664
+ || slug.startsWith('renew-white-pig') || slug.startsWith('deduct-white-pig')
2665
+ || slug.startsWith('delete-white-pig') || slug.startsWith('modify-white-pig')
2666
+ || slug.startsWith('add-white-pig-') || slug.startsWith('query-account')
2667
+ || slug.startsWith('query-white-pig') || slug.startsWith('set-kol')
2668
+ || slug.startsWith('statistics-sales'))
2669
+ return '白猪运营';
2670
+ if (slug.startsWith('metaso-') || slug === 'multi-search-engine'
2671
+ || slug === 'news-summary' || slug === 'global-financial-headlines'
2672
+ || slug.startsWith('qveris'))
2673
+ return '搜索 / 资讯';
2674
+ if (slug.startsWith('mailsender') || slug === 'sendmail')
2675
+ return '邮件 / 通讯';
2676
+ if (slug.startsWith('claude-api') || slug === 'security-review' || slug === 'review'
2677
+ || slug === 'simplify' || slug === 'first-principles-decomposer'
2678
+ || slug === 'session_summary' || slug === 'init')
2679
+ return '开发 / 评审';
2680
+ if (slug === 'find-skills' || slug === 'update-config' || slug === 'keybindings-help'
2681
+ || slug === 'fewer-permission-prompts' || slug === 'loop' || slug === 'schedule'
2682
+ || slug === 'yanshen-manual')
2683
+ return '元能力 / 平台';
2684
+ return '其他';
2685
+ }
2686
+ function readSkillFrontmatter(skillMdPath) {
2687
+ try {
2688
+ const raw = readFileSync(skillMdPath, 'utf-8');
2689
+ if (!raw.startsWith('---'))
2690
+ return null;
2691
+ const end = raw.indexOf('\n---', 3);
2692
+ if (end < 0)
2693
+ return null;
2694
+ const fm = raw.slice(3, end);
2695
+ const out = {};
2696
+ for (const line of fm.split('\n')) {
2697
+ const m = line.match(/^(\w+)\s*:\s*(.+?)\s*$/);
2698
+ if (!m)
2699
+ continue;
2700
+ const k = m[1].toLowerCase();
2701
+ const v = m[2].replace(/^["']|["']$/g, '');
2702
+ if (k === 'name')
2703
+ out.name = v;
2704
+ else if (k === 'description')
2705
+ out.description = v;
2706
+ }
2707
+ return out;
2708
+ }
2709
+ catch {
2710
+ return null;
2711
+ }
2712
+ }
2713
+ function scanSkillDir(rootDir) {
2714
+ try {
2715
+ if (!existsSync(rootDir))
2716
+ return [];
2717
+ const out = [];
2718
+ for (const entry of readdirSync(rootDir, { withFileTypes: true })) {
2719
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
2720
+ continue;
2721
+ const md = join(rootDir, entry.name, 'SKILL.md');
2722
+ if (existsSync(md))
2723
+ out.push({ slug: entry.name, skillMd: md });
2724
+ }
2725
+ return out;
2726
+ }
2727
+ catch {
2728
+ return [];
2729
+ }
2730
+ }
2731
+ const HOME = process.env.HOME || '/root';
2732
+ const CLAUDE_SKILLS = `${HOME}/.claude/skills`;
2733
+ const OPENCODE_SKILLS = `${HOME}/.config/opencode/skills`;
2734
+ function listSkills() {
2735
+ const fromClaude = scanSkillDir(CLAUDE_SKILLS);
2736
+ const fromOpencode = scanSkillDir(OPENCODE_SKILLS);
2737
+ const map = new Map();
2738
+ for (const { slug, skillMd } of fromClaude) {
2739
+ const fm = readSkillFrontmatter(skillMd);
2740
+ if (!fm?.name)
2741
+ continue;
2742
+ map.set(slug, {
2743
+ slug,
2744
+ name: fm.name,
2745
+ description: fm.description || '',
2746
+ category: inferCategory(slug),
2747
+ agents: ['claude'],
2748
+ path: skillMd,
2749
+ });
2750
+ }
2751
+ for (const { slug, skillMd } of fromOpencode) {
2752
+ const fm = readSkillFrontmatter(skillMd);
2753
+ if (!fm?.name)
2754
+ continue;
2755
+ const existing = map.get(slug);
2756
+ if (existing) {
2757
+ if (!existing.agents.includes('opencode'))
2758
+ existing.agents.push('opencode');
2759
+ }
2760
+ else {
2761
+ map.set(slug, {
2762
+ slug,
2763
+ name: fm.name,
2764
+ description: fm.description || '',
2765
+ category: inferCategory(slug),
2766
+ agents: ['opencode'],
2767
+ path: skillMd,
2768
+ });
2769
+ }
2770
+ }
2771
+ return Array.from(map.values()).sort((a, b) => {
2772
+ if (a.category !== b.category)
2773
+ return a.category.localeCompare(b.category);
2774
+ return a.slug.localeCompare(b.slug);
2775
+ });
2776
+ }
2777
+ async function handleSkillsList(_req, res) {
2778
+ try {
2779
+ const skills = listSkills();
2780
+ const byCategory = {};
2781
+ for (const s of skills)
2782
+ byCategory[s.category] = (byCategory[s.category] || 0) + 1;
2783
+ sendJson(res, 200, {
2784
+ total: skills.length,
2785
+ byCategory,
2786
+ skills,
2787
+ sources: { claude: CLAUDE_SKILLS, opencode: OPENCODE_SKILLS },
2788
+ });
2789
+ }
2790
+ catch (err) {
2791
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2792
+ }
2793
+ }
2794
+ // skillhub.cn proxy with 5-min cache — shows top-50 popular skills so the
2795
+ // user can discover new ones without leaving the dashboard. The actual
2796
+ // install still happens via the skillhub CLI on the host (see docs).
2797
+ let remoteHotCache = null;
2798
+ const REMOTE_HOT_TTL_MS = 5 * 60_000;
2799
+ async function handleSkillsRemoteHot(_req, res) {
2800
+ try {
2801
+ if (remoteHotCache && Date.now() - remoteHotCache.fetchedAt < REMOTE_HOT_TTL_MS) {
2802
+ sendJson(res, 200, { ...remoteHotCache.data, cached: true, fetchedAt: remoteHotCache.fetchedAt });
2803
+ return;
2804
+ }
2805
+ const r = await fetch('https://api.skillhub.cn/api/v1/showcase/hot', {
2806
+ signal: AbortSignal.timeout(8000),
2807
+ headers: { accept: 'application/json' },
2808
+ });
2809
+ if (!r.ok) {
2810
+ // Serve stale-while-error if we have any cache
2811
+ if (remoteHotCache) {
2812
+ sendJson(res, 200, { ...remoteHotCache.data, cached: true, fetchedAt: remoteHotCache.fetchedAt, stale: true, upstreamStatus: r.status });
2813
+ return;
2814
+ }
2815
+ sendJson(res, 502, { error: `skillhub upstream ${r.status}` });
2816
+ return;
2817
+ }
2818
+ const json = await r.json();
2819
+ remoteHotCache = { fetchedAt: Date.now(), data: json };
2820
+ sendJson(res, 200, { ...json, cached: false, fetchedAt: remoteHotCache.fetchedAt });
2821
+ }
2822
+ catch (err) {
2823
+ if (remoteHotCache) {
2824
+ sendJson(res, 200, {
2825
+ ...remoteHotCache.data,
2826
+ cached: true, fetchedAt: remoteHotCache.fetchedAt, stale: true,
2827
+ upstreamError: err instanceof Error ? err.message : String(err),
2828
+ });
2829
+ return;
2830
+ }
2831
+ sendJson(res, 502, { error: err instanceof Error ? err.message : String(err) });
2832
+ }
2833
+ }
2834
+ async function handleSkillDetail(_req, res, slug) {
2835
+ try {
2836
+ // Guard against traversal — only allow slug chars (already enforced by
2837
+ // the route regex but check again here to be safe).
2838
+ if (!/^[A-Za-z0-9._-]+$/.test(slug)) {
2839
+ sendJson(res, 400, { error: 'bad slug' });
2840
+ return;
2841
+ }
2842
+ const skills = listSkills();
2843
+ const meta = skills.find((s) => s.slug === slug);
2844
+ if (!meta) {
2845
+ sendJson(res, 404, { error: 'skill not found' });
2846
+ return;
2847
+ }
2848
+ const raw = readFileSync(meta.path, 'utf-8');
2849
+ sendJson(res, 200, { ...meta, content: raw });
2850
+ }
2851
+ catch (err) {
2852
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2853
+ }
2854
+ }
2373
2855
  async function handleVectorClear(_req, res, url) {
2374
2856
  try {
2375
2857
  const { clearEmbeddings } = await import('../core/memory.js');
@@ -2954,11 +3436,28 @@ async function handleEventsSSE(req, res) {
2954
3436
  async function handleAcpDiscover(req, res) {
2955
3437
  try {
2956
3438
  const body = await readBody(req, res);
2957
- const { baseUrl, register } = JSON.parse(body);
3439
+ const parsed = JSON.parse(body);
3440
+ const baseUrl = typeof parsed.baseUrl === 'string' ? parsed.baseUrl : '';
3441
+ // Strict-boolean: only literal `true` enables auto-registration. A
3442
+ // truthy `{}` / 'yes' / 1 won't trip it.
3443
+ const register = parsed.register === true;
2958
3444
  if (!baseUrl) {
2959
- sendJson(res, 400, { error: 'Missing baseUrl' });
3445
+ sendError(res, 400, 'ACP_BASEURL_REQUIRED', { message: 'Missing baseUrl' });
2960
3446
  return;
2961
3447
  }
3448
+ // Guard SSRF: discovery doesn't forward auth, but a malicious baseUrl
3449
+ // can still probe internal services (e.g. cloud metadata).
3450
+ const { assertSafeAcpUrl, UnsafeAcpUrlError } = await import('../plugins/agents/acp/url-guard.js');
3451
+ try {
3452
+ await assertSafeAcpUrl(baseUrl, { forwardsCredentials: false });
3453
+ }
3454
+ catch (err) {
3455
+ if (err instanceof UnsafeAcpUrlError) {
3456
+ sendError(res, 400, 'ACP_URL_BLOCKED', { detail: err.message, extra: { ok: false, reason: err.reason } });
3457
+ return;
3458
+ }
3459
+ throw err;
3460
+ }
2962
3461
  const { discoverAgents } = await import('../plugins/agents/acp/discovery.js');
2963
3462
  const result = await discoverAgents(baseUrl);
2964
3463
  if (register) {
@@ -2972,8 +3471,12 @@ async function handleAcpDiscover(req, res) {
2972
3471
  return;
2973
3472
  const status = e?.statusCode || 500;
2974
3473
  const msg = e instanceof Error ? e.message : String(err);
2975
- if (!res.headersSent)
2976
- sendJson(res, status, { ok: false, error: msg });
3474
+ if (!res.headersSent) {
3475
+ sendError(res, status, 'ACP_DISCOVER_FAILED', {
3476
+ detail: msg,
3477
+ extra: { ok: false },
3478
+ });
3479
+ }
2977
3480
  }
2978
3481
  }
2979
3482
  async function handleAcpTest(req, res) {
@@ -2987,14 +3490,38 @@ async function handleAcpTest(req, res) {
2987
3490
  parsed = JSON.parse(body);
2988
3491
  }
2989
3492
  catch {
2990
- sendJson(res, 400, { ok: false, error: 'Invalid JSON body' });
3493
+ sendError(res, 400, 'INVALID_JSON_BODY', {
3494
+ message: 'Invalid JSON body',
3495
+ extra: { ok: false },
3496
+ });
2991
3497
  return;
2992
3498
  }
2993
3499
  const { endpoint, auth } = parsed;
2994
3500
  if (!endpoint || typeof endpoint !== 'string') {
2995
- sendJson(res, 400, { ok: false, error: 'Missing or invalid "endpoint"' });
3501
+ sendError(res, 400, 'ACP_ENDPOINT_REQUIRED', {
3502
+ message: 'Missing or invalid "endpoint"',
3503
+ extra: { ok: false },
3504
+ });
2996
3505
  return;
2997
3506
  }
3507
+ // Guard SSRF + credential exfil: the manifest fetch carries the
3508
+ // operator-supplied auth.token in the Authorization header, so a
3509
+ // malicious endpoint URL would have it on the wire. Require https
3510
+ // when a token would travel.
3511
+ const hasAuthToken = !!(auth && typeof auth === 'object'
3512
+ && typeof auth.token === 'string'
3513
+ && auth.token.length > 0);
3514
+ const { assertSafeAcpUrl, UnsafeAcpUrlError } = await import('../plugins/agents/acp/url-guard.js');
3515
+ try {
3516
+ await assertSafeAcpUrl(endpoint, { forwardsCredentials: hasAuthToken });
3517
+ }
3518
+ catch (err) {
3519
+ if (err instanceof UnsafeAcpUrlError) {
3520
+ sendError(res, 400, 'ACP_URL_BLOCKED', { detail: err.message, extra: { ok: false, reason: err.reason } });
3521
+ return;
3522
+ }
3523
+ throw err;
3524
+ }
2998
3525
  // Dynamic import to avoid circular deps
2999
3526
  const { ACPClient } = await import('../plugins/agents/acp/acp-client.js');
3000
3527
  const client = new ACPClient({ name: 'test', endpoint, auth: auth });
@@ -3007,7 +3534,10 @@ async function handleAcpTest(req, res) {
3007
3534
  }
3008
3535
  catch (err) {
3009
3536
  const msg = err instanceof Error ? err.message : String(err);
3010
- sendJson(res, 400, { ok: false, error: msg });
3537
+ sendError(res, 400, 'ACP_TEST_FAILED', {
3538
+ detail: msg,
3539
+ extra: { ok: false },
3540
+ });
3011
3541
  }
3012
3542
  }
3013
3543
  // ============================================
@@ -3075,6 +3605,33 @@ function sendJson(res, status, data) {
3075
3605
  res.writeHead(status, { 'Content-Type': 'application/json' });
3076
3606
  res.end(JSON.stringify(data));
3077
3607
  }
3608
+ /**
3609
+ * Send a machine-readable error response. The SPA's `lib/api/client.ts`
3610
+ * reads `body.code` and maps it through `errors.json` → user-facing text;
3611
+ * the legacy `error` string remains as a fallback for tools that don't
3612
+ * speak the code dictionary (curl, older clients, tests grepping the
3613
+ * response).
3614
+ *
3615
+ * Convention: codes are UPPER_SNAKE, namespaced by feature when needed
3616
+ * (e.g. `EMAIL_NOT_CONFIGURED`, `MEMORY_USER_KEY_REQUIRED`). Bare names
3617
+ * like `VALIDATION` / `NOT_FOUND` cover broad buckets. Add new entries
3618
+ * to `src/web-app/src/i18n/locales/{en,zh}/errors.json` together with
3619
+ * the call site so the SPA picks up the human text.
3620
+ *
3621
+ * Use `detail` when the human message has variable parts the SPA can
3622
+ * interpolate (`{{detail}}` in the i18n template). Use `message` when
3623
+ * you want a fully-formed line that's safe to surface verbatim (the
3624
+ * SPA fallback path renders this when no per-code key exists).
3625
+ */
3626
+ function sendError(res, status, code, options) {
3627
+ const message = options?.message ?? options?.detail ?? code;
3628
+ const body = { error: message, code };
3629
+ if (options?.detail !== undefined)
3630
+ body.detail = options.detail;
3631
+ if (options?.extra)
3632
+ Object.assign(body, options.extra);
3633
+ sendJson(res, status, body);
3634
+ }
3078
3635
  // ============================================
3079
3636
  // Outbox (v1.1.2)
3080
3637
  // ============================================
@@ -3300,31 +3857,45 @@ async function handleArtifactsFile(_req, res, jobId, name) {
3300
3857
  }
3301
3858
  }
3302
3859
  // ─── v1.1.10 auth handlers ───
3860
+ /** Build the cookie attribute string. When the request looks HTTPS
3861
+ * (req.socket.encrypted OR x-forwarded-proto=https from a reverse
3862
+ * proxy / cloudflared / nginx), include `Secure`. Without `Secure`,
3863
+ * modern Chrome / Safari downgrade or REJECT cookies with
3864
+ * `SameSite=Strict` on HTTPS pages, which manifests as "logged in
3865
+ * works on the same tab once, but a refresh kicks back to /login"
3866
+ * — the symptom the operator reported 2026-05-19.
3867
+ * Also relax SameSite=Strict → Lax so following an external link
3868
+ * back to the dashboard doesn't lose the session. */
3869
+ function buildAuthCookie(req, token, maxAgeSec) {
3870
+ const isHttps = req.socket.encrypted === true
3871
+ || String(req.headers['x-forwarded-proto'] || '').toLowerCase() === 'https';
3872
+ const secure = isHttps ? '; Secure' : '';
3873
+ const value = token === null ? '' : encodeURIComponent(token);
3874
+ return `agim_token=${value}; Path=/; Max-Age=${maxAgeSec}; SameSite=Lax${secure}`;
3875
+ }
3303
3876
  async function handleAuthLogin(req, res) {
3304
3877
  try {
3305
3878
  const body = await readBody(req, res);
3306
3879
  const parsed = JSON.parse(body || '{}');
3307
3880
  const token = (parsed.token || '').trim();
3308
3881
  if (!token) {
3309
- sendJson(res, 400, { error: 'token required' });
3882
+ sendError(res, 400, 'AUTH_TOKEN_REQUIRED', { message: 'token required' });
3310
3883
  return;
3311
3884
  }
3312
3885
  if (!_tokenModule) {
3313
- sendJson(res, 503, { error: 'auth module not ready' });
3886
+ sendError(res, 503, 'AUTH_NOT_READY', { message: 'auth module not ready' });
3314
3887
  return;
3315
3888
  }
3316
3889
  const id = _tokenModule.verifyToken(token);
3317
3890
  if (!id) {
3318
- sendJson(res, 401, { error: 'invalid token' });
3891
+ sendError(res, 401, 'AUTH_INVALID', { message: 'invalid token' });
3319
3892
  return;
3320
3893
  }
3321
- // 30-day cookie, Path=/, SameSite=Strict. Not HttpOnly so the page JS
3322
- // can also read it back if needed (e.g. to attach Authorization header
3323
- // on fetch — though the cookie alone suffices).
3894
+ // 30-day cookie. SameSite=Lax + conditional Secure see buildAuthCookie.
3324
3895
  const maxAge = 30 * 24 * 60 * 60;
3325
3896
  res.writeHead(200, {
3326
3897
  'Content-Type': 'application/json; charset=utf-8',
3327
- 'Set-Cookie': `agim_token=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAge}; SameSite=Strict`,
3898
+ 'Set-Cookie': buildAuthCookie(req, token, maxAge),
3328
3899
  });
3329
3900
  res.end(JSON.stringify({ ok: true, tokenId: id }));
3330
3901
  }
@@ -3332,10 +3903,10 @@ async function handleAuthLogin(req, res) {
3332
3903
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
3333
3904
  }
3334
3905
  }
3335
- function handleAuthLogout(_req, res) {
3906
+ function handleAuthLogout(req, res) {
3336
3907
  res.writeHead(200, {
3337
3908
  'Content-Type': 'application/json; charset=utf-8',
3338
- 'Set-Cookie': 'agim_token=; Path=/; Max-Age=0; SameSite=Strict',
3909
+ 'Set-Cookie': buildAuthCookie(req, null, 0),
3339
3910
  });
3340
3911
  res.end(JSON.stringify({ ok: true }));
3341
3912
  }
@@ -3418,8 +3989,23 @@ async function handleViewerTunnelStatus(_req, res) {
3418
3989
  /**
3419
3990
  * Handle a message from a web client
3420
3991
  */
3421
- async function handleClientMessage(client, msg, defaultAgent) {
3422
- const { ws, id: clientId } = client;
3992
+ async function handleClientMessage(client, msg, defaultAgent,
3993
+ /** Notify the caller that the client's id changed mid-handler so the
3994
+ * WS handler can keep its `clients` map keyed by the live id. Without
3995
+ * this the approval-card sender (which does clients.get(threadId))
3996
+ * silently misses the rekeyed client and the approval lands only in
3997
+ * the global /approvals tab. */
3998
+ onRekey,
3999
+ /** R9: token id of the connecting peer (or 'anon' on loopback /
4000
+ * auth-off). Used to enforce thread ownership on get-history rekey. */
4001
+ tokenId,
4002
+ /** R9: shared threadId → tokenId map. The handler validates
4003
+ * ownership before adopting a client-supplied threadId. */
4004
+ threadOwners) {
4005
+ const { ws } = client;
4006
+ // Read client.id fresh — `get-history` may rekey it mid-handler so the
4007
+ // session lookup matches the client's persisted threadId.
4008
+ let clientId = client.id;
3423
4009
  switch (msg.type) {
3424
4010
  case 'message': {
3425
4011
  if (!msg.text?.trim())
@@ -3494,9 +4080,57 @@ async function handleClientMessage(client, msg, defaultAgent) {
3494
4080
  break;
3495
4081
  }
3496
4082
  case 'get-history': {
4083
+ // 2026-05-19: support client-supplied threadId so a browser refresh
4084
+ // (or returning to the chat after a navigation) resumes the existing
4085
+ // conversation instead of starting fresh. Client persists the id in
4086
+ // localStorage; if it provides one, we treat it as the authoritative
4087
+ // session key going forward. We update client.id (so subsequent
4088
+ // `message` / `switch-agent` ops use the persisted thread) but leave
4089
+ // the clients map alone — push-by-clientId is a separate code path
4090
+ // that doesn't rely on the persisted id today.
4091
+ //
4092
+ // R9: before adopting the threadId, validate ownership. The
4093
+ // pre-R9 path accepted any well-formed `web-*` id, letting an
4094
+ // authenticated peer enumerate Date.now()-based ids and hijack
4095
+ // (or wipe via new-conversation) another peer's chat session.
4096
+ // Now the threadId is owned by the tokenId that first claimed it;
4097
+ // a rekey from a different tokenId is refused.
4098
+ const wantedThreadId = typeof msg.threadId === 'string' ? msg.threadId.trim() : '';
4099
+ if (wantedThreadId && /^web-[A-Za-z0-9_-]{4,64}$/.test(wantedThreadId) && wantedThreadId !== clientId) {
4100
+ const peer = tokenId ?? 'anon';
4101
+ const owner = threadOwners?.get(wantedThreadId);
4102
+ if (owner !== undefined && owner !== peer) {
4103
+ webLog.warn({
4104
+ event: 'web.session.rekey_denied',
4105
+ clientId, wantedThreadId, peer,
4106
+ }, 'WS get-history rekey refused — threadId owned by a different token');
4107
+ sendToClient(ws, { type: 'error', message: 'thread not owned by this session' });
4108
+ return;
4109
+ }
4110
+ // First-claim OK (owner === undefined) or same-owner reconnect.
4111
+ const oldId = clientId;
4112
+ client.id = wantedThreadId;
4113
+ clientId = wantedThreadId;
4114
+ if (threadOwners && owner === undefined) {
4115
+ threadOwners.set(wantedThreadId, peer);
4116
+ }
4117
+ onRekey?.(oldId, wantedThreadId);
4118
+ webLog.info({ clientId, oldId, event: 'web.session.rekey' }, 'WS client adopted persisted threadId');
4119
+ }
3497
4120
  await sendSessionHistory(ws, clientId, defaultAgent);
3498
4121
  break;
3499
4122
  }
4123
+ // v1's "New conversation" button in the web chat. Clears the
4124
+ // session messages, forks a fresh session id, drops per-agent CLI
4125
+ // session bindings (Claude/opencode/codex) and any auto-allow
4126
+ // approval rules, so the next prompt starts on a clean slate. The
4127
+ // WS client receives a follow-up `history` message with an empty
4128
+ // list so the UI repaints the cleared state.
4129
+ case 'new-conversation': {
4130
+ await sessionManager.resetConversation('web', 'web', clientId);
4131
+ sendToClient(ws, { type: 'history', messages: [], agent: client.agent });
4132
+ break;
4133
+ }
3500
4134
  }
3501
4135
  }
3502
4136
  /**