leedab 0.2.5 → 0.3.0
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/bin/leedab.js +38 -9
- package/dist/console-launcher.d.ts +11 -0
- package/dist/console-launcher.js +184 -0
- package/dist/gateway.js +43 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/license.d.ts +2 -0
- package/dist/license.js +2 -0
- package/dist/onboard/steps/provider.d.ts +7 -1
- package/dist/onboard/steps/provider.js +25 -9
- package/package.json +3 -3
- package/dist/dashboard/routes.d.ts +0 -17
- package/dist/dashboard/routes.js +0 -777
- package/dist/dashboard/server.d.ts +0 -2
- package/dist/dashboard/server.js +0 -85
- package/dist/dashboard/static/admin.html +0 -695
- package/dist/dashboard/static/favicon.png +0 -0
- package/dist/dashboard/static/index.html +0 -936
- package/dist/dashboard/static/logo-dark.png +0 -0
- package/dist/dashboard/static/logo-light.png +0 -0
- package/dist/dashboard/static/sessions.html +0 -162
- package/dist/dashboard/static/style.css +0 -493
package/dist/dashboard/routes.js
DELETED
|
@@ -1,777 +0,0 @@
|
|
|
1
|
-
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
|
-
import { userInfo } from "node:os";
|
|
6
|
-
import { resolveOpenClawBin, openclawEnv } from "../openclaw.js";
|
|
7
|
-
import { addEntry, removeEntry, listEntries } from "../vault.js";
|
|
8
|
-
import { readAuditLog, logAudit } from "../audit.js";
|
|
9
|
-
import { getAnalytics } from "../analytics.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";
|
|
16
|
-
import { STATE_DIR } from "../paths.js";
|
|
17
|
-
const execFileAsync = promisify(execFile);
|
|
18
|
-
async function restartGatewayQuietly(stateDir) {
|
|
19
|
-
try {
|
|
20
|
-
const bin = resolveOpenClawBin();
|
|
21
|
-
const env = openclawEnv(stateDir);
|
|
22
|
-
await execFileAsync(bin, ["gateway", "health"], { env, timeout: 3000 });
|
|
23
|
-
await execFileAsync(bin, ["gateway", "restart"], { env, timeout: 10000 });
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
// Gateway not running, no restart needed
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
export function createRoutes(config) {
|
|
30
|
-
const bin = resolveOpenClawBin();
|
|
31
|
-
const stateDir = STATE_DIR;
|
|
32
|
-
return {
|
|
33
|
-
/**
|
|
34
|
-
* POST /api/chat — send a message to the agent, get a response
|
|
35
|
-
*/
|
|
36
|
-
"POST /api/chat": async (req, res) => {
|
|
37
|
-
const body = await readBody(req);
|
|
38
|
-
const { message, session } = JSON.parse(body);
|
|
39
|
-
if (!message) {
|
|
40
|
-
json(res, { error: "message is required" }, 400);
|
|
41
|
-
return;
|
|
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;
|
|
49
|
-
try {
|
|
50
|
-
const args = [
|
|
51
|
-
"agent",
|
|
52
|
-
"--message", agentMessage,
|
|
53
|
-
"--session-id", session ?? "console",
|
|
54
|
-
"--json",
|
|
55
|
-
];
|
|
56
|
-
const { stdout, stderr } = await execFileAsync(bin, args, {
|
|
57
|
-
env: openclawEnv(stateDir),
|
|
58
|
-
timeout: 120000,
|
|
59
|
-
});
|
|
60
|
-
try {
|
|
61
|
-
const result = JSON.parse(stdout);
|
|
62
|
-
// Extract text from various response shapes
|
|
63
|
-
const reply = result.result?.payloads?.[0]?.text ??
|
|
64
|
-
result.reply ??
|
|
65
|
-
result.text ??
|
|
66
|
-
result.content ??
|
|
67
|
-
stdout.trim();
|
|
68
|
-
const thoughts = await readLatestThoughts(stateDir, session ?? "console");
|
|
69
|
-
json(res, { reply, thoughts, session: session ?? "console" });
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
json(res, {
|
|
73
|
-
reply: stdout.trim() || stderr.trim() || "No response",
|
|
74
|
-
session: session ?? "console",
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
json(res, { error: err.message }, 500);
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
/**
|
|
83
|
-
* GET /api/chat/history — get conversation history from JSONL logs
|
|
84
|
-
*/
|
|
85
|
-
"GET /api/chat/history": async (_req, res, url) => {
|
|
86
|
-
const session = url.searchParams.get("session") ?? "console";
|
|
87
|
-
try {
|
|
88
|
-
const sessionsDir = resolve(stateDir, "agents", "main", "sessions");
|
|
89
|
-
const jsonlPath = resolve(sessionsDir, `${session}.jsonl`);
|
|
90
|
-
const raw = await readFile(jsonlPath, "utf-8");
|
|
91
|
-
const messages = [];
|
|
92
|
-
let pendingThoughts = [];
|
|
93
|
-
for (const line of raw.split("\n")) {
|
|
94
|
-
if (!line.trim())
|
|
95
|
-
continue;
|
|
96
|
-
try {
|
|
97
|
-
const entry = JSON.parse(line);
|
|
98
|
-
if (entry.type !== "message")
|
|
99
|
-
continue;
|
|
100
|
-
const msg = entry.message;
|
|
101
|
-
if (!msg || (msg.role !== "user" && msg.role !== "assistant"))
|
|
102
|
-
continue;
|
|
103
|
-
let text = "";
|
|
104
|
-
const theseThoughts = [];
|
|
105
|
-
if (Array.isArray(msg.content)) {
|
|
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
|
-
}
|
|
114
|
-
}
|
|
115
|
-
else if (typeof msg.content === "string") {
|
|
116
|
-
text = msg.content;
|
|
117
|
-
}
|
|
118
|
-
// Strip all OpenClaw "(untrusted metadata)" blocks and timestamp prefix
|
|
119
|
-
text = text.replace(/^(\w[\w\s]*\(untrusted metadata\):\n```json\n[\s\S]*?\n```\n\n)+/, "");
|
|
120
|
-
text = text.replace(/^\[[\w]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2} \w+\] /, "");
|
|
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 = [];
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
catch { }
|
|
140
|
-
}
|
|
141
|
-
json(res, messages);
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
json(res, []);
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
/**
|
|
148
|
-
* GET /api/whoami — current OS user's first name + configured agent name
|
|
149
|
-
*/
|
|
150
|
-
"GET /api/whoami": async (_req, res) => {
|
|
151
|
-
let firstName = userInfo().username;
|
|
152
|
-
try {
|
|
153
|
-
const { stdout } = await execFileAsync("id", ["-F"], { timeout: 2000 });
|
|
154
|
-
const fullName = stdout.trim();
|
|
155
|
-
if (fullName)
|
|
156
|
-
firstName = fullName.split(/\s+/)[0];
|
|
157
|
-
}
|
|
158
|
-
catch { }
|
|
159
|
-
json(res, { name: firstName, agent: config.agent?.name ?? "LeedAB" });
|
|
160
|
-
},
|
|
161
|
-
/**
|
|
162
|
-
* GET /api/status — channel health status
|
|
163
|
-
*/
|
|
164
|
-
"GET /api/status": async (_req, res) => {
|
|
165
|
-
let secrets = {};
|
|
166
|
-
try {
|
|
167
|
-
const raw = await readFile(resolve(stateDir, "secrets.json"), "utf-8");
|
|
168
|
-
secrets = JSON.parse(raw);
|
|
169
|
-
}
|
|
170
|
-
catch { }
|
|
171
|
-
const status = {
|
|
172
|
-
whatsapp: {
|
|
173
|
-
connected: !!secrets.whatsapp?.connected || !!secrets.whatsapp?.accessToken,
|
|
174
|
-
label: "WhatsApp",
|
|
175
|
-
},
|
|
176
|
-
telegram: {
|
|
177
|
-
connected: !!secrets.telegram?.token,
|
|
178
|
-
label: "Telegram",
|
|
179
|
-
},
|
|
180
|
-
teams: {
|
|
181
|
-
connected: !!secrets.teams?.appSecret,
|
|
182
|
-
label: "Microsoft Teams",
|
|
183
|
-
},
|
|
184
|
-
};
|
|
185
|
-
json(res, status);
|
|
186
|
-
},
|
|
187
|
-
/**
|
|
188
|
-
* POST /api/whatsapp/connect — register channel and start QR pairing
|
|
189
|
-
*/
|
|
190
|
-
"POST /api/whatsapp/connect": async (_req, res) => {
|
|
191
|
-
try {
|
|
192
|
-
// Register channel if not already added
|
|
193
|
-
await execFileAsync(bin, ["channels", "add", "--channel", "whatsapp"], {
|
|
194
|
-
env: openclawEnv(stateDir),
|
|
195
|
-
}).catch(() => { });
|
|
196
|
-
// Spawn login and capture output (including QR)
|
|
197
|
-
const login = spawn(bin, ["channels", "login", "--channel", "whatsapp"], {
|
|
198
|
-
env: {
|
|
199
|
-
...openclawEnv(stateDir),
|
|
200
|
-
NO_COLOR: "1",
|
|
201
|
-
},
|
|
202
|
-
});
|
|
203
|
-
let output = "";
|
|
204
|
-
const qrReady = new Promise((resolve, reject) => {
|
|
205
|
-
const timeout = setTimeout(() => {
|
|
206
|
-
resolve(output);
|
|
207
|
-
}, 10000);
|
|
208
|
-
const onData = (chunk) => {
|
|
209
|
-
output += chunk.toString();
|
|
210
|
-
if (output.includes("▄▄██")) {
|
|
211
|
-
clearTimeout(timeout);
|
|
212
|
-
resolve(output);
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
login.stdout?.on("data", onData);
|
|
216
|
-
login.stderr?.on("data", onData);
|
|
217
|
-
login.on("error", reject);
|
|
218
|
-
});
|
|
219
|
-
const qrOutput = await qrReady;
|
|
220
|
-
// Mark connected when login completes
|
|
221
|
-
login.on("close", async (code) => {
|
|
222
|
-
if (code === 0) {
|
|
223
|
-
await mkdir(stateDir, { recursive: true });
|
|
224
|
-
let secrets = {};
|
|
225
|
-
try {
|
|
226
|
-
const raw = await readFile(resolve(stateDir, "secrets.json"), "utf-8");
|
|
227
|
-
secrets = JSON.parse(raw);
|
|
228
|
-
}
|
|
229
|
-
catch { }
|
|
230
|
-
secrets.whatsapp = { connected: true };
|
|
231
|
-
await writeFile(resolve(stateDir, "secrets.json"), JSON.stringify(secrets, null, 2) + "\n");
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
json(res, { status: "pairing", qr: qrOutput });
|
|
235
|
-
}
|
|
236
|
-
catch (err) {
|
|
237
|
-
json(res, { error: err.message }, 500);
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
/**
|
|
241
|
-
* GET /api/sessions — list all conversation sessions
|
|
242
|
-
*/
|
|
243
|
-
"GET /api/sessions": async (_req, res) => {
|
|
244
|
-
try {
|
|
245
|
-
const sessionsDir = resolve(stateDir, "agents", "main", "sessions");
|
|
246
|
-
// Get indexed sessions from openclaw, keyed by sessionId
|
|
247
|
-
const indexed = new Map();
|
|
248
|
-
try {
|
|
249
|
-
const { stdout } = await execFileAsync(bin, ["sessions", "--json"], { env: openclawEnv(stateDir), timeout: 10000 });
|
|
250
|
-
const parsed = JSON.parse(stdout);
|
|
251
|
-
for (const s of parsed.sessions ?? (Array.isArray(parsed) ? parsed : [])) {
|
|
252
|
-
if (s.sessionId)
|
|
253
|
-
indexed.set(s.sessionId, s);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
catch { }
|
|
257
|
-
// Walk JSONL files on disk — this is the source of truth
|
|
258
|
-
const results = [];
|
|
259
|
-
const files = await readdir(sessionsDir);
|
|
260
|
-
for (const f of files) {
|
|
261
|
-
if (!f.endsWith(".jsonl"))
|
|
262
|
-
continue;
|
|
263
|
-
const name = f.replace(/\.jsonl$/, "");
|
|
264
|
-
const entry = indexed.get(name);
|
|
265
|
-
if (entry) {
|
|
266
|
-
results.push(entry);
|
|
267
|
-
}
|
|
268
|
-
else {
|
|
269
|
-
// Unindexed file (e.g. console.jsonl) — build minimal metadata
|
|
270
|
-
try {
|
|
271
|
-
const fstat = await stat(resolve(sessionsDir, f));
|
|
272
|
-
results.push({
|
|
273
|
-
key: `agent:main:${name}`,
|
|
274
|
-
sessionId: name,
|
|
275
|
-
updatedAt: fstat.mtimeMs,
|
|
276
|
-
chatType: "direct",
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
catch { }
|
|
280
|
-
}
|
|
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
|
-
}));
|
|
312
|
-
// Sort newest first
|
|
313
|
-
results.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
314
|
-
json(res, results);
|
|
315
|
-
}
|
|
316
|
-
catch {
|
|
317
|
-
json(res, []);
|
|
318
|
-
}
|
|
319
|
-
},
|
|
320
|
-
/**
|
|
321
|
-
* GET /api/audit — recent audit log entries
|
|
322
|
-
*/
|
|
323
|
-
"GET /api/audit": async (_req, res, url) => {
|
|
324
|
-
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
325
|
-
const channel = url.searchParams.get("channel") ?? undefined;
|
|
326
|
-
const sinceParam = url.searchParams.get("since");
|
|
327
|
-
const since = sinceParam ? new Date(sinceParam) : undefined;
|
|
328
|
-
const entries = await readAuditLog({ limit, channel, since });
|
|
329
|
-
json(res, entries);
|
|
330
|
-
},
|
|
331
|
-
/**
|
|
332
|
-
* GET /api/analytics — usage analytics summary
|
|
333
|
-
*/
|
|
334
|
-
"GET /api/analytics": async (_req, res, url) => {
|
|
335
|
-
const days = parseInt(url.searchParams.get("days") ?? "30", 10);
|
|
336
|
-
const summary = await getAnalytics(days);
|
|
337
|
-
json(res, summary);
|
|
338
|
-
},
|
|
339
|
-
/**
|
|
340
|
-
* GET /api/vault — list stored credentials (no passwords)
|
|
341
|
-
*/
|
|
342
|
-
"GET /api/vault": async (_req, res) => {
|
|
343
|
-
const entries = await listEntries();
|
|
344
|
-
json(res, entries);
|
|
345
|
-
},
|
|
346
|
-
/**
|
|
347
|
-
* POST /api/vault — add a credential to the vault
|
|
348
|
-
*/
|
|
349
|
-
"POST /api/vault": async (req, res) => {
|
|
350
|
-
const body = await readBody(req);
|
|
351
|
-
const { service, url, username, password, notes } = JSON.parse(body);
|
|
352
|
-
if (!service || typeof service !== "string") {
|
|
353
|
-
json(res, { error: "service name is required" }, 400);
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
await addEntry(service.trim(), {
|
|
357
|
-
url: url || undefined,
|
|
358
|
-
username: username || undefined,
|
|
359
|
-
password: password || undefined,
|
|
360
|
-
notes: notes || undefined,
|
|
361
|
-
});
|
|
362
|
-
json(res, { ok: true, service: service.trim() });
|
|
363
|
-
},
|
|
364
|
-
/**
|
|
365
|
-
* POST /api/telegram/connect — validate token and save
|
|
366
|
-
*/
|
|
367
|
-
"POST /api/telegram/connect": async (req, res) => {
|
|
368
|
-
const body = await readBody(req);
|
|
369
|
-
const { token } = JSON.parse(body);
|
|
370
|
-
if (!token || typeof token !== "string") {
|
|
371
|
-
json(res, { error: "token is required" }, 400);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
// Validate token via Telegram API
|
|
375
|
-
try {
|
|
376
|
-
const tgRes = await fetch(`https://api.telegram.org/bot${token.trim()}/getMe`);
|
|
377
|
-
const data = await tgRes.json();
|
|
378
|
-
if (!data.ok) {
|
|
379
|
-
json(res, { error: "Invalid bot token" }, 400);
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
// Save to secrets
|
|
383
|
-
await mkdir(stateDir, { recursive: true });
|
|
384
|
-
let secrets = {};
|
|
385
|
-
try {
|
|
386
|
-
const raw = await readFile(resolve(stateDir, "secrets.json"), "utf-8");
|
|
387
|
-
secrets = JSON.parse(raw);
|
|
388
|
-
}
|
|
389
|
-
catch { }
|
|
390
|
-
secrets.telegram = { token: token.trim() };
|
|
391
|
-
await writeFile(resolve(stateDir, "secrets.json"), JSON.stringify(secrets, null, 2) + "\n");
|
|
392
|
-
json(res, { connected: true, username: data.result.username });
|
|
393
|
-
}
|
|
394
|
-
catch {
|
|
395
|
-
json(res, { error: "Could not reach Telegram API" }, 500);
|
|
396
|
-
}
|
|
397
|
-
},
|
|
398
|
-
/**
|
|
399
|
-
* GET /api/allowlist — get allowlist for a channel
|
|
400
|
-
* Expects ?channel=<name> query param
|
|
401
|
-
*/
|
|
402
|
-
"GET /api/allowlist": async (_req, res, url) => {
|
|
403
|
-
const channel = url.searchParams.get("channel");
|
|
404
|
-
if (!channel) {
|
|
405
|
-
json(res, { error: "channel query param is required" }, 400);
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
try {
|
|
409
|
-
const raw = await readFile(resolve(stateDir, "openclaw.json"), "utf-8");
|
|
410
|
-
const config = JSON.parse(raw);
|
|
411
|
-
const allowFrom = config.channels?.[channel]?.allowFrom ?? [];
|
|
412
|
-
const dmPolicy = config.channels?.[channel]?.dmPolicy ?? "pairing";
|
|
413
|
-
json(res, { channel, dmPolicy, allowFrom });
|
|
414
|
-
}
|
|
415
|
-
catch {
|
|
416
|
-
json(res, { channel, dmPolicy: "pairing", allowFrom: [] });
|
|
417
|
-
}
|
|
418
|
-
},
|
|
419
|
-
/**
|
|
420
|
-
* POST /api/allowlist — add a user to a channel's allowlist
|
|
421
|
-
*/
|
|
422
|
-
"POST /api/allowlist": async (req, res) => {
|
|
423
|
-
const body = await readBody(req);
|
|
424
|
-
const { channel, userId } = JSON.parse(body);
|
|
425
|
-
if (!channel || !userId) {
|
|
426
|
-
json(res, { error: "channel and userId are required" }, 400);
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
try {
|
|
430
|
-
const configPath = resolve(stateDir, "openclaw.json");
|
|
431
|
-
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
432
|
-
if (!config.channels?.[channel]) {
|
|
433
|
-
json(res, { error: `Channel "${channel}" not configured` }, 400);
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
if (!config.channels[channel].allowFrom) {
|
|
437
|
-
config.channels[channel].allowFrom = [];
|
|
438
|
-
}
|
|
439
|
-
if (!config.channels[channel].allowFrom.includes(userId)) {
|
|
440
|
-
config.channels[channel].allowFrom.push(userId);
|
|
441
|
-
}
|
|
442
|
-
config.channels[channel].dmPolicy = "allowlist";
|
|
443
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
444
|
-
await restartGatewayQuietly(stateDir);
|
|
445
|
-
json(res, { ok: true });
|
|
446
|
-
}
|
|
447
|
-
catch (err) {
|
|
448
|
-
json(res, { error: err.message }, 500);
|
|
449
|
-
}
|
|
450
|
-
},
|
|
451
|
-
/**
|
|
452
|
-
* DELETE /api/allowlist — remove a user from a channel's allowlist
|
|
453
|
-
* Expects ?channel=<name>&userId=<id> query params
|
|
454
|
-
*/
|
|
455
|
-
"DELETE /api/allowlist": async (_req, res, url) => {
|
|
456
|
-
const channel = url.searchParams.get("channel");
|
|
457
|
-
const userId = url.searchParams.get("userId");
|
|
458
|
-
if (!channel || !userId) {
|
|
459
|
-
json(res, { error: "channel and userId query params are required" }, 400);
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
try {
|
|
463
|
-
const configPath = resolve(stateDir, "openclaw.json");
|
|
464
|
-
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
465
|
-
const list = config.channels?.[channel]?.allowFrom;
|
|
466
|
-
if (!list) {
|
|
467
|
-
json(res, { ok: false, error: "No allowlist for this channel" }, 400);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
config.channels[channel].allowFrom = list.filter((id) => id !== userId);
|
|
471
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
472
|
-
await restartGatewayQuietly(stateDir);
|
|
473
|
-
json(res, { ok: true });
|
|
474
|
-
}
|
|
475
|
-
catch (err) {
|
|
476
|
-
json(res, { error: err.message }, 500);
|
|
477
|
-
}
|
|
478
|
-
},
|
|
479
|
-
/**
|
|
480
|
-
* DELETE /api/vault — remove a credential from the vault
|
|
481
|
-
* Expects ?service=<name> query param
|
|
482
|
-
*/
|
|
483
|
-
"DELETE /api/vault": async (_req, res, url) => {
|
|
484
|
-
const service = url.searchParams.get("service");
|
|
485
|
-
if (!service) {
|
|
486
|
-
json(res, { error: "service query param is required" }, 400);
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
const removed = await removeEntry(service);
|
|
490
|
-
json(res, { ok: removed, service });
|
|
491
|
-
},
|
|
492
|
-
/**
|
|
493
|
-
* GET /api/team — list local team members
|
|
494
|
-
*/
|
|
495
|
-
"GET /api/team": async (_req, res) => {
|
|
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
|
-
}
|
|
503
|
-
},
|
|
504
|
-
/**
|
|
505
|
-
* POST /api/team — add a team member locally
|
|
506
|
-
*/
|
|
507
|
-
"POST /api/team": async (req, res) => {
|
|
508
|
-
const body = await readBody(req);
|
|
509
|
-
const { name, email, role } = JSON.parse(body);
|
|
510
|
-
if (!name || typeof name !== "string" || !name.trim()) {
|
|
511
|
-
json(res, { error: "name is required" }, 400);
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
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
|
-
}
|
|
526
|
-
},
|
|
527
|
-
/**
|
|
528
|
-
* DELETE /api/team — remove a team member
|
|
529
|
-
* Expects ?id=<uuid> query param
|
|
530
|
-
*/
|
|
531
|
-
"DELETE /api/team": async (_req, res, url) => {
|
|
532
|
-
const id = url.searchParams.get("id");
|
|
533
|
-
if (!id) {
|
|
534
|
-
json(res, { error: "id query param is required" }, 400);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
const removed = await removeMember(id);
|
|
538
|
-
if (removed) {
|
|
539
|
-
try {
|
|
540
|
-
await syncAllowlists();
|
|
541
|
-
}
|
|
542
|
-
catch { /* best-effort */ }
|
|
543
|
-
}
|
|
544
|
-
json(res, { ok: removed });
|
|
545
|
-
},
|
|
546
|
-
/**
|
|
547
|
-
* PUT /api/team/role — update a member's role
|
|
548
|
-
*/
|
|
549
|
-
"PUT /api/team/role": async (req, res) => {
|
|
550
|
-
const body = await readBody(req);
|
|
551
|
-
const { id, role } = JSON.parse(body);
|
|
552
|
-
if (!id || !role) {
|
|
553
|
-
json(res, { error: "id and role are required" }, 400);
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
const updated = await updateRole(id, role);
|
|
557
|
-
json(res, { ok: updated });
|
|
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
|
-
},
|
|
622
|
-
};
|
|
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
|
-
}
|
|
766
|
-
function json(res, data, status = 200) {
|
|
767
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
768
|
-
res.end(JSON.stringify(data));
|
|
769
|
-
}
|
|
770
|
-
function readBody(req) {
|
|
771
|
-
return new Promise((resolve, reject) => {
|
|
772
|
-
let body = "";
|
|
773
|
-
req.on("data", (chunk) => (body += chunk));
|
|
774
|
-
req.on("end", () => resolve(body));
|
|
775
|
-
req.on("error", reject);
|
|
776
|
-
});
|
|
777
|
-
}
|