@workclaw/openclaw-workchat 1.0.4 → 1.0.5

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/index.js CHANGED
@@ -279,7 +279,8 @@ __export(accounts_exports, {
279
279
  listIndexedWorkChatAccountIds: () => listIndexedWorkChatAccountIds,
280
280
  listWorkChatAccountIds: () => listWorkChatAccountIds,
281
281
  loadWorkChatAccount: () => loadWorkChatAccount,
282
- pruneStaleWorkChatAccounts: () => pruneStaleWorkChatAccounts,
282
+ migrateLegacyDefaultWorkChatAccount: () => migrateLegacyDefaultWorkChatAccount,
283
+ pruneOtherWorkChatAccounts: () => pruneOtherWorkChatAccounts,
283
284
  registerWorkChatAccountId: () => registerWorkChatAccountId,
284
285
  removeWorkChatAccountBinding: () => removeWorkChatAccountBinding,
285
286
  resolveWorkChatAccount: () => resolveWorkChatAccount,
@@ -296,6 +297,9 @@ function resolveAccountIndexPath() {
296
297
  return path4.join(resolveWorkChatStateDir(), "accounts.json");
297
298
  }
298
299
  function listIndexedWorkChatAccountIds() {
300
+ return readAccountIndex();
301
+ }
302
+ function readAccountIndex() {
299
303
  const filePath = resolveAccountIndexPath();
300
304
  try {
301
305
  if (!fs3.existsSync(filePath)) return [];
@@ -306,19 +310,23 @@ function listIndexedWorkChatAccountIds() {
306
310
  return [];
307
311
  }
308
312
  }
309
- function registerWorkChatAccountId(accountId) {
310
- const dir = resolveWorkChatStateDir();
311
- fs3.mkdirSync(dir, { recursive: true });
312
- const existing = listIndexedWorkChatAccountIds();
313
- if (existing.includes(accountId)) return;
314
- fs3.writeFileSync(resolveAccountIndexPath(), JSON.stringify([...existing, accountId], null, 2), "utf-8");
313
+ async function updateAccountIndex(mutator) {
314
+ const run = indexUpdateChain.then(() => {
315
+ const filePath = resolveAccountIndexPath();
316
+ fs3.mkdirSync(path4.dirname(filePath), { recursive: true });
317
+ const updated = mutator(readAccountIndex());
318
+ fs3.writeFileSync(filePath, JSON.stringify(updated, null, 2), "utf-8");
319
+ });
320
+ indexUpdateChain = run.then(() => void 0, () => void 0);
321
+ await run;
315
322
  }
316
- function unregisterWorkChatAccountId(accountId) {
317
- const existing = listIndexedWorkChatAccountIds();
318
- const updated = existing.filter((id) => id !== accountId);
319
- if (updated.length !== existing.length) {
320
- fs3.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
321
- }
323
+ async function registerWorkChatAccountId(accountId) {
324
+ await updateAccountIndex(
325
+ (ids) => ids.includes(accountId) ? ids : [...ids, accountId]
326
+ );
327
+ }
328
+ async function unregisterWorkChatAccountId(accountId) {
329
+ await updateAccountIndex((ids) => ids.filter((id) => id !== accountId));
322
330
  }
323
331
  function resolveAccountsDir() {
324
332
  return path4.join(resolveWorkChatStateDir(), "accounts");
@@ -355,14 +363,14 @@ function saveWorkChatAccount(accountId, update) {
355
363
  } catch {
356
364
  }
357
365
  }
358
- function clearWorkChatAccount(accountId) {
366
+ async function clearWorkChatAccount(accountId) {
359
367
  const filePath = resolveAccountPath(accountId);
360
368
  try {
361
369
  if (fs3.existsSync(filePath)) fs3.unlinkSync(filePath);
362
370
  } catch (err) {
363
371
  logger.warn(`clearWorkChatAccount: failed to remove ${filePath}: ${String(err)}`);
364
372
  }
365
- unregisterWorkChatAccountId(accountId);
373
+ await unregisterWorkChatAccountId(accountId);
366
374
  }
367
375
  async function removeWorkChatAccountBinding(accountId) {
368
376
  clearWorkChatAccount(accountId);
@@ -378,21 +386,57 @@ async function removeWorkChatAccountBinding(accountId) {
378
386
  logger.warn(`removeWorkChatAccountBinding: channel reload trigger failed: ${String(err)}`);
379
387
  }
380
388
  }
381
- async function pruneStaleWorkChatAccounts(keepAgentId) {
389
+ async function pruneOtherWorkChatAccounts(keepAccountId) {
390
+ const keep = keepAccountId.trim();
382
391
  const removed = [];
383
392
  for (const accountId of listIndexedWorkChatAccountIds()) {
384
- const data = loadWorkChatAccount(accountId);
385
- const storedAgentId = data?.agentId?.trim();
386
- if (storedAgentId && storedAgentId !== keepAgentId) {
387
- await removeWorkChatAccountBinding(accountId);
388
- removed.push(accountId);
389
- logger.info(
390
- `pruneStaleWorkChatAccounts: removed account=${accountId} agent=${storedAgentId} (superseded by agent=${keepAgentId})`
391
- );
392
- }
393
+ if (accountId === keep) continue;
394
+ await removeWorkChatAccountBinding(accountId);
395
+ removed.push(accountId);
396
+ logger.info(
397
+ `pruneOtherWorkChatAccounts: removed account=${accountId} (superseded by ${keep})`
398
+ );
393
399
  }
394
400
  return removed;
395
401
  }
402
+ async function migrateLegacyDefaultWorkChatAccount() {
403
+ const ids = listIndexedWorkChatAccountIds();
404
+ if (!ids.includes("default")) return false;
405
+ const data = loadWorkChatAccount("default");
406
+ const agentId = data?.agentId?.trim();
407
+ if (!agentId) return false;
408
+ const targetId = `agent-${agentId}`;
409
+ if (!ids.includes(targetId)) {
410
+ saveWorkChatAccount(targetId, { ...data });
411
+ }
412
+ try {
413
+ const { readFrameworkAllowFromList: readFrameworkAllowFromList2, registerUserInFrameworkStore: registerUserInFrameworkStore2 } = await Promise.resolve().then(() => (init_pairing(), pairing_exports));
414
+ for (const userId of readFrameworkAllowFromList2("default")) {
415
+ await registerUserInFrameworkStore2({ accountId: targetId, userId });
416
+ }
417
+ } catch (err) {
418
+ logger.warn(`migrateLegacyDefaultWorkChatAccount: allowFrom move failed: ${String(err)}`);
419
+ }
420
+ await updateAccountIndex((existing) => {
421
+ const next = existing.filter((id) => id !== "default" && id !== targetId);
422
+ next.push(targetId);
423
+ return next;
424
+ });
425
+ try {
426
+ fs3.rmSync(path4.join(resolveWorkChatStateDir(), "accounts", "default.json"), { force: true });
427
+ } catch {
428
+ }
429
+ try {
430
+ const { clearFrameworkAllowFromList: clearFrameworkAllowFromList2 } = await Promise.resolve().then(() => (init_pairing(), pairing_exports));
431
+ clearFrameworkAllowFromList2("default");
432
+ } catch (err) {
433
+ logger.warn(`migrateLegacyDefaultWorkChatAccount: allowFrom cleanup failed: ${String(err)}`);
434
+ }
435
+ logger.info(
436
+ `migrateLegacyDefaultWorkChatAccount: renamed default -> ${targetId} (agent=${agentId})`
437
+ );
438
+ return true;
439
+ }
396
440
  function listWorkChatAccountIds(_cfg) {
397
441
  return listIndexedWorkChatAccountIds();
398
442
  }
@@ -442,12 +486,14 @@ async function triggerWorkChatChannelReload() {
442
486
  logger.warn(`triggerWorkChatChannelReload: failed to update config: ${String(err)}`);
443
487
  }
444
488
  }
489
+ var indexUpdateChain;
445
490
  var init_accounts = __esm({
446
491
  "src/auth/accounts.ts"() {
447
492
  "use strict";
448
493
  init_types();
449
494
  init_state_dir();
450
495
  init_logger();
496
+ indexUpdateChain = Promise.resolve();
451
497
  }
452
498
  });
453
499
 
@@ -27127,28 +27173,27 @@ async function handleListenerFrame(sessionKey, data, ws) {
27127
27173
  if (result.type !== "init_agent") return;
27128
27174
  const eventData = result.eventData;
27129
27175
  if (!eventData) return;
27130
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
27176
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
27131
27177
  if (!agentId) return;
27132
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
27178
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
27133
27179
  const entry = bindingListeners.get(sessionKey);
27134
27180
  if (entry) entry.done = true;
27135
- const isRebind = listIndexedWorkChatAccountIds().length > 0;
27181
+ const accountId = `agent-${agentId}`;
27136
27182
  try {
27137
- const { pruneStaleWorkChatAccounts: pruneStaleWorkChatAccounts2 } = await Promise.resolve().then(() => (init_accounts(), accounts_exports));
27138
- const removed = await pruneStaleWorkChatAccounts2(agentId);
27183
+ const { pruneOtherWorkChatAccounts: pruneOtherWorkChatAccounts2 } = await Promise.resolve().then(() => (init_accounts(), accounts_exports));
27184
+ const removed = await pruneOtherWorkChatAccounts2(accountId);
27139
27185
  if (removed.length > 0) {
27140
- logger.info(`binding listener: pruned stale account(s): ${removed.join(", ")}`);
27186
+ logger.info(`binding listener: pruned previous account(s): ${removed.join(", ")}`);
27141
27187
  }
27142
27188
  } catch (err) {
27143
27189
  logger.error(`binding listener: rebind cleanup failed: ${String(err)}`);
27144
27190
  }
27145
- const accountId = isRebind ? `agent-${agentId}` : "default";
27146
27191
  try {
27147
27192
  saveWorkChatAccount(accountId, {
27148
27193
  agentId,
27149
27194
  ...userId ? { userId } : {}
27150
27195
  });
27151
- registerWorkChatAccountId(accountId);
27196
+ await registerWorkChatAccountId(accountId);
27152
27197
  const { bindAgentToAccountFromBinding: bindAgentToAccountFromBinding2 } = await Promise.resolve().then(() => (init_bind_agent(), bind_agent_exports));
27153
27198
  await bindAgentToAccountFromBinding2(accountId, agentId, userId);
27154
27199
  logger.info(`binding listener: account=${accountId} agent=${agentId} persisted`);
@@ -27843,31 +27888,30 @@ function emitWorkChatMessageSent2(params) {
27843
27888
  async function handleAgentLifecycleEvent(frameType, result, deps) {
27844
27889
  const eventData = result.eventData;
27845
27890
  if (!eventData) return;
27846
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
27891
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
27847
27892
  if (!agentId) {
27848
27893
  logger.warn(`${frameType}: missing agent id, ignoring`);
27849
27894
  return;
27850
27895
  }
27851
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
27896
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
27852
27897
  logger.info(`${frameType}: agentId=${agentId} userId=${userId ? "present" : "missing"}`);
27853
27898
  deps.log(`${frameType}: agentId=${agentId}`);
27854
- const isRebind = listWorkChatAccountIdsCount() > 0;
27899
+ const { completeWorkChatBinding: completeWorkChatBinding2 } = await Promise.resolve().then(() => (init_login_qr(), login_qr_exports));
27900
+ const accountId = frameType === "init_agent" ? `agent-${agentId}` : deps.accountId;
27855
27901
  try {
27856
- const removed = await pruneStaleWorkChatAccounts(agentId);
27902
+ const removed = await pruneOtherWorkChatAccounts(accountId);
27857
27903
  if (removed.length > 0) {
27858
- deps.log(`rebind: removed stale account(s): ${removed.join(", ")}`);
27904
+ deps.log(`rebind: removed previous account(s): ${removed.join(", ")}`);
27859
27905
  }
27860
27906
  } catch (err) {
27861
27907
  logger.error(`rebind cleanup failed: ${String(err)}`);
27862
27908
  }
27863
- const { completeWorkChatBinding: completeWorkChatBinding2 } = await Promise.resolve().then(() => (init_login_qr(), login_qr_exports));
27864
- const accountId = frameType === "init_agent" ? isRebind ? `agent-${agentId}` : "default" : deps.accountId;
27865
27909
  try {
27866
27910
  saveWorkChatAccount(accountId, {
27867
27911
  agentId,
27868
27912
  ...userId ? { userId } : {}
27869
27913
  });
27870
- registerWorkChatAccountId(accountId);
27914
+ await registerWorkChatAccountId(accountId);
27871
27915
  const { bindAgentToAccount: bindAgentToAccount2 } = await Promise.resolve().then(() => (init_bind_agent(), bind_agent_exports));
27872
27916
  await bindAgentToAccount2(accountId, agentId, userId);
27873
27917
  logger.info(`${frameType}: bound agent to account accountId=${accountId}`);
@@ -27889,9 +27933,6 @@ async function handleAgentLifecycleEvent(frameType, result, deps) {
27889
27933
  void notifyAgentCreatedStatus(deps.apiOpts, agentId);
27890
27934
  }
27891
27935
  }
27892
- function listWorkChatAccountIdsCount() {
27893
- return listIndexedWorkChatAccountIds().length;
27894
- }
27895
27936
  function loadCreateTypingCallbacks() {
27896
27937
  createTypingCallbacksPromise ??= (async () => {
27897
27938
  for (const specifier of ["openclaw/plugin-sdk/channel-runtime", "openclaw/plugin-sdk/channel-message"]) {
@@ -29529,6 +29570,7 @@ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-sch
29529
29570
 
29530
29571
  // src/channel.ts
29531
29572
  init_accounts();
29573
+ init_types();
29532
29574
  init_login_qr();
29533
29575
  init_send();
29534
29576
  init_logger();
@@ -29705,11 +29747,13 @@ var workchatPlugin = {
29705
29747
  loginWithQrStart: async ({ accountId, force, verbose }) => {
29706
29748
  const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime");
29707
29749
  const cfg = loadConfig();
29708
- const account = resolveWorkChatAccount(cfg, accountId ?? "default");
29750
+ const requestedAccountId = accountId?.trim() || listWorkChatAccountIds(cfg)[0];
29751
+ const existing = requestedAccountId ? resolveWorkChatAccount(cfg, requestedAccountId) : void 0;
29752
+ const section = cfg.channels?.["openclaw-workchat"];
29709
29753
  const result = await startWorkChatLoginWithQr({
29710
- baseUrl: account.baseUrl,
29711
- appKey: account.appKey,
29712
- appSecret: account.appSecret
29754
+ baseUrl: existing?.baseUrl || section?.baseUrl || DEFAULT_BASE_URL,
29755
+ appKey: existing?.appKey || section?.appKey?.trim() || void 0,
29756
+ appSecret: existing?.appSecret || section?.appSecret?.trim() || void 0
29713
29757
  });
29714
29758
  return {
29715
29759
  qrDataUrl: result.qrcodeUrl,
@@ -29816,6 +29860,9 @@ function assertHostCompatibility(hostVersion) {
29816
29860
  );
29817
29861
  }
29818
29862
 
29863
+ // index.ts
29864
+ init_accounts();
29865
+
29819
29866
  // node_modules/zod/v4/classic/external.js
29820
29867
  var external_exports = {};
29821
29868
  __export(external_exports, {
@@ -49552,6 +49599,9 @@ var openclaw_workchat_default = {
49552
49599
  configSchema: buildChannelConfigSchema(WorkChatConfigSchema),
49553
49600
  register(api) {
49554
49601
  assertHostCompatibility(api.runtime?.version);
49602
+ void migrateLegacyDefaultWorkChatAccount().catch((err) => {
49603
+ console.error("[openclaw-workchat] legacy default account migration failed:", err);
49604
+ });
49555
49605
  api.registerChannel({ plugin: workchatPlugin });
49556
49606
  }
49557
49607
  };
package/index.ts CHANGED
@@ -3,6 +3,7 @@ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-sch
3
3
 
4
4
  import { workchatPlugin } from "./src/channel.js";
5
5
  import { assertHostCompatibility } from "./src/compat.js";
6
+ import { migrateLegacyDefaultWorkChatAccount } from "./src/auth/accounts.js";
6
7
  import { WorkChatConfigSchema } from "./src/config/config-schema.js";
7
8
 
8
9
  export default {
@@ -14,6 +15,12 @@ export default {
14
15
  // Fail-fast: reject incompatible host versions before any side-effects.
15
16
  assertHostCompatibility(api.runtime?.version);
16
17
 
18
+ // Rename legacy `default` accounts to the agent-scoped key before the
19
+ // channel starts enumerating accounts (one-way, idempotent).
20
+ void migrateLegacyDefaultWorkChatAccount().catch(err => {
21
+ console.error("[openclaw-workchat] legacy default account migration failed:", err);
22
+ });
23
+
17
24
  api.registerChannel({ plugin: workchatPlugin });
18
25
  },
19
26
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workclaw/openclaw-workchat",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WorkChat channel plugin: QR binding + WebSocket messaging with the WorkChat open platform",
6
6
  "license": "MIT",
@@ -21,6 +21,10 @@ function resolveAccountIndexPath(): string {
21
21
 
22
22
  /** Returns all accountIds registered via binding QR. */
23
23
  export function listIndexedWorkChatAccountIds(): string[] {
24
+ return readAccountIndex();
25
+ }
26
+
27
+ function readAccountIndex(): string[] {
24
28
  const filePath = resolveAccountIndexPath();
25
29
  try {
26
30
  if (!fs.existsSync(filePath)) return [];
@@ -32,22 +36,43 @@ export function listIndexedWorkChatAccountIds(): string[] {
32
36
  }
33
37
  }
34
38
 
39
+ /**
40
+ * Serialize index mutations inside the plugin process. All writers (binding
41
+ * registration, rebind pruning, legacy migration) run in this one process;
42
+ * the shared promise chain guarantees read-modify-write never interleaves.
43
+ */
44
+ let indexUpdateChain: Promise<void> = Promise.resolve();
45
+
46
+ /**
47
+ * Read-modify-write the account index. Mutations are serialized in-process
48
+ * (see indexUpdateChain); the framework file lock is not used because the
49
+ * index is only ever written by this plugin process, and the lock sidecar
50
+ * behaves inconsistently under test-runner module isolation.
51
+ */
52
+ async function updateAccountIndex(
53
+ mutator: (ids: string[]) => string[],
54
+ ): Promise<void> {
55
+ const run = indexUpdateChain.then(() => {
56
+ const filePath = resolveAccountIndexPath();
57
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
58
+ const updated = mutator(readAccountIndex());
59
+ fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), "utf-8");
60
+ });
61
+ // Keep the chain alive even when a mutation fails.
62
+ indexUpdateChain = run.then(() => undefined, () => undefined);
63
+ await run;
64
+ }
65
+
35
66
  /** Add accountId to the persistent index (no-op if already present). */
36
- export function registerWorkChatAccountId(accountId: string): void {
37
- const dir = resolveWorkChatStateDir();
38
- fs.mkdirSync(dir, { recursive: true });
39
- const existing = listIndexedWorkChatAccountIds();
40
- if (existing.includes(accountId)) return;
41
- fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify([...existing, accountId], null, 2), "utf-8");
67
+ export async function registerWorkChatAccountId(accountId: string): Promise<void> {
68
+ await updateAccountIndex(ids =>
69
+ ids.includes(accountId) ? ids : [...ids, accountId],
70
+ );
42
71
  }
43
72
 
44
73
  /** Remove accountId from the persistent index. */
45
- export function unregisterWorkChatAccountId(accountId: string): void {
46
- const existing = listIndexedWorkChatAccountIds();
47
- const updated = existing.filter(id => id !== accountId);
48
- if (updated.length !== existing.length) {
49
- fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
50
- }
74
+ export async function unregisterWorkChatAccountId(accountId: string): Promise<void> {
75
+ await updateAccountIndex(ids => ids.filter(id => id !== accountId));
51
76
  }
52
77
 
53
78
  // ---------------------------------------------------------------------------
@@ -115,14 +140,14 @@ export function saveWorkChatAccount(
115
140
  }
116
141
 
117
142
  /** Remove all files associated with an account. */
118
- export function clearWorkChatAccount(accountId: string): void {
143
+ export async function clearWorkChatAccount(accountId: string): Promise<void> {
119
144
  const filePath = resolveAccountPath(accountId);
120
145
  try {
121
146
  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
122
147
  } catch (err) {
123
148
  logger.warn(`clearWorkChatAccount: failed to remove ${filePath}: ${String(err)}`);
124
149
  }
125
- unregisterWorkChatAccountId(accountId);
150
+ await unregisterWorkChatAccountId(accountId);
126
151
  }
127
152
 
128
153
  /**
@@ -152,23 +177,22 @@ export async function removeWorkChatAccountBinding(accountId: string): Promise<v
152
177
  }
153
178
 
154
179
  /**
155
- * Remove every registered account whose stored agentId differs from
156
- * `keepAgentId` (rebind cleanup). Returns the removed accountIds.
157
- * `default` is normalized: if it holds a foreign agentId it is removed too;
158
- * the surviving binding keeps whatever account key it was registered under.
180
+ * Remove every registered account other than `keepAccountId` (rebind cleanup).
181
+ * One active agent per host: a completed binding owns the agent-scoped key
182
+ * `agent-${agentId}`, and every other account — a previous agent, or a legacy
183
+ * `default` registered by older plugin versions even for the SAME agent — is
184
+ * removed so a re-scan can never leave duplicate accounts behind.
159
185
  */
160
- export async function pruneStaleWorkChatAccounts(keepAgentId: string): Promise<string[]> {
186
+ export async function pruneOtherWorkChatAccounts(keepAccountId: string): Promise<string[]> {
187
+ const keep = keepAccountId.trim();
161
188
  const removed: string[] = [];
162
189
  for (const accountId of listIndexedWorkChatAccountIds()) {
163
- const data = loadWorkChatAccount(accountId);
164
- const storedAgentId = data?.agentId?.trim();
165
- if (storedAgentId && storedAgentId !== keepAgentId) {
166
- await removeWorkChatAccountBinding(accountId);
167
- removed.push(accountId);
168
- logger.info(
169
- `pruneStaleWorkChatAccounts: removed account=${accountId} agent=${storedAgentId} (superseded by agent=${keepAgentId})`,
170
- );
171
- }
190
+ if (accountId === keep) continue;
191
+ await removeWorkChatAccountBinding(accountId);
192
+ removed.push(accountId);
193
+ logger.info(
194
+ `pruneOtherWorkChatAccounts: removed account=${accountId} (superseded by ${keep})`,
195
+ );
172
196
  }
173
197
  return removed;
174
198
  }
@@ -211,6 +235,62 @@ type WorkChatSectionConfig = WorkChatAccountConfig & {
211
235
  channelConfigUpdatedAt?: string;
212
236
  };
213
237
 
238
+ /**
239
+ * One-time migration for accounts registered by older plugin versions under
240
+ * the `default` key: rename them to the agent-scoped key `agent-${agentId}`.
241
+ * Moves the account data file and the framework allowFrom credentials so no
242
+ * orphaned files remain (the gateway startup migration treats orphaned
243
+ * allowFrom files as a fatal unresolved state). Idempotent — a no-op once no
244
+ * `default` account exists. Runs once at plugin registration (see index.ts),
245
+ * before any binding listener can start, so it cannot interleave with
246
+ * concurrent binding writes (register/unregister hold the index file lock).
247
+ */
248
+ export async function migrateLegacyDefaultWorkChatAccount(): Promise<boolean> {
249
+ const ids = listIndexedWorkChatAccountIds();
250
+ if (!ids.includes("default")) return false;
251
+ const data = loadWorkChatAccount("default");
252
+ const agentId = data?.agentId?.trim();
253
+ if (!agentId) return false;
254
+ const targetId = `agent-${agentId}`;
255
+
256
+ // 1. Persist the agent-scoped copy (unless it already exists — the
257
+ // agent-scoped file is the newer binding when both are present).
258
+ if (!ids.includes(targetId)) {
259
+ saveWorkChatAccount(targetId, { ...data });
260
+ }
261
+ // 2. Move the framework allowFrom credentials to the new key.
262
+ try {
263
+ const { readFrameworkAllowFromList, registerUserInFrameworkStore } = await import("./pairing.js");
264
+ for (const userId of readFrameworkAllowFromList("default")) {
265
+ await registerUserInFrameworkStore({ accountId: targetId, userId });
266
+ }
267
+ } catch (err) {
268
+ logger.warn(`migrateLegacyDefaultWorkChatAccount: allowFrom move failed: ${String(err)}`);
269
+ }
270
+ // 3. Register the new key and drop the legacy one, both under the index
271
+ // file lock so a concurrent binding cannot be lost.
272
+ await updateAccountIndex(existing => {
273
+ const next = existing.filter(id => id !== "default" && id !== targetId);
274
+ next.push(targetId);
275
+ return next;
276
+ });
277
+ try {
278
+ fs.rmSync(path.join(resolveWorkChatStateDir(), "accounts", "default.json"), { force: true });
279
+ } catch {
280
+ // Best-effort data file cleanup; the index above is authoritative.
281
+ }
282
+ try {
283
+ const { clearFrameworkAllowFromList } = await import("./pairing.js");
284
+ clearFrameworkAllowFromList("default");
285
+ } catch (err) {
286
+ logger.warn(`migrateLegacyDefaultWorkChatAccount: allowFrom cleanup failed: ${String(err)}`);
287
+ }
288
+ logger.info(
289
+ `migrateLegacyDefaultWorkChatAccount: renamed default -> ${targetId} (agent=${agentId})`,
290
+ );
291
+ return true;
292
+ }
293
+
214
294
  /** List accountIds from the index file (written at binding). */
215
295
  export function listWorkChatAccountIds(_cfg: OpenClawConfig): string[] {
216
296
  return listIndexedWorkChatAccountIds();
@@ -293,7 +293,6 @@ import { buildPongFrame } from "../messaging/inbound.js";
293
293
  import {
294
294
  saveWorkChatAccount,
295
295
  registerWorkChatAccountId,
296
- listIndexedWorkChatAccountIds,
297
296
  } from "./accounts.js";
298
297
 
299
298
  const bindingListeners = new Map<string, { ws: WebSocket; done: boolean }>();
@@ -388,45 +387,39 @@ async function handleListenerFrame(sessionKey: string, data: string, ws: WebSock
388
387
 
389
388
  const eventData = result.eventData;
390
389
  if (!eventData) return;
391
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
390
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
392
391
  if (!agentId) return;
393
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
392
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
394
393
 
395
394
  const entry = bindingListeners.get(sessionKey);
396
395
  if (entry) entry.done = true;
397
396
 
398
- // Rebind detection must happen BEFORE pruning empties the index: if any
399
- // account existed, this scan replaces it. The QR login flow force-stops the
400
- // channel (marked manually-stopped in the gateway), and a reused account id
401
- // would stay stopped after reload — so a rebind always registers under a
402
- // FRESH agent-scoped key instead of reusing `default`.
403
- const isRebind = listIndexedWorkChatAccountIds().length > 0;
397
+ // One active agent per host (mirrors handleAgentLifecycleEvent): every
398
+ // binding registers under the agent-scoped key `agent-${agentId}`. The QR
399
+ // login flow force-stops the channel (marked manually-stopped in the
400
+ // gateway), and a reused account id would stay stopped after reload — the
401
+ // fresh agent-scoped key plus the channel reload restarts it.
402
+ const accountId = `agent-${agentId}`;
404
403
 
405
- // Rebind semantics (mirrors handleAgentLifecycleEvent): a re-scan replaces
406
- // the previously bound agent, so drop accounts bound to other agentIds
407
- // before registering this one.
404
+ // A re-scan replaces the previously bound agent: drop every OTHER
405
+ // registered account (including a same-agent legacy `default`) before
406
+ // registering this one.
408
407
  try {
409
- const { pruneStaleWorkChatAccounts } = await import("./accounts.js");
410
- const removed = await pruneStaleWorkChatAccounts(agentId);
408
+ const { pruneOtherWorkChatAccounts } = await import("./accounts.js");
409
+ const removed = await pruneOtherWorkChatAccounts(accountId);
411
410
  if (removed.length > 0) {
412
- logger.info(`binding listener: pruned stale account(s): ${removed.join(", ")}`);
411
+ logger.info(`binding listener: pruned previous account(s): ${removed.join(", ")}`);
413
412
  }
414
413
  } catch (err) {
415
414
  logger.error(`binding listener: rebind cleanup failed: ${String(err)}`);
416
415
  }
417
416
 
418
- // First bound agent becomes the `default` account; a rebind gets a fresh
419
- // agent-scoped key (see isRebind note above).
420
- const accountId = isRebind
421
- ? `agent-${agentId}`
422
- : "default";
423
-
424
417
  try {
425
418
  saveWorkChatAccount(accountId, {
426
419
  agentId,
427
420
  ...(userId ? { userId } : {}),
428
421
  });
429
- registerWorkChatAccountId(accountId);
422
+ await registerWorkChatAccountId(accountId);
430
423
  // Complete the binding: account data is already in the private state
431
424
  // dir; this call registers the allowFrom user and triggers the channel
432
425
  // hot reload.
package/src/channel.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  listWorkChatAccountIds,
6
6
  resolveWorkChatAccount,
7
7
  } from "./auth/accounts.js";
8
+ import { DEFAULT_BASE_URL } from "./api/types.js";
8
9
  import {
9
10
  startWorkChatLoginWithQr,
10
11
  waitForWorkChatLogin,
@@ -205,13 +206,24 @@ export const workchatPlugin: ChannelPlugin<ResolvedWorkChatAccount> = {
205
206
  // The gateway adapter has no cfg in scope here — read the live config
206
207
  // from disk (the gateway's own config, same file triggerWorkChatChannelReload
207
208
  // writes to) so the ${WORKCHAT_API_KEY} placeholder is resolved by the host.
209
+ // Account ids are always agent-scoped (`agent-${agentId}`); an explicit
210
+ // accountId targets that account, otherwise fall back to the first
211
+ // registered one. A first-time bind (empty index) still resolves an
212
+ // account-like view of the shared channel credentials — the QR flow
213
+ // only consumes baseUrl/appKey/appSecret.
208
214
  const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime");
209
215
  const cfg = loadConfig() as OpenClawConfig;
210
- const account = resolveWorkChatAccount(cfg, accountId ?? "default");
216
+ const requestedAccountId = accountId?.trim() || listWorkChatAccountIds(cfg)[0];
217
+ const existing = requestedAccountId
218
+ ? resolveWorkChatAccount(cfg, requestedAccountId)
219
+ : undefined;
220
+ const section = cfg.channels?.["openclaw-workchat"] as
221
+ | { baseUrl?: string; appKey?: string; appSecret?: string }
222
+ | undefined;
211
223
  const result: WorkChatQrStartResult = await startWorkChatLoginWithQr({
212
- baseUrl: account.baseUrl,
213
- appKey: account.appKey,
214
- appSecret: account.appSecret,
224
+ baseUrl: existing?.baseUrl || section?.baseUrl || DEFAULT_BASE_URL,
225
+ appKey: existing?.appKey || section?.appKey?.trim() || undefined,
226
+ appSecret: existing?.appSecret || section?.appSecret?.trim() || undefined,
215
227
  });
216
228
  return {
217
229
  qrDataUrl: result.qrcodeUrl,
@@ -12,7 +12,7 @@ import {
12
12
  registerWorkChatAccountId,
13
13
  saveWorkChatAccount,
14
14
  triggerWorkChatChannelReload,
15
- pruneStaleWorkChatAccounts,
15
+ pruneOtherWorkChatAccounts,
16
16
  removeWorkChatAccountBinding,
17
17
  } from "../auth/accounts.js";
18
18
  import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
@@ -79,48 +79,41 @@ async function handleAgentLifecycleEvent(
79
79
  const eventData = result.eventData;
80
80
  if (!eventData) return;
81
81
 
82
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
82
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
83
83
  if (!agentId) {
84
84
  logger.warn(`${frameType}: missing agent id, ignoring`);
85
85
  return;
86
86
  }
87
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
87
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
88
88
 
89
89
  logger.info(`${frameType}: agentId=${agentId} userId=${userId ? "present" : "missing"}`);
90
90
  deps.log(`${frameType}: agentId=${agentId}`);
91
91
 
92
- // Rebind detection must happen BEFORE pruning empties the index (see the
93
- // matching note in the binding listener): a rebind always registers under a
94
- // FRESH agent-scoped key so the gateway restarts it — the QR login flow
95
- // force-stops the channel with a manual-stop mark, and a reused account id
96
- // would stay stopped after the config reload.
97
- const isRebind = listWorkChatAccountIdsCount() > 0;
92
+ // One active agent per host: every binding (first scan included) registers
93
+ // under the agent-scoped key `agent-${agentId}`, and every OTHER registered
94
+ // account is removed before persisting. A reused account id would also stay
95
+ // stopped after the QR flow's manual-stop mark, so rebinds always land on
96
+ // the fresh key and the gateway restarts it via the channel reload below.
97
+ const { completeWorkChatBinding } = await import("../auth/login-qr.js");
98
+ const accountId = frameType === "init_agent"
99
+ ? `agent-${agentId}`
100
+ : deps.accountId;
98
101
 
99
- // Rebind semantics: one active agent per host. Before persisting the new
100
- // binding, remove every registered account bound to a different agent
101
- // (a re-scan creates a new platform agent; the old one is deleted there).
102
102
  try {
103
- const removed = await pruneStaleWorkChatAccounts(agentId);
103
+ const removed = await pruneOtherWorkChatAccounts(accountId);
104
104
  if (removed.length > 0) {
105
- deps.log(`rebind: removed stale account(s): ${removed.join(", ")}`);
105
+ deps.log(`rebind: removed previous account(s): ${removed.join(", ")}`);
106
106
  }
107
107
  } catch (err) {
108
108
  logger.error(`rebind cleanup failed: ${String(err)}`);
109
109
  }
110
110
 
111
- // The bound agent becomes the account: `default` for the primary install,
112
- // or a fresh agent-scoped key on rebind.
113
- const { completeWorkChatBinding } = await import("../auth/login-qr.js");
114
- const accountId = frameType === "init_agent"
115
- ? (isRebind ? `agent-${agentId}` : "default")
116
- : deps.accountId;
117
-
118
111
  try {
119
112
  saveWorkChatAccount(accountId, {
120
113
  agentId,
121
114
  ...(userId ? { userId } : {}),
122
115
  });
123
- registerWorkChatAccountId(accountId);
116
+ await registerWorkChatAccountId(accountId);
124
117
  // Bind the account to the incoming agent for message routing. agentId
125
118
  // comes from the event; userId (futureId) is the WorkChat user.
126
119
  const { bindAgentToAccount } = await import("../auth/bind-agent.js");
@@ -149,10 +142,6 @@ async function handleAgentLifecycleEvent(
149
142
  }
150
143
  }
151
144
 
152
- function listWorkChatAccountIdsCount(): number {
153
- return listIndexedWorkChatAccountIds().length;
154
- }
155
-
156
145
  // Typing-indicator helper versions: the host renamed the subpath between
157
146
  // releases (2026.3.x exports openclaw/plugin-sdk/channel-runtime, 2026.8.x
158
147
  // exports openclaw/plugin-sdk/channel-message). Load whichever resolves —