@clawling/clawchat-plugin-openclaw 2026.7.2-1 → 2026.7.6-1

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.
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ // Worded to make the greeting the single required action. Earlier wording
5
+ // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
6
+ // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
7
+ // no greeting reached the user. Lead with the chat reply, forbid file/tool
8
+ // detours, and keep the profile update strictly optional and secondary.
9
+ const ACTIVATION_BOOTSTRAP_FALLBACK = [
10
+ "You are now connected to a ClawChat direct conversation with your user.",
11
+ "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
12
+ "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
13
+ "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
14
+ "Do not ask the user for profile information.",
15
+ ].join("\n");
16
+ // Cross-plugin, user-editable override read lazily so edits apply on the next
17
+ // first-load without a restart. Any read failure falls back to the built-in text.
18
+ export function buildActivationBootstrapText(homeDir = os.homedir()) {
19
+ const greetingPath = path.join(homeDir, "clawchat", "greeting.md");
20
+ try {
21
+ const raw = fs.readFileSync(greetingPath);
22
+ const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
23
+ if (override.length > 0) {
24
+ return override;
25
+ }
26
+ }
27
+ catch (error) {
28
+ const code = error.code;
29
+ if (code !== "ENOENT") {
30
+ console.warn(`clawchat.greeting failed to read override ${greetingPath}:`, error);
31
+ }
32
+ }
33
+ return ACTIVATION_BOOTSTRAP_FALLBACK;
34
+ }
@@ -5,6 +5,7 @@ export function buildPluginReportBody(input) {
5
5
  device_id: input.deviceId,
6
6
  platform: input.platform,
7
7
  plugin_version: input.pluginVersion,
8
+ agent_version: input.agentVersion,
8
9
  runtime_name: input.runtimeName,
9
10
  runtime_version: input.runtimeVersion,
10
11
  };
@@ -207,6 +207,28 @@ function decodeJwtStringClaim(token, claim) {
207
207
  return "";
208
208
  }
209
209
  }
210
+ /**
211
+ * Resolve the bot-account userId, validated against the token's `sub` claim.
212
+ *
213
+ * The WS session authenticates with the access token, so the backend binds the
214
+ * session to the token's `sub` — that IS the bot's user id for this session.
215
+ * Unlike ownerUserId (explicit config wins over the `oid` claim), a configured
216
+ * userId that disagrees with `sub` is always wrong for the session this token
217
+ * creates, and it silently breaks self-echo detection: with the OWNER's id
218
+ * here, every owner message is dropped as the bot's own echo (2026-07-02
219
+ * production incident — a provisioner wrote the owner id into the config). Token
220
+ * wins; the mismatch is logged. Also derives userId from `sub` when nothing is
221
+ * configured, mirroring the `oid` fallback above.
222
+ */
223
+ function resolveUserId(token, configured) {
224
+ const tokenSub = decodeJwtStringClaim(token, "sub");
225
+ if (!tokenSub)
226
+ return configured;
227
+ if (configured && configured !== tokenSub) {
228
+ console.warn(`clawchat userId mismatch: configured ${configured} does not equal the access token's sub claim ${tokenSub}; using the token value. A misconfigured userId (e.g. the owner's id) breaks self-echo detection and mention gating — fix the channel userId / CLAWCHAT_USER_ID.`);
229
+ }
230
+ return tokenSub;
231
+ }
210
232
  function readGroupMode(value) {
211
233
  return value === "mention" ? "mention" : "all";
212
234
  }
@@ -312,7 +334,7 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
312
334
  const mediaBaseUrl = readOptionalString(channel.mediaBaseUrl) || readEnvString(env, CLAWCHAT_MEDIA_BASE_URL_ENV);
313
335
  const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
314
336
  const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
315
- const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
337
+ const userId = resolveUserId(token, readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV));
316
338
  const ownerUserId = readOptionalString(channel.ownerUserId) ||
317
339
  readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
318
340
  // Fall back to the access token's `oid` (owner) claim. A provisioner may
