@clawling/clawchat-plugin-openclaw 2026.10.11-3 → 2026.10.13-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.
@@ -251,6 +251,7 @@ export function createOpenclawClawlingApiClient(opts) {
251
251
  ? env.message
252
252
  : "";
253
253
  if (code !== 0) {
254
+ // Business-code 401 is an API error, not HTTP auth expiry; do not refresh.
254
255
  throw new ClawlingApiError("api", msg || `code=${code}`, {
255
256
  code,
256
257
  status: res.status,
@@ -1,4 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { authRefresh, CODE_INVALID_REFRESH, decodeJwtExp, } from "./api-client.js";
3
+ /** Fingerprint only: revoked refresh credentials must never enter marker storage. */
4
+ export function fingerprintRefreshToken(token) {
5
+ return createHash("sha256").update(token.trim()).digest("hex");
6
+ }
2
7
  /**
3
8
  * §A–§D — ClawChat token refresh + auto-logout orchestration for the OpenClaw
4
9
  * plugin.
@@ -70,12 +75,9 @@ export class RefreshManager {
70
75
  inFlight = null;
71
76
  /** §A.3 — the access token a refresh was last attempted for. */
72
77
  rejectedToken = null;
73
- /**
74
- * §B race — the refresh token we last successfully rotated AWAY from (now
75
- * consumed server-side). A subsequent `code:10003` for this exact token is a
76
- * consumed-rotation race, NOT a revocation, so it must not auto-logout.
77
- */
78
+ /** Consumed-token history is diagnostic; a live successor is required to recover. */
78
79
  lastRotatedFromToken = null;
80
+ consecutiveRaceDowngrades = 0;
79
81
  /** §A.3 — epoch-ms of the last refresh attempt (any token). */
80
82
  lastAttemptAt = 0;
81
83
  proactiveTimer = null;
@@ -135,10 +137,12 @@ export class RefreshManager {
135
137
  result = await authRefresh({ baseUrl: this.ports.baseUrl, ...(this.ports.fetchImpl ? { fetchImpl: this.ports.fetchImpl } : {}) }, { refreshToken, deviceId: this.ports.deviceId });
136
138
  }
137
139
  catch (err) {
140
+ this.consecutiveRaceDowngrades = 0;
138
141
  const message = err instanceof Error ? err.message : String(err);
139
142
  return { kind: "transient", message };
140
143
  }
141
144
  if (result.kind === "success") {
145
+ this.consecutiveRaceDowngrades = 0;
142
146
  // §0 rotation hazard — persist FIRST, swap SECOND. If persistence REJECTS
143
147
  // (a store/config write failed), DO NOT swap the in-memory token: a
144
148
  // sqlite-sourced agent must not keep a now-dead refresh token in its row
@@ -163,8 +167,7 @@ export class RefreshManager {
163
167
  accessToken: result.accessToken,
164
168
  refreshToken: result.refreshToken,
165
169
  });
166
- // §B race — record the now-consumed refresh token so a later `10003` for it
167
- // is recognized as a rotation race rather than a revocation.
170
+ // Keep rotation history for inspection; it never overrides the live-token check.
168
171
  this.lastRotatedFromToken = refreshToken;
169
172
  // Clear the latch; the access token has actually changed.
170
173
  this.rejectedToken = null;
@@ -172,31 +175,27 @@ export class RefreshManager {
172
175
  return { kind: "success", accessToken: result.accessToken, refreshToken: result.refreshToken };
173
176
  }
174
177
  if (result.kind === "permanent") {
175
- // §B race — a `code:10003` is also returned for a refresh token already
176
- // CONSUMED by a prior successful rotation (once the backend grace window
177
- // no longer covers it). Before auto-logging-out
178
- // (which wipes credentials and bricks the agent), distinguish that race
179
- // from a genuine revocation. It is a race when EITHER the submitted token
180
- // is one we already rotated away from, OR the live store refresh token has
181
- // moved on since we read it (another worker rotated underneath us). In
182
- // either case the credential chain is still alive — report transient (no
183
- // latch, no logout) so the next attempt proceeds with the live token. A
184
- // `code:400` (client bug) is never a race.
178
+ // A consumed-token race is recoverable only with a real live successor.
179
+ // Bound repeated downgrades, including across gateway re-enters.
185
180
  if (result.code === CODE_INVALID_REFRESH) {
186
181
  const liveRefresh = this.ports.getRefreshToken();
187
- const rotatedByUs = this.lastRotatedFromToken !== null && refreshToken === this.lastRotatedFromToken;
188
- const rotatedUnderneath = !!liveRefresh && liveRefresh.trim().length > 0 && liveRefresh !== refreshToken;
189
- if (rotatedByUs || rotatedUnderneath) {
190
- this.ports.log?.info?.(`clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race (rotatedByUs=${rotatedByUs} rotatedUnderneath=${rotatedUnderneath}); recovering with live token`);
182
+ const hasSuccessor = !!liveRefresh?.trim() && liveRefresh !== refreshToken;
183
+ if (hasSuccessor && this.consecutiveRaceDowngrades < 2) {
184
+ this.consecutiveRaceDowngrades += 1;
185
+ this.ports.log?.info?.("clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race; recovering with live token");
191
186
  return { kind: "transient", message: "refresh token already rotated (race); retrying with current token" };
192
187
  }
193
188
  }
194
189
  // §A.3 — latch the dead token so reconnect storms don't re-fire refresh.
195
190
  this.rejectedToken = accessToken;
196
191
  this.ports.log?.error?.(`clawchat-plugin-openclaw refresh permanent failure code=${result.code}: ${result.message}`);
197
- await this.ports.onPermanentFailure({ code: result.code, message: result.message });
192
+ await this.ports.onPermanentFailure({
193
+ code: result.code,
194
+ message: result.message,
195
+ }, { fingerprint: fingerprintRefreshToken(refreshToken), revokedAt: this.now() });
198
196
  return { kind: "permanent", code: result.code, message: result.message };
199
197
  }
198
+ this.consecutiveRaceDowngrades = 0;
200
199
  // Transient — never auto-logout; the old refresh token is still valid.
201
200
  this.ports.log?.info?.(`clawchat-plugin-openclaw refresh transient failure: ${result.message}`);
202
201
  return { kind: "transient", message: result.message };
@@ -362,6 +361,7 @@ export class RefreshManager {
362
361
  rejectedToken: this.rejectedToken,
363
362
  lastAttemptAt: this.lastAttemptAt,
364
363
  lastRotatedFromToken: this.lastRotatedFromToken,
364
+ consecutiveRaceDowngrades: this.consecutiveRaceDowngrades,
365
365
  };
366
366
  }
367
367
  /** §A.3/§A.4 — restore guard state from a prior manager (see `exportState`). */
@@ -369,6 +369,7 @@ export class RefreshManager {
369
369
  this.rejectedToken = state.rejectedToken;
370
370
  this.lastAttemptAt = state.lastAttemptAt;
371
371
  this.lastRotatedFromToken = state.lastRotatedFromToken ?? null;
372
+ this.consecutiveRaceDowngrades = state.consecutiveRaceDowngrades ?? 0;
372
373
  }
373
374
  }
374
375
  function decodeJwtIat(token) {
@@ -14,7 +14,7 @@ import { readOnboardingReport } from "./onboarding-report.js";
14
14
  import { ensureLivewareCli, livewareCliHomeDir, livewareSampleRootDir, resolveLivewarePath, } from "./liveware-cli.js";
15
15
  import { LivewareSampleSupervisor, } from "./liveware-sample.js";
16
16
  import { ClawlingApiError } from "./api-types.js";
17
- import { RefreshManager } from "./refresh-manager.js";
17
+ import { RefreshManager, fingerprintRefreshToken } from "./refresh-manager.js";
18
18
  import { RECONNECT_GUIDE_URL } from "./onboarding-context.js";
19
19
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
20
20
  import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, normalizeOpenclawClawlingAccountId, readOpenclawClawlingAccountSection, resolveOpenclawClawlingAccount, CLAWCHAT_REFRESH_TOKEN_ENV, } from "./config.js";
@@ -157,19 +157,25 @@ export function classifyHelloFailReason(reason) {
157
157
  * carries no `token` of its own (i.e. the access token itself came from
158
158
  * `CLAWCHAT_TOKEN`) — an env-provisioned pair can rotate, while a login-written
159
159
  * token is never paired with a stray refresh token left in the environment.
160
+ * A revoked marker also excludes a matching refresh fingerprint from either source.
160
161
  */
161
- function readConfigRefreshToken(cfg, accountId, env = process.env) {
162
+ function readConfigRefreshToken(cfg, accountId, revokedMarker, env = process.env) {
162
163
  const section = readOpenclawClawlingAccountSection(cfg, accountId);
163
164
  const refreshToken = section.refreshToken;
165
+ const usable = (token) => {
166
+ const value = typeof token === "string" ? token.trim() : "";
167
+ return value && (!revokedMarker || fingerprintRefreshToken(value) !== revokedMarker.fingerprint)
168
+ ? value : null;
169
+ };
164
170
  if (typeof refreshToken === "string" && refreshToken.trim())
165
- return refreshToken.trim();
171
+ return usable(refreshToken);
166
172
  if (normalizeOpenclawClawlingAccountId(accountId) !== DEFAULT_ACCOUNT_ID)
167
173
  return null;
168
174
  const configToken = section.token;
169
175
  if (typeof configToken === "string" && configToken.trim())
170
176
  return null;
171
177
  const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
172
- return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
178
+ return usable(envRefreshToken);
173
179
  }
174
180
  function withFullVerboseDispatchConfig(cfg, agentId) {
175
181
  const cfgRecord = cfg;
@@ -657,13 +663,18 @@ function waitForActivationPoll(abortSignal, ms) {
657
663
  async function waitForActivationCredentials(params) {
658
664
  const { abortSignal, getStatus, setStatus, store, log } = params;
659
665
  const accountId = params.account.accountId;
666
+ const revoked = params.revokedRefreshMarker;
667
+ const usable = (credentials) => credentials.accessToken !== params.rejectedActivationToken &&
668
+ (!revoked || (!!credentials.refreshToken &&
669
+ fingerprintRefreshToken(credentials.refreshToken) !== revoked.fingerprint));
660
670
  // SQLite-first: activation and token rotation persist to the activations
661
671
  // row, so a complete row is the durable credential source of truth even
662
672
  // when channel config/env also carries credentials. Config/env credentials
663
- // only bootstrap the no-row case (the caller seeds a row from them).
673
+ // bootstrap the no-row case, or replace a revoked pair with a different
674
+ // refresh fingerprint (the caller seeds a row from the adopted pair).
664
675
  if (store?.getActivationCredentials) {
665
676
  const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
666
- if (stored && stored.accessToken !== params.rejectedActivationToken) {
677
+ if (stored && usable(stored)) {
667
678
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
668
679
  return {
669
680
  source: "sqlite",
@@ -677,10 +688,20 @@ async function waitForActivationCredentials(params) {
677
688
  };
678
689
  }
679
690
  }
680
- if (accountHasConnectCredentials(params.account)) {
681
- return { account: params.account, source: "configured" };
691
+ const configuredCredentials = (account) => {
692
+ const refreshToken = readConfigRefreshToken(params.cfg, accountId, revoked);
693
+ return accountHasConnectCredentials(account) && usable({ accessToken: account.token, refreshToken })
694
+ ? { account, source: "configured" } : null;
695
+ };
696
+ const configured = configuredCredentials(params.account);
697
+ if (configured)
698
+ return configured;
699
+ if (revoked) {
700
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CLAWCHAT_TOKEN_EXPIRED_MESSAGE}`);
701
+ setStatus({ ...getStatus(), connected: false, configured: false, running: false,
702
+ lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR });
682
703
  }
683
- if (!store?.getActivationCredentials) {
704
+ if (!revoked && !store?.getActivationCredentials) {
684
705
  const message = "Clawling Chat token is missing and activation database is unavailable";
685
706
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${message}`);
686
707
  setStatus({
@@ -693,26 +714,17 @@ async function waitForActivationCredentials(params) {
693
714
  return null;
694
715
  }
695
716
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw waiting for activation credentials in sqlite`);
696
- setStatus({
697
- ...getStatus(),
698
- connected: false,
699
- configured: true,
700
- running: true,
701
- lastError: "waiting for ClawChat activation",
702
- });
703
- while (!abortSignal.aborted) {
704
- const credentials = store.getActivationCredentials({
705
- platform: "openclaw",
706
- accountId,
717
+ if (!revoked)
718
+ setStatus({
719
+ ...getStatus(),
720
+ connected: false,
721
+ configured: true,
722
+ running: true,
723
+ lastError: "waiting for ClawChat activation",
707
724
  });
708
- if (credentials) {
709
- if (credentials.accessToken === params.rejectedActivationToken) {
710
- log?.debug?.(`[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=rejected_token_unchanged intervalMs=${params.pollIntervalMs}`);
711
- const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
712
- if (!shouldContinue)
713
- break;
714
- continue;
715
- }
725
+ while (!abortSignal.aborted) {
726
+ const credentials = store?.getActivationCredentials?.({ platform: "openclaw", accountId });
727
+ if (credentials && usable(credentials)) {
716
728
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
717
729
  return {
718
730
  source: "sqlite",
@@ -725,7 +737,14 @@ async function waitForActivationCredentials(params) {
725
737
  },
726
738
  };
727
739
  }
728
- log?.debug?.(`[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=waiting intervalMs=${params.pollIntervalMs}`);
740
+ if (revoked) {
741
+ // Re-read env credentials while parked so a new pair can recover without
742
+ // another gateway invocation. Config credentials use the supplied snapshot.
743
+ const replacement = configuredCredentials(resolveOpenclawClawlingAccount(params.cfg, accountId));
744
+ if (replacement)
745
+ return replacement;
746
+ }
747
+ log?.debug?.(`[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=${credentials ? "rejected_token_unchanged" : "waiting"} intervalMs=${params.pollIntervalMs}`);
729
748
  const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
730
749
  if (!shouldContinue)
731
750
  break;
@@ -741,6 +760,18 @@ async function waitForActivationCredentials(params) {
741
760
  let warnedBelowHostFloor = false;
742
761
  let loggedHostToolSearch = false;
743
762
  export async function startOpenclawClawlingGateway(params) {
763
+ // Every exit, including an interrupted connect or abort during logout, must
764
+ // wait for the one re-pair lifecycle this invocation started: settling the
765
+ // host's startAccount task early would make the host restart the channel.
766
+ const lifecycle = {};
767
+ try {
768
+ await runOpenclawClawlingGateway(params, lifecycle);
769
+ }
770
+ finally {
771
+ await lifecycle.rePairWait;
772
+ }
773
+ }
774
+ async function runOpenclawClawlingGateway(params, lifecycle) {
744
775
  const { cfg, abortSignal, setStatus, getStatus, log } = params;
745
776
  let account = params.account;
746
777
  // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
@@ -801,7 +832,10 @@ export async function startOpenclawClawlingGateway(params) {
801
832
  log,
802
833
  });
803
834
  }
835
+ const revokedRefreshMarker = params.revokedRefreshMarker ??
836
+ store?.getRevokedRefreshMarker?.({ platform: "openclaw", accountId });
804
837
  const activationAccount = await waitForActivationCredentials({
838
+ cfg,
805
839
  account,
806
840
  abortSignal,
807
841
  getStatus,
@@ -810,10 +844,16 @@ export async function startOpenclawClawlingGateway(params) {
810
844
  log,
811
845
  pollIntervalMs: params.activationPollIntervalMs ?? ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS,
812
846
  rejectedActivationToken: params.rejectedActivationToken,
847
+ revokedRefreshMarker,
813
848
  });
814
849
  if (!activationAccount)
815
850
  return;
816
851
  account = activationAccount.account;
852
+ // Reset in-memory refresh guards only after adopting a different pair.
853
+ if (revokedRefreshMarker) {
854
+ params = { ...params, revokedRefreshMarker: undefined, refreshTokenOverride: undefined,
855
+ refreshManagerState: undefined, rejectedActivationToken: undefined };
856
+ }
817
857
  // Config/env bootstrap seeding: converge on SQLite as the single durable
818
858
  // credential store, so token rotation always has a row to update and the
819
859
  // next startup resolves SQLite-first to the same credentials.
@@ -825,7 +865,7 @@ export async function startOpenclawClawlingGateway(params) {
825
865
  userId: account.userId,
826
866
  ownerUserId: account.ownerUserId,
827
867
  accessToken: account.token,
828
- refreshToken: readConfigRefreshToken(cfg, accountId),
868
+ refreshToken: readConfigRefreshToken(cfg, accountId, revokedRefreshMarker),
829
869
  loginMethod: "config-seed",
830
870
  deviceId: CHANNEL_ID,
831
871
  });
@@ -834,6 +874,11 @@ export async function startOpenclawClawlingGateway(params) {
834
874
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw sqlite credential seed failed; continuing with config credentials: ${error instanceof Error ? error.message : String(error)}`);
835
875
  }
836
876
  }
877
+ if (revokedRefreshMarker && store) {
878
+ if (store.clearRevokedRefreshMarker?.({ platform: "openclaw", accountId }) !== true) {
879
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite revoked marker after activation`);
880
+ }
881
+ }
837
882
  // Paired: link the report row via the authenticated endpoint, reusing the
838
883
  // SAME frozen device_id so the backend upserts the existing unpaired row.
839
884
  void reportPluginVersionSafe({
@@ -1023,7 +1068,7 @@ export async function startOpenclawClawlingGateway(params) {
1023
1068
  const mutateConfigFile = resolveMutateConfigFile();
1024
1069
  if (!mutateConfigFile) {
1025
1070
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw config persistence unavailable; cannot ${tokens ? "rotate" : "clear"} tokens in config`);
1026
- return;
1071
+ return false;
1027
1072
  }
1028
1073
  await mutateConfigFile({
1029
1074
  afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw token refresh" },
@@ -1034,6 +1079,7 @@ export async function startOpenclawClawlingGateway(params) {
1034
1079
  }));
1035
1080
  },
1036
1081
  });
1082
+ return true;
1037
1083
  };
1038
1084
  let lastHelloFailTraceId = "-";
1039
1085
  let lastHelloFailReason = "";
@@ -1054,6 +1100,8 @@ export async function startOpenclawClawlingGateway(params) {
1054
1100
  let reconnectWithRefreshedToken = false;
1055
1101
  // §C — set once auto-logout has fired so we don't double-emit or reconnect.
1056
1102
  let autoLoggedOut = false;
1103
+ let refreshClient;
1104
+ let autoLogoutCleanup;
1057
1105
  // §E — connect-time device id for `X-Device-Id` on refresh. Prefer the value
1058
1106
  // recorded in SQLite at connect; backfill legacy rows (no column) to the
1059
1107
  // deterministic constant `CHANNEL_ID` actually sent by `authHeaders`.
@@ -1063,33 +1111,66 @@ export async function startOpenclawClawlingGateway(params) {
1063
1111
  // §C — auto-logout on permanent refresh failure. Blank creds in BOTH stores
1064
1112
  // (KEEP identity), flip not-configured via the auth-failure status path, and
1065
1113
  // emit the user-visible message. Idempotent.
1066
- const performAutoLogout = async (info) => {
1067
- if (autoLoggedOut)
1068
- return;
1114
+ const performAutoLogout = (info, marker) => {
1115
+ if (autoLogoutCleanup)
1116
+ return autoLogoutCleanup;
1069
1117
  autoLoggedOut = true;
1070
1118
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw auto-logout (token permanently expired) code=${info.code}: ${info.message}`);
1071
- // SQLite: blank access/refresh, keep user/owner/device for re-pair.
1072
- if (store?.clearActivationCredentials) {
1073
- recordConnection("clear activation credentials", () => store.clearActivationCredentials?.({ platform: "openclaw", accountId }));
1074
- }
1075
- // Config: blank token/refreshToken keys.
1119
+ refreshManager.stop();
1120
+ activeClients.delete(accountId);
1076
1121
  try {
1077
- await persistConfigTokens(null);
1078
- }
1079
- catch (err) {
1080
- log?.error?.(`[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout: ${err instanceof Error ? err.message : String(err)}`);
1122
+ refreshClient?.close();
1081
1123
  }
1082
- // Flip not-configured (existing auth-failure status path) with the re-pair
1083
- // hint as `lastError`.
1084
- setStatus({
1085
- ...getStatus(),
1086
- connected: false,
1087
- configured: false,
1088
- running: false,
1089
- lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
1090
- });
1091
- // User-visible notification (in addition to logs). Best-effort; never throws.
1092
- emitUserVisibleAuthLogout();
1124
+ catch { /* Best effort shutdown. */ }
1125
+ // Write the marker independently BEFORE clearing, so a failed clear cannot
1126
+ // resurrect this rejected credential at the next process start.
1127
+ autoLogoutCleanup = (async () => {
1128
+ const markerInput = { platform: "openclaw", accountId, ...marker };
1129
+ try {
1130
+ if (store?.setRevokedRefreshMarker?.(markerInput) !== true) {
1131
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
1132
+ }
1133
+ }
1134
+ catch {
1135
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
1136
+ }
1137
+ try {
1138
+ if (store?.clearActivationCredentials?.({ platform: "openclaw", accountId }) !== true) {
1139
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
1140
+ }
1141
+ }
1142
+ catch {
1143
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
1144
+ }
1145
+ try {
1146
+ if (!await persistConfigTokens(null))
1147
+ throw new Error("config mutation unavailable");
1148
+ }
1149
+ catch {
1150
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout`);
1151
+ }
1152
+ // Flip not-configured (existing auth-failure status path) with the re-pair
1153
+ // hint as `lastError`.
1154
+ setStatus({
1155
+ ...getStatus(),
1156
+ connected: false,
1157
+ configured: false,
1158
+ running: false,
1159
+ lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
1160
+ });
1161
+ // User-visible notification (in addition to logs). Best-effort; never throws.
1162
+ emitUserVisibleAuthLogout();
1163
+ })();
1164
+ // Assign the lifetime before yielding, including when config clearing is
1165
+ // still pending and closing the old socket interrupts client.connect().
1166
+ lifecycle.rePairWait = autoLogoutCleanup.then(() => startOpenclawClawlingGateway({
1167
+ ...params, boundaryWarningIssued: true,
1168
+ account: { ...account, configured: false, token: "" },
1169
+ revokedRefreshMarker: marker, refreshTokenOverride: undefined,
1170
+ refreshManagerState: undefined, rejectedActivationToken: undefined,
1171
+ refreshReconnectDepth: undefined, refreshReconnectWindowStartedAt: undefined,
1172
+ }));
1173
+ return autoLogoutCleanup;
1093
1174
  };
1094
1175
  // §C.1.4 — surface the permanent-expiry message to the user/operator. The
1095
1176
  // plugin has no guaranteed live chat target after creds are cleared, so we
@@ -1120,7 +1201,7 @@ export async function startOpenclawClawlingGateway(params) {
1120
1201
  (activationAccount.source === "sqlite" && store?.getActivationCredentials
1121
1202
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
1122
1203
  : null) ??
1123
- readConfigRefreshToken(cfg, accountId);
1204
+ readConfigRefreshToken(cfg, accountId, revokedRefreshMarker);
1124
1205
  const refreshManager = new RefreshManager({
1125
1206
  baseUrl: account.baseUrl,
1126
1207
  deviceId: refreshDeviceId,
@@ -1180,7 +1261,9 @@ export async function startOpenclawClawlingGateway(params) {
1180
1261
  // No SQLite store available — channel config is the only durable
1181
1262
  // store, so its write failure must reject and keep the refresh
1182
1263
  // transient (pre-existing behavior for store-less test transports).
1183
- await persistConfigTokens(tokens);
1264
+ if (!await persistConfigTokens(tokens)) {
1265
+ throw new Error("clawchat-plugin-openclaw config persistence unavailable");
1266
+ }
1184
1267
  },
1185
1268
  swapInMemoryTokens: (tokens) => {
1186
1269
  account = { ...account, token: tokens.accessToken, configured: true };
@@ -1436,7 +1519,8 @@ export async function startOpenclawClawlingGateway(params) {
1436
1519
  if (abortSignal.aborted)
1437
1520
  return;
1438
1521
  if (startupOutcome.kind === "permanent") {
1439
- // Auto-logout already performed by the manager's onPermanentFailure.
1522
+ // Wait for re-pair or abort rather than settling the host channel task.
1523
+ await lifecycle.rePairWait;
1440
1524
  return;
1441
1525
  }
1442
1526
  // success swaps the in-memory token in place; transient/skipped just connect
@@ -1479,6 +1563,7 @@ export async function startOpenclawClawlingGateway(params) {
1479
1563
  },
1480
1564
  },
1481
1565
  });
1566
+ refreshClient = client;
1482
1567
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client created`);
1483
1568
  // §A.3/§A.4 — recompute the refresh-driven reconnect streak (depth + window).
1484
1569
  // Returns the next depth/window to thread into the re-enter, and whether the
@@ -1611,8 +1696,10 @@ export async function startOpenclawClawlingGateway(params) {
1611
1696
  const outcome = await refreshManager.refresh(reason);
1612
1697
  if (abortSignal.aborted)
1613
1698
  return "fallthrough";
1614
- if (autoLoggedOut)
1615
- return "handled"; // permanent → manager auto-logged-out.
1699
+ if (autoLoggedOut) {
1700
+ await lifecycle.rePairWait;
1701
+ return "handled";
1702
+ }
1616
1703
  if (outcome.kind === "success") {
1617
1704
  await closeAndReconnectWithRefreshedToken(reason);
1618
1705
  return "handled";
@@ -3264,8 +3351,9 @@ export async function startOpenclawClawlingGateway(params) {
3264
3351
  // return on abort, but only AFTER it has already taken the idempotency claim
3265
3352
  // (`claimInboundTurn` runs BEFORE the gate wait there, so the frame is durably
3266
3353
  // persisted before the interruptible wait — required by legacy advance-on-
3267
- // write delivery, where a frame written before a disconnect is not re-delivered
3268
- // on reconnect; see docs/client-integration.md §11.7). So even an ingest-time
3354
+ // write delivery, where a frame written before a disconnect is not reliably
3355
+ // re-delivered on reconnect (legacy may lose OR replay; a replay is deduped by
3356
+ // the claim); see docs/client-integration.md §11.7). So even an ingest-time
3269
3357
  // abort keeps the frame; it just skips dispatch. Here at FLUSH the batch was
3270
3358
  // likewise already claimed at ingest AND `flush()` has already removed it from
3271
3359
  // the coalescer's pending set. An abort early-return would therefore silently
@@ -3386,11 +3474,14 @@ export async function startOpenclawClawlingGateway(params) {
3386
3474
  // can interrupt. This client does NOT advertise `reliable_delivery`
3387
3475
  // (src/ws-client.ts), so under legacy delivery the server's replay cursor
3388
3476
  // advances on socket-WRITE (docs/client-integration.md §11.7): a frame
3389
- // already written before a disconnect is NOT re-delivered on reconnect.
3477
+ // already written before a disconnect is NOT reliably re-delivered on
3478
+ // reconnect. Legacy is not at-most-once either — a multi-replica server
3479
+ // (relayed frames do not advance the cursor) or a failed cursor write can
3480
+ // replay frames already received — and the claim below dedupes those.
3390
3481
  // `claimMessageOnce` is the ONLY durability an inbound has, and the store
3391
3482
  // never reprocesses undispatched inbounds. So if we claimed only AFTER the
3392
- // wait, a shutdown-mid-wait would leave the frame neither persisted NOR
3393
- // replayed — permanently lost. Claiming first makes it durable; the abort
3483
+ // wait, a shutdown-mid-wait would leave the frame unpersisted with no
3484
+ // guaranteed replay — lost. Claiming first makes it durable; the abort
3394
3485
  // check below then returns without dispatching, but the frame is kept.
3395
3486
  // `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
3396
3487
  // message_id) is still deduped here exactly once. The claim fails CLOSED:
@@ -3689,6 +3780,10 @@ export async function startOpenclawClawlingGateway(params) {
3689
3780
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client.connect() resolved`);
3690
3781
  }
3691
3782
  catch (err) {
3783
+ if (autoLoggedOut) {
3784
+ await lifecycle.rePairWait;
3785
+ return;
3786
+ }
3692
3787
  const classified = classifyClawlingClientError(err);
3693
3788
  if (classified.kind === "auth") {
3694
3789
  // §A.2/§B — initial-connect hello-fail(auth). Do NOT pre-flip
@@ -3759,6 +3854,10 @@ export async function startOpenclawClawlingGateway(params) {
3759
3854
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw connect failed (${classified.kind}): ${classified.message}`);
3760
3855
  return;
3761
3856
  }
3857
+ if (autoLoggedOut) {
3858
+ await lifecycle.rePairWait;
3859
+ return;
3860
+ }
3762
3861
  activeClients.set(accountId, client);
3763
3862
  // Text "@name" autolinking on outbound group sends reads the same cached
3764
3863
  // roster (participant ids + nicknames) the group prompt shows the model.
@@ -208,6 +208,19 @@ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
208
208
  sql: `
209
209
  CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
210
210
  ON clawchat_messages(chat_id COLLATE NOCASE);
211
+ `,
212
+ },
213
+ {
214
+ version: 13,
215
+ name: "revoked_refresh_tokens",
216
+ sql: `
217
+ CREATE TABLE IF NOT EXISTS revoked_refresh_tokens (
218
+ platform TEXT NOT NULL,
219
+ account_id TEXT NOT NULL,
220
+ fingerprint TEXT NOT NULL,
221
+ revoked_at INTEGER NOT NULL,
222
+ PRIMARY KEY (platform, account_id)
223
+ );
211
224
  `,
212
225
  },
213
226
  ];
@@ -340,6 +353,31 @@ export class ClawChatStore {
340
353
  return result.changes > 0;
341
354
  });
342
355
  }
356
+ setRevokedRefreshMarker(input) {
357
+ return this.write(() => {
358
+ this.requireDb().prepare(`INSERT INTO revoked_refresh_tokens
359
+ (platform, account_id, fingerprint, revoked_at) VALUES (?, ?, ?, ?)
360
+ ON CONFLICT(platform, account_id) DO UPDATE SET
361
+ fingerprint = excluded.fingerprint, revoked_at = excluded.revoked_at`)
362
+ .run(input.platform, input.accountId, input.fingerprint, input.revokedAt);
363
+ return true;
364
+ });
365
+ }
366
+ getRevokedRefreshMarker(input) {
367
+ return this.read(() => {
368
+ const row = this.requireDb().prepare(`SELECT fingerprint, revoked_at AS revokedAt
369
+ FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
370
+ .get(input.platform, input.accountId);
371
+ return row ?? null;
372
+ }) ?? null;
373
+ }
374
+ clearRevokedRefreshMarker(input) {
375
+ return this.write(() => {
376
+ this.requireDb().prepare(`DELETE FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
377
+ .run(input.platform, input.accountId);
378
+ return true;
379
+ });
380
+ }
343
381
  getActivationCredentials(input) {
344
382
  return this.read(() => {
345
383
  const row = this.requireDb()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.10.11-3",
3
+ "version": "2026.10.13-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
package/src/api-client.ts CHANGED
@@ -488,6 +488,7 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
488
488
  ? env.message
489
489
  : "";
490
490
  if (code !== 0) {
491
+ // Business-code 401 is an API error, not HTTP auth expiry; do not refresh.
491
492
  throw new ClawlingApiError("api", msg || `code=${code}`, {
492
493
  code,
493
494
  status: res.status,
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  import {
2
4
  authRefresh,
3
5
  CODE_INVALID_REFRESH,
@@ -5,6 +7,13 @@ import {
5
7
  type AuthRefreshResult,
6
8
  } from "./api-client.ts";
7
9
 
10
+ export type RevokedRefreshMarker = { fingerprint: string; revokedAt: number };
11
+
12
+ /** Fingerprint only: revoked refresh credentials must never enter marker storage. */
13
+ export function fingerprintRefreshToken(token: string): string {
14
+ return createHash("sha256").update(token.trim()).digest("hex");
15
+ }
16
+
8
17
  /**
9
18
  * §A–§D — ClawChat token refresh + auto-logout orchestration for the OpenClaw
10
19
  * plugin.
@@ -77,7 +86,7 @@ export interface RefreshManagerPorts {
77
86
  /** Swap the in-memory access+refresh token AFTER persistence (§0). */
78
87
  swapInMemoryTokens: (tokens: { accessToken: string; refreshToken: string }) => void;
79
88
  /** §C — auto-logout: clear creds in both stores, flip not-configured, notify. */
80
- onPermanentFailure: (info: { code: number; message: string }) => Promise<void> | void;
89
+ onPermanentFailure: (info: { code: number; message: string }, revokedRefresh: RevokedRefreshMarker) => Promise<void> | void;
81
90
  /**
82
91
  * §A.1/§D — invoked AFTER a successful PROACTIVE-timer refresh has persisted +
83
92
  * swapped the in-memory token, so the runtime can perform the §D close-then-
@@ -138,12 +147,9 @@ export class RefreshManager {
138
147
  private inFlight: Promise<RefreshOutcome> | null = null;
139
148
  /** §A.3 — the access token a refresh was last attempted for. */
140
149
  private rejectedToken: string | null = null;
141
- /**
142
- * §B race — the refresh token we last successfully rotated AWAY from (now
143
- * consumed server-side). A subsequent `code:10003` for this exact token is a
144
- * consumed-rotation race, NOT a revocation, so it must not auto-logout.
145
- */
150
+ /** Consumed-token history is diagnostic; a live successor is required to recover. */
146
151
  private lastRotatedFromToken: string | null = null;
152
+ private consecutiveRaceDowngrades = 0;
147
153
  /** §A.3 — epoch-ms of the last refresh attempt (any token). */
148
154
  private lastAttemptAt = 0;
149
155
  private proactiveTimer: TimerHandle | null = null;
@@ -217,11 +223,13 @@ export class RefreshManager {
217
223
  { refreshToken, deviceId: this.ports.deviceId },
218
224
  );
219
225
  } catch (err) {
226
+ this.consecutiveRaceDowngrades = 0;
220
227
  const message = err instanceof Error ? err.message : String(err);
221
228
  return { kind: "transient", message };
222
229
  }
223
230
 
224
231
  if (result.kind === "success") {
232
+ this.consecutiveRaceDowngrades = 0;
225
233
  // §0 rotation hazard — persist FIRST, swap SECOND. If persistence REJECTS
226
234
  // (a store/config write failed), DO NOT swap the in-memory token: a
227
235
  // sqlite-sourced agent must not keep a now-dead refresh token in its row
@@ -247,8 +255,7 @@ export class RefreshManager {
247
255
  accessToken: result.accessToken,
248
256
  refreshToken: result.refreshToken,
249
257
  });
250
- // §B race — record the now-consumed refresh token so a later `10003` for it
251
- // is recognized as a rotation race rather than a revocation.
258
+ // Keep rotation history for inspection; it never overrides the live-token check.
252
259
  this.lastRotatedFromToken = refreshToken;
253
260
  // Clear the latch; the access token has actually changed.
254
261
  this.rejectedToken = null;
@@ -257,25 +264,15 @@ export class RefreshManager {
257
264
  }
258
265
 
259
266
  if (result.kind === "permanent") {
260
- // §B race — a `code:10003` is also returned for a refresh token already
261
- // CONSUMED by a prior successful rotation (once the backend grace window
262
- // no longer covers it). Before auto-logging-out
263
- // (which wipes credentials and bricks the agent), distinguish that race
264
- // from a genuine revocation. It is a race when EITHER the submitted token
265
- // is one we already rotated away from, OR the live store refresh token has
266
- // moved on since we read it (another worker rotated underneath us). In
267
- // either case the credential chain is still alive — report transient (no
268
- // latch, no logout) so the next attempt proceeds with the live token. A
269
- // `code:400` (client bug) is never a race.
267
+ // A consumed-token race is recoverable only with a real live successor.
268
+ // Bound repeated downgrades, including across gateway re-enters.
270
269
  if (result.code === CODE_INVALID_REFRESH) {
271
270
  const liveRefresh = this.ports.getRefreshToken();
272
- const rotatedByUs =
273
- this.lastRotatedFromToken !== null && refreshToken === this.lastRotatedFromToken;
274
- const rotatedUnderneath =
275
- !!liveRefresh && liveRefresh.trim().length > 0 && liveRefresh !== refreshToken;
276
- if (rotatedByUs || rotatedUnderneath) {
271
+ const hasSuccessor = !!liveRefresh?.trim() && liveRefresh !== refreshToken;
272
+ if (hasSuccessor && this.consecutiveRaceDowngrades < 2) {
273
+ this.consecutiveRaceDowngrades += 1;
277
274
  this.ports.log?.info?.(
278
- `clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race (rotatedByUs=${rotatedByUs} rotatedUnderneath=${rotatedUnderneath}); recovering with live token`,
275
+ "clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race; recovering with live token",
279
276
  );
280
277
  return { kind: "transient", message: "refresh token already rotated (race); retrying with current token" };
281
278
  }
@@ -285,10 +282,14 @@ export class RefreshManager {
285
282
  this.ports.log?.error?.(
286
283
  `clawchat-plugin-openclaw refresh permanent failure code=${result.code}: ${result.message}`,
287
284
  );
288
- await this.ports.onPermanentFailure({ code: result.code, message: result.message });
285
+ await this.ports.onPermanentFailure({
286
+ code: result.code,
287
+ message: result.message,
288
+ }, { fingerprint: fingerprintRefreshToken(refreshToken), revokedAt: this.now() });
289
289
  return { kind: "permanent", code: result.code, message: result.message };
290
290
  }
291
291
 
292
+ this.consecutiveRaceDowngrades = 0;
292
293
  // Transient — never auto-logout; the old refresh token is still valid.
293
294
  this.ports.log?.info?.(
294
295
  `clawchat-plugin-openclaw refresh transient failure: ${result.message}`,
@@ -470,11 +471,13 @@ export class RefreshManager {
470
471
  rejectedToken: string | null;
471
472
  lastAttemptAt: number;
472
473
  lastRotatedFromToken: string | null;
474
+ consecutiveRaceDowngrades: number;
473
475
  } {
474
476
  return {
475
477
  rejectedToken: this.rejectedToken,
476
478
  lastAttemptAt: this.lastAttemptAt,
477
479
  lastRotatedFromToken: this.lastRotatedFromToken,
480
+ consecutiveRaceDowngrades: this.consecutiveRaceDowngrades,
478
481
  };
479
482
  }
480
483
 
@@ -483,10 +486,12 @@ export class RefreshManager {
483
486
  rejectedToken: string | null;
484
487
  lastAttemptAt: number;
485
488
  lastRotatedFromToken?: string | null;
489
+ consecutiveRaceDowngrades?: number;
486
490
  }): void {
487
491
  this.rejectedToken = state.rejectedToken;
488
492
  this.lastAttemptAt = state.lastAttemptAt;
489
493
  this.lastRotatedFromToken = state.lastRotatedFromToken ?? null;
494
+ this.consecutiveRaceDowngrades = state.consecutiveRaceDowngrades ?? 0;
490
495
  }
491
496
  }
492
497
 
package/src/runtime.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  type LivewareSampleSupervisorDeps,
37
37
  } from "./liveware-sample.ts";
38
38
  import { ClawlingApiError } from "./api-types.ts";
39
- import { RefreshManager } from "./refresh-manager.ts";
39
+ import { RefreshManager, fingerprintRefreshToken, type RevokedRefreshMarker } from "./refresh-manager.ts";
40
40
  import { RECONNECT_GUIDE_URL } from "./onboarding-context.ts";
41
41
  import {
42
42
  runOpenclawClawlingLogin,
@@ -143,6 +143,9 @@ type RuntimeConnectionStore = Pick<
143
143
  | "upsertActivation"
144
144
  | "rotateActivationTokens"
145
145
  | "clearActivationCredentials"
146
+ | "getRevokedRefreshMarker"
147
+ | "setRevokedRefreshMarker"
148
+ | "clearRevokedRefreshMarker"
146
149
  | "insertMessage"
147
150
  | "claimMessageOnce"
148
151
  | "markMessageAcknowledged"
@@ -279,20 +282,27 @@ export function classifyHelloFailReason(
279
282
  * carries no `token` of its own (i.e. the access token itself came from
280
283
  * `CLAWCHAT_TOKEN`) — an env-provisioned pair can rotate, while a login-written
281
284
  * token is never paired with a stray refresh token left in the environment.
285
+ * A revoked marker also excludes a matching refresh fingerprint from either source.
282
286
  */
283
287
  function readConfigRefreshToken(
284
288
  cfg: OpenClawConfig,
285
289
  accountId: string,
290
+ revokedMarker?: RevokedRefreshMarker | null,
286
291
  env: NodeJS.ProcessEnv = process.env,
287
292
  ): string | null {
288
293
  const section = readOpenclawClawlingAccountSection(cfg, accountId);
289
294
  const refreshToken = section.refreshToken;
290
- if (typeof refreshToken === "string" && refreshToken.trim()) return refreshToken.trim();
295
+ const usable = (token: unknown): string | null => {
296
+ const value = typeof token === "string" ? token.trim() : "";
297
+ return value && (!revokedMarker || fingerprintRefreshToken(value) !== revokedMarker.fingerprint)
298
+ ? value : null;
299
+ };
300
+ if (typeof refreshToken === "string" && refreshToken.trim()) return usable(refreshToken);
291
301
  if (normalizeOpenclawClawlingAccountId(accountId) !== DEFAULT_ACCOUNT_ID) return null;
292
302
  const configToken = section.token;
293
303
  if (typeof configToken === "string" && configToken.trim()) return null;
294
304
  const envRefreshToken = env[CLAWCHAT_REFRESH_TOKEN_ENV];
295
- return typeof envRefreshToken === "string" && envRefreshToken.trim() ? envRefreshToken.trim() : null;
305
+ return usable(envRefreshToken);
296
306
  }
297
307
 
298
308
  function withFullVerboseDispatchConfig(cfg: OpenClawConfig, agentId: string): OpenClawConfig {
@@ -655,7 +665,9 @@ export interface StartGatewayParams {
655
665
  * min-interval floor) actually constrain a cross-reconnect refresh loop instead
656
666
  * of resetting on every fresh `RefreshManager`.
657
667
  */
658
- refreshManagerState?: { rejectedToken: string | null; lastAttemptAt: number };
668
+ refreshManagerState?: Parameters<RefreshManager["restoreState"]>[0];
669
+ /** Internal guard when durable marker writes fail; never contains credentials. */
670
+ revokedRefreshMarker?: RevokedRefreshMarker;
659
671
  /**
660
672
  * Internal — the live (possibly rotated) refresh token, carried across a
661
673
  * §D/§B gateway re-enter. A rotation persists config with `afterWrite:{mode:
@@ -990,6 +1002,7 @@ function waitForActivationPoll(abortSignal: AbortSignal, ms: number): Promise<bo
990
1002
  }
991
1003
 
992
1004
  async function waitForActivationCredentials(params: {
1005
+ cfg: OpenClawConfig;
993
1006
  account: ResolvedOpenclawClawlingAccount;
994
1007
  abortSignal: AbortSignal;
995
1008
  getStatus: () => ChannelAccountSnapshot;
@@ -998,16 +1011,23 @@ async function waitForActivationCredentials(params: {
998
1011
  log?: Log;
999
1012
  pollIntervalMs: number;
1000
1013
  rejectedActivationToken?: string;
1014
+ revokedRefreshMarker?: RevokedRefreshMarker | null;
1001
1015
  }): Promise<{ account: ResolvedOpenclawClawlingAccount; source: "configured" | "sqlite" } | null> {
1002
1016
  const { abortSignal, getStatus, setStatus, store, log } = params;
1003
1017
  const accountId = params.account.accountId;
1018
+ const revoked = params.revokedRefreshMarker;
1019
+ const usable = (credentials: { accessToken: string; refreshToken: string | null }): boolean =>
1020
+ credentials.accessToken !== params.rejectedActivationToken &&
1021
+ (!revoked || (!!credentials.refreshToken &&
1022
+ fingerprintRefreshToken(credentials.refreshToken) !== revoked.fingerprint));
1004
1023
  // SQLite-first: activation and token rotation persist to the activations
1005
1024
  // row, so a complete row is the durable credential source of truth even
1006
1025
  // when channel config/env also carries credentials. Config/env credentials
1007
- // only bootstrap the no-row case (the caller seeds a row from them).
1026
+ // bootstrap the no-row case, or replace a revoked pair with a different
1027
+ // refresh fingerprint (the caller seeds a row from the adopted pair).
1008
1028
  if (store?.getActivationCredentials) {
1009
1029
  const stored = store.getActivationCredentials({ platform: "openclaw", accountId });
1010
- if (stored && stored.accessToken !== params.rejectedActivationToken) {
1030
+ if (stored && usable(stored)) {
1011
1031
  log?.info?.(
1012
1032
  `[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
1013
1033
  );
@@ -1023,10 +1043,19 @@ async function waitForActivationCredentials(params: {
1023
1043
  };
1024
1044
  }
1025
1045
  }
1026
- if (accountHasConnectCredentials(params.account)) {
1027
- return { account: params.account, source: "configured" };
1046
+ const configuredCredentials = (account: ResolvedOpenclawClawlingAccount) => {
1047
+ const refreshToken = readConfigRefreshToken(params.cfg, accountId, revoked);
1048
+ return accountHasConnectCredentials(account) && usable({ accessToken: account.token, refreshToken })
1049
+ ? { account, source: "configured" as const } : null;
1050
+ };
1051
+ const configured = configuredCredentials(params.account);
1052
+ if (configured) return configured;
1053
+ if (revoked) {
1054
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CLAWCHAT_TOKEN_EXPIRED_MESSAGE}`);
1055
+ setStatus({ ...getStatus(), connected: false, configured: false, running: false,
1056
+ lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR });
1028
1057
  }
1029
- if (!store?.getActivationCredentials) {
1058
+ if (!revoked && !store?.getActivationCredentials) {
1030
1059
  const message = "Clawling Chat token is missing and activation database is unavailable";
1031
1060
  log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${message}`);
1032
1061
  setStatus({
@@ -1042,7 +1071,7 @@ async function waitForActivationCredentials(params: {
1042
1071
  log?.info?.(
1043
1072
  `[${accountId}] clawchat-plugin-openclaw waiting for activation credentials in sqlite`,
1044
1073
  );
1045
- setStatus({
1074
+ if (!revoked) setStatus({
1046
1075
  ...getStatus(),
1047
1076
  connected: false,
1048
1077
  configured: true,
@@ -1051,22 +1080,9 @@ async function waitForActivationCredentials(params: {
1051
1080
  });
1052
1081
 
1053
1082
  while (!abortSignal.aborted) {
1054
- const credentials = store.getActivationCredentials({
1055
- platform: "openclaw",
1056
- accountId,
1057
- });
1058
- if (credentials) {
1059
- if (credentials.accessToken === params.rejectedActivationToken) {
1060
- log?.debug?.(
1061
- `[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=rejected_token_unchanged intervalMs=${params.pollIntervalMs}`,
1062
- );
1063
- const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
1064
- if (!shouldContinue) break;
1065
- continue;
1066
- }
1067
- log?.info?.(
1068
- `[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`,
1069
- );
1083
+ const credentials = store?.getActivationCredentials?.({ platform: "openclaw", accountId });
1084
+ if (credentials && usable(credentials)) {
1085
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw loaded activation credentials from sqlite`);
1070
1086
  return {
1071
1087
  source: "sqlite",
1072
1088
  account: {
@@ -1078,8 +1094,14 @@ async function waitForActivationCredentials(params: {
1078
1094
  },
1079
1095
  };
1080
1096
  }
1097
+ if (revoked) {
1098
+ // Re-read env credentials while parked so a new pair can recover without
1099
+ // another gateway invocation. Config credentials use the supplied snapshot.
1100
+ const replacement = configuredCredentials(resolveOpenclawClawlingAccount(params.cfg, accountId));
1101
+ if (replacement) return replacement;
1102
+ }
1081
1103
  log?.debug?.(
1082
- `[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=waiting intervalMs=${params.pollIntervalMs}`,
1104
+ `[${accountId}] clawchat-plugin-openclaw activation credential poll heartbeat status=${credentials ? "rejected_token_unchanged" : "waiting"} intervalMs=${params.pollIntervalMs}`,
1083
1105
  );
1084
1106
  const shouldContinue = await waitForActivationPoll(abortSignal, params.pollIntervalMs);
1085
1107
  if (!shouldContinue) break;
@@ -1097,7 +1119,25 @@ async function waitForActivationCredentials(params: {
1097
1119
  let warnedBelowHostFloor = false;
1098
1120
  let loggedHostToolSearch = false;
1099
1121
 
1122
+ /** Re-pair lifecycle owned by one gateway invocation (see performAutoLogout). */
1123
+ type GatewayLifecycle = { rePairWait?: Promise<void> };
1124
+
1100
1125
  export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
1126
+ // Every exit, including an interrupted connect or abort during logout, must
1127
+ // wait for the one re-pair lifecycle this invocation started: settling the
1128
+ // host's startAccount task early would make the host restart the channel.
1129
+ const lifecycle: GatewayLifecycle = {};
1130
+ try {
1131
+ await runOpenclawClawlingGateway(params, lifecycle);
1132
+ } finally {
1133
+ await lifecycle.rePairWait;
1134
+ }
1135
+ }
1136
+
1137
+ async function runOpenclawClawlingGateway(
1138
+ params: StartGatewayParams,
1139
+ lifecycle: GatewayLifecycle,
1140
+ ): Promise<void> {
1101
1141
  const { cfg, abortSignal, setStatus, getStatus, log } = params;
1102
1142
  let account = params.account;
1103
1143
  // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
@@ -1162,7 +1202,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1162
1202
  log,
1163
1203
  });
1164
1204
  }
1205
+ const revokedRefreshMarker = params.revokedRefreshMarker ??
1206
+ store?.getRevokedRefreshMarker?.({ platform: "openclaw", accountId });
1165
1207
  const activationAccount = await waitForActivationCredentials({
1208
+ cfg,
1166
1209
  account,
1167
1210
  abortSignal,
1168
1211
  getStatus,
@@ -1171,9 +1214,15 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1171
1214
  log,
1172
1215
  pollIntervalMs: params.activationPollIntervalMs ?? ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS,
1173
1216
  rejectedActivationToken: params.rejectedActivationToken,
1217
+ revokedRefreshMarker,
1174
1218
  });
1175
1219
  if (!activationAccount) return;
1176
1220
  account = activationAccount.account;
1221
+ // Reset in-memory refresh guards only after adopting a different pair.
1222
+ if (revokedRefreshMarker) {
1223
+ params = { ...params, revokedRefreshMarker: undefined, refreshTokenOverride: undefined,
1224
+ refreshManagerState: undefined, rejectedActivationToken: undefined };
1225
+ }
1177
1226
  // Config/env bootstrap seeding: converge on SQLite as the single durable
1178
1227
  // credential store, so token rotation always has a row to update and the
1179
1228
  // next startup resolves SQLite-first to the same credentials.
@@ -1185,7 +1234,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1185
1234
  userId: account.userId,
1186
1235
  ownerUserId: account.ownerUserId,
1187
1236
  accessToken: account.token,
1188
- refreshToken: readConfigRefreshToken(cfg, accountId),
1237
+ refreshToken: readConfigRefreshToken(cfg, accountId, revokedRefreshMarker),
1189
1238
  loginMethod: "config-seed",
1190
1239
  deviceId: CHANNEL_ID,
1191
1240
  });
@@ -1195,6 +1244,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1195
1244
  );
1196
1245
  }
1197
1246
  }
1247
+ if (revokedRefreshMarker && store) {
1248
+ if (store.clearRevokedRefreshMarker?.({ platform: "openclaw", accountId }) !== true) {
1249
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite revoked marker after activation`);
1250
+ }
1251
+ }
1198
1252
  // Paired: link the report row via the authenticated endpoint, reusing the
1199
1253
  // SAME frozen device_id so the backend upserts the existing unpaired row.
1200
1254
  void reportPluginVersionSafe({
@@ -1391,13 +1445,13 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1391
1445
  // blanks them (auto-logout); otherwise persists the rotated pair.
1392
1446
  const persistConfigTokens = async (
1393
1447
  tokens: { accessToken: string; refreshToken: string } | null,
1394
- ): Promise<void> => {
1448
+ ): Promise<boolean> => {
1395
1449
  const mutateConfigFile = resolveMutateConfigFile();
1396
1450
  if (!mutateConfigFile) {
1397
1451
  log?.error?.(
1398
1452
  `[${accountId}] clawchat-plugin-openclaw config persistence unavailable; cannot ${tokens ? "rotate" : "clear"} tokens in config`,
1399
1453
  );
1400
- return;
1454
+ return false;
1401
1455
  }
1402
1456
  await mutateConfigFile({
1403
1457
  afterWrite: { mode: "none", reason: "clawchat-plugin-openclaw token refresh" },
@@ -1411,6 +1465,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1411
1465
  );
1412
1466
  },
1413
1467
  });
1468
+ return true;
1414
1469
  };
1415
1470
 
1416
1471
  let lastHelloFailTraceId = "-";
@@ -1432,6 +1487,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1432
1487
  let reconnectWithRefreshedToken = false;
1433
1488
  // §C — set once auto-logout has fired so we don't double-emit or reconnect.
1434
1489
  let autoLoggedOut = false;
1490
+ let refreshClient: ClawlingChatClient | undefined;
1491
+ let autoLogoutCleanup: Promise<void> | undefined;
1435
1492
 
1436
1493
  // §E — connect-time device id for `X-Device-Id` on refresh. Prefer the value
1437
1494
  // recorded in SQLite at connect; backfill legacy rows (no column) to the
@@ -1444,37 +1501,62 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1444
1501
  // §C — auto-logout on permanent refresh failure. Blank creds in BOTH stores
1445
1502
  // (KEEP identity), flip not-configured via the auth-failure status path, and
1446
1503
  // emit the user-visible message. Idempotent.
1447
- const performAutoLogout = async (info: { code: number; message: string }): Promise<void> => {
1448
- if (autoLoggedOut) return;
1504
+ const performAutoLogout = (
1505
+ info: { code: number; message: string }, marker: RevokedRefreshMarker,
1506
+ ): Promise<void> => {
1507
+ if (autoLogoutCleanup) return autoLogoutCleanup;
1449
1508
  autoLoggedOut = true;
1450
1509
  log?.error?.(
1451
1510
  `[${accountId}] clawchat-plugin-openclaw auto-logout (token permanently expired) code=${info.code}: ${info.message}`,
1452
1511
  );
1453
- // SQLite: blank access/refresh, keep user/owner/device for re-pair.
1454
- if (store?.clearActivationCredentials) {
1455
- recordConnection("clear activation credentials", () =>
1456
- store.clearActivationCredentials?.({ platform: "openclaw", accountId }),
1457
- );
1458
- }
1459
- // Config: blank token/refreshToken keys.
1460
- try {
1461
- await persistConfigTokens(null);
1462
- } catch (err) {
1463
- log?.error?.(
1464
- `[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout: ${err instanceof Error ? err.message : String(err)}`,
1465
- );
1466
- }
1467
- // Flip not-configured (existing auth-failure status path) with the re-pair
1468
- // hint as `lastError`.
1469
- setStatus({
1470
- ...getStatus(),
1471
- connected: false,
1472
- configured: false,
1473
- running: false,
1474
- lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
1475
- });
1476
- // User-visible notification (in addition to logs). Best-effort; never throws.
1477
- emitUserVisibleAuthLogout();
1512
+ refreshManager.stop();
1513
+ activeClients.delete(accountId);
1514
+ try { refreshClient?.close(); } catch { /* Best effort shutdown. */ }
1515
+ // Write the marker independently BEFORE clearing, so a failed clear cannot
1516
+ // resurrect this rejected credential at the next process start.
1517
+ autoLogoutCleanup = (async () => {
1518
+ const markerInput = { platform: "openclaw", accountId, ...marker };
1519
+ try {
1520
+ if (store?.setRevokedRefreshMarker?.(markerInput) !== true) {
1521
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
1522
+ }
1523
+ } catch {
1524
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to persist sqlite revoked refresh marker`);
1525
+ }
1526
+ try {
1527
+ if (store?.clearActivationCredentials?.({ platform: "openclaw", accountId }) !== true) {
1528
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
1529
+ }
1530
+ } catch {
1531
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear sqlite credentials on auto-logout`);
1532
+ }
1533
+ try {
1534
+ if (!await persistConfigTokens(null)) throw new Error("config mutation unavailable");
1535
+ } catch {
1536
+ log?.warn?.(`[${accountId}] clawchat-plugin-openclaw failed to clear config credentials on auto-logout`);
1537
+ }
1538
+ // Flip not-configured (existing auth-failure status path) with the re-pair
1539
+ // hint as `lastError`.
1540
+ setStatus({
1541
+ ...getStatus(),
1542
+ connected: false,
1543
+ configured: false,
1544
+ running: false,
1545
+ lastError: CLAWCHAT_TOKEN_EXPIRED_LAST_ERROR,
1546
+ });
1547
+ // User-visible notification (in addition to logs). Best-effort; never throws.
1548
+ emitUserVisibleAuthLogout();
1549
+ })();
1550
+ // Assign the lifetime before yielding, including when config clearing is
1551
+ // still pending and closing the old socket interrupts client.connect().
1552
+ lifecycle.rePairWait = autoLogoutCleanup.then(() => startOpenclawClawlingGateway({
1553
+ ...params, boundaryWarningIssued: true,
1554
+ account: { ...account, configured: false, token: "" },
1555
+ revokedRefreshMarker: marker, refreshTokenOverride: undefined,
1556
+ refreshManagerState: undefined, rejectedActivationToken: undefined,
1557
+ refreshReconnectDepth: undefined, refreshReconnectWindowStartedAt: undefined,
1558
+ }));
1559
+ return autoLogoutCleanup;
1478
1560
  };
1479
1561
 
1480
1562
  // §C.1.4 — surface the permanent-expiry message to the user/operator. The
@@ -1509,7 +1591,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1509
1591
  (activationAccount.source === "sqlite" && store?.getActivationCredentials
1510
1592
  ? store.getActivationCredentials({ platform: "openclaw", accountId })?.refreshToken
1511
1593
  : null) ??
1512
- readConfigRefreshToken(cfg, accountId);
1594
+ readConfigRefreshToken(cfg, accountId, revokedRefreshMarker);
1513
1595
 
1514
1596
  const refreshManager = new RefreshManager({
1515
1597
  baseUrl: account.baseUrl,
@@ -1569,7 +1651,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1569
1651
  // No SQLite store available — channel config is the only durable
1570
1652
  // store, so its write failure must reject and keep the refresh
1571
1653
  // transient (pre-existing behavior for store-less test transports).
1572
- await persistConfigTokens(tokens);
1654
+ if (!await persistConfigTokens(tokens)) {
1655
+ throw new Error("clawchat-plugin-openclaw config persistence unavailable");
1656
+ }
1573
1657
  },
1574
1658
  swapInMemoryTokens: (tokens) => {
1575
1659
  account = { ...account, token: tokens.accessToken, configured: true };
@@ -1848,7 +1932,8 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1848
1932
  const startupOutcome = await refreshManager.refresh("startup-near-expiry");
1849
1933
  if (abortSignal.aborted) return;
1850
1934
  if (startupOutcome.kind === "permanent") {
1851
- // Auto-logout already performed by the manager's onPermanentFailure.
1935
+ // Wait for re-pair or abort rather than settling the host channel task.
1936
+ await lifecycle.rePairWait;
1852
1937
  return;
1853
1938
  }
1854
1939
  // success swaps the in-memory token in place; transient/skipped just connect
@@ -1896,6 +1981,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1896
1981
  },
1897
1982
  },
1898
1983
  });
1984
+ refreshClient = client;
1899
1985
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client created`);
1900
1986
 
1901
1987
  // §A.3/§A.4 — recompute the refresh-driven reconnect streak (depth + window).
@@ -2036,7 +2122,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2036
2122
  }
2037
2123
  const outcome = await refreshManager.refresh(reason);
2038
2124
  if (abortSignal.aborted) return "fallthrough";
2039
- if (autoLoggedOut) return "handled"; // permanent → manager auto-logged-out.
2125
+ if (autoLoggedOut) {
2126
+ await lifecycle.rePairWait;
2127
+ return "handled";
2128
+ }
2040
2129
  if (outcome.kind === "success") {
2041
2130
  await closeAndReconnectWithRefreshedToken(reason);
2042
2131
  return "handled";
@@ -3803,8 +3892,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3803
3892
  // return on abort, but only AFTER it has already taken the idempotency claim
3804
3893
  // (`claimInboundTurn` runs BEFORE the gate wait there, so the frame is durably
3805
3894
  // persisted before the interruptible wait — required by legacy advance-on-
3806
- // write delivery, where a frame written before a disconnect is not re-delivered
3807
- // on reconnect; see docs/client-integration.md §11.7). So even an ingest-time
3895
+ // write delivery, where a frame written before a disconnect is not reliably
3896
+ // re-delivered on reconnect (legacy may lose OR replay; a replay is deduped by
3897
+ // the claim); see docs/client-integration.md §11.7). So even an ingest-time
3808
3898
  // abort keeps the frame; it just skips dispatch. Here at FLUSH the batch was
3809
3899
  // likewise already claimed at ingest AND `flush()` has already removed it from
3810
3900
  // the coalescer's pending set. An abort early-return would therefore silently
@@ -3940,11 +4030,14 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3940
4030
  // can interrupt. This client does NOT advertise `reliable_delivery`
3941
4031
  // (src/ws-client.ts), so under legacy delivery the server's replay cursor
3942
4032
  // advances on socket-WRITE (docs/client-integration.md §11.7): a frame
3943
- // already written before a disconnect is NOT re-delivered on reconnect.
4033
+ // already written before a disconnect is NOT reliably re-delivered on
4034
+ // reconnect. Legacy is not at-most-once either — a multi-replica server
4035
+ // (relayed frames do not advance the cursor) or a failed cursor write can
4036
+ // replay frames already received — and the claim below dedupes those.
3944
4037
  // `claimMessageOnce` is the ONLY durability an inbound has, and the store
3945
4038
  // never reprocesses undispatched inbounds. So if we claimed only AFTER the
3946
- // wait, a shutdown-mid-wait would leave the frame neither persisted NOR
3947
- // replayed — permanently lost. Claiming first makes it durable; the abort
4039
+ // wait, a shutdown-mid-wait would leave the frame unpersisted with no
4040
+ // guaranteed replay — lost. Claiming first makes it durable; the abort
3948
4041
  // check below then returns without dispatching, but the frame is kept.
3949
4042
  // `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
3950
4043
  // message_id) is still deduped here exactly once. The claim fails CLOSED:
@@ -4281,6 +4374,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
4281
4374
  await client.connect();
4282
4375
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client.connect() resolved`);
4283
4376
  } catch (err) {
4377
+ if (autoLoggedOut) {
4378
+ await lifecycle.rePairWait;
4379
+ return;
4380
+ }
4284
4381
  const classified = classifyClawlingClientError(err);
4285
4382
  if (classified.kind === "auth") {
4286
4383
  // §A.2/§B — initial-connect hello-fail(auth). Do NOT pre-flip
@@ -4356,6 +4453,10 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
4356
4453
  );
4357
4454
  return;
4358
4455
  }
4456
+ if (autoLoggedOut) {
4457
+ await lifecycle.rePairWait;
4458
+ return;
4459
+ }
4359
4460
  activeClients.set(accountId, client);
4360
4461
  // Text "@name" autolinking on outbound group sends reads the same cached
4361
4462
  // roster (participant ids + nicknames) the group prompt shows the model.
package/src/storage.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RevokedRefreshMarker } from "./refresh-manager.ts";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { DatabaseSync } from "node:sqlite";
@@ -427,6 +428,19 @@ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
427
428
  sql: `
428
429
  CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
429
430
  ON clawchat_messages(chat_id COLLATE NOCASE);
431
+ `,
432
+ },
433
+ {
434
+ version: 13,
435
+ name: "revoked_refresh_tokens",
436
+ sql: `
437
+ CREATE TABLE IF NOT EXISTS revoked_refresh_tokens (
438
+ platform TEXT NOT NULL,
439
+ account_id TEXT NOT NULL,
440
+ fingerprint TEXT NOT NULL,
441
+ revoked_at INTEGER NOT NULL,
442
+ PRIMARY KEY (platform, account_id)
443
+ );
430
444
  `,
431
445
  },
432
446
  ];
@@ -600,6 +614,34 @@ export class ClawChatStore {
600
614
  });
601
615
  }
602
616
 
617
+ setRevokedRefreshMarker(input: ClearActivationCredentialsInput & RevokedRefreshMarker): boolean | null {
618
+ return this.write(() => {
619
+ this.requireDb().prepare(`INSERT INTO revoked_refresh_tokens
620
+ (platform, account_id, fingerprint, revoked_at) VALUES (?, ?, ?, ?)
621
+ ON CONFLICT(platform, account_id) DO UPDATE SET
622
+ fingerprint = excluded.fingerprint, revoked_at = excluded.revoked_at`)
623
+ .run(input.platform, input.accountId, input.fingerprint, input.revokedAt);
624
+ return true;
625
+ });
626
+ }
627
+
628
+ getRevokedRefreshMarker(input: ClearActivationCredentialsInput): RevokedRefreshMarker | null {
629
+ return this.read(() => {
630
+ const row = this.requireDb().prepare(`SELECT fingerprint, revoked_at AS revokedAt
631
+ FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
632
+ .get(input.platform, input.accountId) as RevokedRefreshMarker | undefined;
633
+ return row ?? null;
634
+ }) ?? null;
635
+ }
636
+
637
+ clearRevokedRefreshMarker(input: ClearActivationCredentialsInput): boolean | null {
638
+ return this.write(() => {
639
+ this.requireDb().prepare(`DELETE FROM revoked_refresh_tokens WHERE platform = ? AND account_id = ?`)
640
+ .run(input.platform, input.accountId);
641
+ return true;
642
+ });
643
+ }
644
+
603
645
  getActivationCredentials(input: ConversationAccountInput): ActivationCredentials | null {
604
646
  return this.read(() => {
605
647
  const row = this.requireDb()