@workclaw/openclaw-workchat 1.0.3 → 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);
@@ -373,40 +381,62 @@ async function removeWorkChatAccountBinding(accountId) {
373
381
  logger.warn(`removeWorkChatAccountBinding: allowFrom cleanup failed: ${String(err)}`);
374
382
  }
375
383
  try {
376
- const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
377
- const cfg = loadConfig();
378
- const channels = cfg.channels ?? {};
379
- const section = channels["openclaw-workchat"];
380
- if (section?.accounts?.[accountId] !== void 0) {
381
- const accounts = { ...section.accounts ?? {} };
382
- delete accounts[accountId];
383
- await writeConfigFile({
384
- ...cfg,
385
- channels: {
386
- ...channels,
387
- "openclaw-workchat": { ...section, accounts }
388
- }
389
- });
390
- }
384
+ await triggerWorkChatChannelReload();
391
385
  } catch (err) {
392
- logger.warn(`removeWorkChatAccountBinding: config cleanup failed: ${String(err)}`);
386
+ logger.warn(`removeWorkChatAccountBinding: channel reload trigger failed: ${String(err)}`);
393
387
  }
394
388
  }
395
- async function pruneStaleWorkChatAccounts(keepAgentId) {
389
+ async function pruneOtherWorkChatAccounts(keepAccountId) {
390
+ const keep = keepAccountId.trim();
396
391
  const removed = [];
397
392
  for (const accountId of listIndexedWorkChatAccountIds()) {
398
- const data = loadWorkChatAccount(accountId);
399
- const storedAgentId = data?.agentId?.trim();
400
- if (storedAgentId && storedAgentId !== keepAgentId) {
401
- await removeWorkChatAccountBinding(accountId);
402
- removed.push(accountId);
403
- logger.info(
404
- `pruneStaleWorkChatAccounts: removed account=${accountId} agent=${storedAgentId} (superseded by agent=${keepAgentId})`
405
- );
406
- }
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
+ );
407
399
  }
408
400
  return removed;
409
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
+ }
410
440
  function listWorkChatAccountIds(_cfg) {
411
441
  return listIndexedWorkChatAccountIds();
412
442
  }
@@ -456,12 +486,14 @@ async function triggerWorkChatChannelReload() {
456
486
  logger.warn(`triggerWorkChatChannelReload: failed to update config: ${String(err)}`);
457
487
  }
458
488
  }
489
+ var indexUpdateChain;
459
490
  var init_accounts = __esm({
460
491
  "src/auth/accounts.ts"() {
461
492
  "use strict";
462
493
  init_types();
463
494
  init_state_dir();
464
495
  init_logger();
496
+ indexUpdateChain = Promise.resolve();
465
497
  }
466
498
  });
467
499
 
@@ -26883,30 +26915,6 @@ __export(bind_agent_exports, {
26883
26915
  bindAgentToAccountFromBinding: () => bindAgentToAccountFromBinding
26884
26916
  });
