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.
- package/CHANGELOG.md +52 -0
- package/README.md +18 -5
- package/README.zh-CN.md +20 -7
- package/dist/cli-ui/entry-menu.d.ts +2 -0
- package/dist/cli-ui/entry-menu.d.ts.map +1 -1
- package/dist/cli-ui/entry-menu.js +1 -0
- package/dist/cli-ui/entry-menu.js.map +1 -1
- package/dist/cli-ui/i18n.d.ts +5 -0
- package/dist/cli-ui/i18n.d.ts.map +1 -1
- package/dist/cli-ui/i18n.js +10 -0
- package/dist/cli-ui/i18n.js.map +1 -1
- package/dist/cli.js +117 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/admin-bootstrap.d.ts +14 -0
- package/dist/core/admin-bootstrap.d.ts.map +1 -1
- package/dist/core/admin-bootstrap.js +21 -0
- package/dist/core/admin-bootstrap.js.map +1 -1
- package/dist/core/commands/builtin.d.ts.map +1 -1
- package/dist/core/commands/builtin.js +2 -0
- package/dist/core/commands/builtin.js.map +1 -1
- package/dist/core/commands/router.js +1 -1
- package/dist/core/commands/router.js.map +1 -1
- package/dist/core/commands/web.d.ts +3 -0
- package/dist/core/commands/web.d.ts.map +1 -0
- package/dist/core/commands/web.js +28 -0
- package/dist/core/commands/web.js.map +1 -0
- package/dist/core/intent.d.ts +11 -2
- package/dist/core/intent.d.ts.map +1 -1
- package/dist/core/intent.js +26 -4
- package/dist/core/intent.js.map +1 -1
- package/dist/core/memory.js.map +1 -1
- package/dist/core/render-router.d.ts.map +1 -1
- package/dist/core/render-router.js +3 -2
- package/dist/core/render-router.js.map +1 -1
- package/dist/core/router.d.ts.map +1 -1
- package/dist/core/router.js +8 -1
- package/dist/core/router.js.map +1 -1
- package/dist/core/types.d.ts +3 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -1
- package/dist/plugins/agents/acp/acp-client.js +7 -0
- package/dist/plugins/agents/acp/acp-client.js.map +1 -1
- package/dist/plugins/agents/acp/discovery.d.ts.map +1 -1
- package/dist/plugins/agents/acp/discovery.js +4 -0
- package/dist/plugins/agents/acp/discovery.js.map +1 -1
- package/dist/plugins/agents/acp/url-guard.d.ts +44 -0
- package/dist/plugins/agents/acp/url-guard.d.ts.map +1 -0
- package/dist/plugins/agents/acp/url-guard.js +109 -0
- package/dist/plugins/agents/acp/url-guard.js.map +1 -0
- package/dist/web/env-mask.d.ts +21 -0
- package/dist/web/env-mask.d.ts.map +1 -0
- package/dist/web/env-mask.js +44 -0
- package/dist/web/env-mask.js.map +1 -0
- package/dist/web/public/assets/a2a-Dk2fSs33.js +7 -0
- package/dist/web/public/assets/a2a-Dk2fSs33.js.map +1 -0
- package/dist/web/public/assets/activity-eiIPshcV.js +7 -0
- package/dist/web/public/assets/activity-eiIPshcV.js.map +1 -0
- package/dist/web/public/assets/admins-DlbQYdW_.js +12 -0
- package/dist/web/public/assets/admins-DlbQYdW_.js.map +1 -0
- package/dist/web/public/assets/agents-BMI1WbZj.js +12 -0
- package/dist/web/public/assets/agents-BMI1WbZj.js.map +1 -0
- package/dist/web/public/assets/approvals-DlXS_sKD.js +10 -0
- package/dist/web/public/assets/approvals-DlXS_sKD.js.map +1 -0
- package/dist/web/public/assets/audit-C8I8xC_6.js +2 -0
- package/dist/web/public/assets/audit-C8I8xC_6.js.map +1 -0
- package/dist/web/public/assets/bgjobs-PFYinH7D.js +7 -0
- package/dist/web/public/assets/bgjobs-PFYinH7D.js.map +1 -0
- package/dist/web/public/assets/brain-DEEJttEL.js +7 -0
- package/dist/web/public/assets/brain-DEEJttEL.js.map +1 -0
- package/dist/web/public/assets/briefcase-BlMy8gI6.js +7 -0
- package/dist/web/public/assets/briefcase-BlMy8gI6.js.map +1 -0
- package/dist/web/public/assets/browser-ponyfill-BOcGq8h9.js +3 -0
- package/dist/web/public/assets/browser-ponyfill-BOcGq8h9.js.map +1 -0
- package/dist/web/public/assets/chevron-right-DmABPvoA.js +7 -0
- package/dist/web/public/assets/chevron-right-DmABPvoA.js.map +1 -0
- package/dist/web/public/assets/circle-check-C0Qpg1vL.js +7 -0
- package/dist/web/public/assets/circle-check-C0Qpg1vL.js.map +1 -0
- package/dist/web/public/assets/circle-check-big-C8LG3beV.js +7 -0
- package/dist/web/public/assets/circle-check-big-C8LG3beV.js.map +1 -0
- package/dist/web/public/assets/circle-x-D_cRHcHK.js +7 -0
- package/dist/web/public/assets/circle-x-D_cRHcHK.js.map +1 -0
- package/dist/web/public/assets/confirm-dialog-Baz_xFle.js +2 -0
- package/dist/web/public/assets/confirm-dialog-Baz_xFle.js.map +1 -0
- package/dist/web/public/assets/data-table--I_ktDF4.js +17 -0
- package/dist/web/public/assets/data-table--I_ktDF4.js.map +1 -0
- package/dist/web/public/assets/dialog-DZpoEskO.js +6 -0
- package/dist/web/public/assets/dialog-DZpoEskO.js.map +1 -0
- package/dist/web/public/assets/download-DbFGHwZ5.js +7 -0
- package/dist/web/public/assets/download-DbFGHwZ5.js.map +1 -0
- package/dist/web/public/assets/email-BB1Hq8eE.js +7 -0
- package/dist/web/public/assets/email-BB1Hq8eE.js.map +1 -0
- package/dist/web/public/assets/empty-state-DXNa90pP.js +2 -0
- package/dist/web/public/assets/empty-state-DXNa90pP.js.map +1 -0
- package/dist/web/public/assets/env-Bqrb9XkC.js +2 -0
- package/dist/web/public/assets/env-Bqrb9XkC.js.map +1 -0
- package/dist/web/public/assets/external-link-nhnJN0qg.js +7 -0
- package/dist/web/public/assets/external-link-nhnJN0qg.js.map +1 -0
- package/dist/web/public/assets/eye-IKkn_oUo.js +12 -0
- package/dist/web/public/assets/eye-IKkn_oUo.js.map +1 -0
- package/dist/web/public/assets/facts-C7Qy9vTw.js +2 -0
- package/dist/web/public/assets/facts-C7Qy9vTw.js.map +1 -0
- package/dist/web/public/assets/health-CMRdeNEW.js +2 -0
- package/dist/web/public/assets/health-CMRdeNEW.js.map +1 -0
- package/dist/web/public/assets/hot-Bh5Nrc7i.js +17 -0
- package/dist/web/public/assets/hot-Bh5Nrc7i.js.map +1 -0
- package/dist/web/public/assets/index-CpGWCLE5.js +166 -0
- package/dist/web/public/assets/index-CpGWCLE5.js.map +1 -0
- package/dist/web/public/assets/index-GpceOxum.css +1 -0
- package/dist/web/public/assets/installed-FYLkPij2.js +7 -0
- package/dist/web/public/assets/installed-FYLkPij2.js.map +1 -0
- package/dist/web/public/assets/jobs-BmqLUzHp.js +2 -0
- package/dist/web/public/assets/jobs-BmqLUzHp.js.map +1 -0
- package/dist/web/public/assets/layout-9Gp_myEd.js +2 -0
- package/dist/web/public/assets/layout-9Gp_myEd.js.map +1 -0
- package/dist/web/public/assets/layout-BZaHqf69.js +2 -0
- package/dist/web/public/assets/layout-BZaHqf69.js.map +1 -0
- package/dist/web/public/assets/layout-CXsUyEpG.js +2 -0
- package/dist/web/public/assets/layout-CXsUyEpG.js.map +1 -0
- package/dist/web/public/assets/layout-DFxtpNut.js +2 -0
- package/dist/web/public/assets/layout-DFxtpNut.js.map +1 -0
- package/dist/web/public/assets/layout-d8qxPKQk.js +2 -0
- package/dist/web/public/assets/layout-d8qxPKQk.js.map +1 -0
- package/dist/web/public/assets/loader-circle-JaKY-xMt.js +7 -0
- package/dist/web/public/assets/loader-circle-JaKY-xMt.js.map +1 -0
- package/dist/web/public/assets/map-pin-hFFSWZ3B.js +7 -0
- package/dist/web/public/assets/map-pin-hFFSWZ3B.js.map +1 -0
- package/dist/web/public/assets/memos-EhjMUvVZ.js +12 -0
- package/dist/web/public/assets/memos-EhjMUvVZ.js.map +1 -0
- package/dist/web/public/assets/messengers-BRV1IVGX.js +7 -0
- package/dist/web/public/assets/messengers-BRV1IVGX.js.map +1 -0
- package/dist/web/public/assets/network-DtCI2ZUU.js +7 -0
- package/dist/web/public/assets/network-DtCI2ZUU.js.map +1 -0
- package/dist/web/public/assets/outbox-CxUbMp6o.js +7 -0
- package/dist/web/public/assets/outbox-CxUbMp6o.js.map +1 -0
- package/dist/web/public/assets/pagination-CkZY8YNa.js +17 -0
- package/dist/web/public/assets/pagination-CkZY8YNa.js.map +1 -0
- package/dist/web/public/assets/persona-B6TFMSnI.js +2 -0
- package/dist/web/public/assets/persona-B6TFMSnI.js.map +1 -0
- package/dist/web/public/assets/play-BxRcWaH5.js +7 -0
- package/dist/web/public/assets/play-BxRcWaH5.js.map +1 -0
- package/dist/web/public/assets/policy-ndE1Y8zD.js +2 -0
- package/dist/web/public/assets/policy-ndE1Y8zD.js.map +1 -0
- package/dist/web/public/assets/react-C9F3QeMB.js +33 -0
- package/dist/web/public/assets/react-C9F3QeMB.js.map +1 -0
- package/dist/web/public/assets/refresh-ccw-Bx817_KW.js +7 -0
- package/dist/web/public/assets/refresh-ccw-Bx817_KW.js.map +1 -0
- package/dist/web/public/assets/reminders-XynkGQc5.js +17 -0
- package/dist/web/public/assets/reminders-XynkGQc5.js.map +1 -0
- package/dist/web/public/assets/save-CqMcATrh.js +7 -0
- package/dist/web/public/assets/save-CqMcATrh.js.map +1 -0
- package/dist/web/public/assets/schedules-VM02w_Om.js +7 -0
- package/dist/web/public/assets/schedules-VM02w_Om.js.map +1 -0
- package/dist/web/public/assets/search-Ba-e1t1P.js +7 -0
- package/dist/web/public/assets/search-Ba-e1t1P.js.map +1 -0
- package/dist/web/public/assets/service-C-wnwJ-b.js +7 -0
- package/dist/web/public/assets/service-C-wnwJ-b.js.map +1 -0
- package/dist/web/public/assets/status-badge-CsdJ6k8Q.js +2 -0
- package/dist/web/public/assets/status-badge-CsdJ6k8Q.js.map +1 -0
- package/dist/web/public/assets/subtasks-mGRKpF0G.js +7 -0
- package/dist/web/public/assets/subtasks-mGRKpF0G.js.map +1 -0
- package/dist/web/public/assets/table-vmLMgj6_.js +2 -0
- package/dist/web/public/assets/table-vmLMgj6_.js.map +1 -0
- package/dist/web/public/assets/topn-nu66Fotx.js +7 -0
- package/dist/web/public/assets/topn-nu66Fotx.js.map +1 -0
- package/dist/web/public/assets/trash-2-ZIitN_U3.js +7 -0
- package/dist/web/public/assets/trash-2-ZIitN_U3.js.map +1 -0
- package/dist/web/public/assets/use-event-stream-BGeFcayX.js +2 -0
- package/dist/web/public/assets/use-event-stream-BGeFcayX.js.map +1 -0
- package/dist/web/public/assets/use-memory-DgEqHEca.js +2 -0
- package/dist/web/public/assets/use-memory-DgEqHEca.js.map +1 -0
- package/dist/web/public/assets/use-observability-CQev_A8e.js +2 -0
- package/dist/web/public/assets/use-observability-CQev_A8e.js.map +1 -0
- package/dist/web/public/assets/use-settings-CU-UcrVD.js +2 -0
- package/dist/web/public/assets/use-settings-CU-UcrVD.js.map +1 -0
- package/dist/web/public/assets/use-skills-Dr77CXLA.js +2 -0
- package/dist/web/public/assets/use-skills-Dr77CXLA.js.map +1 -0
- package/dist/web/public/assets/use-workspace-PNv9Z4de.js +2 -0
- package/dist/web/public/assets/use-workspace-PNv9Z4de.js.map +1 -0
- package/dist/web/public/assets/useQuery-BTyugXYV.js +2 -0
- package/dist/web/public/assets/useQuery-BTyugXYV.js.map +1 -0
- package/dist/web/public/assets/vector-w-Ea3pg6.js +2 -0
- package/dist/web/public/assets/vector-w-Ea3pg6.js.map +1 -0
- package/dist/web/public/assets/viewer-DKA7QP9U.js +12 -0
- package/dist/web/public/assets/viewer-DKA7QP9U.js.map +1 -0
- package/dist/web/public/assets/workspace-DVLZca7t.js +17 -0
- package/dist/web/public/assets/workspace-DVLZca7t.js.map +1 -0
- package/dist/web/public/assets/workspaces-DYZsMmY-.js +7 -0
- package/dist/web/public/assets/workspaces-DYZsMmY-.js.map +1 -0
- package/dist/web/public/assets/x-Ru3rHT82.js +7 -0
- package/dist/web/public/assets/x-Ru3rHT82.js.map +1 -0
- package/dist/web/public/favicon.svg +4 -0
- package/dist/web/public/index.html +37 -928
- package/dist/web/public/manifest.webmanifest +19 -0
- package/dist/web/public/tasks.html +362 -6
- package/dist/web/public/vendor/chart.umd.min.js +20 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +694 -60
- package/dist/web/server.js.map +1 -1
- package/package.json +4 -4
package/dist/web/server.js
CHANGED
|
@@ -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'
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
730
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
802
|
-
|
|
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
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1869
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3882
|
+
sendError(res, 400, 'AUTH_TOKEN_REQUIRED', { message: 'token required' });
|
|
3310
3883
|
return;
|
|
3311
3884
|
}
|
|
3312
3885
|
if (!_tokenModule) {
|
|
3313
|
-
|
|
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
|
-
|
|
3891
|
+
sendError(res, 401, 'AUTH_INVALID', { message: 'invalid token' });
|
|
3319
3892
|
return;
|
|
3320
3893
|
}
|
|
3321
|
-
// 30-day cookie
|
|
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':
|
|
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(
|
|
3906
|
+
function handleAuthLogout(req, res) {
|
|
3336
3907
|
res.writeHead(200, {
|
|
3337
3908
|
'Content-Type': 'application/json; charset=utf-8',
|
|
3338
|
-
'Set-Cookie':
|
|
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
|
-
|
|
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
|
/**
|