@wakeshift/openclaw 0.0.0-placeholder → 1.4.24

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/store.js ADDED
@@ -0,0 +1,202 @@
1
+ // What the plugin keeps on disk, under <OpenClaw state>/wakeshift: each
2
+ // connected conversation's stream links, one per app it watches (a secret:
3
+ // 0600 files in a 0700 directory, named by a hash of the conversation, the
4
+ // issuer and the app, so odd IDs can't escape it), the issuer's signing
5
+ // keys, and this install's random ID.
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { chmodSync, closeSync, fchmodSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
8
+ import { basename, dirname, join } from "node:path";
9
+ /** A link's place among its conversation's: its issuer and app ("" for a link that names no app). */
10
+ export function linkKey(l) {
11
+ return l.app ? `${l.issuer ?? ""}\n${l.app}` : "";
12
+ }
13
+ /** Store.pin's answer when the pins exist but can't be read. */
14
+ export const PINS_UNREADABLE = "the pins can't be read";
15
+ const hash = (s, n) => createHash("sha256").update(s).digest("hex").slice(0, n);
16
+ /**
17
+ * Writes a file only this user can read: a fresh temp file of its own name
18
+ * (O_EXCL, 0600 before any byte, even if the umask or an old file differs),
19
+ * flushed to disk, then renamed over path.
20
+ */
21
+ export function writeAtomic(path, body) {
22
+ const tmp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
23
+ const fd = openSync(tmp, "wx", 0o600);
24
+ try {
25
+ fchmodSync(fd, 0o600);
26
+ writeSync(fd, body);
27
+ fsyncSync(fd);
28
+ }
29
+ catch (e) {
30
+ closeSync(fd);
31
+ rmSync(tmp, { force: true });
32
+ throw e;
33
+ }
34
+ closeSync(fd);
35
+ try {
36
+ renameSync(tmp, path);
37
+ }
38
+ catch (e) {
39
+ rmSync(tmp, { force: true });
40
+ throw e;
41
+ }
42
+ }
43
+ function valid(s) {
44
+ return (!!s &&
45
+ typeof s.conversation === "string" &&
46
+ !!s.conversation &&
47
+ typeof s.link === "string" &&
48
+ !!s.link &&
49
+ typeof s.sessionKey === "string" &&
50
+ (s.issuer === undefined || typeof s.issuer === "string") &&
51
+ (s.app === undefined || typeof s.app === "string") &&
52
+ (s.stream === undefined || typeof s.stream === "string"));
53
+ }
54
+ export class Store {
55
+ dir;
56
+ constructor(dir) {
57
+ this.dir = dir;
58
+ }
59
+ ensure(sub) {
60
+ const d = join(this.dir, sub);
61
+ mkdirSync(d, { recursive: true, mode: 0o700 });
62
+ try {
63
+ chmodSync(this.dir, 0o700);
64
+ chmodSync(d, 0o700);
65
+ }
66
+ catch {
67
+ /* best effort */
68
+ }
69
+ return d;
70
+ }
71
+ write(path, body) {
72
+ writeAtomic(path, body);
73
+ }
74
+ /** This install's ID: random, made once. */
75
+ installId() {
76
+ const p = join(this.ensure("."), "install-id");
77
+ try {
78
+ const id = readFileSync(p, "utf8").trim();
79
+ if (/^[0-9a-f]{16}$/.test(id))
80
+ return id;
81
+ }
82
+ catch {
83
+ /* none yet */
84
+ }
85
+ const id = randomBytes(8).toString("hex");
86
+ this.write(p, id + "\n");
87
+ return id;
88
+ }
89
+ // links/<hash>.json: a conversation's link for one app (key: linkKey),
90
+ // or its one link from before links named their app (key "").
91
+ linkName(conversation, key) {
92
+ return hash(key ? `${conversation}\n${key}` : conversation, 32) + ".json";
93
+ }
94
+ linkPath(conversation, key) {
95
+ return join(this.dir, "links", this.linkName(conversation, key));
96
+ }
97
+ saveLink(l) {
98
+ this.ensure("links");
99
+ this.write(this.linkPath(l.conversation, linkKey(l)), JSON.stringify({ ...l, saved_at: new Date().toISOString() }));
100
+ }
101
+ /** The conversation's link for one app (key: linkKey; "" for the one saved before links named their app). */
102
+ loadLink(conversation, key = "") {
103
+ try {
104
+ const s = JSON.parse(readFileSync(this.linkPath(conversation, key), "utf8"));
105
+ return valid(s) && s.conversation === conversation && linkKey(s) === key ? s : undefined;
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
111
+ forgetLink(conversation, key = "") {
112
+ rmSync(this.linkPath(conversation, key), { force: true });
113
+ }
114
+ /** Every saved link: only a file named for what it holds (a copy under another name isn't read). */
115
+ links() {
116
+ let names;
117
+ try {
118
+ names = readdirSync(join(this.dir, "links"));
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ const out = [];
124
+ for (const n of names) {
125
+ if (!n.endsWith(".json"))
126
+ continue;
127
+ try {
128
+ const s = JSON.parse(readFileSync(join(this.dir, "links", n), "utf8"));
129
+ if (valid(s) && s.sessionKey && n === this.linkName(s.conversation, linkKey(s)))
130
+ out.push(s);
131
+ }
132
+ catch {
133
+ /* skip */
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+ keysPath(issuer) {
139
+ return join(this.dir, "keys", hash(issuer, 16) + ".json");
140
+ }
141
+ /** The issuer's JWKS as last fetched, and when. */
142
+ loadKeys(issuer) {
143
+ const p = this.keysPath(issuer);
144
+ try {
145
+ return { body: readFileSync(p, "utf8"), at: statSync(p).mtimeMs };
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ }
151
+ saveKeys(issuer, body) {
152
+ this.ensure("keys");
153
+ this.write(this.keysPath(issuer), body);
154
+ }
155
+ /** Where the pins are (servers.json: MCP server name → app ID). */
156
+ pinsPath() {
157
+ return join(this.dir, "servers.json");
158
+ }
159
+ /**
160
+ * connector.Pins: "" if the MCP server may carry the app's beacons (the
161
+ * pair is pinned the first time either is seen), else why not. A server's
162
+ * results carry one app's beacons, and an app's come from one server.
163
+ */
164
+ pin(server, app) {
165
+ if (!server || !app)
166
+ return "the result named no app";
167
+ let pins = {};
168
+ let raw;
169
+ try {
170
+ raw = readFileSync(this.pinsPath(), "utf8");
171
+ }
172
+ catch (e) {
173
+ // None yet; pins that exist but can't be read fail closed (read as
174
+ // none, any server could pin itself to any app).
175
+ if (e.code !== "ENOENT")
176
+ return PINS_UNREADABLE;
177
+ }
178
+ if (raw !== undefined) {
179
+ try {
180
+ const f = JSON.parse(raw);
181
+ if (!f || typeof f.servers !== "object" || !f.servers)
182
+ throw new Error("no servers");
183
+ pins = f.servers;
184
+ }
185
+ catch {
186
+ return PINS_UNREADABLE;
187
+ }
188
+ }
189
+ const got = pins[server];
190
+ if (got === app)
191
+ return "";
192
+ if (got)
193
+ return `the MCP server "${server}" carries beacons for "${got}" here, not "${app}"`;
194
+ for (const [s, a] of Object.entries(pins))
195
+ if (a === app)
196
+ return `beacons for "${app}" come from the MCP server "${s}" here, not "${server}"`;
197
+ pins[server] = app;
198
+ this.ensure(".");
199
+ this.write(this.pinsPath(), JSON.stringify({ servers: pins }, null, 2));
200
+ return "";
201
+ }
202
+ }
@@ -0,0 +1,474 @@
1
+ // Transport for one conversation's Wakeshift stream link: reading only. It
2
+ // owns no delivery state (deliveries.ts does): it hands each delivery it
3
+ // reads to `receive`, and sends acknowledgments when asked (`ack`).
4
+ //
5
+ // Server-Sent Events first (GET /v1/stream?format=sse), long polling (GET
6
+ // /v1/stream/poll) as the fallback when SSE doesn't get through (a proxy
7
+ // that buffers it), both with the link's token as Authorization: Bearer,
8
+ // never in a URL. The cursor (Last-Event-ID) moves past a delivery only
9
+ // once `receive` has resolved for it, i.e. once it's recorded. hub.rotate
10
+ // is followed to the same origin only; hub.closed and a 401 or 410 (or
11
+ // Wakeshift's link_revoked) end the stream for good; anything else,
12
+ // including another 403 or 404 (a router during a deploy, a firewall),
13
+ // reconnects with backoff. A connection that ends at once without a
14
+ // delivery counts as a failure for the backoff.
15
+ import { CLIENT_HEADER, clientHeader } from "./version.js";
16
+ /** The longest SSE line, and the largest poll answer, read; a longer line is skipped. */
17
+ export const MAX_LINE = 1 << 20;
18
+ export const MAX_RESPONSE = 8 << 20;
19
+ /** A stream link's parts: its origin as https (or http) and its token. */
20
+ export function parseLink(link) {
21
+ let u;
22
+ try {
23
+ u = new URL(link);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ const scheme = u.protocol === "wss:" || u.protocol === "https:" ? "https:" : u.protocol === "ws:" || u.protocol === "http:" ? "http:" : "";
29
+ const token = u.searchParams.get("link") ?? "";
30
+ if (!scheme || !u.host || !token)
31
+ return undefined;
32
+ return { base: `${scheme}//${u.host}`, token };
33
+ }
34
+ export function sameOrigin(a, b) {
35
+ const x = parseLink(a), y = parseLink(b);
36
+ return !!x && !!y && x.base === y.base;
37
+ }
38
+ // hub.closed reasons after which the stream goes on (reconnect).
39
+ const RETRY_REASONS = new Set(["restarting", "reconnect", "error"]);
40
+ class Gone extends Error {
41
+ reason;
42
+ constructor(reason) {
43
+ super("gone: " + reason);
44
+ this.reason = reason;
45
+ }
46
+ }
47
+ class Replaced extends Error {
48
+ constructor() {
49
+ super("replaced");
50
+ }
51
+ }
52
+ class Outdated extends Error {
53
+ constructor() {
54
+ super("Wakeshift no longer serves this version of the plugin");
55
+ }
56
+ }
57
+ // Wakeshift answered but the stream didn't work: what a proxy that won't
58
+ // pass SSE looks like.
59
+ class SSEBlocked extends Error {
60
+ }
61
+ // A delivery couldn't be recorded: not the stream's fault.
62
+ class RecordFailed extends Error {
63
+ }
64
+ class Unavailable extends Error {
65
+ retryAfter;
66
+ constructor(retryAfter) {
67
+ super("Wakeshift is busy or restarting");
68
+ this.retryAfter = retryAfter;
69
+ }
70
+ }
71
+ export class Transport {
72
+ o;
73
+ link;
74
+ f;
75
+ abort = new AbortController();
76
+ stopped = false;
77
+ lastEventId = "";
78
+ mode = "sse";
79
+ sseFailures = 0;
80
+ pollingSince = 0;
81
+ conn;
82
+ reconnecting = false;
83
+ chain = Promise.resolve();
84
+ progress = false; // a delivery (or a new link) came on the current connection
85
+ done;
86
+ resolveDone;
87
+ constructor(o) {
88
+ this.o = o;
89
+ this.link = o.link;
90
+ this.f = o.fetch ?? fetch;
91
+ this.done = new Promise((r) => (this.resolveDone = r));
92
+ }
93
+ get currentLink() {
94
+ return this.link;
95
+ }
96
+ get transport() {
97
+ return this.mode;
98
+ }
99
+ start() {
100
+ void this.run();
101
+ return this;
102
+ }
103
+ stop() {
104
+ this.stopped = true;
105
+ this.abort.abort();
106
+ }
107
+ /** Waits until what's been read so far is recorded. */
108
+ async settled() {
109
+ await this.chain;
110
+ }
111
+ /**
112
+ * Drops the SSE connection and makes a new one, so Wakeshift hands out
113
+ * again what isn't acknowledged (on SSE that comes back only on a new
114
+ * connection; a poll gets it back by itself).
115
+ */
116
+ reconnect() {
117
+ if (this.mode === "sse" && this.conn && !this.conn.signal.aborted) {
118
+ this.reconnecting = true;
119
+ this.conn.abort();
120
+ }
121
+ }
122
+ /**
123
+ * POST /v1/stream/ack for up to 100 IDs: the HTTP status, or 0 if
124
+ * Wakeshift couldn't be reached.
125
+ */
126
+ async ack(ids) {
127
+ if (ids.length === 0 || ids.length > 100)
128
+ throw new Error("ack: 1 to 100 IDs");
129
+ const p = parseLink(this.link);
130
+ if (!p)
131
+ return 401;
132
+ try {
133
+ const r = await this.f(`${p.base}/v1/stream/ack`, {
134
+ method: "POST",
135
+ headers: { Authorization: `Bearer ${p.token}`, "Content-Type": "application/json", [CLIENT_HEADER]: clientHeader() },
136
+ body: JSON.stringify({ delivery_ids: ids }),
137
+ signal: AbortSignal.any([this.abort.signal, AbortSignal.timeout(10000)]),
138
+ redirect: "error", // never the token to wherever a 3xx points
139
+ });
140
+ if ((r.status === 403 || r.status === 404) && (await revoked(r)))
141
+ return 401;
142
+ await r.body?.cancel().catch(() => { });
143
+ return r.status;
144
+ }
145
+ catch {
146
+ return 0;
147
+ }
148
+ }
149
+ log(m) {
150
+ this.o.log?.(m);
151
+ }
152
+ async run() {
153
+ const minBackoff = this.o.minBackoff ?? 1000;
154
+ const maxBackoff = this.o.maxBackoff ?? 30000;
155
+ const pollWait = this.o.pollWait ?? 25000;
156
+ let backoff = minBackoff;
157
+ let result = { why: "stopped" };
158
+ while (!this.stopped) {
159
+ try {
160
+ if (this.mode === "poll" && Date.now() - this.pollingSince > (this.o.sseRetryEvery ?? 600000))
161
+ this.mode = "sse";
162
+ if (this.mode === "sse") {
163
+ this.progress = false;
164
+ const began = Date.now();
165
+ await this.sse(); // returns when the stream says to reconnect
166
+ if (this.progress || Date.now() - began >= (this.o.healthyAfter ?? 10000)) {
167
+ backoff = minBackoff;
168
+ continue;
169
+ }
170
+ // Ended at once with nothing new (a proxy that closes at once, a
171
+ // repeated hub.closed "restarting"): don't reconnect in a tight loop.
172
+ await sleep(backoff, this.abort.signal);
173
+ backoff = Math.min(backoff * 2, maxBackoff);
174
+ continue;
175
+ }
176
+ const started = Date.now();
177
+ const fresh = await this.poll();
178
+ // Nothing new (empty, or only what's recorded and waiting for its
179
+ // turn, which a poll hands out again at once): don't spin.
180
+ if (fresh === 0 && Date.now() - started < pollWait / 2) {
181
+ await sleep(backoff, this.abort.signal);
182
+ backoff = Math.min(backoff * 2, maxBackoff);
183
+ }
184
+ else
185
+ backoff = minBackoff;
186
+ }
187
+ catch (e) {
188
+ if (this.stopped)
189
+ break;
190
+ if (e instanceof Gone) {
191
+ result = { why: "gone", reason: e.reason };
192
+ break;
193
+ }
194
+ if (e instanceof Replaced) {
195
+ result = { why: "replaced" };
196
+ break;
197
+ }
198
+ if (e instanceof Outdated) {
199
+ this.log("Wakeshift no longer serves this version of the plugin: update it to receive messages");
200
+ result = { why: "outdated" };
201
+ break;
202
+ }
203
+ // Only Wakeshift answering and SSE still not working counts (not
204
+ // Wakeshift being unreachable, which polling wouldn't change).
205
+ if (this.mode === "sse" && e instanceof SSEBlocked && ++this.sseFailures >= (this.o.sseTries ?? 3)) {
206
+ this.log("Server-Sent Events aren't getting through; long polling instead");
207
+ this.mode = "poll";
208
+ this.pollingSince = Date.now();
209
+ this.sseFailures = 0;
210
+ }
211
+ const wait = e instanceof Unavailable && e.retryAfter ? e.retryAfter * 1000 : backoff;
212
+ this.log(`stream: ${e.message}; again in ${Math.round(wait / 1000)}s`);
213
+ await sleep(wait, this.abort.signal);
214
+ backoff = Math.min(backoff * 2, maxBackoff);
215
+ }
216
+ }
217
+ await this.chain.catch(() => { });
218
+ if (this.stopped)
219
+ result = { why: "stopped" };
220
+ this.o.onStop?.(result);
221
+ this.resolveDone(result);
222
+ }
223
+ parts() {
224
+ const p = parseLink(this.link);
225
+ if (!p)
226
+ throw new Gone("not a stream link");
227
+ return p;
228
+ }
229
+ async status(r) {
230
+ if (r.status === 426)
231
+ throw new Outdated();
232
+ if (r.status === 401 || r.status === 410)
233
+ throw new Gone("link_revoked");
234
+ if ((r.status === 403 || r.status === 404) && (await revoked(r)))
235
+ throw new Gone("link_revoked");
236
+ if (r.status === 409)
237
+ throw new Replaced();
238
+ if (r.status === 503 || r.status === 429) {
239
+ const ra = Number(r.headers.get("retry-after"));
240
+ throw new Unavailable(Number.isFinite(ra) && ra > 0 ? Math.min(ra, 120) : undefined);
241
+ }
242
+ if (!r.ok)
243
+ throw new Error(`Wakeshift answered ${r.status}`);
244
+ }
245
+ rotate(next) {
246
+ if (typeof next !== "string" || !next)
247
+ return;
248
+ const p = parseLink(next);
249
+ if (!p || !sameOrigin(next, this.link))
250
+ return;
251
+ const cur = new URL(this.link);
252
+ const link = `${cur.protocol}//${cur.host}/v1/stream?link=${encodeURIComponent(p.token)}`;
253
+ if (link === this.link)
254
+ return;
255
+ this.link = link;
256
+ this.progress = true;
257
+ this.o.onLink?.(link);
258
+ }
259
+ // Records deliveries in order, one batch at a time; resolves with how
260
+ // many were new. The cursor moves only once they're recorded.
261
+ // Throws if recording fails: the caller stops reading this connection
262
+ // (or poll) there and reconnects, with the cursor still before it.
263
+ receive(ds) {
264
+ const valid = ds.filter((d) => d && typeof d.delivery_id === "string" && d.event && typeof d.event === "object");
265
+ const p = this.chain.then(async () => {
266
+ if (valid.length === 0 || this.stopped)
267
+ return 0;
268
+ const n = await this.o.receive(valid);
269
+ this.progress = true;
270
+ this.lastEventId = valid[valid.length - 1].delivery_id; // SSE ids are delivery IDs
271
+ return n;
272
+ });
273
+ this.chain = p.then(() => { }, () => { });
274
+ return p.catch((e) => {
275
+ throw new RecordFailed(`recording failed: ${e.message}`);
276
+ });
277
+ }
278
+ // One SSE connection, until the stream says to reconnect (returns) or
279
+ // ends any other way (throws).
280
+ async sse() {
281
+ const { base, token } = this.parts();
282
+ const conn = new AbortController();
283
+ this.conn = conn;
284
+ this.reconnecting = false;
285
+ const onStop = () => conn.abort();
286
+ this.abort.signal.addEventListener("abort", onStop, { once: true });
287
+ let last = Date.now();
288
+ const silentFor = this.o.silentFor ?? 50000;
289
+ let silent = false;
290
+ let answered = false;
291
+ const watchdog = setInterval(() => {
292
+ if (Date.now() - last > silentFor) {
293
+ silent = true;
294
+ conn.abort();
295
+ }
296
+ }, Math.min(5000, silentFor / 2));
297
+ try {
298
+ const headers = { Accept: "text/event-stream", Authorization: `Bearer ${token}`, [CLIENT_HEADER]: clientHeader() };
299
+ if (this.lastEventId)
300
+ headers["Last-Event-ID"] = this.lastEventId;
301
+ const r = await this.f(`${base}/v1/stream?format=sse`, { headers, signal: conn.signal, redirect: "error" });
302
+ answered = true;
303
+ await this.status(r);
304
+ if (!r.body)
305
+ throw new Error("no body");
306
+ const reader = r.body.pipeThrough(new TextDecoderStream()).getReader();
307
+ try {
308
+ let buf = "";
309
+ let ev = { data: [], size: 0 };
310
+ let longLine = false; // the rest of an over-long line is being skipped
311
+ for (;;) {
312
+ const { value, done } = await reader.read();
313
+ if (done)
314
+ throw new Error("the stream ended");
315
+ last = Date.now();
316
+ this.sseFailures = 0;
317
+ buf += value;
318
+ let i;
319
+ while ((i = buf.search(/\r\n|\r|\n/)) >= 0) {
320
+ const line = buf.slice(0, i);
321
+ buf = buf.slice(i + (buf.startsWith("\r\n", i) ? 2 : 1));
322
+ if (longLine || line.length > MAX_LINE) {
323
+ if (!longLine)
324
+ this.log("skipped an over-long stream line");
325
+ longLine = false; // its end: the event it was in is dropped
326
+ ev = { data: [], size: 0, skip: true };
327
+ continue;
328
+ }
329
+ if (line === "") {
330
+ // Each frame is recorded before the next is read.
331
+ if (!ev.skip && ev.data.length > 0 && (await this.onFrame(ev.event ?? "message", ev.data.join("\n"))) === "reconnect")
332
+ return;
333
+ ev = { data: [], size: 0 };
334
+ continue;
335
+ }
336
+ if (line.startsWith(":"))
337
+ continue;
338
+ const c = line.indexOf(":");
339
+ const k = c < 0 ? line : line.slice(0, c);
340
+ const v = c < 0 ? "" : line.slice(c + 1).replace(/^ /, "");
341
+ if (k === "data") {
342
+ ev.size += v.length;
343
+ if (ev.size > MAX_LINE * 4)
344
+ ev = { data: [], size: 0, skip: true };
345
+ if (!ev.skip)
346
+ ev.data.push(v);
347
+ }
348
+ else if (k === "event")
349
+ ev.event = v;
350
+ }
351
+ if (buf.length > MAX_LINE) {
352
+ this.log("skipped an over-long stream line");
353
+ buf = "";
354
+ longLine = true;
355
+ ev = { data: [], size: 0, skip: true };
356
+ }
357
+ }
358
+ }
359
+ finally {
360
+ reader.cancel().catch(() => { });
361
+ }
362
+ }
363
+ catch (e) {
364
+ if (this.reconnecting && !this.stopped)
365
+ return; // asked to reconnect
366
+ if (silent && !this.stopped)
367
+ throw new SSEBlocked(`nothing came on the stream for ${Math.round(silentFor / 1000)}s`);
368
+ if (answered && !(e instanceof Gone || e instanceof Replaced || e instanceof Outdated || e instanceof Unavailable || e instanceof RecordFailed))
369
+ throw new SSEBlocked(e.message);
370
+ throw e;
371
+ }
372
+ finally {
373
+ clearInterval(watchdog);
374
+ this.abort.signal.removeEventListener("abort", onStop);
375
+ conn.abort();
376
+ }
377
+ }
378
+ // A frame: "reconnect" when the stream ends here and goes on; throws
379
+ // when it ends for good.
380
+ async onFrame(type, data) {
381
+ let m;
382
+ try {
383
+ m = JSON.parse(data);
384
+ }
385
+ catch {
386
+ return undefined;
387
+ }
388
+ const t = typeof m.type === "string" ? m.type : type;
389
+ if (t === "event") {
390
+ await this.receive([m]); // a failure ends this connection here
391
+ return undefined;
392
+ }
393
+ if (t === "hub.rotate") {
394
+ this.rotate(m.url);
395
+ return "reconnect";
396
+ }
397
+ if (t === "hub.closed") {
398
+ const reason = typeof m.reason === "string" ? m.reason : "";
399
+ if (reason === "replaced")
400
+ throw new Replaced();
401
+ if (reason === "update_required")
402
+ throw new Outdated(); // the minimum was raised; the link stays for the update
403
+ if (RETRY_REASONS.has(reason))
404
+ return "reconnect";
405
+ throw new Gone(reason || "closed");
406
+ }
407
+ return undefined;
408
+ }
409
+ // One long poll; how many new deliveries it brought.
410
+ async poll() {
411
+ const { base, token } = this.parts();
412
+ await this.chain;
413
+ const wait = this.o.pollWait ?? 25000;
414
+ const r = await this.f(`${base}/v1/stream/poll?wait=${wait}ms`, {
415
+ headers: { Authorization: `Bearer ${token}`, [CLIENT_HEADER]: clientHeader() },
416
+ signal: AbortSignal.any([this.abort.signal, AbortSignal.timeout(wait + 15000)]),
417
+ redirect: "error",
418
+ });
419
+ await this.status(r);
420
+ const b = JSON.parse(await bounded(r, MAX_RESPONSE));
421
+ if (b.rotate?.url)
422
+ this.rotate(b.rotate.url);
423
+ const ds = b.deliveries ?? [];
424
+ return ds.length > 0 ? this.receive(ds) : 0;
425
+ }
426
+ }
427
+ // Whether a 403 or 404 is Wakeshift's own link_revoked (not a router's or a firewall's).
428
+ async function revoked(r) {
429
+ try {
430
+ const b = JSON.parse(await bounded(r, 1 << 16));
431
+ return b?.error?.code === "link_revoked";
432
+ }
433
+ catch {
434
+ return false;
435
+ }
436
+ }
437
+ // A response's body as text, refusing one over max bytes.
438
+ async function bounded(r, max) {
439
+ if (!r.body)
440
+ return "";
441
+ const reader = r.body.getReader();
442
+ const parts = [];
443
+ let n = 0;
444
+ try {
445
+ for (;;) {
446
+ const { value, done } = await reader.read();
447
+ if (done)
448
+ break;
449
+ n += value.byteLength;
450
+ if (n > max)
451
+ throw new Error(`Wakeshift's answer was over ${max} bytes`);
452
+ parts.push(value);
453
+ }
454
+ }
455
+ finally {
456
+ reader.cancel().catch(() => { });
457
+ }
458
+ return Buffer.concat(parts).toString("utf8");
459
+ }
460
+ export function sleep(ms, signal) {
461
+ return new Promise((resolve) => {
462
+ if (signal.aborted)
463
+ return resolve();
464
+ const onAbort = () => {
465
+ clearTimeout(t);
466
+ resolve();
467
+ };
468
+ const t = setTimeout(() => {
469
+ signal.removeEventListener("abort", onAbort);
470
+ resolve();
471
+ }, ms);
472
+ signal.addEventListener("abort", onAbort, { once: true });
473
+ });
474
+ }
@@ -0,0 +1,12 @@
1
+ // The plugin's version, which every request to Wakeshift names
2
+ // (internal/connectorversion), so Wakeshift records what's in use and can
3
+ // ask the user to update. "dev" here; `npm run pack` stamps the build's
4
+ // version into dist/version.js (scripts/stamp-version.mjs, from
5
+ // WAKESHIFT_VERSION).
6
+ export const VERSION = "2026.11.03.0404+bb5a6d2";
7
+ export const CLIENT_HEADER = "X-Wakeshift-Connector";
8
+ /** X-Wakeshift-Connector's value: wakeshift-openclaw/<version> openclaw. */
9
+ export function clientHeader(version = VERSION) {
10
+ const v = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/.test(version) ? version : "0";
11
+ return `wakeshift-openclaw/${v} openclaw`;
12
+ }