agim-cli 1.2.2 → 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/README.md +18 -5
- package/README.zh-CN.md +20 -7
- package/dist/cli.js +42 -0
- package/dist/cli.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 +307 -5
- 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 +640 -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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
738
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
810
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1877
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3882
|
+
sendError(res, 400, 'AUTH_TOKEN_REQUIRED', { message: 'token required' });
|
|
3364
3883
|
return;
|
|
3365
3884
|
}
|
|
3366
3885
|
if (!_tokenModule) {
|
|
3367
|
-
|
|
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
|
-
|
|
3891
|
+
sendError(res, 401, 'AUTH_INVALID', { message: 'invalid token' });
|
|
3373
3892
|
return;
|
|
3374
3893
|
}
|
|
3375
|
-
// 30-day cookie
|
|
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':
|
|
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(
|
|
3906
|
+
function handleAuthLogout(req, res) {
|
|
3390
3907
|
res.writeHead(200, {
|
|
3391
3908
|
'Content-Type': 'application/json; charset=utf-8',
|
|
3392
|
-
'Set-Cookie':
|
|
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
|
-
|
|
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
|
/**
|