agim-cli 1.2.2 → 1.2.18

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 (190) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +18 -5
  3. package/README.zh-CN.md +20 -7
  4. package/dist/cli.js +42 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/core/commands/builtin.d.ts.map +1 -1
  7. package/dist/core/commands/builtin.js +2 -0
  8. package/dist/core/commands/builtin.js.map +1 -1
  9. package/dist/core/commands/router.js +1 -1
  10. package/dist/core/commands/router.js.map +1 -1
  11. package/dist/core/commands/web.d.ts +3 -0
  12. package/dist/core/commands/web.d.ts.map +1 -0
  13. package/dist/core/commands/web.js +28 -0
  14. package/dist/core/commands/web.js.map +1 -0
  15. package/dist/core/intent.d.ts +11 -2
  16. package/dist/core/intent.d.ts.map +1 -1
  17. package/dist/core/intent.js +26 -4
  18. package/dist/core/intent.js.map +1 -1
  19. package/dist/core/memory.js.map +1 -1
  20. package/dist/core/render-router.d.ts.map +1 -1
  21. package/dist/core/render-router.js +3 -2
  22. package/dist/core/render-router.js.map +1 -1
  23. package/dist/core/router.d.ts.map +1 -1
  24. package/dist/core/router.js +8 -1
  25. package/dist/core/router.js.map +1 -1
  26. package/dist/core/types.d.ts +3 -0
  27. package/dist/core/types.d.ts.map +1 -1
  28. package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -1
  29. package/dist/plugins/agents/acp/acp-client.js +7 -0
  30. package/dist/plugins/agents/acp/acp-client.js.map +1 -1
  31. package/dist/plugins/agents/acp/discovery.d.ts.map +1 -1
  32. package/dist/plugins/agents/acp/discovery.js +4 -0
  33. package/dist/plugins/agents/acp/discovery.js.map +1 -1
  34. package/dist/plugins/agents/acp/url-guard.d.ts +44 -0
  35. package/dist/plugins/agents/acp/url-guard.d.ts.map +1 -0
  36. package/dist/plugins/agents/acp/url-guard.js +109 -0
  37. package/dist/plugins/agents/acp/url-guard.js.map +1 -0
  38. package/dist/plugins/agents/opencode/serve-manager.d.ts.map +1 -1
  39. package/dist/plugins/agents/opencode/serve-manager.js +129 -4
  40. package/dist/plugins/agents/opencode/serve-manager.js.map +1 -1
  41. package/dist/web/env-mask.d.ts +21 -0
  42. package/dist/web/env-mask.d.ts.map +1 -0
  43. package/dist/web/env-mask.js +44 -0
  44. package/dist/web/env-mask.js.map +1 -0
  45. package/dist/web/public/assets/a2a-Dk2fSs33.js +7 -0
  46. package/dist/web/public/assets/a2a-Dk2fSs33.js.map +1 -0
  47. package/dist/web/public/assets/activity-eiIPshcV.js +7 -0
  48. package/dist/web/public/assets/activity-eiIPshcV.js.map +1 -0
  49. package/dist/web/public/assets/admins-DlbQYdW_.js +12 -0
  50. package/dist/web/public/assets/admins-DlbQYdW_.js.map +1 -0
  51. package/dist/web/public/assets/agents-BMI1WbZj.js +12 -0
  52. package/dist/web/public/assets/agents-BMI1WbZj.js.map +1 -0
  53. package/dist/web/public/assets/approvals-DlXS_sKD.js +10 -0
  54. package/dist/web/public/assets/approvals-DlXS_sKD.js.map +1 -0
  55. package/dist/web/public/assets/audit-C8I8xC_6.js +2 -0
  56. package/dist/web/public/assets/audit-C8I8xC_6.js.map +1 -0
  57. package/dist/web/public/assets/bgjobs-PFYinH7D.js +7 -0
  58. package/dist/web/public/assets/bgjobs-PFYinH7D.js.map +1 -0
  59. package/dist/web/public/assets/brain-DEEJttEL.js +7 -0
  60. package/dist/web/public/assets/brain-DEEJttEL.js.map +1 -0
  61. package/dist/web/public/assets/briefcase-BlMy8gI6.js +7 -0
  62. package/dist/web/public/assets/briefcase-BlMy8gI6.js.map +1 -0
  63. package/dist/web/public/assets/browser-ponyfill-BOcGq8h9.js +3 -0
  64. package/dist/web/public/assets/browser-ponyfill-BOcGq8h9.js.map +1 -0
  65. package/dist/web/public/assets/chevron-right-DmABPvoA.js +7 -0
  66. package/dist/web/public/assets/chevron-right-DmABPvoA.js.map +1 -0
  67. package/dist/web/public/assets/circle-check-C0Qpg1vL.js +7 -0
  68. package/dist/web/public/assets/circle-check-C0Qpg1vL.js.map +1 -0
  69. package/dist/web/public/assets/circle-check-big-C8LG3beV.js +7 -0
  70. package/dist/web/public/assets/circle-check-big-C8LG3beV.js.map +1 -0
  71. package/dist/web/public/assets/circle-x-D_cRHcHK.js +7 -0
  72. package/dist/web/public/assets/circle-x-D_cRHcHK.js.map +1 -0
  73. package/dist/web/public/assets/confirm-dialog-Baz_xFle.js +2 -0
  74. package/dist/web/public/assets/confirm-dialog-Baz_xFle.js.map +1 -0
  75. package/dist/web/public/assets/data-table--I_ktDF4.js +17 -0
  76. package/dist/web/public/assets/data-table--I_ktDF4.js.map +1 -0
  77. package/dist/web/public/assets/dialog-DZpoEskO.js +6 -0
  78. package/dist/web/public/assets/dialog-DZpoEskO.js.map +1 -0
  79. package/dist/web/public/assets/download-DbFGHwZ5.js +7 -0
  80. package/dist/web/public/assets/download-DbFGHwZ5.js.map +1 -0
  81. package/dist/web/public/assets/email-BB1Hq8eE.js +7 -0
  82. package/dist/web/public/assets/email-BB1Hq8eE.js.map +1 -0
  83. package/dist/web/public/assets/empty-state-DXNa90pP.js +2 -0
  84. package/dist/web/public/assets/empty-state-DXNa90pP.js.map +1 -0
  85. package/dist/web/public/assets/env-Bqrb9XkC.js +2 -0
  86. package/dist/web/public/assets/env-Bqrb9XkC.js.map +1 -0
  87. package/dist/web/public/assets/external-link-nhnJN0qg.js +7 -0
  88. package/dist/web/public/assets/external-link-nhnJN0qg.js.map +1 -0
  89. package/dist/web/public/assets/eye-IKkn_oUo.js +12 -0
  90. package/dist/web/public/assets/eye-IKkn_oUo.js.map +1 -0
  91. package/dist/web/public/assets/facts-C7Qy9vTw.js +2 -0
  92. package/dist/web/public/assets/facts-C7Qy9vTw.js.map +1 -0
  93. package/dist/web/public/assets/health-CMRdeNEW.js +2 -0
  94. package/dist/web/public/assets/health-CMRdeNEW.js.map +1 -0
  95. package/dist/web/public/assets/hot-Bh5Nrc7i.js +17 -0
  96. package/dist/web/public/assets/hot-Bh5Nrc7i.js.map +1 -0
  97. package/dist/web/public/assets/index-CpGWCLE5.js +166 -0
  98. package/dist/web/public/assets/index-CpGWCLE5.js.map +1 -0
  99. package/dist/web/public/assets/index-GpceOxum.css +1 -0
  100. package/dist/web/public/assets/installed-FYLkPij2.js +7 -0
  101. package/dist/web/public/assets/installed-FYLkPij2.js.map +1 -0
  102. package/dist/web/public/assets/jobs-BmqLUzHp.js +2 -0
  103. package/dist/web/public/assets/jobs-BmqLUzHp.js.map +1 -0
  104. package/dist/web/public/assets/layout-9Gp_myEd.js +2 -0
  105. package/dist/web/public/assets/layout-9Gp_myEd.js.map +1 -0
  106. package/dist/web/public/assets/layout-BZaHqf69.js +2 -0
  107. package/dist/web/public/assets/layout-BZaHqf69.js.map +1 -0
  108. package/dist/web/public/assets/layout-CXsUyEpG.js +2 -0
  109. package/dist/web/public/assets/layout-CXsUyEpG.js.map +1 -0
  110. package/dist/web/public/assets/layout-DFxtpNut.js +2 -0
  111. package/dist/web/public/assets/layout-DFxtpNut.js.map +1 -0
  112. package/dist/web/public/assets/layout-d8qxPKQk.js +2 -0
  113. package/dist/web/public/assets/layout-d8qxPKQk.js.map +1 -0
  114. package/dist/web/public/assets/loader-circle-JaKY-xMt.js +7 -0
  115. package/dist/web/public/assets/loader-circle-JaKY-xMt.js.map +1 -0
  116. package/dist/web/public/assets/map-pin-hFFSWZ3B.js +7 -0
  117. package/dist/web/public/assets/map-pin-hFFSWZ3B.js.map +1 -0
  118. package/dist/web/public/assets/memos-EhjMUvVZ.js +12 -0
  119. package/dist/web/public/assets/memos-EhjMUvVZ.js.map +1 -0
  120. package/dist/web/public/assets/messengers-BRV1IVGX.js +7 -0
  121. package/dist/web/public/assets/messengers-BRV1IVGX.js.map +1 -0
  122. package/dist/web/public/assets/network-DtCI2ZUU.js +7 -0
  123. package/dist/web/public/assets/network-DtCI2ZUU.js.map +1 -0
  124. package/dist/web/public/assets/outbox-CxUbMp6o.js +7 -0
  125. package/dist/web/public/assets/outbox-CxUbMp6o.js.map +1 -0
  126. package/dist/web/public/assets/pagination-CkZY8YNa.js +17 -0
  127. package/dist/web/public/assets/pagination-CkZY8YNa.js.map +1 -0
  128. package/dist/web/public/assets/persona-B6TFMSnI.js +2 -0
  129. package/dist/web/public/assets/persona-B6TFMSnI.js.map +1 -0
  130. package/dist/web/public/assets/play-BxRcWaH5.js +7 -0
  131. package/dist/web/public/assets/play-BxRcWaH5.js.map +1 -0
  132. package/dist/web/public/assets/policy-ndE1Y8zD.js +2 -0
  133. package/dist/web/public/assets/policy-ndE1Y8zD.js.map +1 -0
  134. package/dist/web/public/assets/react-C9F3QeMB.js +33 -0
  135. package/dist/web/public/assets/react-C9F3QeMB.js.map +1 -0
  136. package/dist/web/public/assets/refresh-ccw-Bx817_KW.js +7 -0
  137. package/dist/web/public/assets/refresh-ccw-Bx817_KW.js.map +1 -0
  138. package/dist/web/public/assets/reminders-XynkGQc5.js +17 -0
  139. package/dist/web/public/assets/reminders-XynkGQc5.js.map +1 -0
  140. package/dist/web/public/assets/save-CqMcATrh.js +7 -0
  141. package/dist/web/public/assets/save-CqMcATrh.js.map +1 -0
  142. package/dist/web/public/assets/schedules-VM02w_Om.js +7 -0
  143. package/dist/web/public/assets/schedules-VM02w_Om.js.map +1 -0
  144. package/dist/web/public/assets/search-Ba-e1t1P.js +7 -0
  145. package/dist/web/public/assets/search-Ba-e1t1P.js.map +1 -0
  146. package/dist/web/public/assets/service-C-wnwJ-b.js +7 -0
  147. package/dist/web/public/assets/service-C-wnwJ-b.js.map +1 -0
  148. package/dist/web/public/assets/status-badge-CsdJ6k8Q.js +2 -0
  149. package/dist/web/public/assets/status-badge-CsdJ6k8Q.js.map +1 -0
  150. package/dist/web/public/assets/subtasks-mGRKpF0G.js +7 -0
  151. package/dist/web/public/assets/subtasks-mGRKpF0G.js.map +1 -0
  152. package/dist/web/public/assets/table-vmLMgj6_.js +2 -0
  153. package/dist/web/public/assets/table-vmLMgj6_.js.map +1 -0
  154. package/dist/web/public/assets/topn-nu66Fotx.js +7 -0
  155. package/dist/web/public/assets/topn-nu66Fotx.js.map +1 -0
  156. package/dist/web/public/assets/trash-2-ZIitN_U3.js +7 -0
  157. package/dist/web/public/assets/trash-2-ZIitN_U3.js.map +1 -0
  158. package/dist/web/public/assets/use-event-stream-BGeFcayX.js +2 -0
  159. package/dist/web/public/assets/use-event-stream-BGeFcayX.js.map +1 -0
  160. package/dist/web/public/assets/use-memory-DgEqHEca.js +2 -0
  161. package/dist/web/public/assets/use-memory-DgEqHEca.js.map +1 -0
  162. package/dist/web/public/assets/use-observability-CQev_A8e.js +2 -0
  163. package/dist/web/public/assets/use-observability-CQev_A8e.js.map +1 -0
  164. package/dist/web/public/assets/use-settings-CU-UcrVD.js +2 -0
  165. package/dist/web/public/assets/use-settings-CU-UcrVD.js.map +1 -0
  166. package/dist/web/public/assets/use-skills-Dr77CXLA.js +2 -0
  167. package/dist/web/public/assets/use-skills-Dr77CXLA.js.map +1 -0
  168. package/dist/web/public/assets/use-workspace-PNv9Z4de.js +2 -0
  169. package/dist/web/public/assets/use-workspace-PNv9Z4de.js.map +1 -0
  170. package/dist/web/public/assets/useQuery-BTyugXYV.js +2 -0
  171. package/dist/web/public/assets/useQuery-BTyugXYV.js.map +1 -0
  172. package/dist/web/public/assets/vector-w-Ea3pg6.js +2 -0
  173. package/dist/web/public/assets/vector-w-Ea3pg6.js.map +1 -0
  174. package/dist/web/public/assets/viewer-DKA7QP9U.js +12 -0
  175. package/dist/web/public/assets/viewer-DKA7QP9U.js.map +1 -0
  176. package/dist/web/public/assets/workspace-DVLZca7t.js +17 -0
  177. package/dist/web/public/assets/workspace-DVLZca7t.js.map +1 -0
  178. package/dist/web/public/assets/workspaces-DYZsMmY-.js +7 -0
  179. package/dist/web/public/assets/workspaces-DYZsMmY-.js.map +1 -0
  180. package/dist/web/public/assets/x-Ru3rHT82.js +7 -0
  181. package/dist/web/public/assets/x-Ru3rHT82.js.map +1 -0
  182. package/dist/web/public/favicon.svg +4 -0
  183. package/dist/web/public/index.html +37 -928
  184. package/dist/web/public/manifest.webmanifest +19 -0
  185. package/dist/web/public/tasks.html +307 -5
  186. package/dist/web/public/vendor/chart.umd.min.js +20 -0
  187. package/dist/web/server.d.ts.map +1 -1
  188. package/dist/web/server.js +640 -60
  189. package/dist/web/server.js.map +1 -1
  190. 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
  }