@@ -53,6 +53,7 @@ export async function reportPluginVersionSafe(p) {
53
53
  deviceId: p.deviceId,
54
54
  platform: "openclaw",
55
55
  pluginVersion: p.pluginVersion,
56
+ agentVersion: p.agentVersion,
56
57
  runtimeName: "node",
57
58
  runtimeVersion: process.version,
58
59
  }, { authenticated: p.authenticated });
@@ -4,6 +4,7 @@ import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
4
4
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
5
5
  import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.js";
6
6
  import { createOpenclawClawlingApiClient } from "./api-client.js";
7
+ import { buildActivationBootstrapText } from "./activation-greeting.js";
7
8
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
8
9
  import { ensureLivewareCli } from "./liveware-cli.js";
9
10
  import { ClawlingApiError } from "./api-types.js";
@@ -309,20 +310,6 @@ function withClawChatSessionScope(cfg) {
309
310
  },
310
311
  };
311
312
  }
312
- function buildActivationBootstrapText() {
313
- // Worded to make the greeting the single required action. Earlier wording
314
- // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
315
- // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
316
- // no greeting reached the user. Lead with the chat reply, forbid file/tool
317
- // detours, and keep the profile update strictly optional and secondary.
318
- return [
319
- "You are now connected to a ClawChat direct conversation with your user.",
320
- "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
321
- "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
322
- "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
323
- "Do not ask the user for profile information.",
324
- ].join("\n");
325
- }
326
313
  function buildActivationBootstrapEnvelope(params) {
327
314
  const text = buildActivationBootstrapText();
328
315
  const now = Date.now();
@@ -574,6 +561,26 @@ function waitForActivationPoll(abortSignal, ms) {
574
561
  async function waitForActivationCredentials(params) {
575
562
  const { abortSignal, getStatus, setStatus, store, log } = params;
576
563
  const accountId = params.account.accountId;
564
+ // SQLite-first: activation and token rotation persist to the activations
565
+ // row, so a complete row is the durable credential source of truth even
566
+ // when channel config/env also carries credentials. Config/env credentials
567
+ // only bootstrap the no-row case (the caller seeds a row from them).
568
+ if (store?.getActivationCredentials) {
569
+ const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
570
+ if (stored && stored.accessToken !== params.rejectedActivationToken) {
571
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
572
+ return {
573
+ source: "sqlite",
574
+ account: {
575
+ ...params.account,
576
+ configured: true,
577
+ token: stored.accessToken,
578
+ userId: stored.userId,
579
+ ownerUserId: stored.ownerUserId,
580
+ },
581
+ };
582
+ }
583
+ }
577
584
  if (accountHasConnectCredentials(params.account)) {
578
585
  return { account: params.account, source: "configured" };
579
586
  }
@@ -647,12 +654,14 @@ export async function startOpenclawClawlingGateway(params) {
647
654
  // so the backend links both to the same row. Imported from ./client.ts.
648
655
  const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
649
656
  const pluginVersion = resolvePluginVersion();
657
+ const agentVersion = runtime.version ?? "";
650
658
  void reportPluginVersionSafe({
651
659
  baseUrl: account.baseUrl,
652
660
  mediaBaseUrl: account.mediaBaseUrl,
653
661
  token: "",
654
662
  deviceId: reportDeviceId,
655
663
  pluginVersion,
664
+ agentVersion,
656
665
  authenticated: false,
657
666
  log,
658
667
  });
@@ -693,6 +702,26 @@ export async function startOpenclawClawlingGateway(params) {
693
702
  if (!activationAccount)
694
703
  return;
695
704
  account = activationAccount.account;
705
+ // Config/env bootstrap seeding: converge on SQLite as the single durable
706
+ // credential store, so token rotation always has a row to update and the
707
+ // next startup resolves SQLite-first to the same credentials.
708
+ if (activationAccount.source === "configured" && store?.upsertActivation) {
709
+ try {
710
+ store.upsertActivation({
711
+ platform: "openclaw",
712
+ accountId,
713
+ userId: account.userId,
714
+ ownerUserId: account.ownerUserId,
715
+ accessToken: account.token,
716
+ refreshToken: readConfigRefreshToken(cfg),
717
+ loginMethod: "config-seed",
718
+ deviceId: CHANNEL_ID,
719
+ });
720
+ }
721
+ catch (error) {
722
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw sqlite credential seed failed; continuing with config credentials: ${error instanceof Error ? error.message : String(error)}`);
723
+ }
724
+ }
696
725
  // Paired: link the report row via the authenticated endpoint, reusing the
697
726
  // SAME frozen device_id so the backend upserts the existing unpaired row.
698
727
  void reportPluginVersionSafe({
@@ -701,12 +730,15 @@ export async function startOpenclawClawlingGateway(params) {
701
730
  token: account.token,
702
731
  deviceId: reportDeviceId,
703
732
  pluginVersion,
733
+ agentVersion,
704
734
  authenticated: true,
705
735
  log,
706
736
  });
707
- // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null when
708
- // the credentials came from config (no activation row yet) — in that case the
709
- // refresh manager relies on the JWT `exp` alone.
737
+ // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
738
+ // configured start even though the seed block above may have just created a
739
+ // row — this cell only ever queries `store.getActivationCredentials` when
740
+ // `source === "sqlite"`, so a config-sourced account relies on the JWT `exp`
741
+ // alone until its next SQLite-first resolution.
710
742
  let activatedAtMs = activationAccount.source === "sqlite" && store?.getActivationCredentials
711
743
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.activatedAt ?? null
712
744
  : null;
@@ -872,16 +904,16 @@ export async function startOpenclawClawlingGateway(params) {
872
904
  getAccessToken: () => account.token,
873
905
  getRefreshToken: () => latestRefreshToken,
874
906
  persistRotatedTokens: async (tokens) => {
875
- // §0 — persist to BOTH stores BEFORE the in-memory swap. A failure in
876
- // EITHER store must REJECT so the manager skips the in-memory swap and
877
- // treats the refresh as transient (keep the current tokens, back off). Do
878
- // NOT swallow the SQLite write error: `rotateActivationTokens` returns
879
- // `null` when its internal `write()` caught an exception (a real write
880
- // failure), `false` only when no activation row exists yet (config-sourced
881
- // agent — legitimately nothing to update). A swallowed write failure must
882
- // not leave the SQLite row holding the now-dead refresh token while the
883
- // in-memory token is rotated, which would brick a sqlite-sourced agent on
884
- // restart.
907
+ // §0 — persist durably BEFORE the in-memory swap. SQLite is the
908
+ // startup credential source of truth: when a store is available its
909
+ // write must succeed or the refresh stays transient (no swap, keep
910
+ // current tokens, back off). A 0-row UPDATE (`false`) means the
911
+ // activations row is missing (e.g. cleared out-of-band) rather than a
912
+ // write failure — self-heal by re-seeding it from the in-memory
913
+ // identity with the ROTATED pair (mirrors Hermes's seed-on-rotate
914
+ // §C.2 semantics), then continue exactly as on success. `null` is a
915
+ // caught write exception and stays transient — that's a real failure,
916
+ // not a missing row.
885
917
  if (store?.rotateActivationTokens) {
886
918
  const rotateResult = store.rotateActivationTokens({
887
919
  platform: "openclaw",
@@ -889,12 +921,42 @@ export async function startOpenclawClawlingGateway(params) {
889
921
  accessToken: tokens.accessToken,
890
922
  refreshToken: tokens.refreshToken,
891
923
  });
892
- if (rotateResult === null) {
924
+ if (rotateResult === false) {
925
+ if (!store.upsertActivation) {
926
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
927
+ }
928
+ try {
929
+ store.upsertActivation({
930
+ platform: "openclaw",
931
+ accountId,
932
+ userId: account.userId,
933
+ ownerUserId: account.ownerUserId,
934
+ accessToken: tokens.accessToken,
935
+ refreshToken: tokens.refreshToken,
936
+ loginMethod: "config-seed",
937
+ deviceId: refreshDeviceId,
938
+ });
939
+ }
940
+ catch {
941
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
942
+ }
943
+ }
944
+ else if (rotateResult === null) {
893
945
  throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
894
946
  }
947
+ // Channel config is a human-readable mirror once SQLite holds the
948
+ // pair — sync best-effort, never fail the refresh over it.
949
+ try {
950
+ await persistConfigTokens(tokens);
951
+ }
952
+ catch (error) {
953
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw config token mirror failed (sqlite already rotated): ${error instanceof Error ? error.message : String(error)}`);
954
+ }
955
+ return;
895
956
  }
896
- // A config write failure rejects out of `mutateConfigFile` and propagates
897
- // here, which is what we want — persistence incomplete ⇒ no swap.
957
+ // No SQLite store available — channel config is the only durable
958
+ // store, so its write failure must reject and keep the refresh
959
+ // transient (pre-existing behavior for store-less test transports).
898
960
  await persistConfigTokens(tokens);
899
961
  },
900
962
  swapInMemoryTokens: (tokens) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.7.2-1",
3
+ "version": "2026.7.6-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -0,0 +1,35 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ // Worded to make the greeting the single required action. Earlier wording
6
+ // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
7
+ // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
8
+ // no greeting reached the user. Lead with the chat reply, forbid file/tool
9
+ // detours, and keep the profile update strictly optional and secondary.
10
+ const ACTIVATION_BOOTSTRAP_FALLBACK = [
11
+ "You are now connected to a ClawChat direct conversation with your user.",
12
+ "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
13
+ "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
14
+ "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
15
+ "Do not ask the user for profile information.",
16
+ ].join("\n");
17
+
18
+ // Cross-plugin, user-editable override read lazily so edits apply on the next
19
+ // first-load without a restart. Any read failure falls back to the built-in text.
20
+ export function buildActivationBootstrapText(homeDir: string = os.homedir()): string {
21
+ const greetingPath = path.join(homeDir, "clawchat", "greeting.md");
22
+ try {
23
+ const raw = fs.readFileSync(greetingPath);
24
+ const override = new TextDecoder("utf-8", { fatal: true }).decode(raw).trim();
25
+ if (override.length > 0) {
26
+ return override;
27
+ }
28
+ } catch (error) {
29
+ const code = (error as NodeJS.ErrnoException).code;
30
+ if (code !== "ENOENT") {
31
+ console.warn(`clawchat.greeting failed to read override ${greetingPath}:`, error);
32
+ }
33
+ }
34
+ return ACTIVATION_BOOTSTRAP_FALLBACK;
35
+ }
package/src/api-client.ts CHANGED
@@ -24,6 +24,7 @@ export interface PluginReportInput {
24
24
  deviceId: string;
25
25
  platform: string;
26
26
  pluginVersion: string;
27
+ agentVersion: string;
27
28
  runtimeName: string;
28
29
  runtimeVersion: string;
29
30
  }
@@ -33,6 +34,7 @@ export function buildPluginReportBody(input: PluginReportInput): Record<string,
33
34
  device_id: input.deviceId,
34
35
  platform: input.platform,
35
36
  plugin_version: input.pluginVersion,
37
+ agent_version: input.agentVersion,
36
38
  runtime_name: input.runtimeName,
37
39
  runtime_version: input.runtimeVersion,
38
40
  };
package/src/config.ts CHANGED
@@ -332,6 +332,30 @@ function decodeJwtStringClaim(token: string, claim: string): string {
332
332
  }
333
333
  }
334
334
 
335
+ /**
336
+ * Resolve the bot-account userId, validated against the token's `sub` claim.
337
+ *
338
+ * The WS session authenticates with the access token, so the backend binds the
339
+ * session to the token's `sub` — that IS the bot's user id for this session.
340
+ * Unlike ownerUserId (explicit config wins over the `oid` claim), a configured
341
+ * userId that disagrees with `sub` is always wrong for the session this token
342
+ * creates, and it silently breaks self-echo detection: with the OWNER's id
343
+ * here, every owner message is dropped as the bot's own echo (2026-07-02
344
+ * production incident — a provisioner wrote the owner id into the config). Token
345
+ * wins; the mismatch is logged. Also derives userId from `sub` when nothing is
346
+ * configured, mirroring the `oid` fallback above.
347
+ */
348
+ function resolveUserId(token: string, configured: string): string {
349
+ const tokenSub = decodeJwtStringClaim(token, "sub");
350
+ if (!tokenSub) return configured;
351
+ if (configured && configured !== tokenSub) {
352
+ console.warn(
353
+ `clawchat userId mismatch: configured ${configured} does not equal the access token's sub claim ${tokenSub}; using the token value. A misconfigured userId (e.g. the owner's id) breaks self-echo detection and mention gating — fix the channel userId / CLAWCHAT_USER_ID.`,
354
+ );
355
+ }
356
+ return tokenSub;
357
+ }
358
+
335
359
  function readGroupMode(value: unknown): GroupMode {
336
360
  return value === "mention" ? "mention" : "all";
337
361
  }
@@ -465,7 +489,10 @@ export function resolveOpenclawClawlingAccount(
465
489
  readOptionalString(channel.mediaBaseUrl) || readEnvString(env, CLAWCHAT_MEDIA_BASE_URL_ENV);
466
490
  const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
467
491
  const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
468
- const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
492
+ const userId = resolveUserId(
493
+ token,
494
+ readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV),
495
+ );
469
496
  const ownerUserId =
470
497
  readOptionalString(channel.ownerUserId) ||
471
498
  readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
@@ -46,6 +46,7 @@ export interface ReportParams {
46
46
  token: string;
47
47
  deviceId: string;
48
48
  pluginVersion: string;
49
+ agentVersion: string;
49
50
  authenticated: boolean;
50
51
  log?: { debug?: (msg: string) => void };
51
52
  }
@@ -66,6 +67,7 @@ export async function reportPluginVersionSafe(p: ReportParams): Promise<void> {
66
67
  deviceId: p.deviceId,
67
68
  platform: "openclaw",
68
69
  pluginVersion: p.pluginVersion,
70
+ agentVersion: p.agentVersion,
69
71
  runtimeName: "node",
70
72
  runtimeVersion: process.version,
71
73
  },
package/src/runtime.ts CHANGED
@@ -15,6 +15,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
15
15
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
16
16
  import { createOpenclawClawlingClient, resolveOpenclawClawlingDeviceId } from "./client.ts";
17
17
  import { createOpenclawClawlingApiClient } from "./api-client.ts";
18
+ import { buildActivationBootstrapText } from "./activation-greeting.ts";
18
19
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
19
20
  import { ensureLivewareCli } from "./liveware-cli.ts";
20
21
  import { ClawlingApiError } from "./api-types.ts";
@@ -101,6 +102,7 @@ type RuntimeConnectionStore = Pick<
101
102
  Pick<
102
103
  ClawChatStore,
103
104
  | "getActivationCredentials"
105
+ | "upsertActivation"
104
106
  | "rotateActivationTokens"
105
107
  | "clearActivationCredentials"
106
108
  | "insertMessage"
@@ -456,21 +458,6 @@ function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
456
458
  };
