@clawling/clawchat-plugin-openclaw 2026.9.11-1 → 2026.9.13-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ createAccountListHelpers,
3
+ mergeAccountConfig,
4
+ normalizeAccountId,
5
+ resolveNormalizedAccountEntry,
6
+ } from "openclaw/plugin-sdk/account-core";
1
7
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
8
  import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
3
9
 
@@ -85,7 +91,7 @@ export type OpenclawClawlingAckConfig = {
85
91
  autoResendOnTimeout?: boolean;
86
92
  };
87
93
 
88
- export type OpenclawClawlingConfig = {
94
+ export type OpenclawClawlingAccountConfig = {
89
95
  enabled?: boolean;
90
96
  websocketUrl?: string;
91
97
  baseUrl?: string;
@@ -115,7 +121,18 @@ export type OpenclawClawlingConfig = {
115
121
  ack?: OpenclawClawlingAckConfig;
116
122
  };
117
123
 
118
- export const openclawClawlingConfigSchema = {
124
+ /**
125
+ * Channel section = the `default` account (and the inheritance base for every
126
+ * named account) plus the account map. Named accounts live under
127
+ * `accounts.<id>` and accept every key except the two map keys.
128
+ */
129
+ export type OpenclawClawlingConfig = OpenclawClawlingAccountConfig & {
130
+ /** Which account `channels list/status` treats as default when several exist. */
131
+ defaultAccount?: string;
132
+ accounts?: Record<string, OpenclawClawlingAccountConfig>;
133
+ };
134
+
135
+ export const openclawClawlingAccountConfigSchema = {
119
136
  type: "object",
120
137
  additionalProperties: false,
121
138
  properties: {
@@ -187,6 +204,19 @@ export const openclawClawlingConfigSchema = {
187
204
  },
188
205
  } as const;
189
206
 
207
+ export const openclawClawlingConfigSchema = {
208
+ type: "object",
209
+ additionalProperties: false,
210
+ properties: {
211
+ ...openclawClawlingAccountConfigSchema.properties,
212
+ defaultAccount: { type: "string" },
213
+ accounts: {
214
+ type: "object",
215
+ additionalProperties: openclawClawlingAccountConfigSchema,
216
+ },
217
+ },
218
+ } as const;
219
+
190
220
  function isOpenclawClawchatToolAllowEntry(entry: unknown): boolean {
191
221
  return entry === CHANNEL_ID || entry === "group:plugins";
192
222
  }
@@ -309,6 +339,66 @@ function readChannelSection(cfg: OpenClawConfig): Record<string, unknown> {
309
339
  return channel && typeof channel === "object" ? (channel as Record<string, unknown>) : {};
310
340
  }
311
341
 
342
+ /** Keys that identify a ClawChat account. Never inherited by a named account from the root. */
343
+ export const ACCOUNT_CREDENTIAL_KEYS = ["token", "refreshToken", "agentId", "userId", "ownerUserId"] as const;
344
+ const ACCOUNT_MAP_KEYS = ["accounts", "defaultAccount"] as const;
345
+ const NESTED_ACCOUNT_OBJECT_KEYS = ["reconnect", "heartbeat", "ack"] as const;
346
+
347
+ /** Host-identical account id normalization (lowercase, `[^a-z0-9_-]` → `-`, blank → `default`). */
348
+ export function normalizeOpenclawClawlingAccountId(value?: string | null): string {
349
+ return normalizeAccountId(value);
350
+ }
351
+
352
+ function readAccountsMap(section: Record<string, unknown>): Record<string, Record<string, unknown>> | undefined {
353
+ const accounts = section.accounts;
354
+ return accounts && typeof accounts === "object" && !Array.isArray(accounts)
355
+ ? (accounts as Record<string, Record<string, unknown>>)
356
+ : undefined;
357
+ }
358
+
359
+ /**
360
+ * The single config read point. Root = the `default` account and the
361
+ * inheritance base; `accounts.<id>` overrides it. Credential keys are omitted
362
+ * from the base for named ids so an empty `accounts.work: {}` is unconfigured
363
+ * rather than a clone of the default identity. `accounts.default`, when the
364
+ * host's single-account promotion created one, merges over the root.
365
+ */
366
+ export function readOpenclawClawlingAccountSection(
367
+ cfg: OpenClawConfig,
368
+ accountId?: string | null,
369
+ ): Record<string, unknown> {
370
+ const root = readChannelSection(cfg);
371
+ const id = normalizeOpenclawClawlingAccountId(accountId);
372
+ const resolved = resolveNormalizedAccountEntry(readAccountsMap(root), id, normalizeAccountId);
373
+ // The schema says every entry is an object; the file on disk may disagree
374
+ // (a hand-edited `accounts.work: "work"`). Spreading a non-record merges it
375
+ // in character by character, so treat it as absent.
376
+ const entry =
377
+ resolved && typeof resolved === "object" && !Array.isArray(resolved) ? resolved : undefined;
378
+ const omitKeys: string[] = [...ACCOUNT_MAP_KEYS];
379
+ if (id !== DEFAULT_ACCOUNT_ID) omitKeys.push(...ACCOUNT_CREDENTIAL_KEYS);
380
+ return mergeAccountConfig<Record<string, unknown>>({
381
+ channelConfig: root,
382
+ accountConfig: entry ?? {},
383
+ omitKeys,
384
+ nestedObjectKeys: [...NESTED_ACCOUNT_OBJECT_KEYS],
385
+ });
386
+ }
387
+
388
+ const accountListHelpers = createAccountListHelpers(CHANNEL_ID, {
389
+ normalizeAccountId,
390
+ // The root section is always an account, even when unconfigured.
391
+ hasImplicitDefaultAccount: () => true,
392
+ });
393
+
394
+ export function listOpenclawClawlingAccountIds(cfg: OpenClawConfig): string[] {
395
+ return accountListHelpers.listAccountIds(cfg);
396
+ }
397
+
398
+ export function resolveOpenclawClawlingDefaultAccountId(cfg: OpenClawConfig): string {
399
+ return accountListHelpers.resolveDefaultAccountId(cfg);
400
+ }
401
+
312
402
  function readOptionalString(value: unknown): string {
313
403
  return typeof value === "string" ? value.trim() : "";
314
404
  }
@@ -501,9 +591,19 @@ function readAck(raw: unknown): Required<OpenclawClawlingAckConfig> {
501
591
 
502
592
  export function resolveOpenclawClawlingAccount(
503
593
  cfg: OpenClawConfig,
594
+ accountId?: string | null,
504
595
  env: Record<string, string | undefined> = process.env,
505
596
  ): ResolvedOpenclawClawlingAccount {
506
- const channel = readChannelSection(cfg);
597
+ const resolvedAccountId =
598
+ accountId === undefined || accountId === null || !accountId.trim()
599
+ ? resolveOpenclawClawlingDefaultAccountId(cfg)
600
+ : normalizeOpenclawClawlingAccountId(accountId);
601
+ const isDefault = resolvedAccountId === DEFAULT_ACCOUNT_ID;
602
+ const root = readChannelSection(cfg);
603
+ const channel = readOpenclawClawlingAccountSection(cfg, resolvedAccountId);
604
+ // Credential env vars are process-wide and can only ever describe ONE
605
+ // identity — they belong to the default account. Endpoint env vars are shared.
606
+ const credentialEnv: Record<string, string | undefined> = isDefault ? env : {};
507
607
  // Apply built-in defaults so login/gateway work without prior setup.
508
608
  const websocketUrl =
509
609
  readOptionalString(channel.websocketUrl) ||
@@ -515,16 +615,17 @@ export function resolveOpenclawClawlingAccount(
515
615
  DEFAULT_BASE_URL;
516
616
  const mediaBaseUrl =
517
617
  readOptionalString(channel.mediaBaseUrl) || readEnvString(env, CLAWCHAT_MEDIA_BASE_URL_ENV);
518
- const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
519
- const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
618
+ const token = readOptionalString(channel.token) || readEnvString(credentialEnv, CLAWCHAT_TOKEN_ENV);
619
+ const agentId =
620
+ readOptionalString(channel.agentId) || readEnvString(credentialEnv, CLAWCHAT_AGENT_ID_ENV);
520
621
  const userId = resolveUserId(
521
622
  token,
522
- readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV),
623
+ readOptionalString(channel.userId) || readEnvString(credentialEnv, CLAWCHAT_USER_ID_ENV),
523
624
  );
524
625
  const ownerUserId =
525
626
  rejectPlaceholderOwnerUserId(readOptionalString(channel.ownerUserId), "channel ownerUserId") ||
526
627
  rejectPlaceholderOwnerUserId(
527
- readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV),
628
+ readEnvString(credentialEnv, CLAWCHAT_OWNER_USER_ID_ENV),
528
629
  CLAWCHAT_OWNER_USER_ID_ENV,
529
630
  ) ||
530
631
  // Fall back to the access token's `oid` (owner) claim. A provisioner may
@@ -532,7 +633,9 @@ export function resolveOpenclawClawlingAccount(
532
633
  // (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
533
634
  // in the wait-for-activation loop. The owner is always carried in the token.
534
635
  decodeJwtStringClaim(token, "oid");
535
- const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
636
+ const rootEnabled = typeof root.enabled === "boolean" ? root.enabled : true;
637
+ const sectionEnabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
638
+ const enabled = rootEnabled && sectionEnabled;
536
639
  const outputVisibility = readOutputVisibility(channel.outputVisibility);
537
640
  const chats = readChats(channel.chats);
538
641
  const groupMode = readGroupMode(channel.groupMode);
@@ -550,8 +653,8 @@ export function resolveOpenclawClawlingAccount(
550
653
  typeof channel.livewareSample === "boolean" ? channel.livewareSample : true;
551
654
 
552
655
  return {
553
- accountId: DEFAULT_ACCOUNT_ID,
554
- name: CHANNEL_ID,
656
+ accountId: resolvedAccountId,
657
+ name: isDefault ? CHANNEL_ID : resolvedAccountId,
555
658
  enabled,
556
659
  configured: hasOpenclawClawlingConnectCredentials({
557
660
  websocketUrl,
@@ -582,7 +685,3 @@ export function resolveOpenclawClawlingAccount(
582
685
  ack: readAck(channel.ack),
583
686
  };
584
687
  }
585
-
586
- export function listOpenclawClawlingAccountIds(): string[] {
587
- return [DEFAULT_ACCOUNT_ID];
588
- }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
4
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
5
+ import { normalizeOpenclawClawlingAccountId } from "./config.ts";
4
6
 
5
7
  export const LIVEWARE_BASE_URL = "https://media.clawling.chat/liveware/";
6
8
 
@@ -21,7 +23,36 @@ export function livewareDir(stateDir?: string): string {
21
23
  }
22
24
 
23
25
  /**
24
- * Private HOME for the liveware CLI: `<stateDir>/clawchat/liveware-home`.
26
+ * Root for one account's liveware state: `<stateDir>/clawchat` for the
27
+ * `default` account, `<stateDir>/clawchat/accounts/<id>` for a named one.
28
+ *
29
+ * The `default` account keeps the pre-multi-account layout exactly, so an
30
+ * existing single-account install never has to migrate anything.
31
+ */
32
+ function livewareAccountRoot(stateDir: string | undefined, accountId?: string | null): string {
33
+ const root = stateDir ?? resolveStateDir();
34
+ const id = normalizeOpenclawClawlingAccountId(accountId);
35
+ return id === DEFAULT_ACCOUNT_ID
36
+ ? path.join(root, "clawchat")
37
+ : path.join(root, "clawchat", "accounts", id);
38
+ }
39
+
40
+ /**
41
+ * Root of a NAMED account's private liveware state
42
+ * (`<stateDir>/clawchat/accounts/<id>`), the directory account removal
43
+ * deletes wholesale. Returns null for `default`, whose root is the shared
44
+ * `<stateDir>/clawchat` and must never be removed this way.
45
+ */
46
+ export function livewareNamedAccountRootDir(stateDir: string, accountId?: string | null): string | null {
47
+ return normalizeOpenclawClawlingAccountId(accountId) === DEFAULT_ACCOUNT_ID
48
+ ? null
49
+ : livewareAccountRoot(stateDir, accountId);
50
+ }
51
+
52
+ /**
53
+ * Private HOME for the liveware CLI: `<stateDir>/clawchat/liveware-home` for
54
+ * the default account, `<stateDir>/clawchat/accounts/<id>/liveware-home` for a
55
+ * named one.
25
56
  *
26
57
  * The CLI keeps its login credentials in `$HOME/.clawling`, which is
27
58
  * host-global. Two OpenClaw instances on one box (`openclaw --profile <name>`,
@@ -32,13 +63,30 @@ export function livewareDir(stateDir?: string): string {
32
63
  * credential store per-instance, which is the same isolation the rest of the
33
64
  * plugin already gets for free.
34
65
  *
66
+ * Two ACCOUNTS in ONE instance have the same problem and no state dir to tell
67
+ * them apart, so a named account gets its own sub-root: otherwise `work`'s
68
+ * `liveware login` would overwrite `default`'s session in the shared home and
69
+ * `default`'s next `tunnel bind` / `app create` would run as `work`'s user.
70
+ *
35
71
  * Migration is automatic: `livewareLogin` runs on every bootstrap AND (best
36
72
  * effort) on every relaunch, so an existing install simply re-logs into its new
37
73
  * private home on the next start.
38
74
  */
39
- export function livewareCliHomeDir(stateDir?: string): string {
40
- const root = stateDir ?? resolveStateDir();
41
- return path.join(root, "clawchat", "liveware-home");
75
+ export function livewareCliHomeDir(stateDir?: string, accountId?: string | null): string {
76
+ return path.join(livewareAccountRoot(stateDir, accountId), "liveware-home");
77
+ }
78
+
79
+ /**
80
+ * Where the Liveware Sample app tree is installed for one account:
81
+ * `<stateDir>/clawchat/liveware-sample` (default) or
82
+ * `<stateDir>/clawchat/accounts/<id>/liveware-sample` (named).
83
+ *
84
+ * Per account for the same reason as {@link livewareCliHomeDir}: two
85
+ * supervisors in one process otherwise install, swap and delete inside the
86
+ * same `app/` tree concurrently.
87
+ */
88
+ export function livewareSampleRootDir(stateDir?: string, accountId?: string | null): string {
89
+ return path.join(livewareAccountRoot(stateDir, accountId), "liveware-sample");
42
90
  }
43
91
 
44
92
  /**
@@ -12,6 +12,8 @@ import path from "node:path";
12
12
  import { DEFAULT_SKILLS_REF, OFFICIAL_SKILLS_BASE, type FetchLike } from "./skill-update.ts";
13
13
  import type { LivewareSampleRow, LivewareSampleUpsert } from "./storage.ts";
14
14
  import { livewareCliEnv, type LivewareLogger } from "./liveware-cli.ts";
15
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
16
+ import { normalizeOpenclawClawlingAccountId } from "./config.ts";
15
17
 
16
18
  export const LIVEWARES_TARGET = "openclaw";
17
19
  export const LIVEWARE_SAMPLE_ID = "liveware-sample";
@@ -154,6 +156,16 @@ async function localInstallMatches(
154
156
  }
155
157
  }
156
158
 
159
+ /**
160
+ * Suffix for the `app.tmp-…` / `app.old-…` staging dirs. The pid keeps two
161
+ * PROCESSES apart; the account id keeps two supervisors inside ONE process
162
+ * apart, which share a pid and could otherwise share a staging tree.
163
+ */
164
+ function stagingSuffix(accountId?: string): string {
165
+ const id = normalizeOpenclawClawlingAccountId(accountId);
166
+ return id === DEFAULT_ACCOUNT_ID ? `${process.pid}` : `${process.pid}-${id}`;
167
+ }
168
+
157
169
  /**
158
170
  * Download + verify + atomically install the sample under `<sampleRoot>/app`.
159
171
  * Preserves USER_DATA_FILES from an existing install. Throws on any failure;
@@ -172,6 +184,9 @@ export async function downloadLivewareSample(opts: {
172
184
  sampleRoot: string;
173
185
  ref?: string;
174
186
  base?: string;
187
+ /** Disambiguates the staging/rollback dirs when two accounts run in one
188
+ * process (same pid). Omitted/`default` keeps the pid-only names. */
189
+ accountId?: string;
175
190
  }): Promise<{ version: string; appDir: string; reused?: boolean }> {
176
191
  const ref = opts.ref ?? DEFAULT_SKILLS_REF;
177
192
  const entry = parseLivewaresManifest(
@@ -191,7 +206,8 @@ export async function downloadLivewareSample(opts: {
191
206
  }
192
207
  }
193
208
 
194
- const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
209
+ const swapSuffix = stagingSuffix(opts.accountId);
210
+ const tmpDir = path.join(opts.sampleRoot, `app.tmp-${swapSuffix}`);
195
211
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
196
212
  await fs.promises.mkdir(tmpDir, { recursive: true });
197
213
  try {
@@ -218,7 +234,7 @@ export async function downloadLivewareSample(opts: {
218
234
  // handle, and doing it first leaves no app/ at all when it fails. Renaming
219
235
  // aside puts the new tree in place first and makes the delete best-effort:
220
236
  // a leftover app.old-<pid> is swept on the next run, never a broken install.
221
- const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
237
+ const oldDir = path.join(opts.sampleRoot, `app.old-${swapSuffix}`);
222
238
  await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
223
239
  if (fs.existsSync(appDir)) {
224
240
  await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
@@ -700,9 +716,10 @@ export type LivewareSampleSupervisorDeps = {
700
716
  ref?: string;
701
717
  spawnFn?: SpawnLike;
702
718
  execFileFn?: ExecFileLike;
703
- /** Private HOME for the liveware CLI (`livewareCliHomeDir(stateDir)`), so two
704
- * instances on one host do not share `$HOME/.clawling`. Absent → the CLI
705
- * inherits the ambient HOME, which is the pre-isolation behavior. */
719
+ /** Private HOME for the liveware CLI (`livewareCliHomeDir(stateDir, accountId)`),
720
+ * so two instances on one host — or two accounts in one gateway — do not
721
+ * share `$HOME/.clawling`. Absent → the CLI inherits the ambient HOME, which
722
+ * is the pre-isolation behavior. */
706
723
  cliHome?: string;
707
724
  probePortFn?: ProbePortLike;
708
725
  log?: LivewareLogger;
@@ -894,6 +911,7 @@ export class LivewareSampleSupervisor {
894
911
 
895
912
  const { version, appDir } = await downloadLivewareSample({
896
913
  fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
914
+ accountId: deps.accountId,
897
915
  });
898
916
  if (this.bailIfStopped()) return;
899
917
  const cliEnv = await this.ensureCliEnv();
@@ -977,6 +995,7 @@ export class LivewareSampleSupervisor {
977
995
  try {
978
996
  const dl = await downloadLivewareSample({
979
997
  fetchFn: deps.fetchFn, sampleRoot: deps.sampleRoot, ref: deps.ref,
998
+ accountId: deps.accountId,
980
999
  });
981
1000
  version = dl.version;
982
1001
  appDir = dl.appDir;
@@ -3,10 +3,14 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
3
3
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
4
4
  import { ClawlingApiError, type AgentConnectResult } from "./api-types.ts";
5
5
  import { resolvePluginVersion } from "./plugin-report.ts";
6
+ import { openclawClawlingAccountSlotPath, patchOpenclawClawlingAccountSection } from "./account-config-writes.ts";
7
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
6
8
  import {
7
9
  CHANNEL_ID,
8
10
  mergeOpenclawClawchatRuntimePluginActivation,
9
11
  mergeOpenclawClawchatToolAllow,
12
+ normalizeOpenclawClawlingAccountId,
13
+ readOpenclawClawlingAccountSection,
10
14
  resolveOpenclawClawlingAccount,
11
15
  } from "./config.ts";
12
16
  import { getClawChatStore, type ClawChatStore } from "./storage.ts";
@@ -80,8 +84,7 @@ export type ActivationIntent = "new-account" | "repair";
80
84
  /**
81
85
  * Thrown when redeeming an invite code would silently replace a live activation.
82
86
  *
83
- * This channel holds exactly one ClawChat identity — `channels.<id>` is a single
84
- * slot and `listOpenclawClawlingAccountIds()` returns a single account. Because
87
+ * Each account slot holds exactly one ClawChat identity. Because
85
88
  * the stored `user_id` is replayed as a re-pair hint, redeeming a *new* invite
86
89
  * code here cannot produce a second agent: the server binds the code to the
87
90
  * existing agent, consumes it, creates no second agent and no second contact
@@ -103,9 +106,9 @@ export class ExistingActivationError extends Error {
103
106
  " - RESTORE the identity above (re-pairs that same agent; if it was " +
104
107
  "deleted this brings it back with its history): " +
105
108
  "/clawchat-activate <CODE> --repair\n" +
106
- " - To run a SECOND agent alongside this one instead, give it its own " +
107
- "OpenClaw home:\n" +
108
- " OPENCLAW_HOME=<path> openclaw channels login --channel clawchat-plugin-openclaw",
109
+ " - To run a SECOND agent alongside this one instead, activate it as a " +
110
+ "named account: /clawchat-activate <CODE> --account <name> (or: openclaw " +
111
+ "channels add --channel clawchat-plugin-openclaw --account <name> --token <CODE>)",
109
112
  );
110
113
  this.name = "ExistingActivationError";
111
114
  this.userId = userId;
@@ -177,39 +180,38 @@ async function promptActivationIntentFromStdin(
177
180
  }
178
181
  }
179
182
 
180
- function buildLoginConfig(cfg: OpenClawConfig, result: AgentConnectResult): OpenClawConfig {
181
- const channels = (cfg.channels ?? {}) as Record<string, unknown>;
182
- const existing = (channels[CHANNEL_ID] ?? {}) as Record<string, unknown>;
183
+ function buildLoginConfig(
184
+ cfg: OpenClawConfig,
185
+ accountId: string,
186
+ result: AgentConnectResult,
187
+ ): OpenClawConfig {
188
+ const existing = readOpenclawClawlingAccountSection(cfg, accountId);
183
189
  const groupMode = existing.groupMode === "mention" || existing.groupMode === "all"
184
190
  ? existing.groupMode
185
191
  : "all";
186
192
  const groupCommandMode = existing.groupCommandMode === "all" || existing.groupCommandMode === "off"
187
193
  ? existing.groupCommandMode
188
194
  : "owner";
189
- const nextSection: Record<string, unknown> = {
190
- ...existing,
195
+ const patch: Record<string, unknown> = {
191
196
  enabled: true,
192
- groupMode,
193
- groupCommandMode,
197
+ // Group policy defaults are root-level shared keys; named accounts inherit
198
+ // them and only carry credentials.
199
+ ...(accountId === DEFAULT_ACCOUNT_ID ? { groupMode, groupCommandMode } : {}),
194
200
  token: result.access_token,
201
+ // `undefined` deletes the key so a login without a refresh token never
202
+ // leaves a stale one behind.
203
+ refreshToken: result.refresh_token ? result.refresh_token : undefined,
195
204
  ...(result.agent.id ? { agentId: result.agent.id } : {}),
196
205
  userId: result.agent.user_id,
197
206
  ownerUserId: result.agent.owner_id,
198
207
  };
199
- if (result.refresh_token) {
200
- nextSection.refreshToken = result.refresh_token;
201
- } else {
202
- delete nextSection.refreshToken;
203
- }
204
208
  return mergeOpenclawClawchatRuntimePluginActivation(
205
- mergeOpenclawClawchatToolAllow({
206
- ...cfg,
207
- channels: { ...channels, [CHANNEL_ID]: nextSection },
208
- }),
209
+ mergeOpenclawClawchatToolAllow(patchOpenclawClawlingAccountSection(cfg, accountId, patch)),
209
210
  );
210
211
  }
211
212
 
212
213
  async function persistLoginConfig(
214
+ accountId: string,
213
215
  params: LoginParams,
214
216
  result: AgentConnectResult,
215
217
  ): Promise<void> {
@@ -223,7 +225,7 @@ async function persistLoginConfig(
223
225
  reason: "clawchat-plugin-openclaw credentials changed",
224
226
  },
225
227
  mutate(draft) {
226
- Object.assign(draft, buildLoginConfig(draft, result));
228
+ Object.assign(draft, buildLoginConfig(draft, accountId, result));
227
229
  },
228
230
  });
229
231
  params.runtime.log(
@@ -236,7 +238,7 @@ async function persistLoginConfig(
236
238
  params.runtime.log(
237
239
  `Persisting ClawChat credentials and plugin activation for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`,
238
240
  );
239
- await params.persistConfig(buildLoginConfig(params.cfg, result));
241
+ await params.persistConfig(buildLoginConfig(params.cfg, accountId, result));
240
242
  params.runtime.log(
241
243
  `ClawChat credentials and plugin activation persisted for userId=${result.agent.user_id} ownerUserId=${result.agent.owner_id}.`,
242
244
  );
@@ -280,11 +282,12 @@ function readOptionalConnectString(value: unknown, fieldName: string): string |
280
282
  */
281
283
  export async function runOpenclawClawlingLogin(params: LoginParams): Promise<void> {
282
284
  const { cfg, runtime } = params;
285
+ const accountId = normalizeOpenclawClawlingAccountId(params.accountId);
283
286
 
284
287
  // `resolveOpenclawClawlingAccount` falls back to the built-in
285
288
  // `DEFAULT_BASE_URL` / `DEFAULT_WEBSOCKET_URL` when the operator has not
286
289
  // overridden them, so login works without a prior `openclaw channels setup --channel clawchat-plugin-openclaw`.
287
- const account = resolveOpenclawClawlingAccount(cfg);
290
+ const account = resolveOpenclawClawlingAccount(cfg, accountId);
288
291
 
289
292
  // Fork before the invite code is even read, so neither branch spends the code
290
293
  // before the outcome is settled. "Live" is decided by whether a usable token
@@ -405,12 +408,13 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
405
408
  : {}),
406
409
  };
407
410
 
411
+ const configPath = openclawClawlingAccountSlotPath(params.cfg, accountId);
408
412
  runtime.log(
409
- `Updating config: channels.${CHANNEL_ID}.token=[REDACTED] agentId=${normalizedResult.agent.id || "-"} userId=${normalizedResult.agent.user_id} ownerUserId=${normalizedResult.agent.owner_id}${
413
+ `Updating config: ${configPath}.token=[REDACTED] agentId=${normalizedResult.agent.id || "-"} userId=${normalizedResult.agent.user_id} ownerUserId=${normalizedResult.agent.owner_id}${
410
414
  normalizedResult.refresh_token ? " refreshToken=[REDACTED]" : ""
411
415
  } plugins.entries.${CHANNEL_ID}.enabled=true plugins.allow+=${CHANNEL_ID} …`,
412
416
  );
413
- await persistLoginConfig(params, normalizedResult);
417
+ await persistLoginConfig(accountId, params, normalizedResult);
414
418
  try {
415
419
  const store =
416
420
  params.store ??
package/src/outbound.ts CHANGED
@@ -899,8 +899,8 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
899
899
  textChunkLimit: 4000,
900
900
  ...createAttachedChannelResultAdapter({
901
901
  channel: CHANNEL_ID,
902
- sendText: async ({ cfg, to, text }) => {
903
- const account = resolveOpenclawClawlingAccount(cfg);
902
+ sendText: async ({ cfg, to, text, accountId }) => {
903
+ const account = resolveOpenclawClawlingAccount(cfg, accountId);
904
904
  const client =
905
905
  getOpenclawClawlingClient(account.accountId) ??
906
906
  (await waitForOpenclawClawlingClient(account.accountId));
@@ -936,8 +936,8 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
936
936
  messageId: result?.messageId ?? messageId,
937
937
  };
938
938
  },
939
- sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile }) => {
940
- const account = resolveOpenclawClawlingAccount(cfg);
939
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaAccess, mediaLocalRoots, mediaReadFile, accountId }) => {
940
+ const account = resolveOpenclawClawlingAccount(cfg, accountId);
941
941
  const client =
942
942
  getOpenclawClawlingClient(account.accountId) ??
943
943
  (await waitForOpenclawClawlingClient(account.accountId));