@@ -623,6 +691,18 @@ export async function startWebServer(options) {
623
691
  if (url.pathname === '/api/memory/consolidate/status' && req.method === 'GET') {
624
692
  return handleMemoryConsolidateStatus(req, res);
625
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
+ }
626
706
  // PR-B: HITL approvals — global pending list + per-reqId resolve.
627
707
  if (url.pathname === '/api/approvals' && req.method === 'GET') {
628
708
  return handleListApprovals(req, res);
@@ -707,8 +787,52 @@ export async function startWebServer(options) {
707
787
  res.writeHead(404);
708
788
  res.end('Not found');
709
789
  });
710
- // WebSocket server
711
- 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
+ });
712
836
  // M3: cap concurrent WS clients so a leaked / shared web token can't OOM
713
837
  // the host by opening unbounded connections. Default 100 is generous for
714
838
  // a single-user / small-team setup; production multi-tenant should set
@@ -734,10 +858,24 @@ export async function startWebServer(options) {
734
858
  ws.close(1013, 'Server too busy');
735
859
  return;
736
860
  }
737
- // No auth gate access control is upstream (reverse proxy / firewall).
738
- 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')}`;
739
874
  const client = { ws, id: clientId, agent: options.defaultAgent };
740
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);
741
879
  webLog.info({ clientId }, 'Client connected');
