@clawling/clawchat-plugin-openclaw 2026.5.14-3 → 2026.6.16-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,15 @@ export function decodeJwtExp(token) {
39
39
  const CODE_OK = 0;
40
40
  const CODE_INTERNAL = 1; // CodeInternal — transient.
41
41
  const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
42
- const CODE_INVALID_REFRESH = 10003; // CodeInvalidRefresh — permanent.
42
+ /**
43
+ * CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
44
+ * token AND a single-use refresh token that was already CONSUMED by a prior
45
+ * successful rotation (a duplicate-supervisor / concurrent-refresh / stale-store
46
+ * race). The stateless `authRefresh` cannot tell the two apart, so it reports
47
+ * `permanent`; the stateful `RefreshManager` re-classifies a consumed-rotation
48
+ * race back to transient (see §B race) before any auto-logout.
49
+ */
50
+ export const CODE_INVALID_REFRESH = 10003;
43
51
  /**
44
52
  * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
45
53
  *
@@ -185,6 +185,27 @@ function readOptionalString(value) {
185
185
  function readEnvString(env, key) {
186
186
  return readOptionalString(env[key]);
187
187
  }
188
+ /**
189
+ * Decode a string claim from a JWT access token's payload (no signature
190
+ * verification — this is for self-identifying claims the backend already signed,
191
+ * e.g. `oid` owner id). Returns "" on any malformed input.
192
+ */
193
+ function decodeJwtStringClaim(token, claim) {
194
+ if (typeof token !== "string")
195
+ return "";
196
+ const segments = token.split(".");
197
+ if (segments.length < 2 || !segments[1])
198
+ return "";
199
+ try {
200
+ const json = Buffer.from(segments[1], "base64url").toString("utf8");
201
+ const parsed = JSON.parse(json);
202
+ const value = parsed?.[claim];
203
+ return typeof value === "string" ? value.trim() : "";
204
+ }
205
+ catch {
206
+ return "";
207
+ }
208
+ }
188
209
  function readGroupMode(value) {
189
210
  return value === "mention" ? "mention" : "all";
190
211
  }
@@ -291,7 +312,13 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
291
312
  const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
292
313
  const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
293
314
  const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
294
- const ownerUserId = readOptionalString(channel.ownerUserId) || readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV);
315
+ const ownerUserId = readOptionalString(channel.ownerUserId) ||
316
+ readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
317
+ // Fall back to the access token's `oid` (owner) claim. A provisioner may
318
+ // inject token + userId but omit ownerUserId; without it the connect gate
319
+ // (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
320
+ // in the wait-for-activation loop. The owner is always carried in the token.
321
+ decodeJwtStringClaim(token, "oid");
295
322
  const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
296
323
  const outputVisibility = readOutputVisibility(channel.outputVisibility);
297
324
  const chats = readChats(channel.chats);
@@ -1,4 +1,4 @@
1
- import { authRefresh, decodeJwtExp, } from "./api-client.js";
1
+ import { authRefresh, CODE_INVALID_REFRESH, decodeJwtExp, } from "./api-client.js";
2
2
  /**
3
3
  * §A–§D — ClawChat token refresh + auto-logout orchestration for the OpenClaw
4
4
  * plugin.
@@ -54,6 +54,12 @@ export class RefreshManager {
54
54
  inFlight = null;
55
55
  /** §A.3 — the access token a refresh was last attempted for. */
56
56
  rejectedToken = null;
57
+ /**
58
+ * §B race — the refresh token we last successfully rotated AWAY from (now
59
+ * consumed server-side). A subsequent `code:10003` for this exact token is a
60
+ * consumed-rotation race, NOT a revocation, so it must not auto-logout.
61
+ */
62
+ lastRotatedFromToken = null;
57
63
  /** §A.3 — epoch-ms of the last refresh attempt (any token). */
58
64
  lastAttemptAt = 0;
59
65
  proactiveTimer = null;
@@ -138,12 +144,33 @@ export class RefreshManager {
138
144
  accessToken: result.accessToken,
139
145
  refreshToken: result.refreshToken,
140
146
  });
