@rubytech/taskmaster 1.0.64 → 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.
Files changed (39) hide show
  1. package/dist/agents/pi-embedded-runner/history.js +19 -1
  2. package/dist/agents/pi-embedded-runner/run/attempt.js +23 -5
  3. package/dist/agents/pi-embedded-runner/run.js +6 -31
  4. package/dist/agents/pi-embedded-runner.js +1 -1
  5. package/dist/agents/system-prompt.js +20 -0
  6. package/dist/agents/taskmaster-tools.js +4 -0
  7. package/dist/agents/tool-policy.js +2 -0
  8. package/dist/agents/tools/message-history-tool.js +436 -0
  9. package/dist/agents/tools/sessions-history-tool.js +1 -0
  10. package/dist/build-info.json +3 -3
  11. package/dist/config/zod-schema.js +10 -0
  12. package/dist/control-ui/assets/index-DmifehTc.css +1 -0
  13. package/dist/control-ui/assets/index-o5Xs9S4u.js +3166 -0
  14. package/dist/control-ui/assets/index-o5Xs9S4u.js.map +1 -0
  15. package/dist/control-ui/index.html +2 -2
  16. package/dist/gateway/config-reload.js +1 -0
  17. package/dist/gateway/control-ui.js +173 -0
  18. package/dist/gateway/net.js +16 -0
  19. package/dist/gateway/protocol/client-info.js +1 -0
  20. package/dist/gateway/protocol/schema/logs-chat.js +3 -0
  21. package/dist/gateway/protocol/schema/sessions-transcript.js +1 -3
  22. package/dist/gateway/public-chat/deliver-otp.js +9 -0
  23. package/dist/gateway/public-chat/otp.js +60 -0
  24. package/dist/gateway/public-chat/session.js +45 -0
  25. package/dist/gateway/server/ws-connection/message-handler.js +17 -4
  26. package/dist/gateway/server-chat.js +22 -0
  27. package/dist/gateway/server-http.js +21 -3
  28. package/dist/gateway/server-methods/chat.js +38 -5
  29. package/dist/gateway/server-methods/public-chat.js +110 -0
  30. package/dist/gateway/server-methods/sessions-transcript.js +29 -46
  31. package/dist/gateway/server-methods.js +17 -0
  32. package/dist/hooks/bundled/conversation-archive/handler.js +23 -6
  33. package/dist/plugins/runtime/index.js +2 -0
  34. package/dist/utils/message-channel.js +3 -0
  35. package/package.json +1 -1
  36. package/taskmaster-docs/USER-GUIDE.md +185 -5
  37. package/dist/control-ui/assets/index-BPvR6pln.js +0 -3021
  38. package/dist/control-ui/assets/index-BPvR6pln.js.map +0 -1
  39. 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-BPvR6pln.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-mweBpmCT.css">
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
+ }
@@ -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: 1000 })),
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 (isSetupUi || (allowInsecureControlUi && !device)) {
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 sanitized = stripBase64ImagesFromMessages(stripEnvelopeFromMessages(messages));
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: true,
476
+ CommandAuthorized: client?.connect?.role !== "public",
444
477
  MessageSid: clientRunId,
445
478
  SenderId: clientInfo?.id,
446
479
  SenderName: clientInfo?.displayName,