@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.
- package/dist/src/api-client.js +1 -0
- package/dist/src/refresh-manager.js +23 -22
- package/dist/src/runtime.js +163 -64
- package/dist/src/storage.js +38 -0
- package/package.json +1 -1
- package/src/api-client.ts +1 -0
- package/src/refresh-manager.ts +30 -25
- package/src/runtime.ts +167 -66
- package/src/storage.ts +42 -0
package/dist/src/api-client.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
176
|
-
//
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.ports.log?.info?.(
|
|
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({
|
|
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) {
|
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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 =
|
|
1067
|
-
if (
|
|
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
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
3268
|
-
// on reconnect
|
|
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
|
|
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
|
|
3393
|
-
//
|
|
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.
|
package/dist/src/storage.js
CHANGED
|
@@ -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
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,
|
package/src/refresh-manager.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
261
|
-
//
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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?:
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
1027
|
-
|
|
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
|
|
1055
|
-
|
|
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
|
|
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<
|
|
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 =
|
|
1448
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
|
3807
|
-
// on reconnect
|
|
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
|
|
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
|
|
3947
|
-
//
|
|
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()
|