@clawling/clawchat-plugin-openclaw 2026.5.13-dev.0 → 2026.5.13-dev.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api-client.js +146 -26
- package/dist/src/client.js +4 -1
- package/dist/src/inbound.js +21 -4
- package/dist/src/login.runtime.js +4 -0
- package/dist/src/outbound.js +43 -8
- package/dist/src/refresh-manager.js +278 -0
- package/dist/src/reply-dispatcher.js +5 -2
- package/dist/src/runtime.js +552 -28
- package/dist/src/storage.js +81 -5
- package/dist/src/ws-alignment.js +2 -1
- package/dist/src/ws-client.js +42 -4
- package/package.json +1 -1
- package/src/api-client.ts +174 -31
- package/src/client.ts +12 -1
- package/src/inbound.ts +24 -5
- package/src/login.runtime.ts +4 -0
- package/src/outbound.ts +47 -9
- package/src/protocol-types.ts +8 -2
- package/src/refresh-manager.ts +371 -0
- package/src/reply-dispatcher.ts +5 -2
- package/src/runtime.ts +632 -25
- package/src/storage.ts +124 -4
- package/src/ws-alignment.ts +2 -1
- package/src/ws-client.ts +40 -4
package/dist/src/api-client.js
CHANGED
|
@@ -1,5 +1,120 @@
|
|
|
1
1
|
import { ClawlingApiError, } from "./api-types.js";
|
|
2
2
|
import { CHANNEL_ID } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* §A.0 — decode the access token's `exp` claim locally (base64url-decode the
|
|
5
|
+
* JWT payload segment, read `exp` as epoch seconds). Returns `null` when the
|
|
6
|
+
* token is not a parseable JWT or carries no numeric `exp`, in which case the
|
|
7
|
+
* caller falls back to `activated_at + 24h`. We never persist a separate
|
|
8
|
+
* expiry column; this is derived from the token on every load.
|
|
9
|
+
*/
|
|
10
|
+
export function decodeJwtExp(token) {
|
|
11
|
+
if (typeof token !== "string")
|
|
12
|
+
return null;
|
|
13
|
+
const segments = token.split(".");
|
|
14
|
+
if (segments.length < 2)
|
|
15
|
+
return null;
|
|
16
|
+
const payloadSegment = segments[1];
|
|
17
|
+
if (!payloadSegment)
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const json = Buffer.from(payloadSegment, "base64url").toString("utf8");
|
|
21
|
+
const parsed = JSON.parse(json);
|
|
22
|
+
const exp = parsed?.exp;
|
|
23
|
+
return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Backend envelope codes for `POST /v1/auth/refresh` (§0). */
|
|
30
|
+
const CODE_OK = 0;
|
|
31
|
+
const CODE_INTERNAL = 1; // CodeInternal — transient.
|
|
32
|
+
const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
|
|
33
|
+
const CODE_INVALID_REFRESH = 10003; // CodeInvalidRefresh — permanent.
|
|
34
|
+
/**
|
|
35
|
+
* §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
|
|
36
|
+
*
|
|
37
|
+
* Unauthenticated: the refresh token in the body IS the credential, so we send
|
|
38
|
+
* NO `Authorization` header. `X-Device-Id` MUST equal the connect-time device
|
|
39
|
+
* id (the backend rejects on `sess.DeviceID != X-Device-Id`). The endpoint is
|
|
40
|
+
* always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
|
|
41
|
+
* a standalone function (not a method on the token-bearing client) precisely
|
|
42
|
+
* because no bearer token participates.
|
|
43
|
+
*/
|
|
44
|
+
export async function authRefresh(opts, params) {
|
|
45
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
46
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
47
|
+
if (!params.refreshToken?.trim()) {
|
|
48
|
+
return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
|
|
49
|
+
}
|
|
50
|
+
let res;
|
|
51
|
+
try {
|
|
52
|
+
res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
// No `Authorization` header — refresh is unauthenticated by design.
|
|
55
|
+
headers: {
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
"x-device-id": params.deviceId,
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
// Network error / timeout / DNS — TRANSIENT (no rotation committed).
|
|
64
|
+
return {
|
|
65
|
+
kind: "transient",
|
|
66
|
+
message: `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (res.status !== 200) {
|
|
70
|
+
// Any non-200 (500/LB/transport) — TRANSIENT, regardless of body.
|
|
71
|
+
const text = await res.text().catch(() => "");
|
|
72
|
+
return {
|
|
73
|
+
kind: "transient",
|
|
74
|
+
status: res.status,
|
|
75
|
+
message: `refresh http ${res.status} ${text.slice(0, 200)}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const text = await res.text().catch(() => "");
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
parsed = undefined;
|
|
85
|
+
}
|
|
86
|
+
const code = typeof parsed?.code === "number" ? parsed.code : Number.NaN;
|
|
87
|
+
const message = (typeof parsed?.msg === "string" && parsed.msg) ||
|
|
88
|
+
(typeof parsed?.message === "string" && parsed.message) ||
|
|
89
|
+
`code=${code}`;
|
|
90
|
+
if (!Number.isFinite(code)) {
|
|
91
|
+
// 200 with no usable envelope — treat as transient (do not auto-logout).
|
|
92
|
+
return { kind: "transient", status: 200, message: "refresh: missing numeric code" };
|
|
93
|
+
}
|
|
94
|
+
if (code === CODE_OK) {
|
|
95
|
+
const data = parsed?.data && typeof parsed.data === "object"
|
|
96
|
+
? parsed.data
|
|
97
|
+
: {};
|
|
98
|
+
const accessToken = typeof data.access_token === "string" ? data.access_token : "";
|
|
99
|
+
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
100
|
+
if (!accessToken || !refreshToken) {
|
|
101
|
+
// Rotation succeeded server-side but the body is malformed — transient so
|
|
102
|
+
// we retry; the next attempt will return 10003 (rotation single-use) and
|
|
103
|
+
// escalate to permanent (§B transient→permanent).
|
|
104
|
+
return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
|
|
105
|
+
}
|
|
106
|
+
return { kind: "success", accessToken, refreshToken };
|
|
107
|
+
}
|
|
108
|
+
if (code === CODE_INVALID_REFRESH || code === CODE_BAD_REQUEST) {
|
|
109
|
+
return { kind: "permanent", code, message };
|
|
110
|
+
}
|
|
111
|
+
if (code === CODE_INTERNAL) {
|
|
112
|
+
return { kind: "transient", status: 200, code, message };
|
|
113
|
+
}
|
|
114
|
+
// Unknown non-zero code — conservatively transient (never auto-logout on an
|
|
115
|
+
// unrecognized code; only 10003/400 are permanent per §0).
|
|
116
|
+
return { kind: "transient", status: 200, code, message };
|
|
117
|
+
}
|
|
3
118
|
export function createOpenclawClawlingApiClient(opts) {
|
|
4
119
|
if (!/^https?:\/\//i.test(opts.baseUrl)) {
|
|
5
120
|
throw new ClawlingApiError("validation", `clawchat-plugin-openclaw baseUrl must start with http:// or https:// (got "${opts.baseUrl}")`);
|
|
@@ -33,44 +148,49 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
33
148
|
path,
|
|
34
149
|
});
|
|
35
150
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
151
|
+
// §15.3: HTTP status stays meaningful for proxies, but application code
|
|
152
|
+
// should branch on the business `code`, not the HTTP status. Parse the body
|
|
153
|
+
// FIRST — even on a non-2xx — so callers receive the precise business code
|
|
154
|
+
// (e.g. 41301 vs 41501) rather than a generic transport error. Only fall
|
|
155
|
+
// back to the HTTP status when no structured envelope is present.
|
|
156
|
+
const text = await res.text().catch(() => "");
|
|
43
157
|
let parsed;
|
|
44
158
|
try {
|
|
45
|
-
parsed =
|
|
159
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
46
160
|
}
|
|
47
|
-
catch
|
|
48
|
-
|
|
161
|
+
catch {
|
|
162
|
+
parsed = undefined;
|
|
49
163
|
}
|
|
50
164
|
// Unified envelope: `{ code: number, msg: string, data: T }`.
|
|
51
165
|
// `code === 0` means success; any other value is a business error whose
|
|
52
166
|
// `msg` is surfaced to callers and `code` is preserved on the error meta.
|
|
53
|
-
const env = parsed
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
167
|
+
const env = parsed && typeof parsed === "object"
|
|
168
|
+
? parsed
|
|
169
|
+
: undefined;
|
|
170
|
+
const code = typeof env?.code === "number" ? env.code : Number.NaN;
|
|
171
|
+
if (env && Number.isFinite(code)) {
|
|
172
|
+
const msg = typeof env.msg === "string"
|
|
173
|
+
? env.msg
|
|
174
|
+
: typeof env.message === "string"
|
|
175
|
+
? env.message
|
|
176
|
+
: "";
|
|
177
|
+
if (code !== 0) {
|
|
178
|
+
throw new ClawlingApiError("api", msg || `code=${code}`, {
|
|
179
|
+
code,
|
|
180
|
+
status: res.status,
|
|
181
|
+
path,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return env.data;
|
|
65
185
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
186
|
+
// No usable envelope — fall back to the HTTP status signal.
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
throw new ClawlingApiError("transport", `http ${res.status} ${text.slice(0, 200)}`, {
|
|
69
189
|
status: res.status,
|
|
70
190
|
path,
|
|
71
191
|
});
|
|
72
192
|
}
|
|
73
|
-
|
|
193
|
+
throw new ClawlingApiError("transport", text ? "invalid envelope: missing numeric `code`" : "non-JSON response: empty body", { status: res.status, path });
|
|
74
194
|
}
|
|
75
195
|
async function call(method, path, init) {
|
|
76
196
|
let res;
|
package/dist/src/client.js
CHANGED
|
@@ -8,10 +8,13 @@ export function resolveOpenclawClawlingDeviceId(account) {
|
|
|
8
8
|
return `${CHANNEL_ID}-${digest}`;
|
|
9
9
|
}
|
|
10
10
|
export function createOpenclawClawlingClient(account, overrides = {}) {
|
|
11
|
+
const deviceId = overrides.deviceIdOverride && overrides.deviceIdOverride.trim()
|
|
12
|
+
? overrides.deviceIdOverride.trim()
|
|
13
|
+
: resolveOpenclawClawlingDeviceId(account);
|
|
11
14
|
const client = createClawChatClient({
|
|
12
15
|
url: account.websocketUrl,
|
|
13
16
|
token: account.token,
|
|
14
|
-
deviceId
|
|
17
|
+
deviceId,
|
|
15
18
|
...(overrides.transport ? { transport: overrides.transport } : {}),
|
|
16
19
|
reconnect: {
|
|
17
20
|
enabled: true,
|
package/dist/src/inbound.js
CHANGED
|
@@ -10,8 +10,10 @@ function normalizeSender(sender) {
|
|
|
10
10
|
if (!id)
|
|
11
11
|
return null;
|
|
12
12
|
const nickName = typeof s.nick_name === "string" ? s.nick_name : id;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// §4.1: `sender.type` is the server-stamped routing type and is always
|
|
14
|
+
// "direct" — it never carries the human/agent distinction. The sender's
|
|
15
|
+
// profile_type is resolved downstream from chat metadata, not from the wire.
|
|
16
|
+
return { id, nickName };
|
|
15
17
|
}
|
|
16
18
|
function requireChatId(envelope) {
|
|
17
19
|
const chatId = envelope.chat_id;
|
|
@@ -85,6 +87,18 @@ export async function dispatchOpenclawClawlingInbound(params) {
|
|
|
85
87
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip non-business event=${envelope.event} trace=${envelope.trace_id}`);
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
90
|
+
// Fail-closed self-echo guard: the self-echo check below
|
|
91
|
+
// (`sender.id === account.userId`) is only meaningful when `account.userId`
|
|
92
|
+
// is known. A reactivation auth failure can leave `account.userId` as an
|
|
93
|
+
// empty string (see runtime.ts reactivation edge); with an empty userId the
|
|
94
|
+
// `account.userId && …` short-circuit would silently treat EVERY frame —
|
|
95
|
+
// including our own echoed messages — as non-self and feed it back into the
|
|
96
|
+
// LLM pipeline (self-reply loop). Refuse to process materialized messages in
|
|
97
|
+
// that state rather than risk echoing our own output.
|
|
98
|
+
if (!account.userId) {
|
|
99
|
+
log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw skip (fail-closed): empty account.userId, cannot apply self-echo guard event=${envelope.event} trace=${envelope.trace_id}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
88
102
|
if (isMaterializedMessage && !isInboundMessagePayload(envelope.payload)) {
|
|
89
103
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip: invalid payload trace=${envelope.trace_id}`);
|
|
90
104
|
return;
|
|
@@ -107,7 +121,11 @@ export async function dispatchOpenclawClawlingInbound(params) {
|
|
|
107
121
|
}
|
|
108
122
|
const chatType = envelope.chat_type === "group" ? "group" : "direct";
|
|
109
123
|
const isGroup = chatType === "group";
|
|
110
|
-
|
|
124
|
+
// §7.5: the server does not default message_mode — an omitted field arrives
|
|
125
|
+
// as "" on the downlink. Empty/absent is equivalent to "normal", so only
|
|
126
|
+
// skip genuinely non-normal modes (e.g. "thinking").
|
|
127
|
+
const messageMode = payload.message_mode ?? "";
|
|
128
|
+
if (isMaterializedMessage && messageMode !== "normal" && messageMode !== "") {
|
|
111
129
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip non-normal mode=${payload.message_mode}`);
|
|
112
130
|
return;
|
|
113
131
|
}
|
|
@@ -152,7 +170,6 @@ export async function dispatchOpenclawClawlingInbound(params) {
|
|
|
152
170
|
peer: { kind: isGroup ? "group" : "direct", id: chatId },
|
|
153
171
|
senderId: sender.id,
|
|
154
172
|
senderNickName: sender.nickName,
|
|
155
|
-
...(sender.profileType ? { senderProfileType: sender.profileType } : {}),
|
|
156
173
|
rawBody,
|
|
157
174
|
messageId: payload.message_id,
|
|
158
175
|
traceId: envelope.trace_id,
|
|
@@ -197,6 +197,10 @@ export async function runOpenclawClawlingLogin(params) {
|
|
|
197
197
|
refreshToken: normalizedResult.refresh_token || null,
|
|
198
198
|
conversationId: normalizedResult.conversation?.id ?? null,
|
|
199
199
|
loginMethod: "login",
|
|
200
|
+
// §E — record the exact `X-Device-Id` sent at connect (the constant
|
|
201
|
+
// `CHANNEL_ID`, see `authHeaders` in api-client.ts), so a later refresh
|
|
202
|
+
// sends the same device id the backend baked into the session.
|
|
203
|
+
deviceId: CHANNEL_ID,
|
|
200
204
|
});
|
|
201
205
|
}
|
|
202
206
|
catch {
|
package/dist/src/outbound.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
1
2
|
import { MessageSendError } from "./protocol-types.js";
|
|
2
3
|
import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
|
|
3
4
|
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
|
@@ -198,8 +199,13 @@ async function sendAlignedAckableEnvelope(params) {
|
|
|
198
199
|
return;
|
|
199
200
|
const payload = ack.payload;
|
|
200
201
|
const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
// §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
|
|
203
|
+
const hint = typeof payload.reason === "string" && payload.reason
|
|
204
|
+
? payload.reason
|
|
205
|
+
: typeof payload.message === "string" && payload.message
|
|
206
|
+
? payload.message
|
|
207
|
+
: "message send failed";
|
|
208
|
+
fail(new MessageSendError(traceId, code, hint, ack.chat_id));
|
|
203
209
|
return;
|
|
204
210
|
}
|
|
205
211
|
if (ack.event !== "message.ack")
|
|
@@ -354,13 +360,18 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
354
360
|
const useReply = Boolean(params.replyCtx?.replyPreviewSenderId
|
|
355
361
|
&& params.replyCtx.replyPreviewNickName
|
|
356
362
|
&& params.replyCtx.replyPreviewText);
|
|
357
|
-
|
|
363
|
+
// Outbound message_id is ALWAYS present (at-least-once delivery, protocol
|
|
364
|
+
// §3.1.9): the server's inbox UNIQUE(recipient, message_id) absorbs a
|
|
365
|
+
// bounded resend of the same frame as one coalesced row. A bounded resend
|
|
366
|
+
// (non-terminal socket close → re-enqueue of the same captured wire) reuses
|
|
367
|
+
// this exact id, so a duplicate write is deduped rather than fanned out.
|
|
368
|
+
const messageId = params.messageId ?? mintMessageId();
|
|
358
369
|
let ack;
|
|
359
370
|
let mode;
|
|
360
371
|
if (useReply && params.replyCtx) {
|
|
361
372
|
mode = "reply";
|
|
362
373
|
const payload = {
|
|
363
|
-
|
|
374
|
+
message_id: messageId,
|
|
364
375
|
message_mode: "normal",
|
|
365
376
|
message: {
|
|
366
377
|
body: { fragments },
|
|
@@ -395,7 +406,7 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
395
406
|
}
|
|
396
407
|
: null;
|
|
397
408
|
const payload = {
|
|
398
|
-
|
|
409
|
+
message_id: messageId,
|
|
399
410
|
message_mode: "normal",
|
|
400
411
|
message: {
|
|
401
412
|
body: { fragments },
|
|
@@ -411,7 +422,7 @@ export async function sendOpenclawClawlingText(params) {
|
|
|
411
422
|
...(params.log ? { log: params.log } : {}),
|
|
412
423
|
});
|
|
413
424
|
}
|
|
414
|
-
if (
|
|
425
|
+
if (ack.payload.message_id !== messageId) {
|
|
415
426
|
throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
|
|
416
427
|
}
|
|
417
428
|
params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
|
|
@@ -474,8 +485,32 @@ export async function sendOpenclawClawlingMedia(params) {
|
|
|
474
485
|
...(params.log ? { log: params.log } : {}),
|
|
475
486
|
});
|
|
476
487
|
}
|
|
477
|
-
|
|
478
|
-
|
|
488
|
+
// Crockford base32 alphabet (ULID spec).
|
|
489
|
+
const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
490
|
+
/**
|
|
491
|
+
* Generate a Canonical ULID: 10 chars of millisecond timestamp + 16 chars of
|
|
492
|
+
* randomness, Crockford base32, 26 chars total. Implemented locally (no extra
|
|
493
|
+
* dependency) because this repo has no ULID dep and only needs minting, not
|
|
494
|
+
* monotonic ordering or decoding.
|
|
495
|
+
*/
|
|
496
|
+
function ulid(now = Date.now()) {
|
|
497
|
+
let time = now;
|
|
498
|
+
const out = new Array(26);
|
|
499
|
+
for (let i = 9; i >= 0; i--) {
|
|
500
|
+
out[i] = ULID_ENCODING[time % 32];
|
|
501
|
+
time = Math.floor(time / 32);
|
|
502
|
+
}
|
|
503
|
+
for (let i = 10; i < 26; i++) {
|
|
504
|
+
out[i] = ULID_ENCODING[randomInt(0, 32)];
|
|
505
|
+
}
|
|
506
|
+
return out.join("");
|
|
507
|
+
}
|
|
508
|
+
/** Client-minted message id: `msg-` + ULID (protocol §7.6 format contract). */
|
|
509
|
+
export function mintMessageId() {
|
|
510
|
+
return `msg-${ulid()}`;
|
|
511
|
+
}
|
|
512
|
+
function mintOutboundMessageId(_account) {
|
|
513
|
+
return mintMessageId();
|
|
479
514
|
}
|
|
480
515
|
function resolveChannelOutboundStore() {
|
|
481
516
|
try {
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { authRefresh, decodeJwtExp, } from "./api-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* §A–§D — ClawChat token refresh + auto-logout orchestration for the OpenClaw
|
|
4
|
+
* plugin.
|
|
5
|
+
*
|
|
6
|
+
* The manager is the single owner of:
|
|
7
|
+
* - single-flight dedupe (§A.3): concurrent callers (proactive timer, reactive
|
|
8
|
+
* REST 401, reactive WS hello-fail) await one in-flight refresh;
|
|
9
|
+
* - the rejected-token latch (§A.3): never re-attempt for the same access token
|
|
10
|
+
* until it actually changes;
|
|
11
|
+
* - the minimum interval (§A.3): a reconnect storm cannot become a refresh storm;
|
|
12
|
+
* - the proactive `setTimeout` timer (§A.1), armed from the live token's `exp`;
|
|
13
|
+
* - the persist→swap ordering (§0): the rotated pair is persisted to BOTH stores
|
|
14
|
+
* BEFORE the in-memory token is swapped, then the WS is reconnected (§D).
|
|
15
|
+
*
|
|
16
|
+
* It is deliberately decoupled from the runtime via a small port object so it is
|
|
17
|
+
* unit-testable without a live WS / config / SQLite.
|
|
18
|
+
*/
|
|
19
|
+
const MINUTE_MS = 60_000;
|
|
20
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
21
|
+
/** §A.3 — floor between refresh attempts of the same token. */
|
|
22
|
+
export const MIN_REFRESH_INTERVAL_MS = 30_000;
|
|
23
|
+
/** §A.1 — proactive jitter (±5min). */
|
|
24
|
+
export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
|
|
25
|
+
/** §A.0 — fallback access-token TTL when `exp` is unparseable. */
|
|
26
|
+
export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
|
|
27
|
+
/**
|
|
28
|
+
* §A.0/§A.1 — compute the absolute epoch-ms at which to proactively refresh.
|
|
29
|
+
* `refresh_at = exp - max(30min, min(2h, 0.25 * (exp - iat)))` plus jitter.
|
|
30
|
+
* `iatMs` is the token issue time; when unknown we approximate the lifetime as
|
|
31
|
+
* the full TTL so the lead time clamps to 2h for a 24h token.
|
|
32
|
+
*/
|
|
33
|
+
export function computeProactiveRefreshAtMs(params) {
|
|
34
|
+
const lifetimeMs = typeof params.iatMs === "number" && Number.isFinite(params.iatMs)
|
|
35
|
+
? params.expMs - params.iatMs
|
|
36
|
+
: ACCESS_TOKEN_TTL_MS;
|
|
37
|
+
const lead = Math.max(30 * MINUTE_MS, Math.min(2 * HOUR_MS, 0.25 * lifetimeMs));
|
|
38
|
+
return params.expMs - lead + (params.jitterMs ?? 0);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* §A.0 — derive the access token's expiry (epoch ms). Prefer the JWT `exp`;
|
|
42
|
+
* fall back to `activatedAt + 24h` when the token has no parseable `exp`.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveAccessTokenExpiryMs(token, activatedAtMs) {
|
|
45
|
+
const exp = decodeJwtExp(token);
|
|
46
|
+
if (exp != null)
|
|
47
|
+
return exp * 1000;
|
|
48
|
+
if (activatedAtMs != null)
|
|
49
|
+
return activatedAtMs + ACCESS_TOKEN_TTL_MS;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
export class RefreshManager {
|
|
53
|
+
ports;
|
|
54
|
+
inFlight = null;
|
|
55
|
+
/** §A.3 — the access token a refresh was last attempted for. */
|
|
56
|
+
rejectedToken = null;
|
|
57
|
+
/** §A.3 — epoch-ms of the last refresh attempt (any token). */
|
|
58
|
+
lastAttemptAt = 0;
|
|
59
|
+
proactiveTimer = null;
|
|
60
|
+
stopped = false;
|
|
61
|
+
constructor(ports) {
|
|
62
|
+
this.ports = ports;
|
|
63
|
+
}
|
|
64
|
+
now() {
|
|
65
|
+
return (this.ports.now ?? Date.now)();
|
|
66
|
+
}
|
|
67
|
+
setTimer(cb, ms) {
|
|
68
|
+
return (this.ports.setTimer ?? setTimeout)(cb, ms);
|
|
69
|
+
}
|
|
70
|
+
clearTimer(handle) {
|
|
71
|
+
(this.ports.clearTimer ?? clearTimeout)(handle);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* §A.3 — run a single-flight refresh. Concurrent callers receive the same
|
|
75
|
+
* in-flight promise. Honors the rejected-token latch and the min-interval.
|
|
76
|
+
*/
|
|
77
|
+
refresh(reason) {
|
|
78
|
+
if (this.stopped)
|
|
79
|
+
return Promise.resolve({ kind: "skipped", reason: "in-flight" });
|
|
80
|
+
if (this.inFlight) {
|
|
81
|
+
this.ports.log?.debug?.(`clawchat-plugin-openclaw refresh deduped onto in-flight (${reason})`);
|
|
82
|
+
return this.inFlight;
|
|
83
|
+
}
|
|
84
|
+
const accessToken = this.ports.getAccessToken();
|
|
85
|
+
// §A.3 rejected-token latch: don't re-attempt for an unchanged dead token.
|
|
86
|
+
if (this.rejectedToken !== null && this.rejectedToken === accessToken) {
|
|
87
|
+
this.ports.log?.debug?.(`clawchat-plugin-openclaw refresh skipped rejected-latch (${reason})`);
|
|
88
|
+
return Promise.resolve({ kind: "skipped", reason: "rejected-latch" });
|
|
89
|
+
}
|
|
90
|
+
// §A.3 min-interval floor.
|
|
91
|
+
const sinceLast = this.now() - this.lastAttemptAt;
|
|
92
|
+
if (this.lastAttemptAt !== 0 && sinceLast < MIN_REFRESH_INTERVAL_MS) {
|
|
93
|
+
this.ports.log?.debug?.(`clawchat-plugin-openclaw refresh skipped min-interval (${reason}) sinceLast=${sinceLast}ms`);
|
|
94
|
+
return Promise.resolve({ kind: "skipped", reason: "min-interval" });
|
|
95
|
+
}
|
|
96
|
+
const refreshToken = this.ports.getRefreshToken();
|
|
97
|
+
if (!refreshToken || !refreshToken.trim()) {
|
|
98
|
+
return Promise.resolve({ kind: "skipped", reason: "no-refresh-token" });
|
|
99
|
+
}
|
|
100
|
+
this.lastAttemptAt = this.now();
|
|
101
|
+
const promise = this.runRefresh(accessToken, refreshToken, reason).finally(() => {
|
|
102
|
+
this.inFlight = null;
|
|
103
|
+
});
|
|
104
|
+
this.inFlight = promise;
|
|
105
|
+
return promise;
|
|
106
|
+
}
|
|
107
|
+
async runRefresh(accessToken, refreshToken, reason) {
|
|
108
|
+
this.ports.log?.info?.(`clawchat-plugin-openclaw refresh attempt (${reason})`);
|
|
109
|
+
let result;
|
|
110
|
+
try {
|
|
111
|
+
result = await authRefresh({ baseUrl: this.ports.baseUrl, ...(this.ports.fetchImpl ? { fetchImpl: this.ports.fetchImpl } : {}) }, { refreshToken, deviceId: this.ports.deviceId });
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
115
|
+
return { kind: "transient", message };
|
|
116
|
+
}
|
|
117
|
+
if (result.kind === "success") {
|
|
118
|
+
// §0 rotation hazard — persist FIRST, swap SECOND. If persistence REJECTS
|
|
119
|
+
// (a store/config write failed), DO NOT swap the in-memory token: a
|
|
120
|
+
// sqlite-sourced agent must not keep a now-dead refresh token in its row
|
|
121
|
+
// while running on the rotated token. Treat as transient so the WS stays
|
|
122
|
+
// in backoff with the CURRENT tokens and the next attempt retries. The
|
|
123
|
+
// server already rotated, so the next attempt may return `code:10003`
|
|
124
|
+
// (which escalates to permanent per §B) — that is the accepted hazard, not
|
|
125
|
+
// a silent brick.
|
|
126
|
+
try {
|
|
127
|
+
await this.ports.persistRotatedTokens({
|
|
128
|
+
accessToken: result.accessToken,
|
|
129
|
+
refreshToken: result.refreshToken,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
134
|
+
this.ports.log?.error?.(`clawchat-plugin-openclaw refresh persistence failed; not swapping in-memory token: ${message}`);
|
|
135
|
+
return { kind: "transient", message };
|
|
136
|
+
}
|
|
137
|
+
this.ports.swapInMemoryTokens({
|
|
138
|
+
accessToken: result.accessToken,
|
|
139
|
+
refreshToken: result.refreshToken,
|
|
140
|
+
});
|
|
141
|
+
// Clear the latch; the access token has actually changed.
|
|
142
|
+
this.rejectedToken = null;
|
|
143
|
+
this.ports.log?.info?.("clawchat-plugin-openclaw refresh success (token rotated)");
|
|
144
|
+
return { kind: "success", accessToken: result.accessToken, refreshToken: result.refreshToken };
|
|
145
|
+
}
|
|
146
|
+
if (result.kind === "permanent") {
|
|
147
|
+
// §A.3 — latch the dead token so reconnect storms don't re-fire refresh.
|
|
148
|
+
this.rejectedToken = accessToken;
|
|
149
|
+
this.ports.log?.error?.(`clawchat-plugin-openclaw refresh permanent failure code=${result.code}: ${result.message}`);
|
|
150
|
+
await this.ports.onPermanentFailure({ code: result.code, message: result.message });
|
|
151
|
+
return { kind: "permanent", code: result.code, message: result.message };
|
|
152
|
+
}
|
|
153
|
+
// Transient — never auto-logout; the old refresh token is still valid.
|
|
154
|
+
this.ports.log?.info?.(`clawchat-plugin-openclaw refresh transient failure: ${result.message}`);
|
|
155
|
+
return { kind: "transient", message: result.message };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* §A.1 — (re)arm the proactive timer from the live access token's `exp`.
|
|
159
|
+
* Called when a connection becomes ready and after every successful refresh.
|
|
160
|
+
*/
|
|
161
|
+
armProactiveTimer(activatedAtMs) {
|
|
162
|
+
if (this.stopped)
|
|
163
|
+
return;
|
|
164
|
+
this.disarmProactiveTimer();
|
|
165
|
+
const token = this.ports.getAccessToken();
|
|
166
|
+
const expMs = resolveAccessTokenExpiryMs(token, activatedAtMs);
|
|
167
|
+
if (expMs == null) {
|
|
168
|
+
this.ports.log?.debug?.("clawchat-plugin-openclaw proactive timer not armed (no expiry)");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const iat = decodeJwtIat(token);
|
|
172
|
+
const jitterMs = (this.ports.jitter ?? defaultJitter)();
|
|
173
|
+
const refreshAtMs = computeProactiveRefreshAtMs({
|
|
174
|
+
expMs,
|
|
175
|
+
iatMs: iat != null ? iat * 1000 : null,
|
|
176
|
+
nowMs: this.now(),
|
|
177
|
+
jitterMs,
|
|
178
|
+
});
|
|
179
|
+
const delayMs = Math.max(0, refreshAtMs - this.now());
|
|
180
|
+
this.ports.log?.debug?.(`clawchat-plugin-openclaw proactive timer armed delayMs=${delayMs}`);
|
|
181
|
+
this.proactiveTimer = this.setTimer(() => {
|
|
182
|
+
this.proactiveTimer = null;
|
|
183
|
+
void this.runProactiveRefresh();
|
|
184
|
+
}, delayMs);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* §A.1/§D — the proactive-timer body. Runs the single-flight refresh and, on
|
|
188
|
+
* success, hands the rotated token to the runtime's `onProactiveRefreshed` port
|
|
189
|
+
* so the live WS is closed and reconnected with the new token (the in-memory
|
|
190
|
+
* swap alone does NOT reach the running socket, which captured the old token at
|
|
191
|
+
* `connect` time). Transient/skipped outcomes leave the WS untouched — the next
|
|
192
|
+
* proactive arm (or a reactive hello-fail) handles it.
|
|
193
|
+
*/
|
|
194
|
+
async runProactiveRefresh() {
|
|
195
|
+
const outcome = await this.refresh("proactive-timer");
|
|
196
|
+
if (this.stopped)
|
|
197
|
+
return;
|
|
198
|
+
if (outcome.kind !== "success")
|
|
199
|
+
return;
|
|
200
|
+
if (this.ports.onProactiveRefreshed) {
|
|
201
|
+
try {
|
|
202
|
+
await this.ports.onProactiveRefreshed({
|
|
203
|
+
accessToken: outcome.accessToken,
|
|
204
|
+
refreshToken: outcome.refreshToken,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
this.ports.log?.error?.(`clawchat-plugin-openclaw proactive reconnect hook failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
disarmProactiveTimer() {
|
|
213
|
+
if (this.proactiveTimer != null) {
|
|
214
|
+
this.clearTimer(this.proactiveTimer);
|
|
215
|
+
this.proactiveTimer = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* §A.4 — true when the stored access token is past or within the proactive
|
|
220
|
+
* margin of expiry, so the caller should refresh BEFORE the first connect.
|
|
221
|
+
*/
|
|
222
|
+
isNearExpiry(activatedAtMs) {
|
|
223
|
+
const token = this.ports.getAccessToken();
|
|
224
|
+
const expMs = resolveAccessTokenExpiryMs(token, activatedAtMs);
|
|
225
|
+
if (expMs == null)
|
|
226
|
+
return false;
|
|
227
|
+
const iat = decodeJwtIat(token);
|
|
228
|
+
const refreshAtMs = computeProactiveRefreshAtMs({
|
|
229
|
+
expMs,
|
|
230
|
+
iatMs: iat != null ? iat * 1000 : null,
|
|
231
|
+
nowMs: this.now(),
|
|
232
|
+
jitterMs: 0,
|
|
233
|
+
});
|
|
234
|
+
return this.now() >= refreshAtMs;
|
|
235
|
+
}
|
|
236
|
+
/** Stop the manager — clears the proactive timer; no further refreshes arm. */
|
|
237
|
+
stop() {
|
|
238
|
+
this.stopped = true;
|
|
239
|
+
this.disarmProactiveTimer();
|
|
240
|
+
}
|
|
241
|
+
/** Test/inspection seam — the latched (rejected) access token, if any. */
|
|
242
|
+
getRejectedToken() {
|
|
243
|
+
return this.rejectedToken;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* §A.3/§A.4 — export the single-flight guard state so it can be carried across
|
|
247
|
+
* a gateway re-enter (a refresh-driven reconnect builds a fresh manager; without
|
|
248
|
+
* restoring this state the rejected-token latch + min-interval would reset and
|
|
249
|
+
* the guards would not bound a cross-reconnect loop).
|
|
250
|
+
*/
|
|
251
|
+
exportState() {
|
|
252
|
+
return { rejectedToken: this.rejectedToken, lastAttemptAt: this.lastAttemptAt };
|
|
253
|
+
}
|
|
254
|
+
/** §A.3/§A.4 — restore guard state from a prior manager (see `exportState`). */
|
|
255
|
+
restoreState(state) {
|
|
256
|
+
this.rejectedToken = state.rejectedToken;
|
|
257
|
+
this.lastAttemptAt = state.lastAttemptAt;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function decodeJwtIat(token) {
|
|
261
|
+
if (typeof token !== "string")
|
|
262
|
+
return null;
|
|
263
|
+
const segments = token.split(".");
|
|
264
|
+
if (segments.length < 2 || !segments[1])
|
|
265
|
+
return null;
|
|
266
|
+
try {
|
|
267
|
+
const json = Buffer.from(segments[1], "base64url").toString("utf8");
|
|
268
|
+
const parsed = JSON.parse(json);
|
|
269
|
+
const iat = parsed?.iat;
|
|
270
|
+
return typeof iat === "number" && Number.isFinite(iat) ? iat : null;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function defaultJitter() {
|
|
277
|
+
return (Math.random() * 2 - 1) * PROACTIVE_JITTER_MS;
|
|
278
|
+
}
|