457
459
  }
458
460
 
459
- function buildActivationBootstrapText(): string {
460
- // Worded to make the greeting the single required action. Earlier wording
461
- // ("ClawChat activation bootstrap: ... do both ...") led some agents to treat
462
- // it as a setup task and write a `BOOTSTRAP.md` file instead of replying, so
463
- // no greeting reached the user. Lead with the chat reply, forbid file/tool
464
- // detours, and keep the profile update strictly optional and secondary.
465
- return [
466
- "You are now connected to a ClawChat direct conversation with your user.",
467
- "Reply now with one short, friendly greeting message in this conversation: introduce yourself and say you are connected and ready.",
468
- "Send it as a normal chat reply. Do not write or create any files or notes, and do not call tools just to greet.",
469
- "Only if you already have your own profile details (display name, bio, or avatar) may you also call `clawchat_update_account_profile` (use `clawchat_upload_avatar_image` first for a local avatar image); otherwise skip that and just greet.",
470
- "Do not ask the user for profile information.",
471
- ].join("\n");
472
- }
473
-
474
461
  function buildActivationBootstrapEnvelope(params: {
475
462
  account: ResolvedOpenclawClawlingAccount;
476
463
  conversationId: string;
@@ -872,6 +859,28 @@ async function waitForActivationCredentials(params: {
872
859
  }): Promise<{ account: ResolvedOpenclawClawlingAccount; source: "configured" | "sqlite" } | null> {
873
860
  const { abortSignal, getStatus, setStatus, store, log } = params;
874
861
  const accountId = params.account.accountId;
862
+ // SQLite-first: activation and token rotation persist to the activations
863
+ // row, so a complete row is the durable credential source of truth even
864
+ // when channel config/env also carries credentials. Config/env credentials
865
+ // only bootstrap the no-row case (the caller seeds a row from them).
866
+ if (store?.getActivationCredentials) {
867
+ const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
868
+ if (stored && stored.accessToken !== params.rejectedActivationToken) {
869
+ log?.info?.(
870
+ `[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
871
+ );
872
+ return {
873
+ source: "sqlite",
874
+ account: {
875
+ ...params.account,
876
+ configured: true,
877
+ token: stored.accessToken,
878
+ userId: stored.userId,
879
+ ownerUserId: stored.ownerUserId,
880
+ },
881
+ };
882
+ }
883
+ }
875
884
  if (accountHasConnectCredentials(params.account)) {
876
885
  return { account: params.account, source: "configured" };
877
886
  }
@@ -957,12 +966,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
957
966
  // so the backend links both to the same row. Imported from ./client.ts.
958
967
  const reportDeviceId = resolveOpenclawClawlingDeviceId(account);
959
968
  const pluginVersion = resolvePluginVersion();
969
+ const agentVersion = runtime.version ?? "";
960
970
  void reportPluginVersionSafe({
961
971
  baseUrl: account.baseUrl,
962
972
  mediaBaseUrl: account.mediaBaseUrl,
963
973
  token: "",
964
974
  deviceId: reportDeviceId,
965
975
  pluginVersion,
976
+ agentVersion,
966
977
  authenticated: false,
967
978
  log,
968
979
  });
@@ -1002,6 +1013,27 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1002
1013
  });
1003
1014
  if (!activationAccount) return;
1004
1015
  account = activationAccount.account;
1016
+ // Config/env bootstrap seeding: converge on SQLite as the single durable
1017
+ // credential store, so token rotation always has a row to update and the
1018
+ // next startup resolves SQLite-first to the same credentials.
1019
+ if (activationAccount.source === "configured" && store?.upsertActivation) {
1020
+ try {
1021
+ store.upsertActivation({
1022
+ platform: "openclaw",
1023
+ accountId,
1024
+ userId: account.userId,
1025
+ ownerUserId: account.ownerUserId,
1026
+ accessToken: account.token,
1027
+ refreshToken: readConfigRefreshToken(cfg),
1028
+ loginMethod: "config-seed",
1029
+ deviceId: CHANNEL_ID,
1030
+ });
1031
+ } catch (error) {
1032
+ log?.error?.(
1033
+ `[${accountId}] clawchat-plugin-openclaw sqlite credential seed failed; continuing with config credentials: ${error instanceof Error ? error.message : String(error)}`,
1034
+ );
1035
+ }
1036
+ }
1005
1037
  // Paired: link the report row via the authenticated endpoint, reusing the
1006
1038
  // SAME frozen device_id so the backend upserts the existing unpaired row.
1007
1039
  void reportPluginVersionSafe({
@@ -1010,12 +1042,15 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1010
1042
  token: account.token,
1011
1043
  deviceId: reportDeviceId,
1012
1044
  pluginVersion,
1045
+ agentVersion,
1013
1046
  authenticated: true,
1014
1047
  log,
1015
1048
  });
1016
- // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null when
1017
- // the credentials came from config (no activation row yet) — in that case the
1018
- // refresh manager relies on the JWT `exp` alone.
1049
+ // §A.0 — fallback expiry source. Prefer the SQLite `activated_at`; null for a
1050
+ // configured start even though the seed block above may have just created a
1051
+ // row — this cell only ever queries `store.getActivationCredentials` when
1052
+ // `source === "sqlite"`, so a config-sourced account relies on the JWT `exp`
1053
+ // alone until its next SQLite-first resolution.
1019
1054
  let activatedAtMs: number | null =
1020
1055
  activationAccount.source === "sqlite" && store?.getActivationCredentials
1021
1056
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.activatedAt ?? null
@@ -1208,16 +1243,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1208
1243
  getAccessToken: () => account.token,
1209
1244
  getRefreshToken: () => latestRefreshToken,
1210
1245
  persistRotatedTokens: async (tokens) => {
1211
- // §0 — persist to BOTH stores BEFORE the in-memory swap. A failure in
1212
- // EITHER store must REJECT so the manager skips the in-memory swap and
1213
- // treats the refresh as transient (keep the current tokens, back off). Do
1214
- // NOT swallow the SQLite write error: `rotateActivationTokens` returns
1215
- // `null` when its internal `write()` caught an exception (a real write
1216
- // failure), `false` only when no activation row exists yet (config-sourced
1217
- // agent — legitimately nothing to update). A swallowed write failure must
1218
- // not leave the SQLite row holding the now-dead refresh token while the
1219
- // in-memory token is rotated, which would brick a sqlite-sourced agent on
1220
- // restart.
1246
+ // §0 — persist durably BEFORE the in-memory swap. SQLite is the
1247
+ // startup credential source of truth: when a store is available its
1248
+ // write must succeed or the refresh stays transient (no swap, keep
1249
+ // current tokens, back off). A 0-row UPDATE (`false`) means the
1250
+ // activations row is missing (e.g. cleared out-of-band) rather than a
1251
+ // write failure — self-heal by re-seeding it from the in-memory
1252
+ // identity with the ROTATED pair (mirrors Hermes's seed-on-rotate
1253
+ // §C.2 semantics), then continue exactly as on success. `null` is a
1254
+ // caught write exception and stays transient — that's a real failure,
1255
+ // not a missing row.
1221
1256
  if (store?.rotateActivationTokens) {
1222
1257
  const rotateResult = store.rotateActivationTokens({
1223
1258
  platform: "openclaw",
@@ -1225,12 +1260,41 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1225
1260
  accessToken: tokens.accessToken,
1226
1261
  refreshToken: tokens.refreshToken,
1227
1262
  });
1228
- if (rotateResult === null) {
1263
+ if (rotateResult === false) {
1264
+ if (!store.upsertActivation) {
1265
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
1266
+ }
1267
+ try {
1268
+ store.upsertActivation({
1269
+ platform: "openclaw",
1270
+ accountId,
1271
+ userId: account.userId,
1272
+ ownerUserId: account.ownerUserId,
1273
+ accessToken: tokens.accessToken,
1274
+ refreshToken: tokens.refreshToken,
1275
+ loginMethod: "config-seed",
1276
+ deviceId: refreshDeviceId,
1277
+ });
1278
+ } catch {
1279
+ throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
1280
+ }
1281
+ } else if (rotateResult === null) {
1229
1282
  throw new Error("clawchat-plugin-openclaw sqlite rotate activation tokens failed");
1230
1283
  }
1284
+ // Channel config is a human-readable mirror once SQLite holds the
1285
+ // pair — sync best-effort, never fail the refresh over it.
1286
+ try {
1287
+ await persistConfigTokens(tokens);
1288
+ } catch (error) {
1289
+ log?.error?.(
1290
+ `[${accountId}] clawchat-plugin-openclaw config token mirror failed (sqlite already rotated): ${error instanceof Error ? error.message : String(error)}`,
1291
+ );
1292
+ }
1293
+ return;
1231
1294
  }
1232
- // A config write failure rejects out of `mutateConfigFile` and propagates
1233
- // here, which is what we want — persistence incomplete ⇒ no swap.
1295
+ // No SQLite store available — channel config is the only durable
1296
+ // store, so its write failure must reject and keep the refresh
1297
+ // transient (pre-existing behavior for store-less test transports).
1234
1298
  await persistConfigTokens(tokens);
1235
1299
  },
1236
1300
  swapInMemoryTokens: (tokens) => {