147
+ // §B race — record the now-consumed refresh token so a later `10003` for it
148
+ // is recognized as a rotation race rather than a revocation.
149
+ this.lastRotatedFromToken = refreshToken;
141
150
  // Clear the latch; the access token has actually changed.
142
151
  this.rejectedToken = null;
143
152
  this.ports.log?.info?.("clawchat-plugin-openclaw refresh success (token rotated)");
144
153
  return { kind: "success", accessToken: result.accessToken, refreshToken: result.refreshToken };
145
154
  }
146
155
  if (result.kind === "permanent") {
156
+ // §B race — a `code:10003` is also returned for a single-use refresh token
157
+ // already CONSUMED by a prior successful rotation. Before auto-logging-out
158
+ // (which wipes credentials and bricks the agent), distinguish that race
159
+ // from a genuine revocation. It is a race when EITHER the submitted token
160
+ // is one we already rotated away from, OR the live store refresh token has
161
+ // moved on since we read it (another worker rotated underneath us). In
162
+ // either case the credential chain is still alive — report transient (no
163
+ // latch, no logout) so the next attempt proceeds with the live token. A
164
+ // `code:400` (client bug) is never a race.
165
+ if (result.code === CODE_INVALID_REFRESH) {
166
+ const liveRefresh = this.ports.getRefreshToken();
167
+ const rotatedByUs = this.lastRotatedFromToken !== null && refreshToken === this.lastRotatedFromToken;
168
+ const rotatedUnderneath = !!liveRefresh && liveRefresh.trim().length > 0 && liveRefresh !== refreshToken;
169
+ if (rotatedByUs || rotatedUnderneath) {
170
+ this.ports.log?.info?.(`clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race (rotatedByUs=${rotatedByUs} rotatedUnderneath=${rotatedUnderneath}); recovering with live token`);
171
+ return { kind: "transient", message: "refresh token already rotated (race); retrying with current token" };
172
+ }
173
+ }
147
174
  // §A.3 — latch the dead token so reconnect storms don't re-fire refresh.
148
175
  this.rejectedToken = accessToken;
149
176
  this.ports.log?.error?.(`clawchat-plugin-openclaw refresh permanent failure code=${result.code}: ${result.message}`);
@@ -249,12 +276,17 @@ export class RefreshManager {
249
276
  * the guards would not bound a cross-reconnect loop).
250
277
  */
251
278
  exportState() {
252
- return { rejectedToken: this.rejectedToken, lastAttemptAt: this.lastAttemptAt };
279
+ return {
280
+ rejectedToken: this.rejectedToken,
281
+ lastAttemptAt: this.lastAttemptAt,
282
+ lastRotatedFromToken: this.lastRotatedFromToken,
283
+ };
253
284
  }
254
285
  /** §A.3/§A.4 — restore guard state from a prior manager (see `exportState`). */
255
286
  restoreState(state) {
256
287
  this.rejectedToken = state.rejectedToken;
257
288
  this.lastAttemptAt = state.lastAttemptAt;
289
+ this.lastRotatedFromToken = state.lastRotatedFromToken ?? null;
258
290
  }
259
291
  }
260
292
  function decodeJwtIat(token) {
@@ -7,7 +7,8 @@ import { createOpenclawClawlingApiClient } from "./api-client.js";
7
7
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.js";
8
8
  import { ClawlingApiError } from "./api-types.js";
9
9
  import { RefreshManager } from "./refresh-manager.js";
10
- import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, hasOpenclawClawlingConnectCredentials, } from "./config.js";
10
+ import { runOpenclawClawlingLogin, } from "./login.runtime.js";
11
+ import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
11
12
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
12
13
  import { fetchInboundMedia } from "./media-runtime.js";