742
880
  // Send available agents list
743
881
  sendToClient(ws, {
@@ -761,9 +899,16 @@ export async function startWebServer(options) {
761
899
  if (msg && msg.type === 'approval-action') {
762
900
  const actionData = String(msg.data || '');
763
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;
764
909
  webLog.info({
765
910
  event: 'approval.web.click_received',
766
- clientId, data: actionData, messageId,
911
+ clientId: liveId, data: actionData, messageId,
767
912
  handlerBound: !!webButtonHandler,
768
913
  });
769
914
  if (!actionData || !messageId) {
@@ -775,7 +920,7 @@ export async function startWebServer(options) {
775
920
  // failure mode that PR-A's fix patches. Tell the user and the
776
921
  // operator (via log) instead of dropping the click.
777
922
  const why = 'approval handler not bound (router not installed?). Restart agim to rebind.';
778
- 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);
779
924
  sendToClient(ws, { type: 'error', message: why });
780
925
  return;
781
926
  }
@@ -785,33 +930,67 @@ export async function startWebServer(options) {
785
930
  // ack is a no-op resolving to the in-page status the page itself
786
931
  // chose to render after click.
787
932
  await webButtonHandler({
788
- data: actionData, threadId: clientId, userId: `web:${clientId}`,
933
+ data: actionData, threadId: liveId, userId: `web:${liveId}`,
789
934
  userDisplay: 'Web', messageId, ack: async () => { },
790
935
  });
791
- webLog.info({ event: 'approval.web.click_resolved', clientId, data: actionData });
936
+ webLog.info({ event: 'approval.web.click_resolved', clientId: liveId, data: actionData });
792
937
  }
793
938
  catch (err) {
794
939
  const errMsg = err instanceof Error ? err.message : String(err);
795
- 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 });
796
941
  sendToClient(ws, { type: 'error', message: `Approval click failed: ${errMsg}` });
797
942
  }
798
943
  return;
799
944
  }
800
- 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);
801
966
  }
