@syengup/friday-channel-next 0.1.36 → 0.1.38

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.
Files changed (124) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/src/agent/dispatch-bridge.d.ts +1 -1
  3. package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
  4. package/dist/src/agent/node-pairing-bridge.js +6 -2
  5. package/dist/src/agent/operator-scope.d.ts +19 -0
  6. package/dist/src/agent/operator-scope.js +54 -0
  7. package/dist/src/agent/subagent-registry.js +0 -3
  8. package/dist/src/channel-actions.js +3 -1
  9. package/dist/src/channel.js +0 -2
  10. package/dist/src/collect-message-media-paths.js +10 -1
  11. package/dist/src/friday-session.js +34 -10
  12. package/dist/src/history/normalize-message.js +22 -8
  13. package/dist/src/http/handlers/agent-config.js +10 -4
  14. package/dist/src/http/handlers/cancel.js +4 -2
  15. package/dist/src/http/handlers/device-approve.js +3 -1
  16. package/dist/src/http/handlers/files-download.js +6 -8
  17. package/dist/src/http/handlers/files.js +1 -1
  18. package/dist/src/http/handlers/health.js +18 -4
  19. package/dist/src/http/handlers/history-messages.js +1 -1
  20. package/dist/src/http/handlers/history-sessions.js +5 -3
  21. package/dist/src/http/handlers/messages.js +34 -11
  22. package/dist/src/http/handlers/models-list.js +1 -1
  23. package/dist/src/http/handlers/nodes-approve.js +1 -6
  24. package/dist/src/http/handlers/plugin-info.js +1 -1
  25. package/dist/src/http/server.js +4 -2
  26. package/dist/src/link-preview/og-parse.js +3 -1
  27. package/dist/src/plugin-install-info.js +4 -1
  28. package/dist/src/session/session-manager.js +9 -3
  29. package/dist/src/session-usage-store.js +3 -1
  30. package/dist/src/skills-discovery.d.ts +5 -4
  31. package/dist/src/skills-discovery.js +27 -22
  32. package/dist/src/sse/offline-queue.js +4 -1
  33. package/dist/src/tool-catalog.js +2 -3
  34. package/dist/src/upgrade-runtime.d.ts +1 -1
  35. package/dist/src/version.js +3 -1
  36. package/index.ts +43 -35
  37. package/install.js +131 -43
  38. package/package.json +10 -1
  39. package/src/agent/abort-run.ts +2 -3
  40. package/src/agent/dispatch-bridge.ts +2 -1
  41. package/src/agent/media-bridge.ts +9 -2
  42. package/src/agent/node-pairing-bridge.ts +29 -15
  43. package/src/agent/operator-scope.test.ts +66 -0
  44. package/src/agent/operator-scope.ts +63 -0
  45. package/src/agent/run-usage-accumulator.ts +4 -2
  46. package/src/agent/subagent-registry.ts +0 -4
  47. package/src/agent-run-context-bridge.ts +3 -1
  48. package/src/channel-actions.test.ts +10 -4
  49. package/src/channel-actions.ts +3 -1
  50. package/src/channel.outbound.test.ts +18 -4
  51. package/src/channel.ts +121 -123
  52. package/src/collect-message-media-paths.ts +15 -6
  53. package/src/config.ts +1 -4
  54. package/src/e2e/agents-list.e2e.test.ts +9 -2
  55. package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
  56. package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
  57. package/src/e2e/auto-approve.integration.test.ts +13 -7
  58. package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
  59. package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
  60. package/src/e2e/offline-replay.e2e.test.ts +17 -3
  61. package/src/e2e/send-text.e2e.test.ts +11 -2
  62. package/src/e2e/slash-commands.e2e.test.ts +5 -1
  63. package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
  64. package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
  65. package/src/e2e/subagent.e2e.test.ts +136 -53
  66. package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
  67. package/src/friday-session.forward-agent.test.ts +44 -12
  68. package/src/friday-session.ts +44 -20
  69. package/src/history/normalize-message.test.ts +35 -8
  70. package/src/history/normalize-message.ts +24 -12
  71. package/src/history/read-transcript.ts +1 -4
  72. package/src/http/handlers/agent-config.test.ts +10 -3
  73. package/src/http/handlers/agent-config.ts +22 -8
  74. package/src/http/handlers/agents-list.test.ts +1 -5
  75. package/src/http/handlers/cancel.test.ts +12 -3
  76. package/src/http/handlers/cancel.ts +4 -2
  77. package/src/http/handlers/device-approve.test.ts +12 -3
  78. package/src/http/handlers/device-approve.ts +33 -21
  79. package/src/http/handlers/files-download.ts +17 -13
  80. package/src/http/handlers/files.test.ts +8 -2
  81. package/src/http/handlers/files.ts +21 -7
  82. package/src/http/handlers/health.test.ts +43 -11
  83. package/src/http/handlers/health.ts +22 -6
  84. package/src/http/handlers/history-messages.test.ts +51 -9
  85. package/src/http/handlers/history-messages.ts +4 -1
  86. package/src/http/handlers/history-sessions.test.ts +46 -9
  87. package/src/http/handlers/history-sessions.ts +5 -3
  88. package/src/http/handlers/history-set-title.test.ts +14 -5
  89. package/src/http/handlers/link-preview.test.ts +57 -16
  90. package/src/http/handlers/link-preview.ts +4 -1
  91. package/src/http/handlers/messages.test.ts +12 -8
  92. package/src/http/handlers/messages.ts +67 -19
  93. package/src/http/handlers/models-list.ts +14 -8
  94. package/src/http/handlers/nodes-approve.test.ts +15 -4
  95. package/src/http/handlers/nodes-approve.ts +38 -40
  96. package/src/http/handlers/plugin-info.ts +5 -6
  97. package/src/http/handlers/plugin-upgrade.ts +4 -1
  98. package/src/http/handlers/sse.ts +3 -1
  99. package/src/http/server.ts +9 -6
  100. package/src/link-preview/og-parse.test.ts +6 -2
  101. package/src/link-preview/og-parse.ts +10 -3
  102. package/src/link-preview/preview-service.ts +4 -1
  103. package/src/link-preview/ssrf-guard.test.ts +72 -15
  104. package/src/link-preview/ssrf-guard.ts +2 -1
  105. package/src/media-fetch.test.ts +7 -2
  106. package/src/media-fetch.ts +1 -2
  107. package/src/openclaw.d.ts +26 -9
  108. package/src/plugin-install-info.ts +20 -9
  109. package/src/run-metadata.ts +2 -1
  110. package/src/session/session-manager.ts +19 -11
  111. package/src/session-usage-snapshot.ts +3 -1
  112. package/src/session-usage-store.ts +3 -1
  113. package/src/skills-discovery.test.ts +14 -10
  114. package/src/skills-discovery.ts +43 -27
  115. package/src/sse/emitter.test.ts +1 -1
  116. package/src/sse/emitter.ts +9 -3
  117. package/src/sse/offline-queue.ts +17 -8
  118. package/src/test-support/app-simulator.ts +17 -3
  119. package/src/test-support/mock-dispatch.ts +17 -4
  120. package/src/thinking-levels.ts +3 -1
  121. package/src/tool-catalog.ts +16 -7
  122. package/src/upgrade-runtime.ts +4 -2
  123. package/src/version.ts +5 -1
  124. package/tsconfig.json +1 -1