13
14
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
@@ -339,6 +340,72 @@ function resolveConnectionStore(params, runtime) {
339
340
  function accountHasConnectCredentials(account) {
340
341
  return hasOpenclawClawlingConnectCredentials(account);
341
342
  }
343
+ /**
344
+ * §B self-heal — env var carrying a one-shot connect code for non-interactive
345
+ * (re)activation. Containers / orchestrators set this so a plugin that lost its
346
+ * token (e.g. a consumed-rotation auto-logout, or a wiped/rotated env) can
347
+ * recover WITHOUT the openclaw CLI channel registry (which cannot reach this
348
+ * plugin pre-config) and without an in-chat `/clawchat-activate` (which needs the
349
+ * agent already online — a chicken/egg). Declared in `openclaw.plugin.json`
350
+ * `channelEnvVars` so the host forwards it.
351
+ */
352
+ export const CONNECT_CODE_ENV = "CLAWCHAT_CONNECT_CODE";
353
+ /**
354
+ * §B self-heal — process-scoped record of connect codes we have already tried to
355
+ * redeem, so a repeated `startAccount` (reconnect / re-enter) does not hammer the
356
+ * server with the same (often single-use) code. The real guard against
357
+ * re-redeeming a SUCCESSFUL code is `accountHasConnectCredentials`; this set only
358
+ * bounds retries of a code that has not yet produced credentials this process.
359
+ */
360
+ const consumedAutoActivationCodes = new Set();
361
+ /** Test seam — clear the process-scoped consumed-code set between cases. */
362
+ export function resetAutoActivationStateForTests() {
363
+ consumedAutoActivationCodes.clear();
364
+ }
365
+ /**
366
+ * §B self-heal — if a connect code is present in the environment and the account
367
+ * is NOT already credentialed, redeem it via the shared invite-code exchange
368
+ * (`runOpenclawClawlingLogin`, which also persists the activation row to SQLite),
369
+ * then return the re-resolved account. Credentials are written into the in-memory
370
+ * `cfg` (no host restart); durability comes from the SQLite activation row. All
371
+ * failures are swallowed — a bad/expired code must not crash startup; the gateway
372
+ * falls through to the normal wait-for-activation loop.
373
+ */
374
+ export async function maybeAutoActivateFromEnv(params) {
375
+ const code = (params.connectCode ?? "").trim();
376
+ if (!code)
377
+ return params.account;
378
+ if (accountHasConnectCredentials(params.account))
379
+ return params.account;
380
+ if (consumedAutoActivationCodes.has(code))
381
+ return params.account;
382
+ consumedAutoActivationCodes.add(code);
383
+ const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
384
+ const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
385
+ const accountId = params.account.accountId;
386
+ params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`);
387
+ try {
388
+ await runLogin({
389
+ cfg: params.cfg,
390
+ accountId: params.accountId,
391
+ runtime: { log: (m) => params.log?.info?.(m) },
392
+ readInviteCode: async () => code,
393
+ // Update the IN-MEMORY config so this process re-resolves to a configured
394
+ // account immediately (no host restart). Durable persistence is the SQLite
395
+ // activations row that runOpenclawClawlingLogin writes alongside this.
396
+ persistConfig: (next) => {
397
+ Object.assign(params.cfg, next);
398
+ },
399
+ });
400
+ }
401
+ catch (err) {
402
+ params.log?.error?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation failed: ${err instanceof Error ? err.message : String(err)}`);
403
+ return params.account;
404
+ }
405
+ const next = resolveAccount(params.cfg);
406
+ params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation succeeded hasToken=${Boolean(next.token)}`);
407
+ return next;
408
+ }
342
409
  function waitForActivationPoll(abortSignal, ms) {
343
410
  if (abortSignal.aborted)
344
411
  return Promise.resolve(false);
@@ -439,6 +506,21 @@ export async function startOpenclawClawlingGateway(params) {
439
506
  authenticated: false,
440
507
  log,
441
508
  });
509
+ // §B self-heal — non-interactive env connect-code activation BEFORE blocking on
510
+ // the wait-for-activation loop. Gated so the common path (no env code, or an
511
+ // already-credentialed account) adds NO async work: on success it credentials
512
+ // the in-memory account (and persists the SQLite activation row) so the loop
513
+ // below returns immediately.
514
+ const envConnectCode = process.env[CONNECT_CODE_ENV];
515
+ if (envConnectCode && envConnectCode.trim() && !accountHasConnectCredentials(account)) {
516
+ account = await maybeAutoActivateFromEnv({
517
+ cfg,
518
+ account,
519
+ accountId,
520
+ connectCode: envConnectCode,
521
+ log,
522
+ });
523
+ }
442
524
  const activationAccount = await waitForActivationCredentials({
443
525
  account,
444
526
  abortSignal,
@@ -22,6 +22,7 @@
22
22
  "CLAWCHAT_USER_ID",
23
23
  "CLAWCHAT_OWNER_USER_ID",
24
24
  "CLAWCHAT_REFRESH_TOKEN",
25
+ "CLAWCHAT_CONNECT_CODE",
25
26
  "CLAWCHAT_BASE_URL",
26
27
  "CLAWCHAT_WEBSOCKET_URL",
27
28
  "CLAWCHAT_MEDIA_BASE_URL"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.5.14-3",
3
+ "version": "2026.6.16-2",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "files": [
package/src/api-client.ts CHANGED
@@ -164,7 +164,15 @@ export interface OpenclawClawlingApiClient {
164
164
  const CODE_OK = 0;
165
165
  const CODE_INTERNAL = 1; // CodeInternal — transient.
166
166
  const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
167
- const CODE_INVALID_REFRESH = 10003; // CodeInvalidRefresh — permanent.
167
+ /**
168
+ * CodeInvalidRefresh — returned for BOTH a genuinely revoked/invalid refresh
169
+ * token AND a single-use refresh token that was already CONSUMED by a prior
170
+ * successful rotation (a duplicate-supervisor / concurrent-refresh / stale-store
171
+ * race). The stateless `authRefresh` cannot tell the two apart, so it reports
172
+ * `permanent`; the stateful `RefreshManager` re-classifies a consumed-rotation
173
+ * race back to transient (see §B race) before any auto-logout.
174
+ */
175
+ export const CODE_INVALID_REFRESH = 10003;
168
176
 
169
177
  /**
170
178
  * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
package/src/config.ts CHANGED
@@ -309,6 +309,25 @@ function readEnvString(env: Record<string, string | undefined>, key: string): st
309
309
  return readOptionalString(env[key]);
310
310
  }
311
311
 
312
+ /**
313
+ * Decode a string claim from a JWT access token's payload (no signature
314
+ * verification — this is for self-identifying claims the backend already signed,
315
+ * e.g. `oid` owner id). Returns "" on any malformed input.
316
+ */
317
+ function decodeJwtStringClaim(token: string, claim: string): string {
318
+ if (typeof token !== "string") return "";
319
+ const segments = token.split(".");
320
+ if (segments.length < 2 || !segments[1]) return "";
321
+ try {
322
+ const json = Buffer.from(segments[1], "base64url").toString("utf8");
323
+ const parsed = JSON.parse(json) as Record<string, unknown>;
324
+ const value = parsed?.[claim];
325
+ return typeof value === "string" ? value.trim() : "";
326
+ } catch {
327
+ return "";
328
+ }
329
+ }
330
+
312
331
  function readGroupMode(value: unknown): GroupMode {
313
332
  return value === "mention" ? "mention" : "all";
314
333
  }
@@ -444,7 +463,13 @@ export function resolveOpenclawClawlingAccount(
444
463
  const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
445
464
  const userId = readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV);
446
465
  const ownerUserId =
447
- readOptionalString(channel.ownerUserId) || readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV);
466
+ readOptionalString(channel.ownerUserId) ||
467
+ readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
468
+ // Fall back to the access token's `oid` (owner) claim. A provisioner may
469
+ // inject token + userId but omit ownerUserId; without it the connect gate
470
+ // (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
471
+ // in the wait-for-activation loop. The owner is always carried in the token.
472
+ decodeJwtStringClaim(token, "oid");
448
473
  const enabled = typeof channel.enabled === "boolean" ? channel.enabled : true;
449
474
  const outputVisibility = readOutputVisibility(channel.outputVisibility);
450
475
  const chats = readChats(channel.chats);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  authRefresh,
3
+ CODE_INVALID_REFRESH,
3
4
  decodeJwtExp,
4
5
  type AuthRefreshResult,
5
6
  } from "./api-client.ts";
@@ -119,6 +120,12 @@ export class RefreshManager {
119
120
  private inFlight: Promise<RefreshOutcome> | null = null;
120
121
  /** §A.3 — the access token a refresh was last attempted for. */
121
122
  private rejectedToken: string | null = null;
123
+ /**
124
+ * §B race — the refresh token we last successfully rotated AWAY from (now
125
+ * consumed server-side). A subsequent `code:10003` for this exact token is a
126
+ * consumed-rotation race, NOT a revocation, so it must not auto-logout.
127
+ */
128
+ private lastRotatedFromToken: string | null = null;
122
129
  /** §A.3 — epoch-ms of the last refresh attempt (any token). */
123
130
  private lastAttemptAt = 0;
124
131
  private proactiveTimer: TimerHandle | null = null;
@@ -219,6 +226,9 @@ export class RefreshManager {
219
226
  accessToken: result.accessToken,
220
227
  refreshToken: result.refreshToken,
221
228
  });
229
+ // §B race — record the now-consumed refresh token so a later `10003` for it
230
+ // is recognized as a rotation race rather than a revocation.
231
+ this.lastRotatedFromToken = refreshToken;
222
232
  // Clear the latch; the access token has actually changed.
223
233
  this.rejectedToken = null;
224
234
  this.ports.log?.info?.("clawchat-plugin-openclaw refresh success (token rotated)");
@@ -226,6 +236,28 @@ export class RefreshManager {
226
236
  }
227
237
 
228
238
  if (result.kind === "permanent") {
239
+ // §B race — a `code:10003` is also returned for a single-use refresh token
240
+ // already CONSUMED by a prior successful rotation. Before auto-logging-out
241
+ // (which wipes credentials and bricks the agent), distinguish that race
242
+ // from a genuine revocation. It is a race when EITHER the submitted token
243
+ // is one we already rotated away from, OR the live store refresh token has
244
+ // moved on since we read it (another worker rotated underneath us). In
245
+ // either case the credential chain is still alive — report transient (no
246
+ // latch, no logout) so the next attempt proceeds with the live token. A
247
+ // `code:400` (client bug) is never a race.
248
+ if (result.code === CODE_INVALID_REFRESH) {
249
+ const liveRefresh = this.ports.getRefreshToken();
250
+ const rotatedByUs =
251
+ this.lastRotatedFromToken !== null && refreshToken === this.lastRotatedFromToken;
252
+ const rotatedUnderneath =
253
+ !!liveRefresh && liveRefresh.trim().length > 0 && liveRefresh !== refreshToken;
254
+ if (rotatedByUs || rotatedUnderneath) {
255
+ this.ports.log?.info?.(
256
+ `clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race (rotatedByUs=${rotatedByUs} rotatedUnderneath=${rotatedUnderneath}); recovering with live token`,
257
+ );
258
+ return { kind: "transient", message: "refresh token already rotated (race); retrying with current token" };
259
+ }
260
+ }
229
261
  // §A.3 — latch the dead token so reconnect storms don't re-fire refresh.
230
262
  this.rejectedToken = accessToken;
231
263
  this.ports.log?.error?.(
@@ -341,14 +373,27 @@ export class RefreshManager {
341
373
  * restoring this state the rejected-token latch + min-interval would reset and
342
374
  * the guards would not bound a cross-reconnect loop).
343
375
  */
344
- exportState(): { rejectedToken: string | null; lastAttemptAt: number } {
345
- return { rejectedToken: this.rejectedToken, lastAttemptAt: this.lastAttemptAt };
376
+ exportState(): {
377
+ rejectedToken: string | null;
378
+ lastAttemptAt: number;
379
+ lastRotatedFromToken: string | null;
380
+ } {
381
+ return {
382
+ rejectedToken: this.rejectedToken,
383
+ lastAttemptAt: this.lastAttemptAt,
384
+ lastRotatedFromToken: this.lastRotatedFromToken,
385
+ };
346
386
  }
347
387
 
348
388
  /** §A.3/§A.4 — restore guard state from a prior manager (see `exportState`). */
349
- restoreState(state: { rejectedToken: string | null; lastAttemptAt: number }): void {
389
+ restoreState(state: {
390
+ rejectedToken: string | null;
391
+ lastAttemptAt: number;
392
+ lastRotatedFromToken?: string | null;
393
+ }): void {
350
394
  this.rejectedToken = state.rejectedToken;
351
395
  this.lastAttemptAt = state.lastAttemptAt;
396
+ this.lastRotatedFromToken = state.lastRotatedFromToken ?? null;
352
397
  }
353
398
  }
354
399
 
package/src/runtime.ts CHANGED
@@ -18,12 +18,17 @@ import { createOpenclawClawlingApiClient } from "./api-client.ts";
18
18
  import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.ts";
19
19
  import { ClawlingApiError } from "./api-types.ts";
20
20
  import { RefreshManager } from "./refresh-manager.ts";
21
- import type { OpenclawClawchatMutateConfigFile } from "./login.runtime.ts";
21
+ import {
22
+ runOpenclawClawlingLogin,
23
+ type LoginParams,
24
+ type OpenclawClawchatMutateConfigFile,
25
+ } from "./login.runtime.ts";
22
26
  import {
23
27
  CHANNEL_ID,
24
28
  effectiveOutputVisibility,
25
29
  effectiveGroupCommandMode,
26
30
  hasOpenclawClawlingConnectCredentials,
31
+ resolveOpenclawClawlingAccount,
27
32
  type ResolvedOpenclawClawlingAccount,
28
33
  } from "./config.ts";
29
34
  import type { ClawlingChatClient } from "./ws-client.ts";
@@ -548,6 +553,94 @@ function accountHasConnectCredentials(account: ResolvedOpenclawClawlingAccount):
548
553
  return hasOpenclawClawlingConnectCredentials(account);
549
554
  }
550
555
 
556
+ /**
557
+ * §B self-heal — env var carrying a one-shot connect code for non-interactive
558
+ * (re)activation. Containers / orchestrators set this so a plugin that lost its
559
+ * token (e.g. a consumed-rotation auto-logout, or a wiped/rotated env) can
560
+ * recover WITHOUT the openclaw CLI channel registry (which cannot reach this
561
+ * plugin pre-config) and without an in-chat `/clawchat-activate` (which needs the
562
+ * agent already online — a chicken/egg). Declared in `openclaw.plugin.json`
563
+ * `channelEnvVars` so the host forwards it.
564
+ */
565
+ export const CONNECT_CODE_ENV = "CLAWCHAT_CONNECT_CODE";
566
+
567
+ /**
568
+ * §B self-heal — process-scoped record of connect codes we have already tried to
569
+ * redeem, so a repeated `startAccount` (reconnect / re-enter) does not hammer the
570
+ * server with the same (often single-use) code. The real guard against
571
+ * re-redeeming a SUCCESSFUL code is `accountHasConnectCredentials`; this set only
572
+ * bounds retries of a code that has not yet produced credentials this process.
573
+ */
574
+ const consumedAutoActivationCodes = new Set<string>();
575
+
576
+ /** Test seam — clear the process-scoped consumed-code set between cases. */
577
+ export function resetAutoActivationStateForTests(): void {
578
+ consumedAutoActivationCodes.clear();
579
+ }
580
+
581
+ export interface AutoActivateFromEnvParams {
582
+ cfg: OpenClawConfig;
583
+ account: ResolvedOpenclawClawlingAccount;
584
+ accountId: string | null;
585
+ /** The connect code (production: `process.env[CONNECT_CODE_ENV]`). */
586
+ connectCode: string | undefined;
587
+ log?: Log;
588
+ /** Test seam — login implementation. */
589
+ runLogin?: (params: LoginParams) => Promise<void>;
590
+ /** Test seam — account resolver applied to the (mutated) cfg after login. */
591
+ resolveAccount?: (cfg: OpenClawConfig) => ResolvedOpenclawClawlingAccount;
592
+ }
593
+
594
+ /**
595
+ * §B self-heal — if a connect code is present in the environment and the account
596
+ * is NOT already credentialed, redeem it via the shared invite-code exchange
597
+ * (`runOpenclawClawlingLogin`, which also persists the activation row to SQLite),
598
+ * then return the re-resolved account. Credentials are written into the in-memory
599
+ * `cfg` (no host restart); durability comes from the SQLite activation row. All
600
+ * failures are swallowed — a bad/expired code must not crash startup; the gateway
601
+ * falls through to the normal wait-for-activation loop.
602
+ */
603
+ export async function maybeAutoActivateFromEnv(
604
+ params: AutoActivateFromEnvParams,
605
+ ): Promise<ResolvedOpenclawClawlingAccount> {
606
+ const code = (params.connectCode ?? "").trim();
607
+ if (!code) return params.account;
608
+ if (accountHasConnectCredentials(params.account)) return params.account;
609
+ if (consumedAutoActivationCodes.has(code)) return params.account;
610
+ consumedAutoActivationCodes.add(code);
611
+
612
+ const runLogin = params.runLogin ?? runOpenclawClawlingLogin;
613
+ const resolveAccount = params.resolveAccount ?? resolveOpenclawClawlingAccount;
614
+ const accountId = params.account.accountId;
615
+ params.log?.info?.(
616
+ `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} present; attempting non-interactive activation`,
617
+ );
618
+ try {
619
+ await runLogin({
620
+ cfg: params.cfg,
621
+ accountId: params.accountId,
622
+ runtime: { log: (m: string) => params.log?.info?.(m) },
623
+ readInviteCode: async () => code,
624
+ // Update the IN-MEMORY config so this process re-resolves to a configured
625
+ // account immediately (no host restart). Durable persistence is the SQLite
626
+ // activations row that runOpenclawClawlingLogin writes alongside this.
627
+ persistConfig: (next) => {
628
+ Object.assign(params.cfg, next);
629
+ },
630
+ });
631
+ } catch (err) {
632
+ params.log?.error?.(
633
+ `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation failed: ${err instanceof Error ? err.message : String(err)}`,
634
+ );
635
+ return params.account;
636
+ }
637
+ const next = resolveAccount(params.cfg);
638
+ params.log?.info?.(
639
+ `[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation succeeded hasToken=${Boolean(next.token)}`,
640
+ );
641
+ return next;
642
+ }
643
+
551
644
  function waitForActivationPoll(abortSignal: AbortSignal, ms: number): Promise<boolean> {
552
645
  if (abortSignal.aborted) return Promise.resolve(false);
553
646
  return new Promise((resolve) => {
@@ -669,6 +762,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
669
762
  authenticated: false,
670
763
  log,
671
764
  });
765
+ // §B self-heal — non-interactive env connect-code activation BEFORE blocking on
766
+ // the wait-for-activation loop. Gated so the common path (no env code, or an
767
+ // already-credentialed account) adds NO async work: on success it credentials
768
+ // the in-memory account (and persists the SQLite activation row) so the loop
769
+ // below returns immediately.
770
+ const envConnectCode = process.env[CONNECT_CODE_ENV];
771
+ if (envConnectCode && envConnectCode.trim() && !accountHasConnectCredentials(account)) {
772
+ account = await maybeAutoActivateFromEnv({
773
+ cfg,
774
+ account,
775
+ accountId,
776
+ connectCode: envConnectCode,
777
+ log,
778
+ });
779
+ }
672
780
  const activationAccount = await waitForActivationCredentials({
673
781
  account,
674
782
  abortSignal,