26885
26917
  async function bindAgentToAccount(accountId, agentId, userId) {
26886
- const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
26887
- const raw = loadConfig();
26888
- const channels = raw.channels ?? {};
26889
- const section = channels["openclaw-workchat"] ?? {};
26890
- const accounts = { ...section.accounts ?? {} };
26891
- accounts[accountId] = {
26892
- ...accounts[accountId] ?? {},
26893
- enabled: true,
26894
- agentId,
26895
- ...userId ? { userId } : {}
26896
- };
26897
- const updated = {
26898
- ...raw,
26899
- channels: {
26900
- ...channels,
26901
- "openclaw-workchat": {
26902
- ...section,
26903
- accounts,
26904
- ...userId && !section.userId ? { userId } : {},
26905
- channelConfigUpdatedAt: (/* @__PURE__ */ new Date()).toISOString()
26906
- }
26907
- }
26908
- };
26909
- await writeConfigFile(updated);
26910
26918
  if (userId) {
26911
26919
  try {
26912
26920
  const { registerUserInFrameworkStore: registerUserInFrameworkStore2 } = await Promise.resolve().then(() => (init_pairing(), pairing_exports));
@@ -26915,8 +26923,14 @@ async function bindAgentToAccount(accountId, agentId, userId) {
26915
26923
  logger.warn(`bindAgentToAccount: allowFrom registration failed: ${String(err)}`);
26916
26924
  }
26917
26925
  }
26926
+ try {
26927
+ const { triggerWorkChatChannelReload: triggerWorkChatChannelReload2 } = await Promise.resolve().then(() => (init_accounts(), accounts_exports));
26928
+ await triggerWorkChatChannelReload2();
26929
+ } catch (err) {
26930
+ logger.warn(`bindAgentToAccount: channel reload trigger failed: ${String(err)}`);
26931
+ }
26918
26932
  logger.info(
26919
- `bindAgentToAccount: accountId=${accountId} agentId=${agentId} userId=${userId || "(none)"} (apiKey NOT written \u2014 env-injected)`
26933
+ `bindAgentToAccount: accountId=${accountId} agentId=${agentId} userId=${userId || "(none)"} (private-dir only; apiKey NOT written \u2014 env-injected)`
26920
26934
  );
26921
26935
  }
26922
26936
  var bindAgentToAccountFromBinding;
@@ -27159,28 +27173,27 @@ async function handleListenerFrame(sessionKey, data, ws) {
27159
27173
  if (result.type !== "init_agent") return;
27160
27174
  const eventData = result.eventData;
27161
27175
  if (!eventData) return;
27162
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
27176
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
27163
27177
  if (!agentId) return;
27164
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
27178
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
27165
27179
  const entry = bindingListeners.get(sessionKey);
27166
27180
  if (entry) entry.done = true;
27167
- const isRebind = listIndexedWorkChatAccountIds().length > 0;
27181
+ const accountId = `agent-${agentId}`;
27168
27182
  try {
27169
- const { pruneStaleWorkChatAccounts: pruneStaleWorkChatAccounts2 } = await Promise.resolve().then(() => (init_accounts(), accounts_exports));
27170
- const removed = await pruneStaleWorkChatAccounts2(agentId);
27183
+ const { pruneOtherWorkChatAccounts: pruneOtherWorkChatAccounts2 } = await Promise.resolve().then(() => (init_accounts(), accounts_exports));
27184
+ const removed = await pruneOtherWorkChatAccounts2(accountId);
27171
27185
  if (removed.length > 0) {
27172
- logger.info(`binding listener: pruned stale account(s): ${removed.join(", ")}`);
27186
+ logger.info(`binding listener: pruned previous account(s): ${removed.join(", ")}`);
27173
27187
  }
27174
27188
  } catch (err) {
27175
27189
  logger.error(`binding listener: rebind cleanup failed: ${String(err)}`);
27176
27190
  }
27177
- const accountId = isRebind ? `agent-${agentId}` : "default";
27178
27191
  try {
27179
27192
  saveWorkChatAccount(accountId, {
27180
27193
  agentId,
27181
27194
  ...userId ? { userId } : {}
27182
27195
  });
27183
- registerWorkChatAccountId(accountId);
27196
+ await registerWorkChatAccountId(accountId);
27184
27197
  const { bindAgentToAccountFromBinding: bindAgentToAccountFromBinding2 } = await Promise.resolve().then(() => (init_bind_agent(), bind_agent_exports));
27185
27198
  await bindAgentToAccountFromBinding2(accountId, agentId, userId);
27186
27199
  logger.info(`binding listener: account=${accountId} agent=${agentId} persisted`);
@@ -27875,31 +27888,30 @@ function emitWorkChatMessageSent2(params) {
27875
27888
  async function handleAgentLifecycleEvent(frameType, result, deps) {
27876
27889
  const eventData = result.eventData;
27877
27890
  if (!eventData) return;
27878
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
27891
+ const agentId = String(eventData.id ?? eventData.agentId ?? "").trim();
27879
27892
  if (!agentId) {
27880
27893
  logger.warn(`${frameType}: missing agent id, ignoring`);
27881
27894
  return;
27882
27895
  }
27883
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
27896
+ const userId = String(eventData.futureId ?? eventData.userId ?? "").trim();
27884
27897
  logger.info(`${frameType}: agentId=${agentId} userId=${userId ? "present" : "missing"}`);
27885
27898
  deps.log(`${frameType}: agentId=${agentId}`);
27886
- 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;
27887
27901
  try {
27888
- const removed = await pruneStaleWorkChatAccounts(agentId);
27902
+ const removed = await pruneOtherWorkChatAccounts(accountId);
27889
27903
  if (removed.length > 0) {
27890
- deps.log(`rebind: removed stale account(s): ${removed.join(", ")}`);
27904
+ deps.log(`rebind: removed previous account(s): ${removed.join(", ")}`);
27891
27905
  }
27892
27906
  } catch (err) {
27893
27907
  logger.error(`rebind cleanup failed: ${String(err)}`);
27894
27908
  }
27895
- const { completeWorkChatBinding: completeWorkChatBinding2 } = await Promise.resolve().then(() => (init_login_qr(), login_qr_exports));
27896
- const accountId = frameType === "init_agent" ? isRebind ? `agent-${agentId}` : "default" : deps.accountId;
27897
27909
  try {
27898
27910
  saveWorkChatAccount(accountId, {
27899
27911
  agentId,
27900
27912
  ...userId ? { userId } : {}
27901
27913
  });
27902
- registerWorkChatAccountId(accountId);
27914
+ await registerWorkChatAccountId(accountId);
27903
27915
  const { bindAgentToAccount: bindAgentToAccount2 } = await Promise.resolve().then(() => (init_bind_agent(), bind_agent_exports));
27904
27916
  await bindAgentToAccount2(accountId, agentId, userId);
27905
27917
  logger.info(`${frameType}: bound agent to account accountId=${accountId}`);
@@ -27921,9 +27933,6 @@ async function handleAgentLifecycleEvent(frameType, result, deps) {
27921
27933
  void notifyAgentCreatedStatus(deps.apiOpts, agentId);
27922
27934
  }
27923
27935
  }
27924
- function listWorkChatAccountIdsCount() {
27925
- return listIndexedWorkChatAccountIds().length;
27926
- }
27927
27936
  function loadCreateTypingCallbacks() {
27928
27937
  createTypingCallbacksPromise ??= (async () => {
27929
27938
  for (const specifier of ["openclaw/plugin-sdk/channel-runtime", "openclaw/plugin-sdk/channel-message"]) {
@@ -29561,6 +29570,7 @@ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-sch
29561
29570
 
29562
29571
  // src/channel.ts
29563
29572
  init_accounts();
29573
+ init_types();
29564
29574
  init_login_qr();
29565
29575
  init_send();
29566
29576
  init_logger();
@@ -29737,11 +29747,13 @@ var workchatPlugin = {
29737
29747
  loginWithQrStart: async ({ accountId, force, verbose }) => {
29738
29748
  const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime");
29739
29749
  const cfg = loadConfig();
29740
- 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"];
29741
29753
  const result = await startWorkChatLoginWithQr({
29742
- baseUrl: account.baseUrl,
29743
- appKey: account.appKey,
29744
- 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
29745
29757
  });
29746
29758
  return {
29747
29759
  qrDataUrl: result.qrcodeUrl,
@@ -29848,6 +29860,9 @@ function assertHostCompatibility(hostVersion) {
29848
29860
  );
29849
29861
  }
29850
29862
 
29863
+ // index.ts
29864
+ init_accounts();
29865
+
29851
29866
  // node_modules/zod/v4/classic/external.js
29852
29867
  var external_exports = {};
29853
29868
  __export(external_exports, {
@@ -49584,6 +49599,9 @@ var openclaw_workchat_default = {
49584
49599
  configSchema: buildChannelConfigSchema(WorkChatConfigSchema),
49585
49600
  register(api) {
49586
49601
  assertHostCompatibility(api.runtime?.version);
49602
+ void migrateLegacyDefaultWorkChatAccount().catch((err) => {
49603
+ console.error("[openclaw-workchat] legacy default account migration failed:", err);
49604
+ });
49587
49605
  api.registerChannel({ plugin: workchatPlugin });
49588
49606
  }
49589
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.3",
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
  /**
@@ -141,45 +166,33 @@ export async function removeWorkChatAccountBinding(accountId: string): Promise<v
141
166
  logger.warn(`removeWorkChatAccountBinding: allowFrom cleanup failed: ${String(err)}`);
142
167
  }
143
168
 
169
+ // Account entries no longer live in openclaw.json (weixin-style private-dir
170
+ // ownership), so there is no shared config to clean up here — the channel
171
+ // reload bump below lets the gateway drop the removed account.
144
172
  try {
145
- const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
146
- const cfg = loadConfig();
147
- const channels = (cfg.channels ?? {}) as Record<string, any>;
148
- const section = channels["openclaw-workchat"];
149
- if (section?.accounts?.[accountId] !== undefined) {
150
- const accounts = { ...(section.accounts ?? {}) };
151
- delete accounts[accountId];
152
- await writeConfigFile({
153
- ...cfg,
154
- channels: {
155
- ...channels,
156
- "openclaw-workchat": { ...section, accounts },
157
- },
158
- } as import("openclaw/plugin-sdk").OpenClawConfig);
159
- }
173
+ await triggerWorkChatChannelReload();
160
174
  } catch (err) {
161
- logger.warn(`removeWorkChatAccountBinding: config cleanup failed: ${String(err)}`);
175
+ logger.warn(`removeWorkChatAccountBinding: channel reload trigger failed: ${String(err)}`);
162
176
  }
163
177
  }
164
178
 
165
179
  /**
166
- * Remove every registered account whose stored agentId differs from
167
- * `keepAgentId` (rebind cleanup). Returns the removed accountIds.
168
- * `default` is normalized: if it holds a foreign agentId it is removed too;
169
- * 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.
170
185
  */
171
- export async function pruneStaleWorkChatAccounts(keepAgentId: string): Promise<string[]> {
186
+ export async function pruneOtherWorkChatAccounts(keepAccountId: string): Promise<string[]> {
187
+ const keep = keepAccountId.trim();
172
188
  const removed: string[] = [];
173
189
  for (const accountId of listIndexedWorkChatAccountIds()) {
174
- const data = loadWorkChatAccount(accountId);
175
- const storedAgentId = data?.agentId?.trim();
176
- if (storedAgentId && storedAgentId !== keepAgentId) {
177
- await removeWorkChatAccountBinding(accountId);
178
- removed.push(accountId);
179
- logger.info(
180
- `pruneStaleWorkChatAccounts: removed account=${accountId} agent=${storedAgentId} (superseded by agent=${keepAgentId})`,
181
- );
182
- }
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
+ );
183
196
  }
184
197
  return removed;
185
198
  }
@@ -222,6 +235,62 @@ type WorkChatSectionConfig = WorkChatAccountConfig & {
222
235
  channelConfigUpdatedAt?: string;
223
236
  };
224
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
+
225
294
  /** List accountIds from the index file (written at binding). */
226
295
  export function listWorkChatAccountIds(_cfg: OpenClawConfig): string[] {
227
296
  return listIndexedWorkChatAccountIds();
@@ -1,53 +1,26 @@
1
1
  import { logger } from "../util/logger.js";
2
2
 
3
3
  /**
4
- * Persist the (userId, agentId) -> account binding into
5
- * channels.openclaw-workchat.accounts of openclaw.json so inbound
6
- * AGENT_MESSAGE frames route to the right account and outbound sends find
7
- * the agent.
4
+ * Complete the binding on the plugin side.
8
5
  *
9
- * Weixin-style model: no dedicated sub-agent, no explicit binding — the
10
- * account routes to the main agent via the channel's default binding the
11
- * desktop configSync maintains (and the gateway's default-agent fallback).
12
- * Credentials are never written here; they arrive via
13
- * WORKCHAT_API_KEY / WORKCHAT_APP_SECRET env injection.
6
+ * The account payload (agentId/userId) lives exclusively in the plugin's
7
+ * private state dir (`state/openclaw-workchat/accounts/`) — weixin-style. The
8
+ * caller (binding listener / monitor INIT_AGENT handling) persists it via
9
+ * saveWorkChatAccount + registerWorkChatAccountId before calling this.
14
10
  *
15
- * Shared by the binding listener (login-qr wait path) and the monitor's
16
- * INIT_AGENT / AGENT_CREATED handling (process-message).
11
+ * This function only bumps `channelConfigUpdatedAt` in openclaw.json so the
12
+ * gateway reloads config from disk and restarts the channel with the fresh
13
+ * private-dir account data. Writing account entries into
14
+ * channels.openclaw-workchat.accounts here would race the desktop configSync
15
+ * (which rebuilds that field) and orphan the credentials/
16
+ * <account>-allowFrom.json files — the gateway's startup migration treats
17
+ * those as a fatal unresolved state.
17
18
  */
18
19
  export async function bindAgentToAccount(
19
20
  accountId: string,
20
21
  agentId: string,
21
22
  userId?: string,
22
23
  ): Promise<void> {
23
- const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
24
- const raw = loadConfig() as Record<string, any>;
25
- const channels = (raw.channels ?? {}) as Record<string, any>;
26
- const section = (channels["openclaw-workchat"] ?? {}) as Record<string, any>;
27
- const accounts = { ...(section.accounts ?? {}) };
28
-
29
- accounts[accountId] = {
30
- ...(accounts[accountId] ?? {}),
31
- enabled: true,
32
- agentId,
33
- ...(userId ? { userId } : {}),
34
- };
35
-
36
- const updated = {
37
- ...raw,
38
- channels: {
39
- ...channels,
40
- "openclaw-workchat": {
41
- ...section,
42
- accounts,
43
- ...(userId && !section.userId ? { userId } : {}),
44
- channelConfigUpdatedAt: new Date().toISOString(),
45
- },
46
- },
47
- } as Record<string, any>;
48
-
49
- await writeConfigFile(updated as import("openclaw/plugin-sdk").OpenClawConfig);
50
-
51
24
  // Persist the binding user in the framework allowFrom store so command
52
25
  // authorization has an explicit source of truth (the inbound path also
53
26
  // falls back to the account's userId, but writing it keeps parity with the
@@ -61,8 +34,17 @@ export async function bindAgentToAccount(
61
34
  }
62
35
  }
63
36
 
37
+ // Trigger the channel hot reload; account data itself stays in the private
38
+ // state dir (already persisted by the caller before this runs).
39
+ try {
40
+ const { triggerWorkChatChannelReload } = await import("./accounts.js");
41
+ await triggerWorkChatChannelReload();
42
+ } catch (err) {
43
+ logger.warn(`bindAgentToAccount: channel reload trigger failed: ${String(err)}`);
44
+ }
45
+
64
46
  logger.info(
65
- `bindAgentToAccount: accountId=${accountId} agentId=${agentId} userId=${userId || "(none)"} (apiKey NOT written — env-injected)`,
47
+ `bindAgentToAccount: accountId=${accountId} agentId=${agentId} userId=${userId || "(none)"} (private-dir only; apiKey NOT written — env-injected)`,
66
48
  );
67
49
  }
68
50
 
@@ -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,46 +387,42 @@ 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);
430
- // Bind agent into openclaw.json accounts (routing) — no apiKey ever written.
422
+ await registerWorkChatAccountId(accountId);
423
+ // Complete the binding: account data is already in the private state
424
+ // dir; this call registers the allowFrom user and triggers the channel
425
+ // hot reload.
431
426
  const { bindAgentToAccountFromBinding } = await import("./bind-agent.js");
432
427
  await bindAgentToAccountFromBinding(accountId, agentId, userId);
433
428
  logger.info(`binding listener: account=${accountId} agent=${agentId} persisted`);
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 —