@memtensor/memos-local-openclaw-plugin 1.0.4-beta.15 → 1.0.4-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,7 @@ export interface ViewerServerOptions {
34
34
  log: Logger;
35
35
  dataDir: string;
36
36
  ctx?: PluginContext;
37
+ defaultHubPort?: number;
37
38
  }
38
39
 
39
40
  interface AuthState {
@@ -51,6 +52,8 @@ export class ViewerServer {
51
52
  private readonly authFile: string;
52
53
  private readonly auth: AuthState;
53
54
  private readonly ctx?: PluginContext;
55
+ private readonly cookieName: string;
56
+ private readonly defaultHubPort: number;
54
57
 
55
58
  private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
56
59
  private static readonly PLUGIN_VERSION: string = (() => {
@@ -99,17 +102,31 @@ export class ViewerServer {
99
102
  this.ctx = opts.ctx;
100
103
  this.authFile = path.join(opts.dataDir, "viewer-auth.json");
101
104
  this.auth = { passwordHash: null, sessions: new Map() };
105
+ this.cookieName = `memos_token_${opts.port}`;
106
+ this.defaultHubPort = opts.defaultHubPort ?? 18800;
102
107
  this.resetToken = crypto.randomBytes(16).toString("hex");
103
108
  this.loadAuth();
104
109
  }
105
110
 
111
+ private getHubPort(): number {
112
+ const configured = this.ctx?.config?.sharing?.hub?.port;
113
+ if (configured && configured !== 18800) return configured;
114
+ return this.defaultHubPort;
115
+ }
116
+
106
117
  start(): Promise<string> {
118
+ const MAX_PORT_RETRIES = 5;
107
119
  return new Promise((resolve, reject) => {
120
+ let retries = 0;
108
121
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
109
122
  this.server.on("error", (err: NodeJS.ErrnoException) => {
110
- if (err.code === "EADDRINUSE") {
111
- this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
112
- this.server!.listen(this.port + 1, "0.0.0.0");
123
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
124
+ retries++;
125
+ const nextPort = this.port + retries;
126
+ this.log.warn(`Viewer port ${this.port + retries - 1} in use, trying ${nextPort}`);
127
+ this.server!.listen(nextPort, "0.0.0.0");
128
+ } else if (err.code === "EADDRINUSE") {
129
+ reject(new Error(`Viewer failed to find open port after ${MAX_PORT_RETRIES} retries (tried ${this.port}–${this.port + MAX_PORT_RETRIES})`));
113
130
  } else {
114
131
  reject(err);
115
132
  }
@@ -187,7 +204,8 @@ export class ViewerServer {
187
204
 
188
205
  private isValidSession(req: http.IncomingMessage): boolean {
189
206
  const cookie = req.headers.cookie ?? "";
190
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
207
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
208
+ const match = cookie.match(re);
191
209
  if (!match) return false;
192
210
  const expiry = this.auth.sessions.get(match[1]);
193
211
  if (!expiry) return false;
@@ -270,6 +288,7 @@ export class ViewerServer {
270
288
  else if (p === "/api/sharing/remove-user" && req.method === "POST") this.handleSharingRemoveUser(req, res);
271
289
  else if (p === "/api/sharing/change-role" && req.method === "POST") this.handleSharingChangeRole(req, res);
272
290
  else if (p === "/api/sharing/retry-join" && req.method === "POST") this.handleRetryJoin(req, res);
291
+ else if (p === "/api/sharing/leave" && req.method === "POST") this.handleLeaveTeam(req, res);
273
292
  else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
274
293
  else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
275
294
  else if (p === "/api/sharing/tasks/list" && req.method === "GET") this.serveSharingTaskList(res, url);
@@ -350,7 +369,7 @@ export class ViewerServer {
350
369
  const token = this.createSession();
351
370
  res.writeHead(200, {
352
371
  "Content-Type": "application/json",
353
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
372
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
354
373
  });
355
374
  res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
356
375
  } catch (err) {
@@ -372,7 +391,7 @@ export class ViewerServer {
372
391
  const token = this.createSession();
373
392
  res.writeHead(200, {
374
393
  "Content-Type": "application/json",
375
- "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
394
+ "Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
376
395
  });
377
396
  res.end(JSON.stringify({ ok: true }));
378
397
  } catch (err) {
@@ -384,11 +403,12 @@ export class ViewerServer {
384
403
 
385
404
  private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
386
405
  const cookie = req.headers.cookie ?? "";
387
- const match = cookie.match(/memos_token=([a-f0-9]+)/);
406
+ const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
407
+ const match = cookie.match(re);
388
408
  if (match) this.auth.sessions.delete(match[1]);
389
409
  res.writeHead(200, {
390
410
  "Content-Type": "application/json",
391
- "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
411
+ "Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
392
412
  });
393
413
  res.end(JSON.stringify({ ok: true }));
394
414
  }
@@ -415,7 +435,7 @@ export class ViewerServer {
415
435
  const sessionToken = this.createSession();
416
436
  res.writeHead(200, {
417
437
  "Content-Type": "application/json",
418
- "Set-Cookie": `memos_token=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
438
+ "Set-Cookie": `${this.cookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
419
439
  });
420
440
  res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
421
441
  } catch (err) {
@@ -1522,6 +1542,7 @@ export class ViewerServer {
1522
1542
  // ─── Config API ───
1523
1543
 
1524
1544
  private getOpenClawConfigPath(): string {
1545
+ if (process.env.OPENCLAW_CONFIG_PATH) return process.env.OPENCLAW_CONFIG_PATH;
1525
1546
  const home = process.env.HOME || process.env.USERPROFILE || "";
1526
1547
  const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
1527
1548
  return path.join(ocHome, "openclaw.json");
@@ -1611,7 +1632,20 @@ export class ViewerServer {
1611
1632
  base.connection.teamName = info?.teamName ?? sharing.hub?.teamName ?? null;
1612
1633
  base.connection.apiVersion = info?.apiVersion ?? null;
1613
1634
  } catch { /* ignore */ }
1614
- this.jsonResponse(res, base);
1635
+
1636
+ const hubStats: any = { totalMembers: 0, onlineMembers: 0, pendingMembers: 0 };
1637
+ try {
1638
+ const activeUsers = this.store.listHubUsers("active");
1639
+ const pendingUsers = this.store.listHubUsers("pending");
1640
+ const now = Date.now();
1641
+ const OFFLINE_THRESHOLD = 120_000;
1642
+ hubStats.totalMembers = activeUsers.length;
1643
+ hubStats.onlineMembers = activeUsers.filter(u =>
1644
+ u.lastActiveAt && (now - u.lastActiveAt < OFFLINE_THRESHOLD),
1645
+ ).length;
1646
+ hubStats.pendingMembers = pendingUsers.length;
1647
+ } catch { /* best-effort */ }
1648
+ this.jsonResponse(res, { ...base, hubStats });
1615
1649
  return;
1616
1650
  }
1617
1651
 
@@ -1776,15 +1810,6 @@ export class ViewerServer {
1776
1810
  }
1777
1811
  try {
1778
1812
  const hubUrl = normalizeHubUrl(hubAddress);
1779
- const localIPs = this.getLocalIPs();
1780
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
1781
- try {
1782
- const u = new URL(hubUrl);
1783
- const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
1784
- if (localIPs.includes(u.hostname) && targetPort === String(this.port)) {
1785
- return this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
1786
- }
1787
- } catch {}
1788
1813
  const os = await import("os");
1789
1814
  const nickname = sharing.client?.nickname;
1790
1815
  const username = nickname || os.userInfo().username || "user";
@@ -2213,7 +2238,7 @@ export class ViewerServer {
2213
2238
  // Hub 模式:连接自己,用 bootstrap admin token
2214
2239
  const sharing = this.ctx.config.sharing;
2215
2240
  if (sharing?.role === "hub") {
2216
- const hubPort = sharing.hub?.port ?? 18800;
2241
+ const hubPort = this.getHubPort();
2217
2242
  const hubUrl = `http://127.0.0.1:${hubPort}`;
2218
2243
  try {
2219
2244
  const authPath = path.join(this.dataDir, "hub-auth.json");
@@ -2607,13 +2632,14 @@ export class ViewerServer {
2607
2632
  if (merged.role === "client" && merged.client) {
2608
2633
  const clientCfg = merged.client as Record<string, unknown>;
2609
2634
  const addr = String(clientCfg.hubAddress || "");
2610
- if (addr) {
2635
+ if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
2636
+ const selfHubPort = (oldSharing?.hub as Record<string, unknown>)?.port ?? 18800;
2611
2637
  const localIPs = this.getLocalIPs();
2612
2638
  localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2613
2639
  try {
2614
2640
  const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
2615
2641
  const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
2616
- if (localIPs.includes(u.hostname) && targetPort === String(this.port)) {
2642
+ if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
2617
2643
  res.writeHead(400, { "Content-Type": "application/json" });
2618
2644
  res.end(JSON.stringify({ error: "cannot_join_self" }));
2619
2645
  return;
@@ -2638,12 +2664,9 @@ export class ViewerServer {
2638
2664
  const wasClient = oldSharingEnabled && oldSharingRole === "client";
2639
2665
  const isClient = newEnabled && newRole === "client";
2640
2666
  if (wasClient && !isClient) {
2641
- this.notifyHubLeave();
2642
- const oldConn = this.store.getClientHubConnection();
2643
- if (oldConn) {
2644
- this.store.setClientHubConnection({ ...oldConn, userToken: "", lastKnownStatus: "left" });
2645
- }
2646
- this.log.info("Client hub connection token cleared (sharing disabled or role changed), identity preserved");
2667
+ await this.withdrawOrLeaveHub();
2668
+ this.store.clearClientHubConnection();
2669
+ this.log.info("Client hub connection cleared (sharing disabled or role changed)");
2647
2670
  }
2648
2671
 
2649
2672
  if (wasClient && isClient) {
@@ -2661,7 +2684,7 @@ export class ViewerServer {
2661
2684
  if (merged.role === "hub") {
2662
2685
  merged.client = { hubAddress: "", userToken: "", teamToken: "" };
2663
2686
  } else if (merged.role === "client") {
2664
- merged.hub = { port: 18800, teamName: "", teamToken: "" };
2687
+ merged.hub = { teamName: "", teamToken: "" };
2665
2688
  }
2666
2689
  config.sharing = merged;
2667
2690
  }
@@ -2684,7 +2707,12 @@ export class ViewerServer {
2684
2707
  }
2685
2708
  }
2686
2709
 
2687
- this.jsonResponse(res, { ok: true, joinStatus });
2710
+ this.jsonResponse(res, { ok: true, joinStatus, restart: true });
2711
+
2712
+ setTimeout(() => {
2713
+ this.log.info("config-save: triggering gateway restart via SIGUSR1...");
2714
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2715
+ }, 500);
2688
2716
  } catch (e) {
2689
2717
  this.log.warn(`handleSaveConfig error: ${e}`);
2690
2718
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2727,6 +2755,41 @@ export class ViewerServer {
2727
2755
  return result.status;
2728
2756
  }
2729
2757
 
2758
+ private handleLeaveTeam(_req: http.IncomingMessage, res: http.ServerResponse): void {
2759
+ this.readBody(_req, async () => {
2760
+ try {
2761
+ await this.withdrawOrLeaveHub();
2762
+ this.store.clearClientHubConnection();
2763
+
2764
+ const configPath = this.getOpenClawConfigPath();
2765
+ if (configPath && fs.existsSync(configPath)) {
2766
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
2767
+ const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
2768
+ if (pluginKey) {
2769
+ const cfg = raw.plugins.entries[pluginKey].config ?? {};
2770
+ if (cfg.sharing) {
2771
+ cfg.sharing.enabled = false;
2772
+ cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
2773
+ }
2774
+ raw.plugins.entries[pluginKey].config = cfg;
2775
+ fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
2776
+ this.log.info("handleLeaveTeam: config updated, sharing disabled");
2777
+ }
2778
+ }
2779
+
2780
+ this.jsonResponse(res, { ok: true, restart: true });
2781
+
2782
+ setTimeout(() => {
2783
+ this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
2784
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
2785
+ }, 500);
2786
+ } catch (e) {
2787
+ this.log.warn(`handleLeaveTeam error: ${e}`);
2788
+ this.jsonResponse(res, { ok: false, error: String(e) });
2789
+ }
2790
+ });
2791
+ }
2792
+
2730
2793
  private async notifyHubLeave(): Promise<void> {
2731
2794
  try {
2732
2795
  const hub = this.resolveHubConnection();
@@ -2745,11 +2808,49 @@ export class ViewerServer {
2745
2808
  }
2746
2809
  }
2747
2810
 
2811
+ private async withdrawOrLeaveHub(): Promise<void> {
2812
+ try {
2813
+ const persisted = this.store.getClientHubConnection();
2814
+ const sharing = this.ctx?.config?.sharing;
2815
+
2816
+ if (persisted?.userToken && persisted?.hubUrl) {
2817
+ await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
2818
+ this.log.info("Notified Hub of voluntary leave (had token)");
2819
+ return;
2820
+ }
2821
+
2822
+ const hub = this.resolveHubConnection();
2823
+ if (hub?.userToken) {
2824
+ await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
2825
+ this.log.info("Notified Hub of voluntary leave (resolved connection)");
2826
+ return;
2827
+ }
2828
+
2829
+ const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? normalizeHubUrl(sharing.client.hubAddress) : null);
2830
+ const userId = persisted?.userId;
2831
+ const teamToken = sharing?.client?.teamToken;
2832
+ if (hubUrl && userId && teamToken) {
2833
+ const withdrawUrl = `${normalizeHubUrl(hubUrl)}/api/v1/hub/withdraw-pending`;
2834
+ await fetch(withdrawUrl, {
2835
+ method: "POST",
2836
+ headers: { "content-type": "application/json" },
2837
+ body: JSON.stringify({ teamToken, userId }),
2838
+ });
2839
+ this.log.info("Withdrew pending application from Hub");
2840
+ return;
2841
+ }
2842
+
2843
+ this.log.info("No hub connection to clean up (no token, no pending)");
2844
+ } catch (e) {
2845
+ this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
2846
+ }
2847
+ }
2848
+
2748
2849
  private async notifyHubShutdown(): Promise<void> {
2749
2850
  try {
2750
2851
  const sharing = this.ctx?.config.sharing;
2751
2852
  if (!sharing || sharing.role !== "hub") return;
2752
- const hubPort = sharing.hub?.port ?? 18800;
2853
+ const hubPort = this.getHubPort();
2753
2854
  const authPath = path.join(this.dataDir, "hub-auth.json");
2754
2855
  let adminToken: string | undefined;
2755
2856
  try {
@@ -2834,13 +2935,17 @@ export class ViewerServer {
2834
2935
  const { hubUrl } = JSON.parse(body);
2835
2936
  if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
2836
2937
  try {
2837
- const localIPs = this.getLocalIPs();
2838
- localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2839
- const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2840
- const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
2841
- if (localIPs.includes(parsed.hostname) && targetPort === String(this.port)) {
2842
- this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2843
- return;
2938
+ const sharing = this.ctx?.config?.sharing;
2939
+ if (sharing?.enabled && sharing.role === "hub") {
2940
+ const selfHubPort = this.getHubPort();
2941
+ const localIPs = this.getLocalIPs();
2942
+ localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
2943
+ const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
2944
+ const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
2945
+ if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
2946
+ this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
2947
+ return;
2948
+ }
2844
2949
  }
2845
2950
  } catch {}
2846
2951
  const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
@@ -3078,10 +3183,9 @@ export class ViewerServer {
3078
3183
  this.log.info(`update-install: success! Updated to ${newVersion}`);
3079
3184
  this.jsonResponse(res, { ok: true, version: newVersion });
3080
3185
 
3081
- // Trigger Gateway restart after response is sent
3082
3186
  setTimeout(() => {
3083
- this.log.info(`update-install: triggering gateway restart...`);
3084
- process.kill(process.pid, "SIGUSR1");
3187
+ this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
3188
+ try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
3085
3189
  }, 500);
3086
3190
  });
3087
3191
  });