@clawling/clawchat-plugin-openclaw 2026.5.14-3 → 2026.6.16-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.
- package/dist/src/api-client.js +9 -1
- package/dist/src/refresh-manager.js +34 -2
- package/dist/src/runtime.js +83 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/src/api-client.ts +9 -1
- package/src/refresh-manager.ts +48 -3
- package/src/runtime.ts +109 -1
package/dist/src/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
|
@@ -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 {
|
|
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) {
|
package/dist/src/runtime.js
CHANGED
|
@@ -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 {
|
|
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,
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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
|
-
|
|
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/refresh-manager.ts
CHANGED
|
@@ -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(): {
|
|
345
|
-
|
|
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: {
|
|
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
|
|
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,
|