@rubytech/taskmaster 1.0.63 → 1.0.65
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/dist/agents/pi-embedded-runner/compact.js +1 -1
- package/dist/agents/pi-embedded-runner/history.js +57 -15
- package/dist/agents/pi-embedded-runner/run/attempt.js +23 -5
- package/dist/agents/pi-embedded-runner/run.js +6 -31
- package/dist/agents/pi-embedded-runner.js +1 -1
- package/dist/agents/system-prompt.js +20 -0
- package/dist/agents/taskmaster-tools.js +4 -0
- package/dist/agents/tool-policy.js +2 -0
- package/dist/agents/tools/message-history-tool.js +436 -0
- package/dist/agents/tools/sessions-history-tool.js +1 -0
- package/dist/build-info.json +3 -3
- package/dist/config/zod-schema.js +10 -0
- package/dist/control-ui/assets/index-DmifehTc.css +1 -0
- package/dist/control-ui/assets/index-o5Xs9S4u.js +3166 -0
- package/dist/control-ui/assets/index-o5Xs9S4u.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/config-reload.js +1 -0
- package/dist/gateway/control-ui.js +173 -0
- package/dist/gateway/net.js +16 -0
- package/dist/gateway/protocol/client-info.js +1 -0
- package/dist/gateway/protocol/schema/logs-chat.js +3 -0
- package/dist/gateway/protocol/schema/sessions-transcript.js +1 -3
- package/dist/gateway/public-chat/deliver-otp.js +9 -0
- package/dist/gateway/public-chat/otp.js +60 -0
- package/dist/gateway/public-chat/session.js +45 -0
- package/dist/gateway/server/ws-connection/message-handler.js +17 -4
- package/dist/gateway/server-chat.js +22 -0
- package/dist/gateway/server-http.js +21 -3
- package/dist/gateway/server-methods/chat.js +38 -5
- package/dist/gateway/server-methods/public-chat.js +110 -0
- package/dist/gateway/server-methods/sessions-transcript.js +29 -46
- package/dist/gateway/server-methods.js +17 -0
- package/dist/hooks/bundled/conversation-archive/handler.js +23 -6
- package/dist/infra/session-recovery.js +1 -3
- package/dist/plugins/runtime/index.js +2 -0
- package/dist/utils/message-channel.js +3 -0
- package/package.json +1 -1
- package/taskmaster-docs/USER-GUIDE.md +185 -5
- package/dist/control-ui/assets/index-BPvR6pln.js +0 -3021
- package/dist/control-ui/assets/index-BPvR6pln.js.map +0 -1
- package/dist/control-ui/assets/index-mweBpmCT.css +0 -1
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<title>Taskmaster Control</title>
|
|
7
7
|
<meta name="color-scheme" content="dark light" />
|
|
8
8
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-o5Xs9S4u.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-DmifehTc.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<taskmaster-app></taskmaster-app>
|
|
@@ -7,6 +7,7 @@ const DEFAULT_RELOAD_SETTINGS = {
|
|
|
7
7
|
};
|
|
8
8
|
const BASE_RELOAD_RULES = [
|
|
9
9
|
{ prefix: "access", kind: "none" },
|
|
10
|
+
{ prefix: "publicChat", kind: "none" },
|
|
10
11
|
{ prefix: "apiKeys", kind: "none" },
|
|
11
12
|
{ prefix: "gateway.remote", kind: "none" },
|
|
12
13
|
{ prefix: "gateway.reload", kind: "none" },
|
|
@@ -345,3 +345,176 @@ export function handleBrandIconRequest(req, res, opts) {
|
|
|
345
345
|
serveFile(res, filePath);
|
|
346
346
|
return true;
|
|
347
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Serve `/public/chat` — the same SPA but with public-chat flags injected.
|
|
350
|
+
*/
|
|
351
|
+
export function handlePublicChatHttpRequest(req, res, opts) {
|
|
352
|
+
const urlRaw = req.url;
|
|
353
|
+
if (!urlRaw)
|
|
354
|
+
return false;
|
|
355
|
+
const url = new URL(urlRaw, "http://localhost");
|
|
356
|
+
const pathname = url.pathname;
|
|
357
|
+
if (!pathname.startsWith("/public/"))
|
|
358
|
+
return false;
|
|
359
|
+
// Static asset passthrough (JS/CSS/images served from /public/assets/*)
|
|
360
|
+
if (pathname.startsWith("/public/assets/")) {
|
|
361
|
+
const root = resolveControlUiRoot();
|
|
362
|
+
if (!root) {
|
|
363
|
+
respondNotFound(res);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
const rel = pathname.slice("/public/".length);
|
|
367
|
+
if (!isSafeRelativePath(rel)) {
|
|
368
|
+
respondNotFound(res);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
const filePath = path.join(root, rel);
|
|
372
|
+
if (!filePath.startsWith(root)) {
|
|
373
|
+
respondNotFound(res);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
377
|
+
serveFile(res, filePath);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
respondNotFound(res);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
384
|
+
res.statusCode = 405;
|
|
385
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
386
|
+
res.end("Method Not Allowed");
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
// Only /public/chat (the SPA route)
|
|
390
|
+
if (pathname !== "/public/chat" && !pathname.startsWith("/public/chat/")) {
|
|
391
|
+
// /public/widget.js is handled separately
|
|
392
|
+
if (pathname === "/public/widget.js")
|
|
393
|
+
return false;
|
|
394
|
+
respondNotFound(res);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
const config = opts?.config;
|
|
398
|
+
if (!config?.publicChat?.enabled) {
|
|
399
|
+
respondNotFound(res);
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
const root = resolveControlUiRoot();
|
|
403
|
+
if (!root) {
|
|
404
|
+
res.statusCode = 503;
|
|
405
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
406
|
+
res.end("Control UI assets not found.");
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
const indexPath = path.join(root, "index.html");
|
|
410
|
+
if (!fs.existsSync(indexPath)) {
|
|
411
|
+
respondNotFound(res);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
const identity = resolveAssistantIdentity({ cfg: config });
|
|
415
|
+
const avatarValue = resolveAssistantAvatarUrl({
|
|
416
|
+
avatar: identity.avatar,
|
|
417
|
+
basePath: "",
|
|
418
|
+
}) ?? identity.avatar;
|
|
419
|
+
const authMode = config.publicChat.auth ?? "anonymous";
|
|
420
|
+
const cookieTtlDays = config.publicChat.cookieTtlDays ?? 30;
|
|
421
|
+
const brandName = config.ui?.brand?.name;
|
|
422
|
+
const brandIconUrl = resolveBrandIconUrl(config.ui?.brand?.icon, root);
|
|
423
|
+
const accentColor = config.ui?.seamColor;
|
|
424
|
+
const raw = fs.readFileSync(indexPath, "utf8");
|
|
425
|
+
const injected = injectControlUiConfig(raw, {
|
|
426
|
+
basePath: "",
|
|
427
|
+
assistantName: identity.name,
|
|
428
|
+
assistantAvatar: avatarValue,
|
|
429
|
+
brandName,
|
|
430
|
+
brandIconUrl,
|
|
431
|
+
accentColor,
|
|
432
|
+
});
|
|
433
|
+
// Inject public-chat globals
|
|
434
|
+
const publicScript = `<script>` +
|
|
435
|
+
`window.__TASKMASTER_PUBLIC_CHAT__=true;` +
|
|
436
|
+
`window.__TASKMASTER_PUBLIC_CHAT_CONFIG__=${JSON.stringify({ auth: authMode, cookieTtlDays })};` +
|
|
437
|
+
`</script>`;
|
|
438
|
+
const headClose = injected.indexOf("</head>");
|
|
439
|
+
const withPublic = headClose !== -1
|
|
440
|
+
? `${injected.slice(0, headClose)}${publicScript}${injected.slice(headClose)}`
|
|
441
|
+
: `${publicScript}${injected}`;
|
|
442
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
443
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
444
|
+
res.end(withPublic);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
/** Widget script content — self-contained JS for embedding. */
|
|
448
|
+
const WIDGET_SCRIPT = `(function(){
|
|
449
|
+
"use strict";
|
|
450
|
+
var cfg={server:""};
|
|
451
|
+
var isOpen=false;
|
|
452
|
+
var btn,overlay,iframe;
|
|
453
|
+
|
|
454
|
+
function init(opts){
|
|
455
|
+
if(opts&&opts.server) cfg.server=opts.server.replace(/\\/$/,"");
|
|
456
|
+
build();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function build(){
|
|
460
|
+
var css=document.createElement("style");
|
|
461
|
+
css.textContent=[
|
|
462
|
+
".tm-widget-btn{position:fixed;bottom:20px;right:20px;width:60px;height:60px;",
|
|
463
|
+
"border-radius:50%;background:#0078ff;color:#fff;border:none;cursor:pointer;",
|
|
464
|
+
"box-shadow:0 4px 12px rgba(0,0,0,.25);z-index:999999;font-size:28px;",
|
|
465
|
+
"display:flex;align-items:center;justify-content:center;transition:transform .2s}",
|
|
466
|
+
".tm-widget-btn:hover{transform:scale(1.1)}",
|
|
467
|
+
".tm-widget-overlay{position:fixed;bottom:90px;right:20px;width:400px;height:600px;",
|
|
468
|
+
"max-width:calc(100vw - 40px);max-height:calc(100vh - 110px);",
|
|
469
|
+
"border-radius:12px;overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,.3);",
|
|
470
|
+
"z-index:999998;display:none;background:#1a1a2e}",
|
|
471
|
+
".tm-widget-overlay.open{display:block}",
|
|
472
|
+
".tm-widget-iframe{width:100%;height:100%;border:none}",
|
|
473
|
+
"@media(max-width:480px){",
|
|
474
|
+
".tm-widget-overlay{bottom:0;right:0;width:100vw;height:100vh;max-width:100vw;",
|
|
475
|
+
"max-height:100vh;border-radius:0}}",
|
|
476
|
+
].join("");
|
|
477
|
+
document.head.appendChild(css);
|
|
478
|
+
|
|
479
|
+
btn=document.createElement("button");
|
|
480
|
+
btn.className="tm-widget-btn";
|
|
481
|
+
btn.textContent="\\uD83D\\uDCAC";
|
|
482
|
+
btn.setAttribute("aria-label","Chat");
|
|
483
|
+
btn.onclick=toggle;
|
|
484
|
+
document.body.appendChild(btn);
|
|
485
|
+
|
|
486
|
+
overlay=document.createElement("div");
|
|
487
|
+
overlay.className="tm-widget-overlay";
|
|
488
|
+
iframe=document.createElement("iframe");
|
|
489
|
+
iframe.className="tm-widget-iframe";
|
|
490
|
+
iframe.src=cfg.server+"/public/chat";
|
|
491
|
+
overlay.appendChild(iframe);
|
|
492
|
+
document.body.appendChild(overlay);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function toggle(){
|
|
496
|
+
isOpen=!isOpen;
|
|
497
|
+
overlay.classList.toggle("open",isOpen);
|
|
498
|
+
btn.textContent=isOpen?"\\u2715":"\\uD83D\\uDCAC";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
window.Taskmaster={init:init};
|
|
502
|
+
})();`;
|
|
503
|
+
/**
|
|
504
|
+
* Serve `/public/widget.js` — embeddable floating chat widget.
|
|
505
|
+
*/
|
|
506
|
+
export function handlePublicWidgetRequest(req, res, opts) {
|
|
507
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
508
|
+
if (url.pathname !== "/public/widget.js")
|
|
509
|
+
return false;
|
|
510
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
511
|
+
return false;
|
|
512
|
+
if (!opts?.config?.publicChat?.enabled) {
|
|
513
|
+
respondNotFound(res);
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
517
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
518
|
+
res.end(WIDGET_SCRIPT);
|
|
519
|
+
return true;
|
|
520
|
+
}
|
package/dist/gateway/net.js
CHANGED
|
@@ -174,3 +174,19 @@ function isValidIPv4(host) {
|
|
|
174
174
|
export function isLoopbackHost(host) {
|
|
175
175
|
return isLoopbackAddress(host);
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Determine whether an HTTP request originates from outside the local machine.
|
|
179
|
+
* Uses the same trusted-proxy logic as the WS handler.
|
|
180
|
+
*/
|
|
181
|
+
export function isExternalRequest(req, trustedProxies) {
|
|
182
|
+
const remoteAddr = req.socket.remoteAddress;
|
|
183
|
+
const forwardedFor = req.headers["x-forwarded-for"];
|
|
184
|
+
const realIp = req.headers["x-real-ip"];
|
|
185
|
+
const clientIp = resolveGatewayClientIp({
|
|
186
|
+
remoteAddr,
|
|
187
|
+
forwardedFor,
|
|
188
|
+
realIp,
|
|
189
|
+
trustedProxies,
|
|
190
|
+
});
|
|
191
|
+
return !isLocalGatewayAddress(clientIp);
|
|
192
|
+
}
|
|
@@ -12,6 +12,7 @@ export const GATEWAY_CLIENT_IDS = {
|
|
|
12
12
|
TEST: "test",
|
|
13
13
|
FINGERPRINT: "fingerprint",
|
|
14
14
|
PROBE: "taskmaster-probe",
|
|
15
|
+
PUBLIC_CHAT: "public-chat",
|
|
15
16
|
};
|
|
16
17
|
// Back-compat naming (internal): these values are IDs, not display names.
|
|
17
18
|
export const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS;
|
|
@@ -19,6 +19,9 @@ export const ChatHistoryParamsSchema = Type.Object({
|
|
|
19
19
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10_000 })),
|
|
20
20
|
/** When set, read from this specific session transcript instead of the current one. */
|
|
21
21
|
sessionId: Type.Optional(NonEmptyString),
|
|
22
|
+
/** When true, preserve envelope headers (channel, timestamp, sender metadata) on user messages.
|
|
23
|
+
* Defaults to false (strip envelopes) for backward compatibility with the webchat UI. */
|
|
24
|
+
preserveEnvelopes: Type.Optional(Type.Boolean()),
|
|
22
25
|
}, { additionalProperties: false });
|
|
23
26
|
export const ChatSendParamsSchema = Type.Object({
|
|
24
27
|
sessionKey: NonEmptyString,
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
export const SessionsTranscriptParamsSchema = Type.Object({
|
|
3
3
|
cursors: Type.Optional(Type.Record(Type.String(), Type.Integer({ minimum: 0 }))),
|
|
4
|
-
limit: Type.Optional(Type.Integer({ minimum: 1, maximum:
|
|
5
|
-
maxBytesPerFile: Type.Optional(Type.Integer({ minimum: 1, maximum: 250_000 })),
|
|
4
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10_000 })),
|
|
6
5
|
agents: Type.Optional(Type.Array(Type.String())),
|
|
7
|
-
full: Type.Optional(Type.Boolean()),
|
|
8
6
|
}, { additionalProperties: false });
|
|
9
7
|
export const SessionsTranscriptEntrySchema = Type.Object({
|
|
10
8
|
sessionId: Type.String(),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deliver OTP verification codes via WhatsApp.
|
|
3
|
+
*/
|
|
4
|
+
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
|
5
|
+
export async function deliverOtp(phone, code) {
|
|
6
|
+
await sendMessageWhatsApp(phone, `Your verification code is: ${code}`, {
|
|
7
|
+
verbose: false,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory OTP store for public-chat phone verification.
|
|
3
|
+
*
|
|
4
|
+
* - 6-digit codes, 5-minute TTL
|
|
5
|
+
* - 60-second rate limit between requests per phone
|
|
6
|
+
* - Max 3 verification attempts per code
|
|
7
|
+
*/
|
|
8
|
+
const store = new Map();
|
|
9
|
+
const OTP_TTL_MS = 5 * 60 * 1000;
|
|
10
|
+
const OTP_RATE_LIMIT_MS = 60 * 1000;
|
|
11
|
+
const OTP_MAX_ATTEMPTS = 3;
|
|
12
|
+
function generateCode() {
|
|
13
|
+
return String(Math.floor(100_000 + Math.random() * 900_000));
|
|
14
|
+
}
|
|
15
|
+
export function requestOtp(phone) {
|
|
16
|
+
const existing = store.get(phone);
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
if (existing && now - existing.lastRequestedAt < OTP_RATE_LIMIT_MS) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: "rate_limited",
|
|
22
|
+
retryAfterMs: OTP_RATE_LIMIT_MS - (now - existing.lastRequestedAt),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const code = generateCode();
|
|
26
|
+
store.set(phone, {
|
|
27
|
+
code,
|
|
28
|
+
expiresAt: now + OTP_TTL_MS,
|
|
29
|
+
attempts: 0,
|
|
30
|
+
lastRequestedAt: now,
|
|
31
|
+
});
|
|
32
|
+
return { ok: true, code };
|
|
33
|
+
}
|
|
34
|
+
export function verifyOtp(phone, code) {
|
|
35
|
+
const entry = store.get(phone);
|
|
36
|
+
if (!entry)
|
|
37
|
+
return { ok: false, error: "not_found" };
|
|
38
|
+
if (Date.now() > entry.expiresAt) {
|
|
39
|
+
store.delete(phone);
|
|
40
|
+
return { ok: false, error: "expired" };
|
|
41
|
+
}
|
|
42
|
+
if (entry.attempts >= OTP_MAX_ATTEMPTS) {
|
|
43
|
+
store.delete(phone);
|
|
44
|
+
return { ok: false, error: "max_attempts" };
|
|
45
|
+
}
|
|
46
|
+
entry.attempts += 1;
|
|
47
|
+
if (entry.code !== code) {
|
|
48
|
+
return { ok: false, error: "invalid" };
|
|
49
|
+
}
|
|
50
|
+
store.delete(phone);
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
/** Remove expired entries periodically. */
|
|
54
|
+
export function cleanupExpired() {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
for (const [phone, entry] of store) {
|
|
57
|
+
if (now > entry.expiresAt)
|
|
58
|
+
store.delete(phone);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve public-chat session keys (anonymous and verified).
|
|
3
|
+
*
|
|
4
|
+
* The public agent is the agent handling WhatsApp DMs for the default account.
|
|
5
|
+
* Anonymous sessions use a cookie-based identifier; verified sessions use the
|
|
6
|
+
* phone number so they share the same DM session as WhatsApp.
|
|
7
|
+
*/
|
|
8
|
+
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
|
9
|
+
import { normalizeAgentId } from "../../routing/session-key.js";
|
|
10
|
+
/**
|
|
11
|
+
* Find the agent that handles public-facing WhatsApp DMs.
|
|
12
|
+
* Priority: binding to whatsapp DM > agent named "public" > default agent.
|
|
13
|
+
*/
|
|
14
|
+
export function resolvePublicAgentId(cfg) {
|
|
15
|
+
const bindings = cfg.bindings ?? [];
|
|
16
|
+
// Find agent bound to whatsapp DMs (the public-facing agent)
|
|
17
|
+
for (const binding of bindings) {
|
|
18
|
+
if (binding.match.channel === "whatsapp" &&
|
|
19
|
+
binding.match.peer?.kind === "dm" &&
|
|
20
|
+
!binding.match.peer.id) {
|
|
21
|
+
return normalizeAgentId(binding.agentId);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Any whatsapp binding
|
|
25
|
+
for (const binding of bindings) {
|
|
26
|
+
if (binding.match.channel === "whatsapp") {
|
|
27
|
+
return normalizeAgentId(binding.agentId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Agent explicitly named "public"
|
|
31
|
+
const agents = cfg.agents?.list ?? [];
|
|
32
|
+
const publicAgent = agents.find((a) => a.id === "public");
|
|
33
|
+
if (publicAgent)
|
|
34
|
+
return normalizeAgentId(publicAgent.id);
|
|
35
|
+
// Fall back to default agent
|
|
36
|
+
return resolveDefaultAgentId(cfg);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build the session key for a public-chat visitor.
|
|
40
|
+
* Verified users get the same key as their WhatsApp DM session for cross-channel continuity.
|
|
41
|
+
* Anonymous users get a cookie-based key.
|
|
42
|
+
*/
|
|
43
|
+
export function buildPublicSessionKey(agentId, identifier) {
|
|
44
|
+
return `agent:${normalizeAgentId(agentId)}:dm:${identifier.toLowerCase()}`;
|
|
45
|
+
}
|
|
@@ -179,7 +179,7 @@ export function attachGatewayWsMessageHandler(params) {
|
|
|
179
179
|
return;
|
|
180
180
|
}
|
|
181
181
|
const roleRaw = connectParams.role ?? "operator";
|
|
182
|
-
const role = roleRaw === "operator" || roleRaw === "node" ? roleRaw : null;
|
|
182
|
+
const role = roleRaw === "operator" || roleRaw === "node" || roleRaw === "public" ? roleRaw : null;
|
|
183
183
|
if (!role) {
|
|
184
184
|
setHandshakeState("failed");
|
|
185
185
|
setCloseCause("invalid-role", {
|
|
@@ -206,6 +206,12 @@ export function attachGatewayWsMessageHandler(params) {
|
|
|
206
206
|
: [];
|
|
207
207
|
connectParams.role = role;
|
|
208
208
|
connectParams.scopes = scopes;
|
|
209
|
+
// Public role: skip device identity, gateway auth, and pairing entirely.
|
|
210
|
+
// Public clients get empty scopes and can only call public.* and chat.* methods.
|
|
211
|
+
const isPublicRole = role === "public";
|
|
212
|
+
if (isPublicRole) {
|
|
213
|
+
connectParams.scopes = [];
|
|
214
|
+
}
|
|
209
215
|
const device = connectParams.device;
|
|
210
216
|
let devicePublicKey = null;
|
|
211
217
|
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
|
@@ -214,7 +220,10 @@ export function attachGatewayWsMessageHandler(params) {
|
|
|
214
220
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
|
215
221
|
const isSetupUi = connectParams.client.id === GATEWAY_CLIENT_IDS.SETUP_UI;
|
|
216
222
|
const allowInsecureControlUi = isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
|
217
|
-
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
|
|
223
|
+
if (isPublicRole && hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
|
|
224
|
+
// Public role from external: allowed (Funnel restriction is enforced at HTTP level).
|
|
225
|
+
}
|
|
226
|
+
else if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
|
|
218
227
|
setHandshakeState("failed");
|
|
219
228
|
setCloseCause("proxy-auth-required", {
|
|
220
229
|
client: connectParams.client.id,
|
|
@@ -239,7 +248,7 @@ export function attachGatewayWsMessageHandler(params) {
|
|
|
239
248
|
// Setup UI and insecure control-ui are allowed without device identity.
|
|
240
249
|
// When allowInsecureAuth is on, control-ui can skip device identity entirely —
|
|
241
250
|
// PIN auth still protects account access (same as setup-ui).
|
|
242
|
-
const canSkipDevice = isSetupUi || allowInsecureControlUi || hasTokenAuth;
|
|
251
|
+
const canSkipDevice = isPublicRole || isSetupUi || allowInsecureControlUi || hasTokenAuth;
|
|
243
252
|
if (isControlUi && !allowInsecureControlUi) {
|
|
244
253
|
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
|
245
254
|
setHandshakeState("failed");
|
|
@@ -436,7 +445,11 @@ export function attachGatewayWsMessageHandler(params) {
|
|
|
436
445
|
// Setup UI is allowed without auth (limited to WhatsApp pairing methods).
|
|
437
446
|
// Insecure control-ui (allowInsecureAuth + no device identity) gets the same
|
|
438
447
|
// treatment — PIN auth is the security layer, not gateway auth.
|
|
439
|
-
if (
|
|
448
|
+
if (isPublicRole) {
|
|
449
|
+
authOk = true;
|
|
450
|
+
authMethod = "public";
|
|
451
|
+
}
|
|
452
|
+
else if (isSetupUi || (allowInsecureControlUi && !device)) {
|
|
440
453
|
authOk = true;
|
|
441
454
|
authMethod = isSetupUi ? "setup-ui" : "insecure-control-ui";
|
|
442
455
|
}
|
|
@@ -146,6 +146,28 @@ export function createAgentEventHandler({ broadcast, nodeSendToSession, agentRun
|
|
|
146
146
|
// Include sessionKey so Control UI can filter tool streams per session.
|
|
147
147
|
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
|
|
148
148
|
const last = agentRunSeq.get(evt.runId) ?? 0;
|
|
149
|
+
// When a tool call starts during a chat run, signal the client to show the
|
|
150
|
+
// working indicator (bouncing dots) regardless of verbose level. Verbose
|
|
151
|
+
// controls whether tool *details* are broadcast; the working signal is about
|
|
152
|
+
// UX feedback that the agent is still active.
|
|
153
|
+
// Note: chat.send runs don't populate the registry (chatLink is null) —
|
|
154
|
+
// sessionKey alone is sufficient since it resolves via runContext.
|
|
155
|
+
if (evt.stream === "tool" && evt.data?.phase === "start" && sessionKey && !isAborted) {
|
|
156
|
+
// Include the full buffered text so the client has the complete
|
|
157
|
+
// interim message — the last streaming delta may have been suppressed
|
|
158
|
+
// by the 150ms throttle.
|
|
159
|
+
const bufferedText = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
|
|
160
|
+
const workingPayload = {
|
|
161
|
+
runId: clientRunId,
|
|
162
|
+
sessionKey,
|
|
163
|
+
state: "working",
|
|
164
|
+
message: bufferedText
|
|
165
|
+
? { role: "assistant", content: [{ type: "text", text: bufferedText }] }
|
|
166
|
+
: undefined,
|
|
167
|
+
};
|
|
168
|
+
broadcast("chat", workingPayload);
|
|
169
|
+
nodeSendToSession(sessionKey, "chat", workingPayload);
|
|
170
|
+
}
|
|
149
171
|
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
|
|
150
172
|
agentRunSeq.set(evt.runId, evt.seq);
|
|
151
173
|
return;
|
|
@@ -5,8 +5,9 @@ import { loadConfig } from "../config/config.js";
|
|
|
5
5
|
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
|
6
6
|
import { createCloudApiWebhookHandler } from "../web/providers/cloud/webhook-http.js";
|
|
7
7
|
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
|
8
|
-
import { handleBrandIconRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest, } from "./control-ui.js";
|
|
8
|
+
import { handleBrandIconRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest, handlePublicChatHttpRequest, handlePublicWidgetRequest, } from "./control-ui.js";
|
|
9
9
|
import { isLicensed } from "../license/state.js";
|
|
10
|
+
import { isExternalRequest } from "./net.js";
|
|
10
11
|
import { extractHookToken, getHookChannelError, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody, resolveHookChannel, resolveHookDeliver, } from "./hooks.js";
|
|
11
12
|
import { applyHookMappings } from "./hooks-mapping.js";
|
|
12
13
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
|
@@ -150,6 +151,25 @@ export function createGatewayHttpServer(opts) {
|
|
|
150
151
|
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket")
|
|
151
152
|
return;
|
|
152
153
|
try {
|
|
154
|
+
const configSnapshot = loadConfig();
|
|
155
|
+
// Public chat routes — served before license enforcement so public visitors
|
|
156
|
+
// are never redirected to /setup.
|
|
157
|
+
if (handlePublicChatHttpRequest(req, res, { config: configSnapshot }))
|
|
158
|
+
return;
|
|
159
|
+
if (handlePublicWidgetRequest(req, res, { config: configSnapshot }))
|
|
160
|
+
return;
|
|
161
|
+
// Funnel restriction: block non-local requests from accessing non-public paths.
|
|
162
|
+
// /public/* is already handled above, so any request reaching here is non-public.
|
|
163
|
+
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
|
164
|
+
if (isExternalRequest(req, trustedProxies)) {
|
|
165
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
166
|
+
if (!pathname.startsWith("/public/")) {
|
|
167
|
+
res.statusCode = 403;
|
|
168
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
169
|
+
res.end("Forbidden");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
153
173
|
// License enforcement: redirect browser page navigations to /setup when unlicensed.
|
|
154
174
|
// Only affects GET requests for HTML pages — API calls, webhooks, and assets are unaffected.
|
|
155
175
|
if (controlUiEnabled && !isLicensed() && req.method === "GET") {
|
|
@@ -173,8 +193,6 @@ export function createGatewayHttpServer(opts) {
|
|
|
173
193
|
}
|
|
174
194
|
}
|
|
175
195
|
}
|
|
176
|
-
const configSnapshot = loadConfig();
|
|
177
|
-
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
|
178
196
|
if (await handleHooksRequest(req, res))
|
|
179
197
|
return;
|
|
180
198
|
if (await handleToolsInvokeHttpRequest(req, res, {
|
|
@@ -11,7 +11,9 @@ import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.j
|
|
|
11
11
|
import { extractShortModelName, } from "../../auto-reply/reply/response-prefix-template.js";
|
|
12
12
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
|
13
13
|
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
|
14
|
+
import { loadConfig as loadConfigFn } from "../../config/config.js";
|
|
14
15
|
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
|
16
|
+
import { resolvePublicAgentId } from "../public-chat/session.js";
|
|
15
17
|
import { abortChatRunById, abortChatRunsForSessionKey, isChatStopCommandText, resolveChatRunExpiresAtMs, } from "../chat-abort.js";
|
|
16
18
|
import { ErrorCodes, errorShape, formatValidationErrors, validateChatAbortParams, validateChatHistoryParams, validateChatInjectParams, validateChatSendParams, } from "../protocol/index.js";
|
|
17
19
|
import { loadSessionEntry, readSessionMessages, resolveSessionModelRef } from "../session-utils.js";
|
|
@@ -118,13 +120,33 @@ function broadcastChatError(params) {
|
|
|
118
120
|
params.context.broadcast("chat", payload);
|
|
119
121
|
params.context.nodeSendToSession(params.sessionKey, "chat", payload);
|
|
120
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Validate that a public-role client is allowed to access the given session key.
|
|
125
|
+
* Returns an error shape if denied, null if allowed.
|
|
126
|
+
*/
|
|
127
|
+
function validatePublicSessionAccess(role, sessionKey) {
|
|
128
|
+
if (role !== "public")
|
|
129
|
+
return null;
|
|
130
|
+
const cfg = loadConfigFn();
|
|
131
|
+
const publicAgentId = resolvePublicAgentId(cfg);
|
|
132
|
+
const prefix = `agent:${publicAgentId}:dm:`;
|
|
133
|
+
if (!sessionKey.startsWith(prefix)) {
|
|
134
|
+
return errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized session key");
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
121
138
|
export const chatHandlers = {
|
|
122
|
-
"chat.history": async ({ params, respond, context }) => {
|
|
139
|
+
"chat.history": async ({ params, respond, context, client }) => {
|
|
123
140
|
if (!validateChatHistoryParams(params)) {
|
|
124
141
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`));
|
|
125
142
|
return;
|
|
126
143
|
}
|
|
127
|
-
const { sessionKey, limit, sessionId: requestedSessionId, } = params;
|
|
144
|
+
const { sessionKey, limit, sessionId: requestedSessionId, preserveEnvelopes, } = params;
|
|
145
|
+
const publicError = validatePublicSessionAccess(client?.connect?.role, sessionKey);
|
|
146
|
+
if (publicError) {
|
|
147
|
+
respond(false, undefined, publicError);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
128
150
|
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
|
|
129
151
|
// When a specific sessionId is requested, resolve only that transcript.
|
|
130
152
|
// Otherwise, stitch all previous sessions + current into one continuous history.
|
|
@@ -170,7 +192,8 @@ export const chatHandlers = {
|
|
|
170
192
|
const requested = typeof limit === "number" ? limit : defaultLimit;
|
|
171
193
|
const max = Math.min(hardMax, requested);
|
|
172
194
|
const messages = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
|
173
|
-
const
|
|
195
|
+
const withoutBase64 = stripBase64ImagesFromMessages(messages);
|
|
196
|
+
const sanitized = preserveEnvelopes ? withoutBase64 : stripEnvelopeFromMessages(withoutBase64);
|
|
174
197
|
// Diagnostic: log resolution details so we can trace "lost history" reports.
|
|
175
198
|
const prevCount = entry?.previousSessions?.length ?? 0;
|
|
176
199
|
context.logGateway.info(`chat.history: sessionKey=${sessionKey} resolvedSessionId=${sessionId ?? "none"} storePath=${storePath ?? "none"} entryExists=${!!entry} previousSessions=${prevCount} rawMessages=${rawMessages.length} sent=${sanitized.length}`);
|
|
@@ -206,12 +229,17 @@ export const chatHandlers = {
|
|
|
206
229
|
fillerEnabled: entry?.fillerEnabled ?? null,
|
|
207
230
|
});
|
|
208
231
|
},
|
|
209
|
-
"chat.abort": ({ params, respond, context }) => {
|
|
232
|
+
"chat.abort": ({ params, respond, context, client }) => {
|
|
210
233
|
if (!validateChatAbortParams(params)) {
|
|
211
234
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`));
|
|
212
235
|
return;
|
|
213
236
|
}
|
|
214
237
|
const { sessionKey, runId } = params;
|
|
238
|
+
const publicAbortError = validatePublicSessionAccess(client?.connect?.role, sessionKey);
|
|
239
|
+
if (publicAbortError) {
|
|
240
|
+
respond(false, undefined, publicAbortError);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
215
243
|
const ops = {
|
|
216
244
|
chatAbortControllers: context.chatAbortControllers,
|
|
217
245
|
chatRunBuffers: context.chatRunBuffers,
|
|
@@ -256,6 +284,11 @@ export const chatHandlers = {
|
|
|
256
284
|
return;
|
|
257
285
|
}
|
|
258
286
|
const p = params;
|
|
287
|
+
const publicSendError = validatePublicSessionAccess(client?.connect?.role, p.sessionKey);
|
|
288
|
+
if (publicSendError) {
|
|
289
|
+
respond(false, undefined, publicSendError);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
259
292
|
const stopCommand = isChatStopCommandText(p.message);
|
|
260
293
|
const normalizedAttachments = p.attachments
|
|
261
294
|
?.map((a) => ({
|
|
@@ -440,7 +473,7 @@ export const chatHandlers = {
|
|
|
440
473
|
Surface: INTERNAL_MESSAGE_CHANNEL,
|
|
441
474
|
OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
|
|
442
475
|
ChatType: "direct",
|
|
443
|
-
CommandAuthorized:
|
|
476
|
+
CommandAuthorized: client?.connect?.role !== "public",
|
|
444
477
|
MessageSid: clientRunId,
|
|
445
478
|
SenderId: clientInfo?.id,
|
|
446
479
|
SenderName: clientInfo?.displayName,
|