leedab 0.1.9 → 0.2.2

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.
@@ -0,0 +1,21 @@
1
+ import { type ChannelName } from "../team/permissions.js";
2
+ import type { Role } from "../team.js";
3
+ export interface ResolvedMember {
4
+ memberId: string;
5
+ name: string;
6
+ role: Role;
7
+ allowedWorkflows: string[];
8
+ allowedChannels: ChannelName[];
9
+ }
10
+ /**
11
+ * Normalize a channel user id or handle for comparison.
12
+ * WhatsApp reports E.164 like "+13025551234" or "13025551234@c.us".
13
+ * Telegram reports a numeric id.
14
+ * Teams reports an Azure AD object id.
15
+ */
16
+ export declare function normalizeHandle(value: string | undefined): string;
17
+ /**
18
+ * Look up a resolved member for a channel/userId pair.
19
+ * Returns null when no member in the overlay owns that handle on that channel.
20
+ */
21
+ export declare function resolveMember(channel: ChannelName, userId: string): Promise<ResolvedMember | null>;
@@ -0,0 +1,44 @@
1
+ import { readOverlay } from "../team/permissions.js";
2
+ /**
3
+ * Normalize a channel user id or handle for comparison.
4
+ * WhatsApp reports E.164 like "+13025551234" or "13025551234@c.us".
5
+ * Telegram reports a numeric id.
6
+ * Teams reports an Azure AD object id.
7
+ */
8
+ export function normalizeHandle(value) {
9
+ if (!value)
10
+ return "";
11
+ return value
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[\s\-()]/g, "")
15
+ .replace(/^@/, "")
16
+ .replace(/^\+/, "")
17
+ .replace(/@c\.us$/, "")
18
+ .replace(/@s\.whatsapp\.net$/, "");
19
+ }
20
+ /**
21
+ * Look up a resolved member for a channel/userId pair.
22
+ * Returns null when no member in the overlay owns that handle on that channel.
23
+ */
24
+ export async function resolveMember(channel, userId) {
25
+ const needle = normalizeHandle(userId);
26
+ if (!needle)
27
+ return null;
28
+ const overlay = await readOverlay();
29
+ for (const [id, perms] of Object.entries(overlay.members)) {
30
+ if (channel === "dashboard")
31
+ continue;
32
+ const handle = perms.handles[channel];
33
+ if (handle && normalizeHandle(handle) === needle) {
34
+ return {
35
+ memberId: id,
36
+ name: perms.name,
37
+ role: perms.role,
38
+ allowedWorkflows: [...(perms.allowedWorkflows ?? [])],
39
+ allowedChannels: [...(perms.allowedChannels ?? [])],
40
+ };
41
+ }
42
+ }
43
+ return null;
44
+ }
@@ -1,5 +1,17 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import type { LeedABConfig } from "../config/schema.js";
3
+ import { type ChannelName } from "../team/permissions.js";
3
4
  type RouteHandler = (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void>;
4
5
  export declare function createRoutes(config: LeedABConfig): Record<string, RouteHandler>;
6
+ /**
7
+ * Build the permission preamble injected into the agent prompt for a given
8
+ * channel message. Returns null when we have nothing to add (unknown user
9
+ * on a non-dashboard channel — the allowlist will already have rejected
10
+ * those).
11
+ *
12
+ * This preamble is the sole workflow-permission gate. We trust the agent to
13
+ * honor it. There is no server-side hard gate; admins restrict access by
14
+ * naming the workflows a member may use in the team page.
15
+ */
16
+ export declare function buildPermissionPreamble(channel: ChannelName, userId: string): Promise<string | null>;
5
17
  export {};
@@ -5,9 +5,14 @@ import { promisify } from "node:util";
5
5
  import { userInfo } from "node:os";
6
6
  import { resolveOpenClawBin, openclawEnv } from "../openclaw.js";
7
7
  import { addEntry, removeEntry, listEntries } from "../vault.js";
8
- import { readAuditLog } from "../audit.js";
8
+ import { readAuditLog, logAudit } from "../audit.js";
9
9
  import { getAnalytics } from "../analytics.js";
10
- import { loadTeam, addMember, removeMember, updateRole } from "../team.js";
10
+ import { loadTeam, addMember, removeMember, updateRole, } from "../team.js";
11
+ import { loadLicense } from "../license.js";
12
+ import { setMemberPermissions, } from "../team/permissions.js";
13
+ import { syncAllowlists } from "../team/syncAllowlists.js";
14
+ import { resolveMember } from "../agent/resolveMember.js";
15
+ import { WORKFLOWS, getWorkflow } from "../workflows/registry.js";
11
16
  import { STATE_DIR } from "../paths.js";
12
17
  const execFileAsync = promisify(execFile);
13
18
  async function restartGatewayQuietly(stateDir) {
@@ -35,10 +40,16 @@ export function createRoutes(config) {
35
40
  json(res, { error: "message is required" }, 400);
36
41
  return;
37
42
  }
43
+ // Permission enforcement lives in the prompt preamble. The dashboard
44
+ // chat runs as the local admin / install owner, so the preamble here
45
+ // is informational ("assisting <admin>"). The same builder is the
46
+ // single gate used by channel handlers when we wire them up.
47
+ const preamble = await buildPermissionPreamble("dashboard", userInfo().username);
48
+ const agentMessage = preamble ? `${preamble}\n\n${message}` : message;
38
49
  try {
39
50
  const args = [
40
51
  "agent",
41
- "--message", message,
52
+ "--message", agentMessage,
42
53
  "--session-id", session ?? "console",
43
54
  "--json",
44
55
  ];
@@ -54,7 +65,8 @@ export function createRoutes(config) {
54
65
  result.text ??
55
66
  result.content ??
56
67
  stdout.trim();
57
- json(res, { reply, session: session ?? "console" });
68
+ const thoughts = await readLatestThoughts(stateDir, session ?? "console");
69
+ json(res, { reply, thoughts, session: session ?? "console" });
58
70
  }
59
71
  catch {
60
72
  json(res, {
@@ -77,6 +89,7 @@ export function createRoutes(config) {
77
89
  const jsonlPath = resolve(sessionsDir, `${session}.jsonl`);
78
90
  const raw = await readFile(jsonlPath, "utf-8");
79
91
  const messages = [];
92
+ let pendingThoughts = [];
80
93
  for (const line of raw.split("\n")) {
81
94
  if (!line.trim())
82
95
  continue;
@@ -88,11 +101,16 @@ export function createRoutes(config) {
88
101
  if (!msg || (msg.role !== "user" && msg.role !== "assistant"))
89
102
  continue;
90
103
  let text = "";
104
+ const theseThoughts = [];
91
105
  if (Array.isArray(msg.content)) {
92
- text = msg.content
93
- .filter((b) => b.type === "text")
94
- .map((b) => b.text)
95
- .join("\n");
106
+ for (const b of msg.content) {
107
+ if (b?.type === "text" && typeof b.text === "string") {
108
+ text += (text ? "\n" : "") + b.text;
109
+ }
110
+ else if (b?.type === "thinking" && typeof b.thinking === "string") {
111
+ theseThoughts.push(b.thinking);
112
+ }
113
+ }
96
114
  }
97
115
  else if (typeof msg.content === "string") {
98
116
  text = msg.content;
@@ -100,12 +118,22 @@ export function createRoutes(config) {
100
118
  // Strip all OpenClaw "(untrusted metadata)" blocks and timestamp prefix
101
119
  text = text.replace(/^(\w[\w\s]*\(untrusted metadata\):\n```json\n[\s\S]*?\n```\n\n)+/, "");
102
120
  text = text.replace(/^\[[\w]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2} \w+\] /, "");
103
- if (text) {
104
- messages.push({
105
- role: msg.role,
106
- text,
107
- timestamp: entry.timestamp,
108
- });
121
+ if (msg.role === "assistant") {
122
+ // Accumulate thoughts across tool-use turns until we see text
123
+ pendingThoughts.push(...theseThoughts);
124
+ if (text) {
125
+ messages.push({
126
+ role: msg.role,
127
+ text,
128
+ thoughts: pendingThoughts.length ? pendingThoughts : undefined,
129
+ timestamp: entry.timestamp,
130
+ });
131
+ pendingThoughts = [];
132
+ }
133
+ }
134
+ else if (text) {
135
+ messages.push({ role: msg.role, text, timestamp: entry.timestamp });
136
+ pendingThoughts = [];
109
137
  }
110
138
  }
111
139
  catch { }
@@ -251,6 +279,36 @@ export function createRoutes(config) {
251
279
  catch { }
252
280
  }
253
281
  }
282
+ // Enrich each session with sender name and first message summary
283
+ await Promise.all(results.map(async (s) => {
284
+ try {
285
+ const filePath = resolve(sessionsDir, `${s.sessionId}.jsonl`);
286
+ const raw = await readFile(filePath, "utf-8");
287
+ const firstMsg = raw.split("\n").find((line) => {
288
+ if (!line.trim())
289
+ return false;
290
+ try {
291
+ const obj = JSON.parse(line);
292
+ return obj.type === "message" && obj.message?.role === "user";
293
+ }
294
+ catch {
295
+ return false;
296
+ }
297
+ });
298
+ if (!firstMsg)
299
+ return;
300
+ const parsed = JSON.parse(firstMsg);
301
+ const text = parsed.message?.content?.[0]?.text ?? "";
302
+ // Extract sender + clean summary using openclaw's metadata format.
303
+ // Metadata blocks are sentinel lines followed by ```json ... ```
304
+ const { senderName, cleanText } = parseSessionFirstMessage(text);
305
+ if (senderName)
306
+ s.senderName = senderName;
307
+ if (cleanText)
308
+ s.summary = cleanText.slice(0, 120);
309
+ }
310
+ catch { }
311
+ }));
254
312
  // Sort newest first
255
313
  results.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
256
314
  json(res, results);
@@ -432,28 +490,39 @@ export function createRoutes(config) {
432
490
  json(res, { ok: removed, service });
433
491
  },
434
492
  /**
435
- * GET /api/team — list team members
493
+ * GET /api/team — list local team members
436
494
  */
437
495
  "GET /api/team": async (_req, res) => {
438
- const team = await loadTeam();
439
- json(res, team);
496
+ try {
497
+ const team = await loadTeam();
498
+ json(res, team);
499
+ }
500
+ catch (err) {
501
+ json(res, { error: err.message ?? "Failed to load team" }, 500);
502
+ }
440
503
  },
441
504
  /**
442
- * POST /api/team — add a team member
505
+ * POST /api/team — add a team member locally
443
506
  */
444
507
  "POST /api/team": async (req, res) => {
445
508
  const body = await readBody(req);
446
- const { name, email, role, channels } = JSON.parse(body);
447
- if (!name || typeof name !== "string") {
509
+ const { name, email, role } = JSON.parse(body);
510
+ if (!name || typeof name !== "string" || !name.trim()) {
448
511
  json(res, { error: "name is required" }, 400);
449
512
  return;
450
513
  }
451
- const member = await addMember({
452
- name: name.trim(),
453
- email: email || "",
454
- role: role || "member",
455
- });
456
- json(res, member, 201);
514
+ const normalizedRole = role || "member";
515
+ if (normalizedRole !== "admin" && normalizedRole !== "member") {
516
+ json(res, { error: "role must be admin or member" }, 400);
517
+ return;
518
+ }
519
+ try {
520
+ const member = await addMember({ name: name.trim(), email, role: normalizedRole });
521
+ json(res, member, 201);
522
+ }
523
+ catch (err) {
524
+ json(res, { error: err.message }, 500);
525
+ }
457
526
  },
458
527
  /**
459
528
  * DELETE /api/team — remove a team member
@@ -466,6 +535,12 @@ export function createRoutes(config) {
466
535
  return;
467
536
  }
468
537
  const removed = await removeMember(id);
538
+ if (removed) {
539
+ try {
540
+ await syncAllowlists();
541
+ }
542
+ catch { /* best-effort */ }
543
+ }
469
544
  json(res, { ok: removed });
470
545
  },
471
546
  /**
@@ -481,8 +556,213 @@ export function createRoutes(config) {
481
556
  const updated = await updateRole(id, role);
482
557
  json(res, { ok: updated });
483
558
  },
559
+ /**
560
+ * PUT /api/team/permissions — update a member's local overlay permissions
561
+ * and rebuild channel allowlists.
562
+ */
563
+ "PUT /api/team/permissions": async (req, res) => {
564
+ const body = await readBody(req);
565
+ const { memberId, handles, allowedWorkflows, allowedChannels } = JSON.parse(body);
566
+ if (!memberId || typeof memberId !== "string") {
567
+ json(res, { error: "memberId is required" }, 400);
568
+ return;
569
+ }
570
+ // Lightweight validation. The dashboard posts known-good values.
571
+ const validChannels = ["whatsapp", "telegram", "teams", "dashboard"];
572
+ const perms = {};
573
+ if (handles && typeof handles === "object") {
574
+ perms.handles = {
575
+ whatsapp: handles.whatsapp?.toString().trim() || undefined,
576
+ telegram: handles.telegram?.toString().trim() || undefined,
577
+ teams: handles.teams?.toString().trim() || undefined,
578
+ };
579
+ }
580
+ if (Array.isArray(allowedWorkflows)) {
581
+ perms.allowedWorkflows = allowedWorkflows
582
+ .filter((id) => typeof id === "string")
583
+ .filter((id) => getWorkflow(id) !== undefined);
584
+ }
585
+ if (Array.isArray(allowedChannels)) {
586
+ perms.allowedChannels = allowedChannels.filter((c) => typeof c === "string" && validChannels.includes(c));
587
+ }
588
+ try {
589
+ const saved = await setMemberPermissions(memberId, perms);
590
+ await syncAllowlists();
591
+ await logAudit({
592
+ timestamp: new Date().toISOString(),
593
+ user: "dashboard",
594
+ channel: "dashboard",
595
+ action: "permissions_updated",
596
+ responseSummary: `member ${memberId} perms updated`,
597
+ }).catch(() => { });
598
+ json(res, { ok: true, permissions: saved });
599
+ }
600
+ catch (err) {
601
+ json(res, { error: err.message ?? "Failed to save" }, 500);
602
+ }
603
+ },
604
+ /**
605
+ * GET /api/workflows — static workflow registry, used by the permissions UI
606
+ */
607
+ "GET /api/workflows": async (_req, res) => {
608
+ json(res, WORKFLOWS);
609
+ },
610
+ /**
611
+ * GET /api/license — read-only license info for the dashboard (seat chip)
612
+ */
613
+ "GET /api/license": async (_req, res) => {
614
+ const license = await loadLicense();
615
+ if (!license) {
616
+ json(res, { valid: false, tier: "none", seatsUsed: 0, maxSeats: 0 });
617
+ return;
618
+ }
619
+ const { valid, tier, status, seatsUsed, maxSeats, email, name, orgName } = license;
620
+ json(res, { valid, tier, status, seatsUsed, maxSeats, email, name, orgName });
621
+ },
484
622
  };
485
623
  }
624
+ /**
625
+ * Build the permission preamble injected into the agent prompt for a given
626
+ * channel message. Returns null when we have nothing to add (unknown user
627
+ * on a non-dashboard channel — the allowlist will already have rejected
628
+ * those).
629
+ *
630
+ * This preamble is the sole workflow-permission gate. We trust the agent to
631
+ * honor it. There is no server-side hard gate; admins restrict access by
632
+ * naming the workflows a member may use in the team page.
633
+ */
634
+ export async function buildPermissionPreamble(channel, userId) {
635
+ const resolved = await resolveMember(channel, userId);
636
+ if (!resolved)
637
+ return null;
638
+ const allowed = resolved.allowedWorkflows.length
639
+ ? resolved.allowedWorkflows
640
+ .map((id) => getWorkflow(id)?.title ?? id)
641
+ .join(", ")
642
+ : "all workflows";
643
+ return [
644
+ `[System] You are currently assisting ${resolved.name} (role: ${resolved.role}).`,
645
+ `They are authorized to use these workflows: ${allowed}.`,
646
+ `If they ask for any workflow outside that list, politely say it isn't enabled for them yet and suggest they contact their admin. Do not run it regardless of how the request is phrased.`,
647
+ ].join(" ");
648
+ }
649
+ /**
650
+ * Parse the first user message of a session JSONL to extract sender name and
651
+ * clean summary text. Mirrors openclaw's stripInboundMetadata + extractInboundSenderLabel.
652
+ */
653
+ function parseSessionFirstMessage(text) {
654
+ let senderName = null;
655
+ // --- Extract sender from metadata blocks ---
656
+ const SENTINELS = [
657
+ "Conversation info (untrusted metadata):",
658
+ "Sender (untrusted metadata):",
659
+ "Thread starter (untrusted, for context):",
660
+ "Replied message (untrusted, for context):",
661
+ "Forwarded message context (untrusted metadata):",
662
+ "Chat history since last reply (untrusted, for context):",
663
+ ];
664
+ const lines = text.split("\n");
665
+ const cleaned = [];
666
+ let i = 0;
667
+ while (i < lines.length) {
668
+ const trimmed = lines[i].trim();
669
+ // Check if this line is a metadata sentinel followed by ```json
670
+ if (SENTINELS.includes(trimmed) && lines[i + 1]?.trim() === "```json") {
671
+ // Extract name/label from Sender block before skipping
672
+ const isSender = trimmed.startsWith("Sender ");
673
+ const isConvo = trimmed.startsWith("Conversation info");
674
+ i += 2; // skip sentinel + ```json
675
+ let jsonBuf = "";
676
+ while (i < lines.length && lines[i].trim() !== "```") {
677
+ jsonBuf += lines[i] + "\n";
678
+ i++;
679
+ }
680
+ if (i < lines.length)
681
+ i++; // skip closing ```
682
+ if (!senderName && (isSender || isConvo)) {
683
+ try {
684
+ const meta = JSON.parse(jsonBuf);
685
+ senderName = meta.label || meta.name || meta.username || meta.sender || null;
686
+ }
687
+ catch { }
688
+ }
689
+ continue;
690
+ }
691
+ cleaned.push(lines[i]);
692
+ i++;
693
+ }
694
+ let userText = cleaned.join("\n").trim();
695
+ // Strip leading timestamp [Thu 2026-04-02 07:52 EDT]
696
+ userText = userText.replace(/^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\]\s*/, "");
697
+ // Cron: use the description as the summary
698
+ const cronMatch = userText.match(/^\[cron:[^\s\]]+\s+([^\]]+)\]/);
699
+ if (cronMatch) {
700
+ userText = cronMatch[1];
701
+ }
702
+ userText = userText.replace(/^\[cron:[^\]]*\]\s*/, "");
703
+ // Subagent: extract the task
704
+ const subagentTask = userText.match(/\[Subagent Task\]:\s*([\s\S]*)/);
705
+ if (subagentTask) {
706
+ userText = subagentTask[1];
707
+ }
708
+ // Strip [Subagent Context] blocks
709
+ userText = userText.replace(/\[Subagent Context\][\s\S]*?\n\n/g, "");
710
+ // Strip another leading timestamp that may remain
711
+ userText = userText.replace(/^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\]\s*/, "");
712
+ // Heartbeat
713
+ if (userText.startsWith("Read HEARTBEAT.md")) {
714
+ userText = "Heartbeat check";
715
+ }
716
+ // Strip trailing cron boilerplate
717
+ userText = userText.replace(/\nCurrent time:[\s\S]*$/, "");
718
+ userText = userText.replace(/\nReturn your summary[\s\S]*$/, "");
719
+ // Strip "Untrusted context" trailing block
720
+ userText = userText.replace(/\nUntrusted context \(metadata[\s\S]*$/, "");
721
+ return { senderName, cleanText: userText.trim() };
722
+ }
723
+ /**
724
+ * Walk the session JSONL backwards and collect `thinking` block content from
725
+ * the most recent assistant turn(s) since the last user message. Returns an
726
+ * ordered list of thought strings (oldest → newest).
727
+ */
728
+ async function readLatestThoughts(stateDir, session) {
729
+ try {
730
+ const jsonlPath = resolve(stateDir, "agents", "main", "sessions", `${session}.jsonl`);
731
+ const raw = await readFile(jsonlPath, "utf-8");
732
+ const lines = raw.split("\n").filter((l) => l.trim());
733
+ const thoughts = [];
734
+ for (let i = lines.length - 1; i >= 0; i--) {
735
+ let entry;
736
+ try {
737
+ entry = JSON.parse(lines[i]);
738
+ }
739
+ catch {
740
+ continue;
741
+ }
742
+ if (entry.type !== "message")
743
+ continue;
744
+ const msg = entry.message;
745
+ if (!msg)
746
+ continue;
747
+ // Stop once we hit the user message that triggered this turn.
748
+ if (msg.role === "user")
749
+ break;
750
+ if (msg.role !== "assistant")
751
+ continue;
752
+ if (!Array.isArray(msg.content))
753
+ continue;
754
+ for (const block of msg.content) {
755
+ if (block?.type === "thinking" && typeof block.thinking === "string") {
756
+ thoughts.unshift(block.thinking);
757
+ }
758
+ }
759
+ }
760
+ return thoughts;
761
+ }
762
+ catch {
763
+ return [];
764
+ }
765
+ }
486
766
  function json(res, data, status = 200) {
487
767
  res.writeHead(status, { "Content-Type": "application/json" });
488
768
  res.end(JSON.stringify(data));
@@ -12,7 +12,12 @@ const MIME_TYPES = {
12
12
  };
13
13
  export async function startDashboard(config, port = 3000) {
14
14
  const routes = createRoutes(config);
15
- const staticDir = resolve(import.meta.dirname, "static");
15
+ // In dev, serve from src/ so HTML/CSS changes are instant (no rebuild needed).
16
+ // In production (running from dist/), fall back to the compiled copy.
17
+ const devStaticDir = resolve(import.meta.dirname, "../../src/dashboard/static");
18
+ const prodStaticDir = resolve(import.meta.dirname, "static");
19
+ const { existsSync } = await import("node:fs");
20
+ const staticDir = existsSync(devStaticDir) ? devStaticDir : prodStaticDir;
16
21
  const server = createServer(async (req, res) => {
17
22
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
18
23
  const method = req.method ?? "GET";