@clawling/clawchat-plugin-openclaw 2026.5.13-dev.0 → 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.
@@ -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
- if (!res.ok) {
37
- const snippet = await res.text().catch(() => "");
38
- throw new ClawlingApiError("transport", `http ${res.status} ${snippet.slice(0, 200)}`, {
39
- status: res.status,
40
- path,
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 = await res.json();
159
+ parsed = text ? JSON.parse(text) : undefined;
46
160
  }
47
- catch (err) {
48
- throw new ClawlingApiError("transport", `non-JSON response: ${err instanceof Error ? err.message : String(err)}`, { status: res.status, path });
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
- const code = typeof env.code === "number" ? env.code : Number.NaN;
55
- const msg = typeof env.msg === "string"
56
- ? env.msg
57
- : typeof env.message === "string"
58
- ? env.message
59
- : "";
60
- if (!Number.isFinite(code)) {
61
- throw new ClawlingApiError("transport", "invalid envelope: missing numeric `code`", {
62
- status: res.status,
63
- path,
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
- if (code !== 0) {
67
- throw new ClawlingApiError("api", msg || `code=${code}`, {
68
- code,
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
- return env.data;
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;
@@ -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: resolveOpenclawClawlingDeviceId(account),
17
+ deviceId,
15
18
  ...(overrides.transport ? { transport: overrides.transport } : {}),
16
19
  reconnect: {
17
20
  enabled: true,
@@ -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
- const profileType = s.type === "agent" || s.type === "user" ? s.type : null;
14
- return { id, nickName, profileType };
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
- if (isMaterializedMessage && payload.message_mode !== "normal") {
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 {
@@ -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
- const message = typeof payload.message === "string" && payload.message ? payload.message : "message send failed";
202
- fail(new MessageSendError(traceId, code, message, ack.chat_id));
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
- const messageId = params.messageId;
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
- ...(messageId ? { message_id: messageId } : {}),
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
- ...(messageId ? { message_id: messageId } : {}),
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 (messageId && ack.payload.message_id !== messageId) {
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
- function mintOutboundMessageId(account) {
478
- return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
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
+ }