802
967
  catch (err) {
803
- 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');
804
969
  sendToClient(ws, { type: 'error', message: 'Invalid message format' });
805
970
  }
806
971
  });
807
972
  });
808
973
  ws.on('close', () => {
809
- webLog.info({ clientId }, 'Client disconnected');
810
- 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);
811
987
  });
812
988
  ws.on('error', (err) => {
813
- webLog.error({ clientId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
814
- 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);
815
994
  });
816
995
  });
817
996
  // Default to loopback; operators can opt into LAN/public exposure with
@@ -1030,7 +1209,10 @@ async function handlePutConfig(req, res) {
1030
1209
  }
1031
1210
  const result = validateConfig(merged);
1032
1211
  if (!result.ok) {
1033
- 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
+ });
1034
1216
  return;
1035
1217
  }
1036
1218
  await saveConfig(result.config);
@@ -1038,7 +1220,7 @@ async function handlePutConfig(req, res) {
1038
1220
  }
1039
1221
  catch (err) {
1040
1222
  const msg = err instanceof Error ? err.message : String(err);
1041
- sendJson(res, 400, { error: msg });
1223
+ sendError(res, 400, 'CONFIG_SAVE_FAILED', { detail: msg });
1042
1224
  }
1043
1225
  }
1044
1226
  async function handleAgentsStatus(_req, res) {
@@ -1873,13 +2055,8 @@ const ENV_EDITABLE_KEYS = [
1873
2055
  'IMHUB_MEMORY_VECTOR_HYBRID_WEIGHT',
1874
2056
  ];
1875
2057
  const SECRET_KEYS = new Set(['IMHUB_SMTP_PASS', 'IMHUB_BAIDU_MAP_AK', 'IMHUB_MEMORY_VECTOR_OPENAI_API_KEY']);
1876
- function maskSecret(v) {
1877
- if (!v)
1878
- return '';
1879
- if (v.length <= 8)
1880
- return '*'.repeat(v.length);
1881
- return v.slice(0, 4) + '*'.repeat(Math.max(0, v.length - 8)) + v.slice(-4);
1882
- }
2058
+ // maskSecret moved to ./env-mask.ts (imported at the top of this file
2059
+ // alongside isMasked).
1883
2060
  async function handleGetEnv(_req, res, url) {
1884
2061
  try {
1885
2062
  const { readEnvFile } = await import('../cli-ui/env-file.js');
@@ -1904,7 +2081,7 @@ async function handlePutEnv(req, res) {
1904
2081
  const parsed = JSON.parse(body || '{}');
1905
2082
  const updates = parsed.updates;
1906
2083
  if (!updates || typeof updates !== 'object') {
1907
- sendJson(res, 400, { error: 'updates object required' });
2084
+ sendError(res, 400, 'ENV_UPDATES_REQUIRED', { message: 'updates object required' });
1908
2085
  return;
1909
2086
  }
1910
2087
  // Filter to whitelist — never let arbitrary keys through.
@@ -1922,7 +2099,7 @@ async function handlePutEnv(req, res) {
1922
2099
  }
1923
2100
  }
1924
2101
  if (Object.keys(safe).length === 0) {
1925
- sendJson(res, 400, { error: 'no editable keys in updates' });
2102
+ sendError(res, 400, 'ENV_NO_EDITABLE_KEYS', { message: 'no editable keys in updates' });
1926
2103
  return;
1927
2104
  }
1928
2105
  const { updateEnvFile } = await import('../cli-ui/env-file.js');
@@ -1944,6 +2121,59 @@ async function handlePutEnv(req, res) {
1944
2121
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
1945
2122
  }
1946
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
+ }
1947
2177
  async function handleListBgjobs(_req, res, url) {
1948
2178
  try {
1949
2179
  const { resolveRoots, listJobsForRoot, listAllJobs } = await import('../core/bgjob-reader.js');
@@ -2099,7 +2329,7 @@ async function handleMemoryFacts(_req, res, url) {
2099
2329
  try {
2100
2330
  const user_key = readUserKey(url);
2101
2331
  if (!user_key) {
2102
- sendJson(res, 400, { error: 'user_key required (platform:userId)' });
2332
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required (platform:userId)' });
2103
2333
  return;
2104
2334
  }
2105
2335
  const { listFacts } = await import('../core/memory.js');
@@ -2119,7 +2349,7 @@ async function handleMemoryDeleteOne(req, res, url, id) {
2119
2349
  try {
2120
2350
  const user_key = readUserKey(url);
2121
2351
  if (!user_key) {
2122
- sendJson(res, 400, { error: 'user_key required' });
2352
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2123
2353
  return;
2124
2354
  }
2125
2355
  const { deleteFact } = await import('../core/memory.js');
@@ -2135,7 +2365,7 @@ async function handleMemoryBulkDelete(req, res, url) {
2135
2365
  try {
2136
2366
  const user_key = readUserKey(url);
2137
2367
  if (!user_key) {
2138
- sendJson(res, 400, { error: 'user_key required' });
2368
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2139
2369
  return;
2140
2370
  }
2141
2371
  const body = await readBody(req, res);
@@ -2164,7 +2394,7 @@ async function handleMemoryPersona(_req, res, url) {
2164
2394
  try {
2165
2395
  const user_key = readUserKey(url);
2166
2396
  if (!user_key) {
2167
- sendJson(res, 400, { error: 'user_key required' });
2397
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2168
2398
  return;
2169
2399
  }
2170
2400
  const { getPersona } = await import('../core/memory.js');
@@ -2183,7 +2413,7 @@ async function handleMemoryPersonaPut(req, res, url) {
2183
2413
  try {
2184
2414
  const user_key = readUserKey(url);
2185
2415
  if (!user_key) {
2186
- sendJson(res, 400, { error: 'user_key required' });
2416
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2187
2417
  return;
2188
2418
  }
2189
2419
  const body = await readBody(req, res);
@@ -2205,7 +2435,7 @@ async function handleMemoryPersonaDelete(_req, res, url) {
2205
2435
  try {
2206
2436
  const user_key = readUserKey(url);
2207
2437
  if (!user_key) {
2208
- sendJson(res, 400, { error: 'user_key required' });
2438
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2209
2439
  return;
2210
2440
  }
2211
2441
  const { deletePersona } = await import('../core/memory.js');
@@ -2220,7 +2450,7 @@ async function handleMemoryExport(_req, res, url) {
2220
2450
  try {
2221
2451
  const user_key = readUserKey(url);
2222
2452
  if (!user_key) {
2223
- sendJson(res, 400, { error: 'user_key required' });
2453
+ sendError(res, 400, 'MEMORY_USER_KEY_REQUIRED', { message: 'user_key required' });
2224
2454
  return;
2225
2455
  }
2226
2456
  const { exportUserMemory } = await import('../core/memory.js');
@@ -2424,6 +2654,204 @@ async function handleMemoryConsolidate(_req, res) {
2424
2654
  async function handleMemoryConsolidateStatus(_req, res) {
2425
2655
  sendJson(res, 200, { jobs: pickRecentConsolidate() });
2426
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
+ }
2427
2855
  async function handleVectorClear(_req, res, url) {
2428
2856
  try {
2429
2857
  const { clearEmbeddings } = await import('../core/memory.js');
@@ -3008,11 +3436,28 @@ async function handleEventsSSE(req, res) {
3008
3436
  async function handleAcpDiscover(req, res) {
3009
3437
  try {
3010
3438
  const body = await readBody(req, res);
3011
- 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;
3012
3444
  if (!baseUrl) {
3013
- sendJson(res, 400, { error: 'Missing baseUrl' });
3445
+ sendError(res, 400, 'ACP_BASEURL_REQUIRED', { message: 'Missing baseUrl' });
3014
3446
  return;
3015
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
+ }
3016
3461
  const { discoverAgents } = await import('../plugins/agents/acp/discovery.js');
3017
3462
  const result = await discoverAgents(baseUrl);
3018
3463
  if (register) {
@@ -3026,8 +3471,12 @@ async function handleAcpDiscover(req, res) {
3026
3471
  return;
3027
3472
  const status = e?.statusCode || 500;
3028
3473
  const msg = e instanceof Error ? e.message : String(err);
3029
- if (!res.headersSent)
3030
- 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
+ }
3031
3480
  }
3032
3481
  }
3033
3482
  async function handleAcpTest(req, res) {
@@ -3041,14 +3490,38 @@ async function handleAcpTest(req, res) {
3041
3490
  parsed = JSON.parse(body);
3042
3491
  }
3043
3492
  catch {
3044
- 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
+ });
3045
3497
  return;
3046
3498
  }
3047
3499
  const { endpoint, auth } = parsed;
3048
3500
  if (!endpoint || typeof endpoint !== 'string') {
3049
- 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
+ });
3050
3505
  return;
3051
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
+ }
3052
3525
  // Dynamic import to avoid circular deps
3053
3526
  const { ACPClient } = await import('../plugins/agents/acp/acp-client.js');
3054
3527
  const client = new ACPClient({ name: 'test', endpoint, auth: auth });
@@ -3061,7 +3534,10 @@ async function handleAcpTest(req, res) {
3061
3534
  }
3062
3535
  catch (err) {
3063
3536
  const msg = err instanceof Error ? err.message : String(err);
3064
- sendJson(res, 400, { ok: false, error: msg });
3537
+ sendError(res, 400, 'ACP_TEST_FAILED', {
3538
+ detail: msg,
3539
+ extra: { ok: false },
3540
+ });
3065
3541
  }
3066
3542
  }
3067
3543
  // ============================================
@@ -3129,6 +3605,33 @@ function sendJson(res, status, data) {
3129
3605
  res.writeHead(status, { 'Content-Type': 'application/json' });
3130
3606
  res.end(JSON.stringify(data));
3131
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
+ }
3132
3635
  // ============================================
3133
3636
  // Outbox (v1.1.2)
3134
3637
  // ============================================
@@ -3354,31 +3857,45 @@ async function handleArtifactsFile(_req, res, jobId, name) {
3354
3857
  }
3355
3858
  }
3356
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
+ }
3357
3876
  async function handleAuthLogin(req, res) {
3358
3877
  try {
3359
3878
  const body = await readBody(req, res);
3360
3879
  const parsed = JSON.parse(body || '{}');
3361
3880
  const token = (parsed.token || '').trim();
3362
3881
  if (!token) {
3363
- sendJson(res, 400, { error: 'token required' });
3882
+ sendError(res, 400, 'AUTH_TOKEN_REQUIRED', { message: 'token required' });
3364
3883
  return;
3365
3884
  }
3366
3885
  if (!_tokenModule) {
3367
- sendJson(res, 503, { error: 'auth module not ready' });
3886
+ sendError(res, 503, 'AUTH_NOT_READY', { message: 'auth module not ready' });
3368
3887
  return;
3369
3888
  }
3370
3889
  const id = _tokenModule.verifyToken(token);
3371
3890
  if (!id) {
3372
- sendJson(res, 401, { error: 'invalid token' });
3891
+ sendError(res, 401, 'AUTH_INVALID', { message: 'invalid token' });
3373
3892
  return;
3374
3893
  }
3375
- // 30-day cookie, Path=/, SameSite=Strict. Not HttpOnly so the page JS
3376
- // can also read it back if needed (e.g. to attach Authorization header
3377
- // on fetch — though the cookie alone suffices).
3894
+ // 30-day cookie. SameSite=Lax + conditional Secure see buildAuthCookie.
3378
3895
  const maxAge = 30 * 24 * 60 * 60;
3379
3896
  res.writeHead(200, {
3380
3897
  'Content-Type': 'application/json; charset=utf-8',
3381
- 'Set-Cookie': `agim_token=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAge}; SameSite=Strict`,
3898
+ 'Set-Cookie': buildAuthCookie(req, token, maxAge),
3382
3899
  });
3383
3900
  res.end(JSON.stringify({ ok: true, tokenId: id }));
3384
3901
  }
@@ -3386,10 +3903,10 @@ async function handleAuthLogin(req, res) {
3386
3903
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
3387
3904
  }
3388
3905
  }
3389
- function handleAuthLogout(_req, res) {
3906
+ function handleAuthLogout(req, res) {
3390
3907
  res.writeHead(200, {
3391
3908
  'Content-Type': 'application/json; charset=utf-8',
3392
- 'Set-Cookie': 'agim_token=; Path=/; Max-Age=0; SameSite=Strict',
3909
+ 'Set-Cookie': buildAuthCookie(req, null, 0),
3393
3910
  });
3394
3911
  res.end(JSON.stringify({ ok: true }));
3395
3912
  }
@@ -3472,8 +3989,23 @@ async function handleViewerTunnelStatus(_req, res) {
3472
3989
  /**
3473
3990
  * Handle a message from a web client
3474
3991
  */
3475
- async function handleClientMessage(client, msg, defaultAgent) {
3476
- 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;
3477
4009
  switch (msg.type) {
3478
4010
  case 'message': {
3479
4011
  if (!msg.text?.trim())
@@ -3548,9 +4080,57 @@ async function handleClientMessage(client, msg, defaultAgent) {
3548
4080
  break;
3549
4081
  }
3550
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
+ }
3551
4120
  await sendSessionHistory(ws, clientId, defaultAgent);
3552
4121
  break;
3553
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
+ }
3554
4134
  }
3555
4135
  }
3556
4136
  /**