@wakeshift/openclaw 0.0.0-stage → 1.4.25

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/plugin.js ADDED
@@ -0,0 +1,523 @@
1
+ // Wakeshift's plugin for OpenClaw (verified against 2026.10.4).
2
+ //
3
+ // Connecting: an app's tool result for an OpenClaw session carries a
4
+ // beacon (internal/beacon). OpenClaw drops a result's _meta in its own
5
+ // runtime, so there it's a line of the result's text, which the plugin's
6
+ // tool-result middleware takes out before the model sees it; when OpenClaw
7
+ // runs the turn in Codex's app-server, the beacon is in _meta, which the
8
+ // plugin's hooks see and Codex keeps from the model. The plugin trusts a
9
+ // beacon only if the Wakeshift pinned at install signed it
10
+ // (plugins.entries.wakeshift.config.issuer), redeems its one-time ticket
11
+ // for the conversation's own stream link, and keeps the link on disk.
12
+ //
13
+ // One conversation can watch several apps: it keeps a link per app (and
14
+ // issuer), each with its own transport. A beacon replaces only its own
15
+ // app's link and hands back (supersedes) only that one: another app's link
16
+ // is never sent anywhere but its own stream, and a new link never stops
17
+ // another app's stream. A link saved before links named their app (one per
18
+ // conversation) is handed back and replaced by the first beacon after the
19
+ // update, as before.
20
+ //
21
+ // Waking: per connected conversation, a transport per link reads its stream
22
+ // (transport.ts: Server-Sent Events, falling back to long polling) and one
23
+ // Conversation (deliveries.ts) owns every delivery from all of them: a durable
24
+ // record each, framed as outside data (framing.ts), handed to a turn with
25
+ // OpenClaw's /hooks/agent (wake.ts: persistent, in the conversation's own
26
+ // session, which OpenClaw safety-wraps as outside content), and an ack only
27
+ // once that turn has finished. The hooks it needs are set up and scoped by
28
+ // control.ts (hooks.ts says what changes).
29
+ import { randomBytes } from "node:crypto";
30
+ import { join } from "node:path";
31
+ import { acceptable, BeaconError, beaconIn, findBeacon, jwkKey, mcpServer, mcpTool, verifyBound } from "./beacon.js";
32
+ import { Conversation, Ledger } from "./deliveries.js";
33
+ import { agentOf } from "./hooks.js";
34
+ import { linkKey, PINS_UNREADABLE } from "./store.js";
35
+ import { Transport } from "./transport.js";
36
+ import { CLIENT_HEADER, clientHeader } from "./version.js";
37
+ /** The connected note's start (harnesses.Connected): agents look for it. */
38
+ export const CONNECTED = "Wakeshift: connected";
39
+ export const NOTE_CONNECTED = CONNECTED + ". New messages for this conversation will reach it as soon as they arrive, even while it's idle, as long as the OpenClaw gateway runs.";
40
+ export const noteFailed = (why) => `Wakeshift: couldn't connect this conversation (${why}), so new messages won't reach it yet. To retry, call the app's tool that started watching again.`;
41
+ /** Connected, but OpenClaw's hooks can't wake it yet (why). */
42
+ export const noteNoWake = (why) => `${CONNECTED}, but new messages can't wake this conversation yet: ${why}. Until then they wait in Wakeshift.`;
43
+ /** A wake that OpenClaw's hooks refused (why), said on the conversation's next tool result. */
44
+ export const noteWakeFailed = (why) => `Wakeshift: couldn't wake this conversation: ${why}. New messages wait in Wakeshift until it can.`;
45
+ /** Wakeshift refused this version (426 update_required): its message asks for the update. */
46
+ export const noteOutdated = (message) => `Wakeshift: couldn't connect this conversation. ${message}`;
47
+ /** Wakeshift no longer serves this version of the plugin (426 update_required). */
48
+ class OutdatedError extends Error {
49
+ }
50
+ /** Wakeshift refused to move the conversation to another workspace's session (409 conversation_taken). */
51
+ class TakenError extends Error {
52
+ }
53
+ /** The note when Wakeshift refused to move a connected conversation (its link stays). */
54
+ export const NOTE_TAKEN = "Wakeshift: this conversation stays connected where it was: a result asked to connect it to a session of another account or workspace, and Wakeshift doesn't move a connected conversation. To connect it there, stop it being woken where it is first (the app's unwatch tool), or start a new conversation.";
55
+ // The text beacon line (beacon.Text), with the newlines before it, also as
56
+ // they appear inside JSON text, escaped once or more (OpenClaw's code mode
57
+ // shows an MCP result as JSON inside its exec result).
58
+ const BEACON_LINE = /(?:\\+n|\n)*\(For Wakeshift's hook on this computer, not for you: wakeshift-beacon: [A-Za-z0-9_.-]+\)/g;
59
+ /** Whether a tool result has a beacon text line anywhere in it. */
60
+ export function hasBeaconLine(v) {
61
+ let s;
62
+ try {
63
+ s = typeof v === "string" ? v : (JSON.stringify(v) ?? "");
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ return s.includes("wakeshift-beacon:");
69
+ }
70
+ /** A tool result with the beacon's text line taken out (anywhere in it). */
71
+ export function stripBeacon(v) {
72
+ if (typeof v === "string")
73
+ return v.replace(BEACON_LINE, "");
74
+ if (Array.isArray(v))
75
+ return v.map(stripBeacon);
76
+ if (v && typeof v === "object") {
77
+ const o = {};
78
+ for (const [k, x] of Object.entries(v))
79
+ o[k] = stripBeacon(x);
80
+ return o;
81
+ }
82
+ return v;
83
+ }
84
+ /**
85
+ * Whether a stream link is on the issuer's scheme and host (wss for https,
86
+ * ws for http), as the plugin trusts only links from the pinned Wakeshift.
87
+ */
88
+ export function linkOnIssuer(link, issuer) {
89
+ let l, i;
90
+ try {
91
+ l = new URL(link);
92
+ i = new URL(issuer);
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ const scheme = { "wss:": "https:", "https:": "https:", "ws:": "http:", "http:": "http:" };
98
+ return !!l.host && scheme[l.protocol] !== undefined && scheme[l.protocol] === i.protocol && l.host.toLowerCase() === i.host.toLowerCase();
99
+ }
100
+ /** How often delivery records are pruned while the gateway runs. */
101
+ export const PRUNE_EVERY = 3600 * 1000;
102
+ export const notePinned = (why, file) => why === PINS_UNREADABLE
103
+ ? `Wakeshift: didn't connect this conversation: ${file} can't be read, so Wakeshift can't check which MCP server carries this app's messages. Fix the file, or remove it to start over, and call the app's tool again.`
104
+ : `Wakeshift: didn't connect this conversation: ${why}. If that's the server you use for this app (you renamed or re-added it), remove its line from ${file} and call the app's tool again.`;
105
+ export class Wakeshift {
106
+ o;
107
+ f;
108
+ // This install's ID, read (or made) at first use: loading the plugin
109
+ // writes nothing (OpenClaw's CLI loads it too, `openclaw wakeshift reset`
110
+ // and `openclaw plugins uninstall` included).
111
+ installID;
112
+ get install() {
113
+ return (this.installID ??= this.o.store.installId());
114
+ }
115
+ // Per conversation: its one Conversation, and a transport per link (by
116
+ // linkKey: its issuer and app), each with its link's stream ID.
117
+ streams = new Map();
118
+ // A conversation's last Conversation while it may still be inside a host
119
+ // call, for the next one to wait on.
120
+ settling = new Map();
121
+ ledger;
122
+ // Each session's last beacon and the note it produced: OpenClaw's own
123
+ // runtime shows an MCP result twice (the call inside code mode, then the
124
+ // exec result the model sees), and a ticket works once. One per session,
125
+ // only while it's in flight or connected: dropped when connecting fails,
126
+ // when the session's stream ends for good, and when the gateway starts.
127
+ beacons = new Map();
128
+ keys;
129
+ keysFetchedAt = 0;
130
+ started = false;
131
+ pruneTimer;
132
+ // Each session's last channel route (message_received), for the reply.
133
+ routes = new Map();
134
+ // A wake problem per session, and whether it was said, so it's said once.
135
+ problems = new Map();
136
+ constructor(o) {
137
+ this.o = o;
138
+ this.f = o.fetch ?? fetch;
139
+ this.ledger = new Ledger(join(o.store.dir, "deliveries"));
140
+ }
141
+ /** The conversation as Wakeshift knows it: unique to this install. */
142
+ conversation(sessionKey) {
143
+ return `openclaw/${this.install}/${sessionKey}`;
144
+ }
145
+ /** Starts the streams of conversations connected before (the gateway restarted). */
146
+ start() {
147
+ this.started = true;
148
+ this.beacons.clear();
149
+ this.prune();
150
+ clearInterval(this.pruneTimer);
151
+ this.pruneTimer = setInterval(() => this.prune(), PRUNE_EVERY);
152
+ this.pruneTimer.unref?.();
153
+ for (const l of this.o.store.links()) {
154
+ // Only links from the Wakeshift pinned now: one saved under another
155
+ // issuer (the pin changed since) or pointing elsewhere isn't used.
156
+ if (l.issuer !== this.o.issuer || !linkOnIssuer(l.link, this.o.issuer)) {
157
+ this.o.host.log.warn(`wakeshift (${l.sessionKey}): a saved link isn't from ${this.o.issuer}; not connecting it`);
158
+ continue;
159
+ }
160
+ this.listen(l);
161
+ // Never left to reject: an unhandled rejection would take the gateway down.
162
+ void this.o.hooks.allow(l.sessionKey, l.agentId || agentOf(l.sessionKey)).then((why) => this.setProblem(l.sessionKey, why), (e) => this.o.host.log.warn(`wakeshift (${l.sessionKey}): couldn't check OpenClaw's hooks settings: ${e?.message ?? e}`));
163
+ }
164
+ }
165
+ /** The channel a session last spoke on (OpenClaw's message_received), for a woken turn's reply. */
166
+ onMessage(sessionKey, route) {
167
+ // A message on OpenClaw's own surface (the TUI, the web chat) means the
168
+ // reply belongs in the session, not on the channel it spoke on before.
169
+ if (route)
170
+ this.routes.set(sessionKey, route);
171
+ else
172
+ this.routes.delete(sessionKey);
173
+ }
174
+ setProblem(sessionKey, why) {
175
+ if (!why) {
176
+ this.problems.delete(sessionKey);
177
+ return;
178
+ }
179
+ if (this.problems.get(sessionKey)?.why === why)
180
+ return;
181
+ this.o.host.log.warn(`wakeshift (${sessionKey}): ${why}`);
182
+ this.problems.set(sessionKey, { why, said: false });
183
+ }
184
+ /** A wake problem not yet told to this session's model, once ("" for none). */
185
+ takeNote(sessionKey) {
186
+ const p = this.problems.get(sessionKey);
187
+ if (!p || p.said)
188
+ return "";
189
+ p.said = true;
190
+ return noteWakeFailed(p.why);
191
+ }
192
+ prune() {
193
+ try {
194
+ this.ledger.prune();
195
+ }
196
+ catch (e) {
197
+ this.o.host.log.warn(`wakeshift: pruning delivery records failed: ${e.message}`);
198
+ }
199
+ }
200
+ stop() {
201
+ this.started = false;
202
+ clearInterval(this.pruneTimer);
203
+ this.pruneTimer = undefined;
204
+ for (const [conversation, s] of this.streams) {
205
+ s.c.stop();
206
+ for (const x of s.ts.values())
207
+ x.t.stop();
208
+ this.settling.set(conversation, s.c.settled());
209
+ }
210
+ this.streams.clear();
211
+ }
212
+ /** Each listening conversation's links, by linkKey. */
213
+ get listening() {
214
+ return [...this.streams].flatMap(([c, s]) => [...s.ts.keys()].map((k) => (k ? `${c}\n${k}` : c)));
215
+ }
216
+ /**
217
+ * A tool result in a session: connects the session on a beacon for it.
218
+ * Resolves to the note for the model ("" for none: no beacon, or one
219
+ * that isn't trusted, which it says nothing about).
220
+ */
221
+ onResult(result, call) {
222
+ // Only the result's own _meta, or the last line of its text: a beacon
223
+ // line inside what the app wrote (a chat message a read tool relays)
224
+ // can be anyone's.
225
+ const { token, carried } = findBeacon(result);
226
+ if (!call.sessionKey)
227
+ return Promise.resolve("");
228
+ const seen = this.beacons.get(call.sessionKey);
229
+ // Code mode's exec result shows the MCP call's result again, as JSON
230
+ // in its text: the note of the beacon that call connected with (and
231
+ // only that) comes with it. Nothing is connected from it.
232
+ if (seen && (seen.token === token || (!token && seen.token === beaconIn(result))))
233
+ return seen.note;
234
+ if (!token)
235
+ return Promise.resolve("");
236
+ // A beacon proves Wakeshift minted it, not that this result is its
237
+ // app's: OpenClaw tells servers nothing, so it's bound to no
238
+ // conversation, and one an attacker minted could sit in a page a tool
239
+ // fetched or in another server's result. It connects only from an MCP
240
+ // server's own result (code mode's exec result shows the same beacon
241
+ // again, after its inner call did), from the server pinned to its app.
242
+ const server = mcpServer(call.toolName);
243
+ if (!server)
244
+ return Promise.resolve("");
245
+ const sessionKey = call.sessionKey;
246
+ const note = this.connect(token, carried, call, server)
247
+ .catch((e) => {
248
+ this.o.host.log.warn(`wakeshift: connecting failed: ${e.message}`);
249
+ return "";
250
+ })
251
+ .then((n) => {
252
+ // Kept only while it connected: a failure is tried again when the
253
+ // beacon shows up again.
254
+ if (!n.startsWith(CONNECTED) && this.beacons.get(sessionKey)?.note === note)
255
+ this.beacons.delete(sessionKey);
256
+ return n;
257
+ });
258
+ this.beacons.set(sessionKey, { token, note });
259
+ return note;
260
+ }
261
+ async connect(token, carried, call, server) {
262
+ const sessionKey = call.sessionKey;
263
+ const conversation = this.conversation(sessionKey);
264
+ let claims;
265
+ try {
266
+ claims = await this.verify(token, conversation);
267
+ }
268
+ catch (e) {
269
+ if (e instanceof BeaconError)
270
+ return ""; // not for us: say nothing
271
+ throw e;
272
+ }
273
+ // From the tool it names (one minted for another tool's result, relayed
274
+ // here, isn't), and one naming none only from the result's own _meta.
275
+ if (!acceptable(claims, carried, mcpTool(call.toolName)))
276
+ return "";
277
+ const pinned = this.o.store.pin(server, claims.app);
278
+ if (pinned)
279
+ return notePinned(pinned, this.o.store.pinsPath());
280
+ // The link this conversation had until now for this app, if any: after
281
+ // a gateway restart OpenClaw may run its turns in a new Codex thread,
282
+ // whose calls start a new Wakeshift session. Handing the old link back
283
+ // ends the session it belongs to, so one conversation stays one
284
+ // subscription per app. Another app's link isn't this app's to hand
285
+ // back (Wakeshift supersedes only within an app), and stays as it is. A
286
+ // link saved before links named their app goes too, as it did then.
287
+ const key = linkKey({ issuer: this.o.issuer, app: claims.app });
288
+ const prior = this.o.store.loadLink(conversation, key);
289
+ const legacy = prior ? undefined : this.o.store.loadLink(conversation, "");
290
+ const held = prior ?? legacy;
291
+ const supersedes = held && held.issuer === this.o.issuer && linkOnIssuer(held.link, this.o.issuer) ? held.link : "";
292
+ let link;
293
+ try {
294
+ link = await this.redeem(claims, conversation, call.toolCallId ?? "", supersedes);
295
+ }
296
+ catch (e) {
297
+ if (e instanceof OutdatedError)
298
+ return noteOutdated(e.message);
299
+ if (e instanceof TakenError)
300
+ return NOTE_TAKEN; // the link it had stays
301
+ return noteFailed(e.message);
302
+ }
303
+ if (!linkOnIssuer(link, this.o.issuer))
304
+ return noteFailed("Wakeshift gave a stream link on another host");
305
+ // A new stream ID: what the link it replaces brought isn't this one's to acknowledge.
306
+ const saved = { conversation, sessionKey, agentId: call.agentId ?? "", link, issuer: this.o.issuer, app: claims.app, stream: randomBytes(8).toString("hex") };
307
+ this.o.store.saveLink(saved);
308
+ if (legacy)
309
+ this.o.store.forgetLink(conversation, "");
310
+ this.listen({ ...saved, saved_at: new Date().toISOString() }, legacy ? [""] : []);
311
+ const why = await this.o.hooks.allow(sessionKey, call.agentId || agentOf(sessionKey));
312
+ if (why) {
313
+ // Said now, in the note: not again on the next result.
314
+ this.problems.set(sessionKey, { why, said: true });
315
+ return noteNoWake(why);
316
+ }
317
+ this.problems.delete(sessionKey);
318
+ return NOTE_CONNECTED;
319
+ }
320
+ async verify(token, conversation) {
321
+ const issuers = [this.o.issuer];
322
+ const keyFor = (kid) => this.keys?.byKid.get(kid);
323
+ await this.loadKeys(false);
324
+ try {
325
+ return verifyBound(token, { conversation, unbound: true }, issuers, keyFor);
326
+ }
327
+ catch (e) {
328
+ // A key that changed under a kid we have, or a new one: fetch once (at most once a minute).
329
+ if (e instanceof BeaconError && (await this.loadKeys(true)))
330
+ return verifyBound(token, { conversation, unbound: true }, issuers, keyFor);
331
+ throw e;
332
+ }
333
+ }
334
+ // The pinned issuer's keys, from disk or <issuer>/oauth/jwks; true if fetched now.
335
+ async loadKeys(refresh) {
336
+ const parse = (body) => {
337
+ const byKid = new Map();
338
+ try {
339
+ for (const k of (JSON.parse(body).keys ?? []))
340
+ if (k.kid)
341
+ byKid.set(k.kid, jwkKey(k));
342
+ }
343
+ catch {
344
+ /* none */
345
+ }
346
+ return byKid;
347
+ };
348
+ if (!refresh && this.keys)
349
+ return false;
350
+ if (!refresh) {
351
+ const disk = this.o.store.loadKeys(this.o.issuer);
352
+ if (disk) {
353
+ this.keys = { at: disk.at, byKid: parse(disk.body) };
354
+ return false;
355
+ }
356
+ }
357
+ if (Date.now() - this.keysFetchedAt < 60000)
358
+ return false;
359
+ this.keysFetchedAt = Date.now();
360
+ try {
361
+ const r = await this.f(this.o.issuer.replace(/\/$/, "") + "/oauth/jwks", { signal: AbortSignal.timeout(5000), redirect: "error" });
362
+ if (!r.ok)
363
+ return false;
364
+ const body = await r.text();
365
+ this.o.store.saveKeys(this.o.issuer, body);
366
+ this.keys = { at: Date.now(), byKid: parse(body) };
367
+ return true;
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ }
373
+ async redeem(c, conversation, call, supersedes) {
374
+ const body = { ticket: c.ticket, thread: conversation };
375
+ if (call)
376
+ body.call = call;
377
+ if (supersedes)
378
+ body.supersedes = supersedes;
379
+ let r;
380
+ try {
381
+ r = await this.f(c.redeem, {
382
+ method: "POST",
383
+ headers: { "Content-Type": "application/json", [CLIENT_HEADER]: clientHeader() },
384
+ body: JSON.stringify(body),
385
+ signal: AbortSignal.timeout(10000),
386
+ redirect: "error", // the ticket and the old link go only to the issuer
387
+ });
388
+ }
389
+ catch {
390
+ throw new Error("Wakeshift couldn't be reached");
391
+ }
392
+ let out = {};
393
+ try {
394
+ out = (await r.json());
395
+ }
396
+ catch {
397
+ /* not JSON */
398
+ }
399
+ if (r.ok && typeof out.url === "string" && out.url)
400
+ return out.url;
401
+ if (r.status === 426 || out.error?.code === "update_required") {
402
+ throw new OutdatedError(typeof out.error?.message === "string" && out.error.message
403
+ ? out.error.message
404
+ : "Wakeshift's hooks for OpenClaw are out of date. Ask the user whether to update them; only if they agree, run the install command again.");
405
+ }
406
+ switch (out.error?.code) {
407
+ case "unknown_ticket":
408
+ throw new Error("the ticket expired or was used");
409
+ case "no_grant":
410
+ throw new Error("the app isn't allowed for this agent any more");
411
+ case "wrong_thread":
412
+ throw new Error("the ticket was for another conversation");
413
+ case "conversation_taken":
414
+ throw new TakenError("this conversation is connected to another workspace's session");
415
+ }
416
+ throw new Error(`Wakeshift answered ${r.status}`);
417
+ }
418
+ // One link of a connected conversation, in place of the transport it had
419
+ // for the same app (and for the keys in replaces): its transport reads,
420
+ // the conversation's one Conversation decides (deliveries.ts). What a
421
+ // replaced transport read and no turn has had is still in the
422
+ // conversation's records: it runs, but isn't acknowledged on the new
423
+ // link, which doesn't carry it (a new stream ID).
424
+ listen(l, replaces = []) {
425
+ if (!this.started)
426
+ return;
427
+ const key = linkKey(l);
428
+ const stream = l.stream ?? "";
429
+ const log = (m) => this.o.host.log.info(`wakeshift (${l.sessionKey}): ${m}`);
430
+ let e = this.streams.get(l.conversation);
431
+ if (!e) {
432
+ const ts = new Map();
433
+ const transport = (via) => [...ts.values()].find((x) => x.stream === via)?.t;
434
+ const after = this.settling.get(l.conversation);
435
+ this.settling.delete(l.conversation);
436
+ const c = new Conversation({
437
+ ledger: this.ledger,
438
+ conversation: l.conversation,
439
+ sessionKey: l.sessionKey,
440
+ agentId: l.agentId || undefined,
441
+ host: this.o.host,
442
+ // Only on the link that brought it: a lost stream's are never sent elsewhere.
443
+ ack: (ids, via) => transport(via)?.ack(ids) ?? Promise.resolve(401),
444
+ // Waiting: a saved link start() would listen on (the pinned
445
+ // issuer's) that isn't listening now; any other is lost.
446
+ stream: (via) => transport(via)
447
+ ? "live"
448
+ : this.o.store
449
+ .links()
450
+ .some((x) => x.conversation === l.conversation && (x.stream ?? "") === via && x.issuer === this.o.issuer && linkOnIssuer(x.link, this.o.issuer))
451
+ ? "waiting"
452
+ : "lost",
453
+ redeliver: (vias) => {
454
+ for (const via of vias)
455
+ transport(via)?.reconnect();
456
+ },
457
+ route: () => this.routes.get(l.sessionKey),
458
+ routeRefused: () => this.routes.delete(l.sessionKey),
459
+ problem: (why) => this.setProblem(l.sessionKey, why),
460
+ wakeFor: this.o.wakeFor,
461
+ wakeRetry: this.o.wakeRetry,
462
+ ackRetry: this.o.ackRetry,
463
+ redeliverAfter: this.o.redeliverAfter,
464
+ // A Conversation it had before may still be inside a host call for
465
+ // these records: this one starts once it's done, so nothing is
466
+ // queued twice.
467
+ after,
468
+ });
469
+ e = { c, ts };
470
+ this.streams.set(l.conversation, e);
471
+ }
472
+ const { c, ts } = e;
473
+ for (const k of new Set([key, ...replaces])) {
474
+ ts.get(k)?.t.stop();
475
+ ts.delete(k);
476
+ }
477
+ let t;
478
+ t = new Transport({
479
+ ...this.o.stream,
480
+ link: l.link,
481
+ fetch: this.f,
482
+ log,
483
+ receive: (ds) => c.receive(ds, stream),
484
+ onLink: (link) => {
485
+ // Saved only while it's still this app's transport: a replaced one
486
+ // must not overwrite the newer link.
487
+ if (this.streams.get(l.conversation)?.ts.get(key)?.t === t)
488
+ this.o.store.saveLink({ conversation: l.conversation, sessionKey: l.sessionKey, agentId: l.agentId, link, issuer: l.issuer, app: l.app, stream: l.stream });
489
+ },
490
+ onStop: (why) => {
491
+ const cur = this.streams.get(l.conversation);
492
+ if (cur?.ts.get(key)?.t === t) {
493
+ cur.ts.delete(key);
494
+ if (cur.ts.size === 0) {
495
+ this.streams.delete(l.conversation);
496
+ c.stop();
497
+ this.settling.set(l.conversation, c.settled());
498
+ }
499
+ }
500
+ if (why.why === "gone") {
501
+ this.beacons.delete(l.sessionKey); // its next beacon connects again
502
+ log(`the stream ended (${why.reason}); forgetting its link`);
503
+ // Only this app's link: another app's stays, and goes on.
504
+ if (this.o.store.loadLink(l.conversation, key)?.link === t.currentLink)
505
+ this.o.store.forgetLink(l.conversation, key);
506
+ if (!this.streams.has(l.conversation))
507
+ this.problems.delete(l.sessionKey);
508
+ // Its session and agent are no longer allowed in OpenClaw's hooks
509
+ // (unless another connected conversation, or another app's link
510
+ // of this one, still needs them).
511
+ const rest = this.o.store.links();
512
+ void this.o.hooks
513
+ .release({ sessionKeys: rest.map((x) => x.sessionKey), agentIds: rest.map((x) => x.agentId || agentOf(x.sessionKey) || "").filter(Boolean) })
514
+ .catch((e) => log(`couldn't update OpenClaw's hooks settings: ${e?.message ?? e}`));
515
+ }
516
+ },
517
+ });
518
+ ts.set(key, { t, stream });
519
+ // Picks up what an earlier process left, and what's for this stream.
520
+ c.start();
521
+ t.start();
522
+ }
523
+ }
package/dist/reset.js ADDED
@@ -0,0 +1,95 @@
1
+ // `openclaw wakeshift reset`: undoes what the plugin turned on, before an
2
+ // uninstall (OpenClaw has no uninstall hook, and `openclaw plugins
3
+ // uninstall` leaves hooks.* alone). Run where the gateway runs:
4
+ //
5
+ // openclaw wakeshift reset && openclaw plugins uninstall wakeshift --force
6
+ //
7
+ // It puts OpenClaw's hooks settings back from the record the plugin kept
8
+ // (<state dir>/hooks.json, restorePatch in hooks.ts: only what the plugin
9
+ // added goes, and a setting it changed is put back only while it still
10
+ // holds the plugin's value), drops the plugin's own hooks token with them,
11
+ // removes the user's network-hooks consent (allowNetworkHooks: the next
12
+ // install asks again), and deletes the plugin's state directory (its
13
+ // conversations' stream links and that record). The Wakeshift the plugin
14
+ // trusts (issuer) stays: it says where the plugin connects, not something
15
+ // it turned on, and the uninstall removes it with the plugin's settings.
16
+ import { existsSync, readFileSync, rmSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { restorePatch } from "./hooks.js";
19
+ /** The plugin's record of what it changed in hooks.*, if there is one. */
20
+ export function readChanges(dir) {
21
+ try {
22
+ const c = JSON.parse(readFileSync(join(dir, "hooks.json"), "utf8"));
23
+ return c && c.version === 1 && c.added && c.set ? c : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ /** hooks.* with restorePatch's patch applied (null deletes a setting); undefined when none is left. */
30
+ export function applyPatch(cur, patch) {
31
+ const next = { ...(cur ?? {}) };
32
+ for (const [k, v] of Object.entries(patch)) {
33
+ if (v === null || v === undefined)
34
+ delete next[k];
35
+ else
36
+ next[k] = v;
37
+ }
38
+ return Object.keys(next).length ? next : undefined;
39
+ }
40
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : undefined);
41
+ /** The plugin's own settings in a config draft (plugins.entries.wakeshift.config), if any. */
42
+ function pluginConfig(draft, id) {
43
+ return obj(obj(obj(obj(draft.plugins)?.entries)?.[id])?.config);
44
+ }
45
+ /** Changes the config to undo the plugin's changes; says what it did, one line each. */
46
+ export function undo(draft, changes, id = "wakeshift") {
47
+ const done = [];
48
+ if (changes) {
49
+ const cur = obj(draft.hooks);
50
+ const patch = restorePatch(cur, changes);
51
+ if (patch) {
52
+ const next = applyPatch(cur, patch);
53
+ if (next === undefined)
54
+ delete draft.hooks;
55
+ else
56
+ draft.hooks = next;
57
+ done.push(changes.owned
58
+ ? "OpenClaw's hooks settings are back as they were before Wakeshift turned them on (its token is gone), apart from what you changed since."
59
+ : "Wakeshift's additions to your OpenClaw hooks settings are removed.");
60
+ }
61
+ }
62
+ const cfg = pluginConfig(draft, id);
63
+ if (cfg && "allowNetworkHooks" in cfg) {
64
+ delete cfg.allowNetworkHooks;
65
+ done.push("The agreement to OpenClaw's hooks endpoint on a gateway that listens beyond this computer (allowNetworkHooks) is removed: installing again asks again.");
66
+ }
67
+ return done;
68
+ }
69
+ /**
70
+ * Undoes what the plugin turned on (see the top of this file), and says
71
+ * what it did. Config first: if OpenClaw refuses the write, the record of
72
+ * what to undo stays, for another try.
73
+ */
74
+ export async function reset(o) {
75
+ const changes = readChanges(o.dir);
76
+ // A record that's there but can't be read stays, with the directory:
77
+ // without it nothing says what to put back.
78
+ if (!changes && existsSync(join(o.dir, "hooks.json"))) {
79
+ throw new Error(`${join(o.dir, "hooks.json")}, the record of what the plugin changed in OpenClaw's hooks settings, can't be read; nothing was changed`);
80
+ }
81
+ let done = [];
82
+ const now = o.config.current?.();
83
+ // No record, and no consent in the config as it is: no write (a write to
84
+ // the plugin's settings makes the gateway load the plugin again). With a
85
+ // record it always goes through the config file itself, where undo works
86
+ // out the patch, before the record is deleted.
87
+ if (changes || !now || undo(structuredClone(now), undefined, o.id).length > 0) {
88
+ await o.config.mutate((draft) => {
89
+ done = undo(draft, changes, o.id);
90
+ });
91
+ }
92
+ rmSync(o.dir, { recursive: true, force: true });
93
+ done.push(`Wakeshift's state for OpenClaw (${o.dir}) is deleted.`);
94
+ return done;
95
+ }