package/install.js CHANGED
@@ -14,11 +14,7 @@ function realHome() {
14
14
  const h = execSync(`sh -c 'echo ~${sudoUser}'`, { encoding: "utf8" }).trim();
15
15
  if (h && !h.startsWith("~") && existsSync(h)) return h;
16
16
  } catch {}
17
- for (const g of [
18
- `/home/${sudoUser}`,
19
- `/Users/${sudoUser}`,
20
- `C:\\Users\\${sudoUser}`,
21
- ]) {
17
+ for (const g of [`/home/${sudoUser}`, `/Users/${sudoUser}`, `C:\\Users\\${sudoUser}`]) {
22
18
  if (existsSync(g)) return g;
23
19
  }
24
20
  return current;
@@ -30,13 +26,23 @@ const OPENCLAW_CONFIG = join(USER_HOME, ".openclaw", "openclaw.json");
30
26
  const G = (s) => `\x1b[32m${s}\x1b[0m`;
31
27
  const Y = (s) => `\x1b[33m${s}\x1b[0m`;
32
28
  const R = (s) => `\x1b[31m${s}\x1b[0m`;
33
- function log(msg) { console.log(` ${msg}`); }
34
- function warn(msg) { console.log(` ${Y("!")} ${msg}`); }
35
- function err(msg) { console.error(` ${R("X")} ${msg}`); }
29
+ function log(msg) {
30
+ console.log(` ${msg}`);
31
+ }
32
+ function warn(msg) {
33
+ console.log(` ${Y("!")} ${msg}`);
34
+ }
35
+ function err(msg) {
36
+ console.error(` ${R("X")} ${msg}`);
37
+ }
36
38
 
37
39
  function has(cmd) {
38
- try { execSync(`${cmd} --version`, { stdio: "ignore" }); return true; }
39
- catch { return false; }
40
+ try {
41
+ execSync(`${cmd} --version`, { stdio: "ignore" });
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
40
46
  }
41
47
 
42
48
  let openclawCmd = "openclaw";
@@ -79,7 +85,10 @@ if (!hasOpenclaw()) {
79
85
  let tooOld = false;
80
86
  for (let i = 0; i < 3; i++) {
81
87
  if (cur[i] > MIN_OPENCLAW[i]) break;
82
- if (cur[i] < MIN_OPENCLAW[i]) { tooOld = true; break; }
88
+ if (cur[i] < MIN_OPENCLAW[i]) {
89
+ tooOld = true;
90
+ break;
91
+ }
83
92
  }
84
93
  if (tooOld) {
85
94
  err(`OpenClaw version ${m[0]} is too old.`);
@@ -100,7 +109,7 @@ log("Installing Friday Next channel plugin...");
100
109
  try {
101
110
  const out = execSync(
102
111
  `${openclawCmd} plugins install @syengup/friday-channel-next@latest --force`,
103
- { encoding: "utf8", stdio: "pipe", timeout: 120000 }
112
+ { encoding: "utf8", stdio: "pipe", timeout: 120000 },
104
113
  );
105
114
  if (out.trim()) console.log(out.trim());
106
115
  log("Plugin registered with install record — auto-upgrade enabled.");
@@ -108,8 +117,12 @@ try {
108
117
  // Remove old manual install to avoid "duplicate plugin id" warning.
109
118
  const legacyDir = join(USER_HOME, ".openclaw", "extensions", "friday-channel-next");
110
119
  if (existsSync(legacyDir)) {
111
- try { rmSync(legacyDir, { recursive: true, force: true }); log("Removed legacy manual install."); }
112
- catch { /* non-critical */ }
120
+ try {
121
+ rmSync(legacyDir, { recursive: true, force: true });
122
+ log("Removed legacy manual install.");
123
+ } catch {
124
+ /* non-critical */
125
+ }
113
126
  }
114
127
  } catch (e) {
115
128
  const msg = (e.stderr || e.stdout || e.message || "").toString();
@@ -150,7 +163,10 @@ function setConfig(path, value) {
150
163
  }
151
164
 
152
165
  function ensureArrayContains(arr, item) {
153
- if (!arr.includes(item)) { arr.push(item); configChanged = true; }
166
+ if (!arr.includes(item)) {
167
+ arr.push(item);
168
+ configChanged = true;
169
+ }
154
170
  }
155
171
 
156
172
  // Plugins
@@ -161,30 +177,58 @@ ensureArrayContains(config.plugins.allow, "canvas");
161
177
 
162
178
  if (!config.plugins.entries) config.plugins.entries = {};
163
179
  for (const id of ["friday-next", "canvas"]) {
164
- if (!config.plugins.entries[id]) { config.plugins.entries[id] = { enabled: true }; configChanged = true; }
165
- else if (!config.plugins.entries[id].enabled) { config.plugins.entries[id].enabled = true; configChanged = true; }
180
+ if (!config.plugins.entries[id]) {
181
+ config.plugins.entries[id] = { enabled: true };
182
+ configChanged = true;
183
+ } else if (!config.plugins.entries[id].enabled) {
184
+ config.plugins.entries[id].enabled = true;
185
+ configChanged = true;
186
+ }
166
187
  }
167
188
 
168
189
  // llm_output hook requires allowConversationAccess for non-bundled plugins.
169
- if (!config.plugins.entries["friday-next"].hooks) { config.plugins.entries["friday-next"].hooks = {}; configChanged = true; }
170
- if (!config.plugins.entries["friday-next"].hooks.allowConversationAccess) { config.plugins.entries["friday-next"].hooks.allowConversationAccess = true; configChanged = true; }
190
+ if (!config.plugins.entries["friday-next"].hooks) {
191
+ config.plugins.entries["friday-next"].hooks = {};
192
+ configChanged = true;
193
+ }
194
+ if (!config.plugins.entries["friday-next"].hooks.allowConversationAccess) {
195
+ config.plugins.entries["friday-next"].hooks.allowConversationAccess = true;
196
+ configChanged = true;
197
+ }
171
198
 
172
199
  // Channel
173
200
  if (!config.channels) config.channels = {};
174
- if (!config.channels["friday-next"]) { config.channels["friday-next"] = { enabled: true, transport: "http+sse" }; configChanged = true; }
175
- else {
176
- if (!config.channels["friday-next"].enabled) { config.channels["friday-next"].enabled = true; configChanged = true; }
177
- if (!config.channels["friday-next"].transport) { config.channels["friday-next"].transport = "http+sse"; configChanged = true; }
201
+ if (!config.channels["friday-next"]) {
202
+ config.channels["friday-next"] = { enabled: true, transport: "http+sse" };
203
+ configChanged = true;
204
+ } else {
205
+ if (!config.channels["friday-next"].enabled) {
206
+ config.channels["friday-next"].enabled = true;
207
+ configChanged = true;
208
+ }
209
+ if (!config.channels["friday-next"].transport) {
210
+ config.channels["friday-next"].transport = "http+sse";
211
+ configChanged = true;
212
+ }
178
213
  }
179
214
 
180
215
  // Gateway bind + nodes
181
216
  if (!config.gateway) config.gateway = {};
182
- if (config.gateway.bind !== "lan") { config.gateway.bind = "lan"; configChanged = true; }
217
+ if (config.gateway.bind !== "lan") {
218
+ config.gateway.bind = "lan";
219
+ configChanged = true;
220
+ }
183
221
  if (!config.gateway.nodes) config.gateway.nodes = {};
184
222
  if (!Array.isArray(config.gateway.nodes.allowCommands)) config.gateway.nodes.allowCommands = [];
185
223
  for (const cmd of [
186
- "canvas.navigate", "canvas.present", "canvas.hide", "canvas.eval",
187
- "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.reset", "canvas.a2ui.pushJSONL",
224
+ "canvas.navigate",
225
+ "canvas.present",
226
+ "canvas.hide",
227
+ "canvas.eval",
228
+ "canvas.snapshot",
229
+ "canvas.a2ui.push",
230
+ "canvas.a2ui.reset",
231
+ "canvas.a2ui.pushJSONL",
188
232
  ]) {
189
233
  ensureArrayContains(config.gateway.nodes.allowCommands, cmd);
190
234
  }
@@ -193,7 +237,11 @@ for (const cmd of [
193
237
  if (!config.agents) config.agents = {};
194
238
  if (!Array.isArray(config.agents.list)) config.agents.list = [];
195
239
  let mainAgent = config.agents.list.find((a) => a.id === "main");
196
- if (!mainAgent) { mainAgent = { id: "main" }; config.agents.list.push(mainAgent); configChanged = true; }
240
+ if (!mainAgent) {
241
+ mainAgent = { id: "main" };
242
+ config.agents.list.push(mainAgent);
243
+ configChanged = true;
244
+ }
197
245
  if (!mainAgent.tools) mainAgent.tools = {};
198
246
  if (!Array.isArray(mainAgent.tools.alsoAllow)) mainAgent.tools.alsoAllow = [];
199
247
  for (const tool of ["canvas", "nodes"]) {
@@ -202,7 +250,10 @@ for (const tool of ["canvas", "nodes"]) {
202
250
  if (Array.isArray(mainAgent.tools.deny)) {
203
251
  for (const tool of ["canvas", "nodes"]) {
204
252
  const idx = mainAgent.tools.deny.indexOf(tool);
205
- if (idx !== -1) { mainAgent.tools.deny.splice(idx, 1); configChanged = true; }
253
+ if (idx !== -1) {
254
+ mainAgent.tools.deny.splice(idx, 1);
255
+ configChanged = true;
256
+ }
206
257
  }
207
258
  }
208
259
 
@@ -224,7 +275,11 @@ log("Restarting OpenClaw gateway... (this can take 20-30s)");
224
275
  try {
225
276
  // A full gateway restart commonly takes 20s+ on a fresh boot; give it plenty of room
226
277
  // so we don't kill it mid-restart and report a false failure.
227
- const out = execSync(`${openclawCmd} gateway restart`, { encoding: "utf8", stdio: "pipe", timeout: 90000 });
278
+ const out = execSync(`${openclawCmd} gateway restart`, {
279
+ encoding: "utf8",
280
+ stdio: "pipe",
281
+ timeout: 90000,
282
+ });
228
283
  if (out.trim()) console.log(out.trim());
229
284
  } catch (e) {
230
285
  if (e.stdout?.trim()) console.log(e.stdout.trim());
@@ -250,15 +305,18 @@ function getLanIp() {
250
305
  return "127.0.0.1";
251
306
  }
252
307
 
253
- try { config = JSON.parse(readFileSync(OPENCLAW_CONFIG, "utf8")); } catch { config = {}; }
308
+ try {
309
+ config = JSON.parse(readFileSync(OPENCLAW_CONFIG, "utf8"));
310
+ } catch {
311
+ config = {};
312
+ }
254
313
 
255
314
  const gatewayPort = config.gateway?.port || 18789;
256
315
  const gatewayToken = config.gateway?.auth?.token || "(not set)";
257
316
  const bindMode = config.gateway?.bind || "localhost";
258
317
 
259
- const gatewayUrl = bindMode === "lan"
260
- ? `http://${getLanIp()}:${gatewayPort}`
261
- : `http://127.0.0.1:${gatewayPort}`;
318
+ const gatewayUrl =
319
+ bindMode === "lan" ? `http://${getLanIp()}:${gatewayPort}` : `http://127.0.0.1:${gatewayPort}`;
262
320
 
263
321
  // Always verify against loopback: the gateway binds 0.0.0.0 so it's reachable here,
264
322
  // and this avoids false negatives from LAN/NAT routing of the advertised IP.
@@ -272,19 +330,38 @@ async function verifyGateway(url, token, retries = 30) {
272
330
  try {
273
331
  const res = await new Promise((resolve, reject) => {
274
332
  const req = http.request(
275
- { hostname, port, path: "/friday-next/status", method: "GET",
276
- headers: { authorization: `Bearer ${token}` }, timeout: 5000 },
277
- (res) => { let body = ""; res.on("data", (c) => body += c); res.on("end", () => resolve({ status: res.statusCode, body })); },
333
+ {
334
+ hostname,
335
+ port,
336
+ path: "/friday-next/status",
337
+ method: "GET",
338
+ headers: { authorization: `Bearer ${token}` },
339
+ timeout: 5000,
340
+ },
341
+ (res) => {
342
+ let body = "";
343
+ res.on("data", (c) => (body += c));
344
+ res.on("end", () => resolve({ status: res.statusCode, body }));
345
+ },
278
346
  );
279
347
  req.on("error", reject);
280
- req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
348
+ req.on("timeout", () => {
349
+ req.destroy();
350
+ reject(new Error("timeout"));
351
+ });
281
352
  req.end();
282
353
  });
283
354
  if (res.status === 200) {
284
355
  try {
285
356
  const data = JSON.parse(res.body);
286
357
  if (data.ok) {
287
- log("Gateway verified OK (friday-next " + data.version + ", " + data.connections + " connections).");
358
+ log(
359
+ "Gateway verified OK (friday-next " +
360
+ data.version +
361
+ ", " +
362
+ data.connections +
363
+ " connections).",
364
+ );
288
365
  return true;
289
366
  }
290
367
  warn("Plugin responded but ok=false — " + JSON.stringify(data));
@@ -294,8 +371,14 @@ async function verifyGateway(url, token, retries = 30) {
294
371
  continue;
295
372
  }
296
373
  }
297
- if (res.status === 401) { warn("Auth token mismatch — check gateway.auth.token."); return false; }
298
- if (res.status === 404) { warn("Route not foundplugin may not be loaded."); return false; }
374
+ if (res.status === 401) {
375
+ warn("Auth token mismatchcheck gateway.auth.token.");
376
+ return false;
377
+ }
378
+ if (res.status === 404) {
379
+ warn("Route not found — plugin may not be loaded.");
380
+ return false;
381
+ }
299
382
  if (i < retries) warn(`Gateway responded ${res.status}, retrying (${i}/${retries})...`);
300
383
  } catch {
301
384
  if (i < retries) warn(`Gateway not reachable, retrying (${i}/${retries})...`);
@@ -404,14 +487,19 @@ async function detectPublicIp() {
404
487
  const ipStr = await new Promise((resolve, reject) => {
405
488
  const req = http.get(url, { timeout: 3000 }, (res) => {
406
489
  let body = "";
407
- res.on("data", (c) => body += c);
490
+ res.on("data", (c) => (body += c));
408
491
  res.on("end", () => resolve(body.trim()));
409
492
  });
410
493
  req.on("error", reject);
411
- req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
494
+ req.on("timeout", () => {
495
+ req.destroy();
496
+ reject(new Error("timeout"));
497
+ });
412
498
  });
413
499
  if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ipStr)) return ipStr;
414
- } catch { /* try next */ }
500
+ } catch {
501
+ /* try next */
502
+ }
415
503
  }
416
504
  return null;
417
505
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,10 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsc -p tsconfig.json",
17
+ "lint": "eslint .",
18
+ "lint:fix": "eslint . --fix",
19
+ "format": "prettier --write .",
20
+ "format:check": "prettier --check .",
17
21
  "prepublishOnly": "pnpm build && rm -rf dist/attachments",
18
22
  "test": "npm run test:unit && npm run test:e2e",
19
23
  "test:unit": "vitest run",
@@ -58,12 +62,17 @@
58
62
  "qrcode-terminal": "^0.12.0"
59
63
  },
60
64
  "devDependencies": {
65
+ "@eslint/js": "^10.0.1",
61
66
  "@types/node": "^25.6.0",
62
67
  "chalk": "^5.6.2",
68
+ "eslint": "^10.5.0",
69
+ "eslint-config-prettier": "^10.1.8",
63
70
  "jiti": "^2.6.1",
64
71
  "json5": "^2.2.3",
72
+ "prettier": "^3.8.4",
65
73
  "tslog": "^4.10.2",
66
74
  "typescript": "^6.0.3",
75
+ "typescript-eslint": "^8.61.1",
67
76
  "vitest": "^4.1.5",
68
77
  "zod": "^4.3.6"
69
78
  }
@@ -12,9 +12,8 @@ export async function abortRunForSessionKey(sessionKey: string): Promise<AbortRu
12
12
  const key = sessionKey.trim();
13
13
  if (!key) return { aborted: false, drained: false };
14
14
  try {
15
- const { resolveActiveEmbeddedRunSessionId, abortAndDrainAgentHarnessRun } = await import(
16
- "openclaw/plugin-sdk/agent-harness"
17
- );
15
+ const { resolveActiveEmbeddedRunSessionId, abortAndDrainAgentHarnessRun } =
16
+ await import("openclaw/plugin-sdk/agent-harness");
18
17
  const sessionId = resolveActiveEmbeddedRunSessionId(key);
19
18
  if (!sessionId) return { aborted: false, drained: false };
20
19
  const result = await abortAndDrainAgentHarnessRun({ sessionId, sessionKey: key });
@@ -1,4 +1,5 @@
1
- type DispatchFn = (args: unknown) => Promise<unknown> | unknown;
1
+ // Returns a value or a thenable; `unknown` covers both (callers `await` the result).
2
+ type DispatchFn = (args: unknown) => unknown;
2
3
 
3
4
  let overrideDispatch: DispatchFn | null = null;
4
5
 
@@ -11,7 +11,8 @@ import crypto from "node:crypto";
11
11
  */
12
12
  export async function resolveMediaMaxBytes(mimeType: string): Promise<number | undefined> {
13
13
  try {
14
- const { maxBytesForKind, mediaKindFromMime } = await import("openclaw/plugin-sdk/media-runtime");
14
+ const { maxBytesForKind, mediaKindFromMime } =
15
+ await import("openclaw/plugin-sdk/media-runtime");
15
16
  return maxBytesForKind(mediaKindFromMime(mimeType) ?? "document");
16
17
  } catch {
17
18
  return undefined;
@@ -31,7 +32,13 @@ export async function saveInboundMediaBuffer(
31
32
  // Pass the original filename (5th arg) so core's media-store preserves the
32
33
  // name+extension instead of saving a bare uuid. Otherwise the agent receives
33
34
  // `[media attached: file://.../inbound/<uuid>]` with no file-format signal.
34
- const saved = await sdk.saveMediaBuffer(buffer, mimeType, "inbound", maxBytes, originalFilename);
35
+ const saved = await sdk.saveMediaBuffer(
36
+ buffer,
37
+ mimeType,
38
+ "inbound",
39
+ maxBytes,
40
+ originalFilename,
41
+ );
35
42
  if (saved?.id && saved?.path) return { id: saved.id, path: saved.path };
36
43
  } catch {
37
44
  // fallback for tests or stripped runtime
@@ -1,7 +1,19 @@
1
1
  import { existsSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { delimiter, dirname, join } from "node:path";
3
3
 
4
- let cache: { listNodePairing: Function; approveNodePairing: Function } | null = null;
4
+ // Results come from the untyped OpenClaw dist module, so the resolved shapes are
5
+ // `any` at this host boundary — callers read dynamic fields (.pending, .status, …).
6
+ type ListNodePairingFn = () => Promise<any>;
7
+ type ApproveNodePairingFn = (
8
+ requestId: string,
9
+ options: { callerScopes?: unknown },
10
+ ) => Promise<any>;
11
+ type NodePairingModule = {
12
+ listNodePairing: ListNodePairingFn;
13
+ approveNodePairing: ApproveNodePairingFn;
14
+ };
15
+
16
+ let cache: NodePairingModule | null = null;
5
17
 
6
18
  function resolveOpenClawDistFromPath(): string | null {
7
19
  // Walk PATH looking for the openclaw binary, then resolve its real
@@ -16,7 +28,9 @@ function resolveOpenClawDistFromPath(): string | null {
16
28
  const dist = join(dirname(real), "dist");
17
29
  readdirSync(dist);
18
30
  return dist;
19
- } catch {}
31
+ } catch {
32
+ // Not a real dist dir — keep walking PATH.
33
+ }
20
34
  }
21
35
  return null;
22
36
  }
@@ -43,15 +57,17 @@ function resolveOpenClawDist(): string {
43
57
  ].filter((v): v is string => typeof v === "string" && v.length > 0);
44
58
 
45
59
  for (const root of candidates) {
46
- try { readdirSync(root); return root; } catch {}
60
+ try {
61
+ readdirSync(root);
62
+ return root;
63
+ } catch {
64
+ // Candidate dir doesn't exist — try the next one.
65
+ }
47
66
  }
48
67
  throw new Error("OpenClaw dist directory not found. Set OPENCLAW_DIST env var.");
49
68
  }
50
69
 
51
- export async function loadNodePairingModule(): Promise<{
52
- listNodePairing: Function;
53
- approveNodePairing: Function;
54
- }> {
70
+ export async function loadNodePairingModule(): Promise<NodePairingModule> {
55
71
  if (cache) return cache;
56
72
  const dist = resolveOpenClawDist();
57
73
  const file = readdirSync(dist).find(
@@ -63,12 +79,13 @@ export async function loadNodePairingModule(): Promise<{
63
79
  // bundled module uses `export { listNodePairing as r, … }`. Resolve the
64
80
  // correct functions by Function.name, which preserves the original name.
65
81
  const mod = await import(join(dist, file));
66
- let listNodePairing: Function | undefined;
67
- let approveNodePairing: Function | undefined;
82
+ let listNodePairing: ListNodePairingFn | undefined;
83
+ let approveNodePairing: ApproveNodePairingFn | undefined;
68
84
  for (const value of Object.values(mod)) {
69
85
  if (typeof value === "function") {
70
- if (value.name === "listNodePairing") listNodePairing = value;
71
- else if (value.name === "approveNodePairing") approveNodePairing = value;
86
+ if (value.name === "listNodePairing") listNodePairing = value as ListNodePairingFn;
87
+ else if (value.name === "approveNodePairing")
88
+ approveNodePairing = value as ApproveNodePairingFn;
72
89
  }
73
90
  }
74
91
  if (!listNodePairing || !approveNodePairing) {
@@ -79,9 +96,6 @@ export async function loadNodePairingModule(): Promise<{
79
96
  }
80
97
 
81
98
  /** Vitest-only: inject mock pairing functions. */
82
- export function __setMockNodePairingForTests(mock: {
83
- listNodePairing: Function;
84
- approveNodePairing: Function;
85
- }): void {
99
+ export function __setMockNodePairingForTests(mock: NodePairingModule): void {
86
100
  cache = mock;
87
101
  }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // The helper imports the live scope getter from core; the pure function under test
4
+ // never calls it, but the module-level import must resolve. Mock it so the unit test
5
+ // does not depend on the OpenClaw dist runtime.
6
+ const getScope = vi.fn();
7
+ vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
8
+ getPluginRuntimeGatewayRequestScope: () => getScope(),
9
+ }));
10
+
11
+ import { elevateScopeForSubagentSpawn, ensureSubagentSpawnScope } from "./operator-scope.js";
12
+
13
+ function makeScope(scopes: string[]) {
14
+ return { client: { connect: { role: "operator", scopes } } };
15
+ }
16
+
17
+ describe("elevateScopeForSubagentSpawn", () => {
18
+ it("adds operator.write + operator.read to an empty plugin-route scope", () => {
19
+ // friday-next routes register with auth:"plugin", which core gives EMPTY operator
20
+ // scopes. Subagent spawn re-enters the gateway `agent` method (requires
21
+ // operator.write) and fails with "missing scope: operator.write" without this.
22
+ const scope = makeScope([]);
23
+ const added = elevateScopeForSubagentSpawn(scope);
24
+ expect(scope.client.connect.scopes).toContain("operator.write");
25
+ expect(scope.client.connect.scopes).toContain("operator.read");
26
+ expect(added).toEqual(["operator.write", "operator.read"]);
27
+ });
28
+
29
+ it("is idempotent — does not duplicate already-present scopes", () => {
30
+ const scope = makeScope(["operator.write"]);
31
+ const added = elevateScopeForSubagentSpawn(scope);
32
+ expect(added).toEqual(["operator.read"]);
33
+ expect(scope.client.connect.scopes.filter((s) => s === "operator.write")).toHaveLength(1);
34
+ });
35
+
36
+ it("preserves unrelated existing scopes", () => {
37
+ const scope = makeScope(["operator.admin"]);
38
+ elevateScopeForSubagentSpawn(scope);
39
+ expect(scope.client.connect.scopes).toContain("operator.admin");
40
+ expect(scope.client.connect.scopes).toContain("operator.write");
41
+ });
42
+
43
+ it("returns [] and never throws when no scope/client is present", () => {
44
+ expect(elevateScopeForSubagentSpawn(undefined)).toEqual([]);
45
+ expect(elevateScopeForSubagentSpawn(null)).toEqual([]);
46
+ expect(elevateScopeForSubagentSpawn({})).toEqual([]);
47
+ expect(elevateScopeForSubagentSpawn({ client: { connect: {} } })).toEqual([]);
48
+ });
49
+ });
50
+
51
+ describe("ensureSubagentSpawnScope", () => {
52
+ it("elevates the live scope returned by the SDK getter", () => {
53
+ const scope = makeScope([]);
54
+ getScope.mockReturnValue(scope);
55
+ const added = ensureSubagentSpawnScope();
56
+ expect(added).toEqual(["operator.write", "operator.read"]);
57
+ expect(scope.client.connect.scopes).toContain("operator.write");
58
+ });
59
+
60
+ it("swallows errors from the getter and returns []", () => {
61
+ getScope.mockImplementation(() => {
62
+ throw new Error("no scope");
63
+ });
64
+ expect(ensureSubagentSpawnScope()).toEqual([]);
65
+ });
66
+ });
@@ -0,0 +1,63 @@
1
+ // Operator-scope elevation for friday-next agent dispatch.
2
+ //
3
+ // Why this exists: friday-next registers all its routes with auth:"plugin" (it does
4
+ // its own device-token auth, not the gateway operator token). Core's
5
+ // createPluginRouteRuntimeScope gives auth!="gateway" routes an EMPTY operator-scope
6
+ // set. When an agent dispatched from such a route spawns a subagent, the spawn
7
+ // re-enters the in-process gateway `agent` method, which requires `operator.write`
8
+ // (core-descriptors: { name:"agent", scope:"operator.write" }). With an empty ambient
9
+ // scope the spawn fails with `{"error":"missing scope: operator.write"}`.
10
+ //
11
+ // The subagent spawn reads getPluginRuntimeGatewayRequestScope() at spawn time and
12
+ // uses that scope's client for authorization. Because AsyncLocalStorage returns the
13
+ // SAME store object reference, mutating its client.connect.scopes once — before we
14
+ // kick off the dispatch, while still inside the route's ALS context — propagates the
15
+ // elevated scopes to every later reader, including the subagent spawn.
16
+ //
17
+ // Subagent lifecycle admin methods (sessions.patch/delete) are unaffected: core pins
18
+ // those to ADMIN_SCOPE via a synthetic client, so only the `agent` method depends on
19
+ // this ambient operator.write. See memory: subagent-spawn-missing-operator-write.
20
+ import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
21
+
22
+ /** Operator scopes the friday-next dispatch needs so agents can spawn subagents. */
23
+ const REQUIRED_OPERATOR_SCOPES = ["operator.write", "operator.read"] as const;
24
+
25
+ type ScopeLike =
26
+ | {
27
+ client?: { connect?: { scopes?: unknown } };
28
+ }
29
+ | null
30
+ | undefined;
31
+
32
+ /**
33
+ * Adds the required operator scopes to a gateway-request-scope's
34
+ * `client.connect.scopes` array in place. Pure and idempotent.
35
+ * Returns the scopes that were actually added (empty if none / no array present).
36
+ */
37
+ export function elevateScopeForSubagentSpawn(scope: ScopeLike): string[] {
38
+ const connect = scope?.client?.connect;
39
+ if (!connect || !Array.isArray(connect.scopes)) {
40
+ return [];
41
+ }
42
+ const scopes = connect.scopes as string[];
43
+ const added: string[] = [];
44
+ for (const scopeName of REQUIRED_OPERATOR_SCOPES) {
45
+ if (!scopes.includes(scopeName)) {
46
+ scopes.push(scopeName);
47
+ added.push(scopeName);
48
+ }
49
+ }
50
+ return added;
51
+ }
52
+
53
+ /**
54
+ * Fetches the live plugin gateway-request-scope and elevates it so the dispatched
55
+ * agent can spawn subagents. Never throws — returns the scopes added (or []).
56
+ */
57
+ export function ensureSubagentSpawnScope(): string[] {
58
+ try {
59
+ return elevateScopeForSubagentSpawn(getPluginRuntimeGatewayRequestScope());
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
@@ -39,8 +39,10 @@ export function accumulateRunUsage(
39
39
  const entry = ensure(runId);
40
40
  if (typeof usage.input === "number" && usage.input > 0) entry.input += usage.input;
41
41
  if (typeof usage.output === "number" && usage.output > 0) entry.output += usage.output;
42
- if (typeof usage.cacheRead === "number" && usage.cacheRead > 0) entry.cacheRead += usage.cacheRead;
43
- if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0) entry.cacheWrite += usage.cacheWrite;
42
+ if (typeof usage.cacheRead === "number" && usage.cacheRead > 0)
43
+ entry.cacheRead += usage.cacheRead;
44
+ if (typeof usage.cacheWrite === "number" && usage.cacheWrite > 0)
45
+ entry.cacheWrite += usage.cacheWrite;
44
46
  if (typeof usage.total === "number" && usage.total > 0) entry.total += usage.total;
45
47
  if (model && model.trim()) entry.model = model.trim();
46
48
  if (provider && provider.trim()) entry.provider = provider.trim();
@@ -39,10 +39,6 @@ export function registerSessionKeyForRun(sessionKey: string, runId: string): voi
39
39
  sessionKeyToRunId.set(sessionKey, runId);
40
40
  }
41
41
 
42
- function resolveRunIdForSessionKey(sessionKey: string): string | undefined {
43
- return sessionKeyToRunId.get(sessionKey);
44
- }
45
-
46
42
  /**
47
43
  * Parse OpenClaw announce compound runId:
48
44
  * announce:v<version>:<sessionKey>:<bareRunId>
@@ -26,7 +26,9 @@ function getAgentEventState(): AgentEventStateLike | undefined {
26
26
  return { runContextById };
27
27
  }
28
28
 
29
- export function getOpenClawAgentRunContext(runId: string): OpenClawAgentRunContextBridge | undefined {
29
+ export function getOpenClawAgentRunContext(
30
+ runId: string,
31
+ ): OpenClawAgentRunContextBridge | undefined {
30
32
  if (!runId) return undefined;
31
33
  return getAgentEventState()?.runContextById.get(runId);
32
34
  }