@clawling/clawchat-plugin-openclaw 2026.5.12-39 → 2026.5.13-dev.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 +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/protocol-types.js +2 -0
- package/dist/src/refresh-manager.js +278 -0
- package/dist/src/reply-dispatcher.js +24 -27
- package/dist/src/runtime.js +564 -28
- package/dist/src/storage.js +81 -5
- package/dist/src/ws-alignment.js +69 -1
- package/dist/src/ws-client.js +55 -5
- package/package.json +1 -1
- package/skills/clawchat/SKILL.md +0 -13
- 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 +34 -2
- package/src/refresh-manager.ts +371 -0
- package/src/reply-dispatcher.ts +26 -28
- package/src/runtime.ts +646 -25
- package/src/storage.ts +124 -4
- package/src/ws-alignment.ts +99 -1
- package/src/ws-client.ts +51 -5
- package/dist/src/buffered-stream.js +0 -177
- package/dist/src/streaming.js +0 -65
package/src/login.runtime.ts
CHANGED
|
@@ -268,6 +268,10 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
|
|
|
268
268
|
refreshToken: normalizedResult.refresh_token || null,
|
|
269
269
|
conversationId: normalizedResult.conversation?.id ?? null,
|
|
270
270
|
loginMethod: "login",
|
|
271
|
+
// §E — record the exact `X-Device-Id` sent at connect (the constant
|
|
272
|
+
// `CHANNEL_ID`, see `authHeaders` in api-client.ts), so a later refresh
|
|
273
|
+
// sends the same device id the backend baked into the session.
|
|
274
|
+
deviceId: CHANNEL_ID,
|
|
271
275
|
});
|
|
272
276
|
} catch {
|
|
273
277
|
runtime.log("clawchat-plugin-openclaw sqlite activation persistence failed; login continues.");
|
package/src/outbound.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
1
2
|
import { MessageSendError, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload } from "./protocol-types.ts";
|
|
2
3
|
import type { ClawlingChatClient } from "./ws-client.ts";
|
|
3
4
|
import {
|
|
@@ -321,10 +322,15 @@ async function sendAlignedAckableEnvelope(params: {
|
|
|
321
322
|
if (ack.trace_id !== traceId) return;
|
|
322
323
|
if (ack.event === "message.error") {
|
|
323
324
|
if (state === "acked" || state === "failed") return;
|
|
324
|
-
const payload = ack.payload as Partial<MessageErrorPayload
|
|
325
|
+
const payload = ack.payload as Partial<MessageErrorPayload> & { message?: unknown };
|
|
325
326
|
const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
|
|
326
|
-
|
|
327
|
-
|
|
327
|
+
// §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
|
|
328
|
+
const hint = typeof payload.reason === "string" && payload.reason
|
|
329
|
+
? payload.reason
|
|
330
|
+
: typeof payload.message === "string" && payload.message
|
|
331
|
+
? payload.message
|
|
332
|
+
: "message send failed";
|
|
333
|
+
fail(new MessageSendError(traceId, code, hint, ack.chat_id));
|
|
328
334
|
return;
|
|
329
335
|
}
|
|
330
336
|
if (ack.event !== "message.ack") return;
|
|
@@ -491,14 +497,19 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
491
497
|
&& params.replyCtx.replyPreviewNickName
|
|
492
498
|
&& params.replyCtx.replyPreviewText,
|
|
493
499
|
);
|
|
494
|
-
|
|
500
|
+
// Outbound message_id is ALWAYS present (at-least-once delivery, protocol
|
|
501
|
+
// §3.1.9): the server's inbox UNIQUE(recipient, message_id) absorbs a
|
|
502
|
+
// bounded resend of the same frame as one coalesced row. A bounded resend
|
|
503
|
+
// (non-terminal socket close → re-enqueue of the same captured wire) reuses
|
|
504
|
+
// this exact id, so a duplicate write is deduped rather than fanned out.
|
|
505
|
+
const messageId = params.messageId ?? mintMessageId();
|
|
495
506
|
|
|
496
507
|
let ack: Envelope<MessageAckPayload>;
|
|
497
508
|
let mode: "send" | "reply";
|
|
498
509
|
if (useReply && params.replyCtx) {
|
|
499
510
|
mode = "reply";
|
|
500
511
|
const payload = {
|
|
501
|
-
|
|
512
|
+
message_id: messageId,
|
|
502
513
|
message_mode: "normal",
|
|
503
514
|
message: {
|
|
504
515
|
body: { fragments },
|
|
@@ -532,7 +543,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
532
543
|
}
|
|
533
544
|
: null;
|
|
534
545
|
const payload = {
|
|
535
|
-
|
|
546
|
+
message_id: messageId,
|
|
536
547
|
message_mode: "normal",
|
|
537
548
|
message: {
|
|
538
549
|
body: { fragments },
|
|
@@ -548,7 +559,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
|
|
|
548
559
|
...(params.log ? { log: params.log } : {}),
|
|
549
560
|
});
|
|
550
561
|
}
|
|
551
|
-
if (
|
|
562
|
+
if (ack.payload.message_id !== messageId) {
|
|
552
563
|
throw new Error(
|
|
553
564
|
`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`,
|
|
554
565
|
);
|
|
@@ -638,8 +649,35 @@ export async function sendOpenclawClawlingMedia(
|
|
|
638
649
|
|
|
639
650
|
type OutboundClaimStore = Pick<ClawChatStore, "claimMessageOnce" | "markMessageAcknowledged">;
|
|
640
651
|
|
|
641
|
-
|
|
642
|
-
|
|
652
|
+
// Crockford base32 alphabet (ULID spec).
|
|
653
|
+
const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Generate a Canonical ULID: 10 chars of millisecond timestamp + 16 chars of
|
|
657
|
+
* randomness, Crockford base32, 26 chars total. Implemented locally (no extra
|
|
658
|
+
* dependency) because this repo has no ULID dep and only needs minting, not
|
|
659
|
+
* monotonic ordering or decoding.
|
|
660
|
+
*/
|
|
661
|
+
function ulid(now: number = Date.now()): string {
|
|
662
|
+
let time = now;
|
|
663
|
+
const out = new Array<string>(26);
|
|
664
|
+
for (let i = 9; i >= 0; i--) {
|
|
665
|
+
out[i] = ULID_ENCODING[time % 32]!;
|
|
666
|
+
time = Math.floor(time / 32);
|
|
667
|
+
}
|
|
668
|
+
for (let i = 10; i < 26; i++) {
|
|
669
|
+
out[i] = ULID_ENCODING[randomInt(0, 32)]!;
|
|
670
|
+
}
|
|
671
|
+
return out.join("");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Client-minted message id: `msg-` + ULID (protocol §7.6 format contract). */
|
|
675
|
+
export function mintMessageId(): string {
|
|
676
|
+
return `msg-${ulid()}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function mintOutboundMessageId(_account: ResolvedOpenclawClawlingAccount): string {
|
|
680
|
+
return mintMessageId();
|
|
643
681
|
}
|
|
644
682
|
|
|
645
683
|
function resolveChannelOutboundStore(): OutboundClaimStore | null {
|
package/src/protocol-types.ts
CHANGED
|
@@ -13,6 +13,8 @@ export const EVENT = {
|
|
|
13
13
|
MESSAGE_FAILED: "message.failed",
|
|
14
14
|
TYPING_UPDATE: "typing.update",
|
|
15
15
|
CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
|
|
16
|
+
NOTIFY_SIGNAL: "notify.signal",
|
|
17
|
+
REPLAY_DONE: "replay.done",
|
|
16
18
|
OFFLINE_BATCH: "offline.batch",
|
|
17
19
|
OFFLINE_ACK: "offline.ack",
|
|
18
20
|
OFFLINE_DONE: "offline.done",
|
|
@@ -78,6 +80,11 @@ export interface ConnectCapabilities {
|
|
|
78
80
|
multi_device?: boolean;
|
|
79
81
|
device_replay?: boolean;
|
|
80
82
|
chat_meta_events?: boolean;
|
|
83
|
+
delivery_receipt?: boolean;
|
|
84
|
+
notify_signals?: boolean;
|
|
85
|
+
permission_events?: boolean;
|
|
86
|
+
history_sync?: boolean;
|
|
87
|
+
e2ee?: boolean;
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
export interface ConnectPayload {
|
|
@@ -104,7 +111,9 @@ export interface TextFragment {
|
|
|
104
111
|
|
|
105
112
|
export interface MentionFragment {
|
|
106
113
|
kind: "mention";
|
|
107
|
-
|
|
114
|
+
// §10.1/§10.2: both fields are optional on the wire — a server-emitted
|
|
115
|
+
// mention may carry only `display` with no resolvable `user_id`.
|
|
116
|
+
user_id?: string;
|
|
108
117
|
display?: string;
|
|
109
118
|
}
|
|
110
119
|
|
|
@@ -197,8 +206,12 @@ export interface MessageAckPayload {
|
|
|
197
206
|
}
|
|
198
207
|
|
|
199
208
|
export interface MessageErrorPayload {
|
|
209
|
+
// §14.3 negative-ack on the send path.
|
|
210
|
+
message_id?: string;
|
|
200
211
|
code: string;
|
|
201
|
-
|
|
212
|
+
/** Human-readable hint; omitted by the server when empty. */
|
|
213
|
+
reason?: string;
|
|
214
|
+
rejected_at?: number;
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
export interface ChatMetadataInvalidatedPayload {
|
|
@@ -207,6 +220,25 @@ export interface ChatMetadataInvalidatedPayload {
|
|
|
207
220
|
updated_at?: number;
|
|
208
221
|
}
|
|
209
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Reliable, inbox-coalesced system notification (§9.4). Content-free — only
|
|
225
|
+
* enough identity to dedup and to decide which REST surface to refetch. The
|
|
226
|
+
* agent plugin keeps no friend/roster cache, so this is consumed as an
|
|
227
|
+
* observability signal (see `createNotifySignalObserver`), not a cache refresh.
|
|
228
|
+
*/
|
|
229
|
+
export interface NotifySignalPayload {
|
|
230
|
+
/** Logical event type the client routes on, e.g. `friend.added`. */
|
|
231
|
+
type: string;
|
|
232
|
+
/** Id of the changed entity (meaning depends on `type`). */
|
|
233
|
+
entity_id: string;
|
|
234
|
+
/** Monotonic cursor (ms since epoch at mutation time). */
|
|
235
|
+
version: number;
|
|
236
|
+
/** Globally-unique id for this signal occurrence — cross-channel dedup key. */
|
|
237
|
+
event_id: string;
|
|
238
|
+
/** Inbox coalesce key, formatted `notify:{type}:{entity_id}`. */
|
|
239
|
+
message_id: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
210
242
|
export interface StreamCreatedPayload {
|
|
211
243
|
message_id: string;
|
|
212
244
|
message_mode?: string;
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authRefresh,
|
|
3
|
+
decodeJwtExp,
|
|
4
|
+
type AuthRefreshResult,
|
|
5
|
+
} from "./api-client.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* §A–§D — ClawChat token refresh + auto-logout orchestration for the OpenClaw
|
|
9
|
+
* plugin.
|
|
10
|
+
*
|
|
11
|
+
* The manager is the single owner of:
|
|
12
|
+
* - single-flight dedupe (§A.3): concurrent callers (proactive timer, reactive
|
|
13
|
+
* REST 401, reactive WS hello-fail) await one in-flight refresh;
|
|
14
|
+
* - the rejected-token latch (§A.3): never re-attempt for the same access token
|
|
15
|
+
* until it actually changes;
|
|
16
|
+
* - the minimum interval (§A.3): a reconnect storm cannot become a refresh storm;
|
|
17
|
+
* - the proactive `setTimeout` timer (§A.1), armed from the live token's `exp`;
|
|
18
|
+
* - the persist→swap ordering (§0): the rotated pair is persisted to BOTH stores
|
|
19
|
+
* BEFORE the in-memory token is swapped, then the WS is reconnected (§D).
|
|
20
|
+
*
|
|
21
|
+
* It is deliberately decoupled from the runtime via a small port object so it is
|
|
22
|
+
* unit-testable without a live WS / config / SQLite.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const MINUTE_MS = 60_000;
|
|
26
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
27
|
+
|
|
28
|
+
/** §A.3 — floor between refresh attempts of the same token. */
|
|
29
|
+
export const MIN_REFRESH_INTERVAL_MS = 30_000;
|
|
30
|
+
/** §A.1 — proactive jitter (±5min). */
|
|
31
|
+
export const PROACTIVE_JITTER_MS = 5 * MINUTE_MS;
|
|
32
|
+
/** §A.0 — fallback access-token TTL when `exp` is unparseable. */
|
|
33
|
+
export const ACCESS_TOKEN_TTL_MS = 24 * HOUR_MS;
|
|
34
|
+
|
|
35
|
+
/** Opaque timer handle — `setTimeout` returns a `Timeout` in node, a number in others. */
|
|
36
|
+
export type TimerHandle = ReturnType<typeof setTimeout> | number;
|
|
37
|
+
|
|
38
|
+
export type RefreshOutcome =
|
|
39
|
+
| { kind: "success"; accessToken: string; refreshToken: string }
|
|
40
|
+
| { kind: "permanent"; code: number; message: string }
|
|
41
|
+
| { kind: "transient"; message: string }
|
|
42
|
+
// deduped onto an already in-flight refresh, or skipped by a latch/min-interval.
|
|
43
|
+
| { kind: "skipped"; reason: "in-flight" | "rejected-latch" | "min-interval" | "no-refresh-token" };
|
|
44
|
+
|
|
45
|
+
export interface RefreshManagerPorts {
|
|
46
|
+
/** Server base URL for `POST /v1/auth/refresh`. */
|
|
47
|
+
baseUrl: string;
|
|
48
|
+
/** Connect-time device id (§E) sent as `X-Device-Id`. */
|
|
49
|
+
deviceId: string;
|
|
50
|
+
/** Current in-memory access token (read live so swaps are observed). */
|
|
51
|
+
getAccessToken: () => string;
|
|
52
|
+
/** Current in-memory refresh token, or `null`/empty when absent. */
|
|
53
|
+
getRefreshToken: () => string | null;
|
|
54
|
+
/**
|
|
55
|
+
* §0 — persist the rotated pair to BOTH config and SQLite. MUST resolve only
|
|
56
|
+
* after both writes succeed; the manager swaps the in-memory token only after
|
|
57
|
+
* this resolves.
|
|
58
|
+
*/
|
|
59
|
+
persistRotatedTokens: (tokens: { accessToken: string; refreshToken: string }) => Promise<void>;
|
|
60
|
+
/** Swap the in-memory access+refresh token AFTER persistence (§0). */
|
|
61
|
+
swapInMemoryTokens: (tokens: { accessToken: string; refreshToken: string }) => void;
|
|
62
|
+
/** §C — auto-logout: clear creds in both stores, flip not-configured, notify. */
|
|
63
|
+
onPermanentFailure: (info: { code: number; message: string }) => Promise<void> | void;
|
|
64
|
+
/**
|
|
65
|
+
* §A.1/§D — invoked AFTER a successful PROACTIVE-timer refresh has persisted +
|
|
66
|
+
* swapped the in-memory token, so the runtime can perform the §D close-then-
|
|
67
|
+
* reconnect (a token only enters via a fresh `connect` envelope; it cannot be
|
|
68
|
+
* hot-swapped onto a live socket). Not called for reactive refreshes, whose
|
|
69
|
+
* callers already own the reconnect.
|
|
70
|
+
*/
|
|
71
|
+
onProactiveRefreshed?: (tokens: { accessToken: string; refreshToken: string }) => Promise<void> | void;
|
|
72
|
+
/** Test override for the HTTP impl. */
|
|
73
|
+
fetchImpl?: typeof fetch;
|
|
74
|
+
/** Test override for the clock. */
|
|
75
|
+
now?: () => number;
|
|
76
|
+
/** Test override for setTimeout (returns an opaque handle). */
|
|
77
|
+
setTimer?: (cb: () => void, ms: number) => TimerHandle;
|
|
78
|
+
clearTimer?: (handle: TimerHandle) => void;
|
|
79
|
+
/** Test override for jitter in [-PROACTIVE_JITTER_MS, +PROACTIVE_JITTER_MS]. */
|
|
80
|
+
jitter?: () => number;
|
|
81
|
+
log?: { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* §A.0/§A.1 — compute the absolute epoch-ms at which to proactively refresh.
|
|
86
|
+
* `refresh_at = exp - max(30min, min(2h, 0.25 * (exp - iat)))` plus jitter.
|
|
87
|
+
* `iatMs` is the token issue time; when unknown we approximate the lifetime as
|
|
88
|
+
* the full TTL so the lead time clamps to 2h for a 24h token.
|
|
89
|
+
*/
|
|
90
|
+
export function computeProactiveRefreshAtMs(params: {
|
|
91
|
+
expMs: number;
|
|
92
|
+
iatMs?: number | null;
|
|
93
|
+
nowMs: number;
|
|
94
|
+
jitterMs?: number;
|
|
95
|
+
}): number {
|
|
96
|
+
const lifetimeMs =
|
|
97
|
+
typeof params.iatMs === "number" && Number.isFinite(params.iatMs)
|
|
98
|
+
? params.expMs - params.iatMs
|
|
99
|
+
: ACCESS_TOKEN_TTL_MS;
|
|
100
|
+
const lead = Math.max(30 * MINUTE_MS, Math.min(2 * HOUR_MS, 0.25 * lifetimeMs));
|
|
101
|
+
return params.expMs - lead + (params.jitterMs ?? 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* §A.0 — derive the access token's expiry (epoch ms). Prefer the JWT `exp`;
|
|
106
|
+
* fall back to `activatedAt + 24h` when the token has no parseable `exp`.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveAccessTokenExpiryMs(
|
|
109
|
+
token: string,
|
|
110
|
+
activatedAtMs: number | null,
|
|
111
|
+
): number | null {
|
|
112
|
+
const exp = decodeJwtExp(token);
|
|
113
|
+
if (exp != null) return exp * 1000;
|
|
114
|
+
if (activatedAtMs != null) return activatedAtMs + ACCESS_TOKEN_TTL_MS;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class RefreshManager {
|
|
119
|
+
private inFlight: Promise<RefreshOutcome> | null = null;
|
|
120
|
+
/** §A.3 — the access token a refresh was last attempted for. */
|
|
121
|
+
private rejectedToken: string | null = null;
|
|
122
|
+
/** §A.3 — epoch-ms of the last refresh attempt (any token). */
|
|
123
|
+
private lastAttemptAt = 0;
|
|
124
|
+
private proactiveTimer: TimerHandle | null = null;
|
|
125
|
+
private stopped = false;
|
|
126
|
+
|
|
127
|
+
constructor(private readonly ports: RefreshManagerPorts) {}
|
|
128
|
+
|
|
129
|
+
private now(): number {
|
|
130
|
+
return (this.ports.now ?? Date.now)();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private setTimer(cb: () => void, ms: number): TimerHandle {
|
|
134
|
+
return (this.ports.setTimer ?? setTimeout)(cb, ms);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private clearTimer(handle: TimerHandle): void {
|
|
138
|
+
(this.ports.clearTimer ?? clearTimeout)(handle as ReturnType<typeof setTimeout>);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* §A.3 — run a single-flight refresh. Concurrent callers receive the same
|
|
143
|
+
* in-flight promise. Honors the rejected-token latch and the min-interval.
|
|
144
|
+
*/
|
|
145
|
+
refresh(reason: string): Promise<RefreshOutcome> {
|
|
146
|
+
if (this.stopped) return Promise.resolve({ kind: "skipped", reason: "in-flight" });
|
|
147
|
+
if (this.inFlight) {
|
|
148
|
+
this.ports.log?.debug?.(`clawchat-plugin-openclaw refresh deduped onto in-flight (${reason})`);
|
|
149
|
+
return this.inFlight;
|
|
150
|
+
}
|
|
151
|
+
const accessToken = this.ports.getAccessToken();
|
|
152
|
+
// §A.3 rejected-token latch: don't re-attempt for an unchanged dead token.
|
|
153
|
+
if (this.rejectedToken !== null && this.rejectedToken === accessToken) {
|
|
154
|
+
this.ports.log?.debug?.(
|
|
155
|
+
`clawchat-plugin-openclaw refresh skipped rejected-latch (${reason})`,
|
|
156
|
+
);
|
|
157
|
+
return Promise.resolve({ kind: "skipped", reason: "rejected-latch" });
|
|
158
|
+
}
|
|
159
|
+
// §A.3 min-interval floor.
|
|
160
|
+
const sinceLast = this.now() - this.lastAttemptAt;
|
|
161
|
+
if (this.lastAttemptAt !== 0 && sinceLast < MIN_REFRESH_INTERVAL_MS) {
|
|
162
|
+
this.ports.log?.debug?.(
|
|
163
|
+
`clawchat-plugin-openclaw refresh skipped min-interval (${reason}) sinceLast=${sinceLast}ms`,
|
|
164
|
+
);
|
|
165
|
+
return Promise.resolve({ kind: "skipped", reason: "min-interval" });
|
|
166
|
+
}
|
|
167
|
+
const refreshToken = this.ports.getRefreshToken();
|
|
168
|
+
if (!refreshToken || !refreshToken.trim()) {
|
|
169
|
+
return Promise.resolve({ kind: "skipped", reason: "no-refresh-token" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.lastAttemptAt = this.now();
|
|
173
|
+
const promise = this.runRefresh(accessToken, refreshToken, reason).finally(() => {
|
|
174
|
+
this.inFlight = null;
|
|
175
|
+
});
|
|
176
|
+
this.inFlight = promise;
|
|
177
|
+
return promise;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async runRefresh(
|
|
181
|
+
accessToken: string,
|
|
182
|
+
refreshToken: string,
|
|
183
|
+
reason: string,
|
|
184
|
+
): Promise<RefreshOutcome> {
|
|
185
|
+
this.ports.log?.info?.(`clawchat-plugin-openclaw refresh attempt (${reason})`);
|
|
186
|
+
let result: AuthRefreshResult;
|
|
187
|
+
try {
|
|
188
|
+
result = await authRefresh(
|
|
189
|
+
{ baseUrl: this.ports.baseUrl, ...(this.ports.fetchImpl ? { fetchImpl: this.ports.fetchImpl } : {}) },
|
|
190
|
+
{ refreshToken, deviceId: this.ports.deviceId },
|
|
191
|
+
);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
194
|
+
return { kind: "transient", message };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (result.kind === "success") {
|
|
198
|
+
// §0 rotation hazard — persist FIRST, swap SECOND. If persistence REJECTS
|
|
199
|
+
// (a store/config write failed), DO NOT swap the in-memory token: a
|
|
200
|
+
// sqlite-sourced agent must not keep a now-dead refresh token in its row
|
|
201
|
+
// while running on the rotated token. Treat as transient so the WS stays
|
|
202
|
+
// in backoff with the CURRENT tokens and the next attempt retries. The
|
|
203
|
+
// server already rotated, so the next attempt may return `code:10003`
|
|
204
|
+
// (which escalates to permanent per §B) — that is the accepted hazard, not
|
|
205
|
+
// a silent brick.
|
|
206
|
+
try {
|
|
207
|
+
await this.ports.persistRotatedTokens({
|
|
208
|
+
accessToken: result.accessToken,
|
|
209
|
+
refreshToken: result.refreshToken,
|
|
210
|
+
});
|
|
211
|
+
} catch (err) {
|
|
212
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
213
|
+
this.ports.log?.error?.(
|
|
214
|
+
`clawchat-plugin-openclaw refresh persistence failed; not swapping in-memory token: ${message}`,
|
|
215
|
+
);
|
|
216
|
+
return { kind: "transient", message };
|
|
217
|
+
}
|
|
218
|
+
this.ports.swapInMemoryTokens({
|
|
219
|
+
accessToken: result.accessToken,
|
|
220
|
+
refreshToken: result.refreshToken,
|
|
221
|
+
});
|
|
222
|
+
// Clear the latch; the access token has actually changed.
|
|
223
|
+
this.rejectedToken = null;
|
|
224
|
+
this.ports.log?.info?.("clawchat-plugin-openclaw refresh success (token rotated)");
|
|
225
|
+
return { kind: "success", accessToken: result.accessToken, refreshToken: result.refreshToken };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (result.kind === "permanent") {
|
|
229
|
+
// §A.3 — latch the dead token so reconnect storms don't re-fire refresh.
|
|
230
|
+
this.rejectedToken = accessToken;
|
|
231
|
+
this.ports.log?.error?.(
|
|
232
|
+
`clawchat-plugin-openclaw refresh permanent failure code=${result.code}: ${result.message}`,
|
|
233
|
+
);
|
|
234
|
+
await this.ports.onPermanentFailure({ code: result.code, message: result.message });
|
|
235
|
+
return { kind: "permanent", code: result.code, message: result.message };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Transient — never auto-logout; the old refresh token is still valid.
|
|
239
|
+
this.ports.log?.info?.(
|
|
240
|
+
`clawchat-plugin-openclaw refresh transient failure: ${result.message}`,
|
|
241
|
+
);
|
|
242
|
+
return { kind: "transient", message: result.message };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* §A.1 — (re)arm the proactive timer from the live access token's `exp`.
|
|
247
|
+
* Called when a connection becomes ready and after every successful refresh.
|
|
248
|
+
*/
|
|
249
|
+
armProactiveTimer(activatedAtMs: number | null): void {
|
|
250
|
+
if (this.stopped) return;
|
|
251
|
+
this.disarmProactiveTimer();
|
|
252
|
+
const token = this.ports.getAccessToken();
|
|
253
|
+
const expMs = resolveAccessTokenExpiryMs(token, activatedAtMs);
|
|
254
|
+
if (expMs == null) {
|
|
255
|
+
this.ports.log?.debug?.("clawchat-plugin-openclaw proactive timer not armed (no expiry)");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const iat = decodeJwtIat(token);
|
|
259
|
+
const jitterMs = (this.ports.jitter ?? defaultJitter)();
|
|
260
|
+
const refreshAtMs = computeProactiveRefreshAtMs({
|
|
261
|
+
expMs,
|
|
262
|
+
iatMs: iat != null ? iat * 1000 : null,
|
|
263
|
+
nowMs: this.now(),
|
|
264
|
+
jitterMs,
|
|
265
|
+
});
|
|
266
|
+
const delayMs = Math.max(0, refreshAtMs - this.now());
|
|
267
|
+
this.ports.log?.debug?.(
|
|
268
|
+
`clawchat-plugin-openclaw proactive timer armed delayMs=${delayMs}`,
|
|
269
|
+
);
|
|
270
|
+
this.proactiveTimer = this.setTimer(() => {
|
|
271
|
+
this.proactiveTimer = null;
|
|
272
|
+
void this.runProactiveRefresh();
|
|
273
|
+
}, delayMs);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* §A.1/§D — the proactive-timer body. Runs the single-flight refresh and, on
|
|
278
|
+
* success, hands the rotated token to the runtime's `onProactiveRefreshed` port
|
|
279
|
+
* so the live WS is closed and reconnected with the new token (the in-memory
|
|
280
|
+
* swap alone does NOT reach the running socket, which captured the old token at
|
|
281
|
+
* `connect` time). Transient/skipped outcomes leave the WS untouched — the next
|
|
282
|
+
* proactive arm (or a reactive hello-fail) handles it.
|
|
283
|
+
*/
|
|
284
|
+
private async runProactiveRefresh(): Promise<void> {
|
|
285
|
+
const outcome = await this.refresh("proactive-timer");
|
|
286
|
+
if (this.stopped) return;
|
|
287
|
+
if (outcome.kind !== "success") return;
|
|
288
|
+
if (this.ports.onProactiveRefreshed) {
|
|
289
|
+
try {
|
|
290
|
+
await this.ports.onProactiveRefreshed({
|
|
291
|
+
accessToken: outcome.accessToken,
|
|
292
|
+
refreshToken: outcome.refreshToken,
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
this.ports.log?.error?.(
|
|
296
|
+
`clawchat-plugin-openclaw proactive reconnect hook failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
disarmProactiveTimer(): void {
|
|
303
|
+
if (this.proactiveTimer != null) {
|
|
304
|
+
this.clearTimer(this.proactiveTimer);
|
|
305
|
+
this.proactiveTimer = null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* §A.4 — true when the stored access token is past or within the proactive
|
|
311
|
+
* margin of expiry, so the caller should refresh BEFORE the first connect.
|
|
312
|
+
*/
|
|
313
|
+
isNearExpiry(activatedAtMs: number | null): boolean {
|
|
314
|
+
const token = this.ports.getAccessToken();
|
|
315
|
+
const expMs = resolveAccessTokenExpiryMs(token, activatedAtMs);
|
|
316
|
+
if (expMs == null) return false;
|
|
317
|
+
const iat = decodeJwtIat(token);
|
|
318
|
+
const refreshAtMs = computeProactiveRefreshAtMs({
|
|
319
|
+
expMs,
|
|
320
|
+
iatMs: iat != null ? iat * 1000 : null,
|
|
321
|
+
nowMs: this.now(),
|
|
322
|
+
jitterMs: 0,
|
|
323
|
+
});
|
|
324
|
+
return this.now() >= refreshAtMs;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Stop the manager — clears the proactive timer; no further refreshes arm. */
|
|
328
|
+
stop(): void {
|
|
329
|
+
this.stopped = true;
|
|
330
|
+
this.disarmProactiveTimer();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Test/inspection seam — the latched (rejected) access token, if any. */
|
|
334
|
+
getRejectedToken(): string | null {
|
|
335
|
+
return this.rejectedToken;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* §A.3/§A.4 — export the single-flight guard state so it can be carried across
|
|
340
|
+
* a gateway re-enter (a refresh-driven reconnect builds a fresh manager; without
|
|
341
|
+
* restoring this state the rejected-token latch + min-interval would reset and
|
|
342
|
+
* the guards would not bound a cross-reconnect loop).
|
|
343
|
+
*/
|
|
344
|
+
exportState(): { rejectedToken: string | null; lastAttemptAt: number } {
|
|
345
|
+
return { rejectedToken: this.rejectedToken, lastAttemptAt: this.lastAttemptAt };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** §A.3/§A.4 — restore guard state from a prior manager (see `exportState`). */
|
|
349
|
+
restoreState(state: { rejectedToken: string | null; lastAttemptAt: number }): void {
|
|
350
|
+
this.rejectedToken = state.rejectedToken;
|
|
351
|
+
this.lastAttemptAt = state.lastAttemptAt;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function decodeJwtIat(token: string): number | null {
|
|
356
|
+
if (typeof token !== "string") return null;
|
|
357
|
+
const segments = token.split(".");
|
|
358
|
+
if (segments.length < 2 || !segments[1]) return null;
|
|
359
|
+
try {
|
|
360
|
+
const json = Buffer.from(segments[1], "base64url").toString("utf8");
|
|
361
|
+
const parsed = JSON.parse(json) as { iat?: unknown };
|
|
362
|
+
const iat = parsed?.iat;
|
|
363
|
+
return typeof iat === "number" && Number.isFinite(iat) ? iat : null;
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function defaultJitter(): number {
|
|
370
|
+
return (Math.random() * 2 - 1) * PROACTIVE_JITTER_MS;
|
|
371
|
+
}
|