@syengup/friday-channel-next 0.0.35 → 0.0.37

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 (185) hide show
  1. package/dist/attachments/0768c9b1-53b0-44df-83e8-be15c4ea188f.jpg +0 -0
  2. package/dist/attachments/0a379d01-116b-4da1-bf15-77cb2cbb0093.jpg +0 -0
  3. package/dist/attachments/181caab2-64a7-4004-a057-225a144f949e.mp3 +0 -0
  4. package/dist/attachments/19662331-e527-47d2-bc0e-0e19a7a91419.jpg +0 -0
  5. package/dist/attachments/26a23b2b-52df-4572-a5e1-15b34fb87e44.jpg +0 -0
  6. package/dist/attachments/2f9282c5-8db4-4c4a-a060-e65104f6f9ff.jpg +0 -0
  7. package/dist/attachments/3929ec3d-ea15-4de6-96bc-97e8b0b658a7.jpg +0 -0
  8. package/dist/attachments/403c0cbc-4e3c-4146-a3be-ff3746ee7cda.jpg +0 -0
  9. package/dist/attachments/441977f5-0f7b-4aa2-841a-1d63e787ea53.jpg +0 -0
  10. package/dist/attachments/453e8aa2-76e3-498d-8d6f-d7b96d6bf45b.jpg +0 -0
  11. package/dist/attachments/538cde71-d26e-4d3d-b901-e8dd905e668c.mp3 +0 -0
  12. package/dist/attachments/55c7f628-4ba2-4252-aa4b-4f3eb6045a8a.mp3 +0 -0
  13. package/dist/attachments/5f7683f5-8194-4698-b077-31d209525379.jpg +0 -0
  14. package/dist/attachments/60614a35-8f44-4197-b783-2f58f5a72ac8.jpeg +0 -0
  15. package/dist/attachments/62830489-8814-48b1-851c-3845e514f35e.mp3 +0 -0
  16. package/dist/attachments/66f4a62d-1531-4f38-a531-7456f9edf221.png +0 -0
  17. package/dist/attachments/6735d749-769e-483a-9b84-43b9338a720b.png +0 -0
  18. package/dist/attachments/6d1766b1-05e4-4b04-b3c8-1c25e9d182a1.png +0 -0
  19. package/dist/attachments/782b077b-06e3-484b-baf5-33e7160234ed.png +0 -0
  20. package/dist/attachments/7ad638b2-1f56-4d93-9ad8-b40346e0650f.jpg +0 -0
  21. package/dist/attachments/89f6fb15-e652-4111-a60c-baa414659052.png +0 -0
  22. package/dist/attachments/8a88b14f-442f-45fb-b01d-e51bab8f800d.mp3 +0 -0
  23. package/dist/attachments/92292034-9cf6-4f26-8d77-fddca3deb638.png +0 -0
  24. package/dist/attachments/92c2b414-d33d-4d93-bcb6-013da7bec9a4.jpg +0 -0
  25. package/dist/attachments/9664f69e-3c05-45ca-9a52-f2d0b9f9bf7e.jpg +0 -0
  26. package/dist/attachments/977d28c1-43c0-40e0-95e3-defe0f41afe8.jpg +0 -0
  27. package/dist/attachments/9df40f1a-c6e1-4177-8a03-06757a30b19e.png +0 -0
  28. package/dist/attachments/a68e6815-6163-4421-a70f-34493aa9a217.jpg +0 -0
  29. package/dist/attachments/aab32fea-6d99-47ec-ab1f-2340f31312eb.jpg +0 -0
  30. package/dist/attachments/ab403224-2fb1-49c1-8738-ea194ab65d44.png +0 -0
  31. package/dist/attachments/ac3da190-d6ee-4038-a673-8b893035a687.png +0 -0
  32. package/dist/attachments/af02be9c-87f7-4c5a-9969-7db32039bb58.png +0 -0
  33. package/dist/attachments/b011d42a-00e5-4f77-86bc-08da6112e6e1.mp3 +0 -0
  34. package/dist/attachments/b7d7df40-c627-4b1f-9b09-167b88545c25.mp3 +0 -0
  35. package/dist/attachments/c5e9bf09-a718-422c-bcb3-94c173e3755b.mp3 +0 -0
  36. package/dist/attachments/d5449e13-1995-44ba-9392-ecbfe5f9876f.jpg +0 -0
  37. package/dist/attachments/ea0069f5-01cf-4ea1-985e-3a1e426399c3.png +0 -0
  38. package/dist/attachments/f3989ff2-7b70-4a80-a896-74a6b197f7d8.png +0 -0
  39. package/dist/attachments/f64a4a14-e3aa-4eed-a8d9-1603f04baa5b.jpg +0 -0
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.js +176 -0
  42. package/dist/src/agent/abort-run.d.ts +1 -0
  43. package/dist/src/agent/abort-run.js +11 -0
  44. package/dist/src/agent/active-runs.d.ts +9 -0
  45. package/dist/src/agent/active-runs.js +20 -0
  46. package/dist/src/agent/dispatch-bridge.d.ts +5 -0
  47. package/dist/src/agent/dispatch-bridge.js +12 -0
  48. package/dist/src/agent/media-bridge.d.ts +4 -0
  49. package/dist/src/agent/media-bridge.js +21 -0
  50. package/dist/src/agent/subagent-registry.d.ts +68 -0
  51. package/dist/src/agent/subagent-registry.js +142 -0
  52. package/dist/src/agent-forward-runtime.d.ts +17 -0
  53. package/dist/src/agent-forward-runtime.js +16 -0
  54. package/dist/src/agent-run-context-bridge.d.ts +13 -0
  55. package/dist/src/agent-run-context-bridge.js +23 -0
  56. package/dist/src/channel-actions.d.ts +13 -0
  57. package/dist/src/channel-actions.js +101 -0
  58. package/dist/src/channel.d.ts +6 -0
  59. package/dist/src/channel.js +248 -0
  60. package/dist/src/collect-message-media-paths.d.ts +11 -0
  61. package/dist/src/collect-message-media-paths.js +143 -0
  62. package/dist/src/config.d.ts +15 -0
  63. package/dist/src/config.js +39 -0
  64. package/dist/src/friday-inbound-stats.d.ts +2 -0
  65. package/dist/src/friday-inbound-stats.js +8 -0
  66. package/dist/src/friday-session.d.ts +40 -0
  67. package/dist/src/friday-session.js +395 -0
  68. package/dist/src/host-config.d.ts +1 -0
  69. package/dist/src/host-config.js +15 -0
  70. package/dist/src/http/handlers/cancel.d.ts +2 -0
  71. package/dist/src/http/handlers/cancel.js +33 -0
  72. package/dist/src/http/handlers/device-approve.d.ts +2 -0
  73. package/dist/src/http/handlers/device-approve.js +125 -0
  74. package/dist/src/http/handlers/device-token.d.ts +2 -0
  75. package/dist/src/http/handlers/device-token.js +43 -0
  76. package/dist/src/http/handlers/files-download.d.ts +10 -0
  77. package/dist/src/http/handlers/files-download.js +210 -0
  78. package/dist/src/http/handlers/files-upload.d.ts +8 -0
  79. package/dist/src/http/handlers/files-upload.js +136 -0
  80. package/dist/src/http/handlers/files.d.ts +75 -0
  81. package/dist/src/http/handlers/files.js +305 -0
  82. package/dist/src/http/handlers/import.d.ts +7 -0
  83. package/dist/src/http/handlers/import.js +69 -0
  84. package/dist/src/http/handlers/info.d.ts +2 -0
  85. package/dist/src/http/handlers/info.js +13 -0
  86. package/dist/src/http/handlers/messages-list.d.ts +7 -0
  87. package/dist/src/http/handlers/messages-list.js +44 -0
  88. package/dist/src/http/handlers/messages.d.ts +34 -0
  89. package/dist/src/http/handlers/messages.js +476 -0
  90. package/dist/src/http/handlers/models-list.d.ts +10 -0
  91. package/dist/src/http/handlers/models-list.js +113 -0
  92. package/dist/src/http/handlers/nodes-approve.d.ts +2 -0
  93. package/dist/src/http/handlers/nodes-approve.js +146 -0
  94. package/dist/src/http/handlers/pair.d.ts +2 -0
  95. package/dist/src/http/handlers/pair.js +39 -0
  96. package/dist/src/http/handlers/sessions-delete.d.ts +2 -0
  97. package/dist/src/http/handlers/sessions-delete.js +49 -0
  98. package/dist/src/http/handlers/sessions-list.d.ts +8 -0
  99. package/dist/src/http/handlers/sessions-list.js +24 -0
  100. package/dist/src/http/handlers/sessions-messages-get.d.ts +2 -0
  101. package/dist/src/http/handlers/sessions-messages-get.js +55 -0
  102. package/dist/src/http/handlers/sessions-messages-post.d.ts +2 -0
  103. package/dist/src/http/handlers/sessions-messages-post.js +92 -0
  104. package/dist/src/http/handlers/sessions-messages.d.ts +2 -0
  105. package/dist/src/http/handlers/sessions-messages.js +135 -0
  106. package/dist/src/http/handlers/sessions-settings.d.ts +2 -0
  107. package/dist/src/http/handlers/sessions-settings.js +71 -0
  108. package/dist/src/http/handlers/sse.d.ts +2 -0
  109. package/dist/src/http/handlers/sse.js +70 -0
  110. package/dist/src/http/handlers/status.d.ts +2 -0
  111. package/dist/src/http/handlers/status.js +29 -0
  112. package/dist/src/http/handlers/sync.d.ts +7 -0
  113. package/dist/src/http/handlers/sync.js +56 -0
  114. package/dist/src/http/middleware/auth.d.ts +13 -0
  115. package/dist/src/http/middleware/auth.js +29 -0
  116. package/dist/src/http/middleware/body.d.ts +2 -0
  117. package/dist/src/http/middleware/body.js +24 -0
  118. package/dist/src/http/middleware/cors.d.ts +2 -0
  119. package/dist/src/http/middleware/cors.js +11 -0
  120. package/dist/src/http/server.d.ts +19 -0
  121. package/dist/src/http/server.js +87 -0
  122. package/dist/src/logging.d.ts +7 -0
  123. package/dist/src/logging.js +28 -0
  124. package/dist/src/push/apns.d.ts +15 -0
  125. package/dist/src/push/apns.js +56 -0
  126. package/dist/src/push/device-tokens.d.ts +3 -0
  127. package/dist/src/push/device-tokens.js +39 -0
  128. package/dist/src/run-metadata.d.ts +25 -0
  129. package/dist/src/run-metadata.js +139 -0
  130. package/dist/src/runtime.d.ts +13 -0
  131. package/dist/src/runtime.js +5 -0
  132. package/dist/src/session/session-manager.d.ts +22 -0
  133. package/dist/src/session/session-manager.js +190 -0
  134. package/dist/src/session-usage-snapshot.d.ts +23 -0
  135. package/dist/src/session-usage-snapshot.js +65 -0
  136. package/dist/src/sse/emitter.d.ts +59 -0
  137. package/dist/src/sse/emitter.js +219 -0
  138. package/dist/src/sse/offline-queue.d.ts +26 -0
  139. package/dist/src/sse/offline-queue.js +134 -0
  140. package/dist/src/sync/account-identity.d.ts +14 -0
  141. package/dist/src/sync/account-identity.js +101 -0
  142. package/dist/src/sync/archive.d.ts +9 -0
  143. package/dist/src/sync/archive.js +25 -0
  144. package/dist/src/sync/database.d.ts +66 -0
  145. package/dist/src/sync/database.js +364 -0
  146. package/dist/src/sync/init.d.ts +3 -0
  147. package/dist/src/sync/init.js +14 -0
  148. package/dist/src/sync/installation-id.d.ts +1 -0
  149. package/dist/src/sync/installation-id.js +41 -0
  150. package/dist/src/sync/message-accumulator.d.ts +29 -0
  151. package/dist/src/sync/message-accumulator.js +188 -0
  152. package/dist/src/sync/message-store.d.ts +68 -0
  153. package/dist/src/sync/message-store.js +262 -0
  154. package/dist/src/sync/push-store.d.ts +5 -0
  155. package/dist/src/sync/push-store.js +54 -0
  156. package/dist/src/sync/session-key.d.ts +12 -0
  157. package/dist/src/sync/session-key.js +47 -0
  158. package/dist/src/sync/sync-state.d.ts +5 -0
  159. package/dist/src/sync/sync-state.js +54 -0
  160. package/dist/src/sync/transcript-archive.d.ts +13 -0
  161. package/dist/src/sync/transcript-archive.js +37 -0
  162. package/dist/src/sync/transcript-store.d.ts +35 -0
  163. package/dist/src/sync/transcript-store.js +221 -0
  164. package/dist/src/sync/translate.d.ts +42 -0
  165. package/dist/src/sync/translate.js +171 -0
  166. package/dist/src/vendor/runtime-store.d.ts +26 -0
  167. package/dist/src/vendor/runtime-store.js +60 -0
  168. package/package.json +11 -10
  169. package/src/agent/subagent-registry.ts +195 -0
  170. package/src/channel.ts +6 -4
  171. package/src/e2e/subagent-smoke.e2e.test.ts +223 -0
  172. package/src/e2e/subagent.e2e.test.ts +502 -0
  173. package/src/friday-session.ts +140 -1
  174. package/src/http/handlers/device-approve.test.ts +0 -1
  175. package/src/http/handlers/device-approve.ts +0 -2
  176. package/src/http/handlers/files-download.ts +4 -1
  177. package/src/http/handlers/files.ts +7 -4
  178. package/src/http/handlers/messages.ts +54 -4
  179. package/src/http/handlers/models-list.ts +24 -2
  180. package/src/http/handlers/nodes-approve.test.ts +288 -0
  181. package/src/http/handlers/nodes-approve.ts +189 -0
  182. package/src/http/server.ts +5 -0
  183. package/src/openclaw.d.ts +5 -0
  184. package/src/sse/emitter.ts +1 -1
  185. package/src/test-support/mock-runtime.ts +2 -0
@@ -4,8 +4,6 @@ import { readJsonBody } from "../middleware/body.js";
4
4
  import { extractBearerToken } from "../middleware/auth.js";
5
5
  import { createFridayNextLogger } from "../../logging.js";
6
6
 
7
- // macOS LaunchAgent strips Homebrew from PATH; prepend common prefixes so
8
- // the child process can find openclaw. Windows doesn't need this.
9
7
  const EXEC_ENV = process.platform === "win32"
10
8
  ? process.env
11
9
  : { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { IncomingMessage, ServerResponse } from "node:http";
11
+ import { createFridayNextLogger } from "../../logging.js";
11
12
  import { extractBearerToken } from "../middleware/auth.js";
12
13
  import {
13
14
  getExternalFileSourceByUrlToken,
@@ -20,6 +21,8 @@ import path from "node:path";
20
21
  import fs from "node:fs";
21
22
  import os from "node:os";
22
23
 
24
+ const logger = createFridayNextLogger("files-download");
25
+
23
26
  const MIME_FROM_EXT: Record<string, string> = {
24
27
  png: "image/png",
25
28
  jpg: "image/jpeg",
@@ -232,7 +235,7 @@ export async function handleFilesDownload(
232
235
  sendError(res, 404, "File not found");
233
236
  return true;
234
237
  } catch (err) {
235
- console.error(`[Friday-FILES] GET download failed: ${String(err)}`);
238
+ logger.error(`GET download failed: ${String(err)}`);
236
239
  sendError(res, 500, "Internal Server Error");
237
240
  return true;
238
241
  }
@@ -8,6 +8,7 @@
8
8
  import crypto from "node:crypto";
9
9
  import fs from "node:fs";
10
10
  import os from "node:os";
11
+ import { createFridayNextLogger } from "../../logging.js";
11
12
  import path from "node:path";
12
13
  import { fileURLToPath } from "node:url";
13
14
 
@@ -42,6 +43,8 @@ const fileIndex = new Map<string, StoredFile>();
42
43
  const fileTokenIndex = new Map<string, StoredFile>();
43
44
  const externalFileSourceIndex = new Map<string, string>();
44
45
 
46
+ const logger = createFridayNextLogger("files");
47
+
45
48
  function registerStoredFile(file: StoredFile): void {
46
49
  fileIndex.set(file.id, file);
47
50
  fileIndex.set(file.urlToken, file);
@@ -115,7 +118,7 @@ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
115
118
  // Fallback to read+write so attachment persistence still works.
116
119
  const raw = fs.readFileSync(resolvedPath);
117
120
  fs.writeFileSync(storedPath, raw);
118
- console.error(`[files] copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`);
121
+ logger.warn(`copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`);
119
122
  }
120
123
  const stat = fs.statSync(storedPath);
121
124
  const mimeType = guessMimeType(filename);
@@ -131,7 +134,7 @@ function copyLocalFileToAttachments(sourcePath: string): StoredFile | null {
131
134
  registerStoredFile(file);
132
135
  return file;
133
136
  } catch (err) {
134
- console.error(`[files] copyLocalFileToAttachments failed for "${resolvedPath}": ${String(err)}`);
137
+ logger.error(`copyLocalFileToAttachments failed for "${resolvedPath}": ${String(err)}`);
135
138
  return null;
136
139
  }
137
140
  }
@@ -238,10 +241,10 @@ export function resolveMediaUrl(localPath: string): string {
238
241
 
239
242
  const stored = copyLocalFileToAttachments(localPath);
240
243
  if (!stored) {
241
- console.error(`[files] resolveMediaUrl: file not found or unreadable: ${localPath}`);
244
+ logger.error(`resolveMediaUrl: file not found or unreadable: ${localPath}`);
242
245
  return localPath;
243
246
  }
244
- console.log(`[files] resolveMediaUrl: copied "${stored.filename}" → ${stored.urlToken}`);
247
+ logger.info(`resolveMediaUrl: copied "${stored.filename}" → ${stored.urlToken}`);
245
248
  return `/friday-next/files/${encodeURIComponent(stored.urlToken)}`;
246
249
  }
247
250
 
@@ -27,7 +27,8 @@ export type FridayReplyPayload = {
27
27
  import { resolveFridayNextConfig } from "../../config.js";
28
28
  import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
29
29
  import { getFridayNextRuntime } from "../../runtime.js";
30
- import { ensureSessionLevels, toSessionStoreKey } from "../../session/session-manager.js";
30
+ import { getFridayAgentForwardRuntime } from "../../agent-forward-runtime.js";
31
+ import { setSessionSettings, splitModelRef, toSessionStoreKey } from "../../session/session-manager.js";
31
32
  import { sseEmitter } from "../../sse/emitter.js";
32
33
  import { extractBearerToken } from "../middleware/auth.js";
33
34
  import { readJsonBody } from "../middleware/body.js";
@@ -51,12 +52,14 @@ import {
51
52
  registerRunRoute,
52
53
  setRunMetadata,
53
54
  } from "../../run-metadata.js";
55
+ import { createFridayNextLogger } from "../../logging.js";
56
+
57
+ const logger = createFridayNextLogger("messages");
54
58
 
55
59
  const log = (action: string, deviceId: string, runId?: string, detail?: string) => {
56
- const ts = new Date().toISOString();
57
60
  const runPart = runId ? ` runId=${runId}` : "";
58
61
  const detailPart = detail ? ` detail=${detail}` : "";
59
- console.error(`[Friday-MSG] [${ts}] [${action}] deviceId=${deviceId}${runPart}${detailPart}`);
62
+ logger.info(`[${action}] deviceId=${deviceId}${runPart}${detailPart}`);
60
63
  };
61
64
 
62
65
  function collectReplyPayloadMediaUrls(pl: { mediaUrls?: string[]; mediaUrl?: string | null }): string[] {
@@ -317,6 +320,9 @@ export interface FridayMessagePayload {
317
320
  text: string;
318
321
  sessionKey: string;
319
322
  attachments?: string[];
323
+ modelRef?: string;
324
+ reasoningLevel?: string;
325
+ thinkingLevel?: string;
320
326
  }
321
327
 
322
328
  async function buildBodyForAgentWithAttachments(text: string, attachmentIds: string[]): Promise<string> {
@@ -413,7 +419,51 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
413
419
  );
414
420
 
415
421
  const cfg = resolveFridayNextConfig(getHostOpenClawConfigSnapshot(runtime.config));
416
- ensureSessionLevels(baseSessionKey, "stream", "medium", cfg.historyDir);
422
+
423
+ // Resolve defaults from the OpenClaw agent config so settings are never left empty.
424
+ let defaultModel: string | undefined;
425
+ let defaultThinking: string | undefined;
426
+ try {
427
+ const forwardRt = getFridayAgentForwardRuntime();
428
+ if (forwardRt) {
429
+ const ocCfg = (forwardRt.getConfig() ?? {}) as Record<string, unknown>;
430
+ const agents = ocCfg.agents as Record<string, unknown> | undefined;
431
+ const agentDefaults = agents?.defaults as Record<string, unknown> | undefined;
432
+ const model = agentDefaults?.model as Record<string, unknown> | undefined;
433
+ defaultModel = typeof model?.primary === "string" ? (model.primary as string) : undefined;
434
+ defaultThinking =
435
+ typeof agentDefaults?.thinkingDefault === "string"
436
+ ? (agentDefaults.thinkingDefault as string)
437
+ : undefined;
438
+ }
439
+ } catch {
440
+ // Config not available (tests) — leave defaults undefined.
441
+ }
442
+
443
+ const modelRef = payload.modelRef ?? defaultModel;
444
+ const reasoningLevel = payload.reasoningLevel ?? "stream";
445
+ const thinkingLevel = payload.thinkingLevel ?? defaultThinking;
446
+
447
+ const settings: Record<string, string | undefined> = {};
448
+ if (modelRef) {
449
+ settings.modelRef = modelRef;
450
+ const split = splitModelRef(modelRef);
451
+ settings.providerOverride = split.provider;
452
+ settings.modelOverride = split.modelId;
453
+ }
454
+ if (reasoningLevel) settings.reasoningLevel = reasoningLevel;
455
+ if (thinkingLevel) settings.thinkingLevel = thinkingLevel;
456
+
457
+ if (Object.keys(settings).length > 0) {
458
+ setSessionSettings(baseSessionKey, settings, cfg.historyDir);
459
+ }
460
+
461
+ log(
462
+ "SESSION_SETTINGS",
463
+ normalizedDeviceId,
464
+ runId,
465
+ `sessionKey=${baseSessionKey} modelRef=${modelRef ?? "(default)"} reasoning=${reasoningLevel ?? "(default)"} thinking=${thinkingLevel ?? "(default)"}`,
466
+ );
417
467
 
418
468
  registerFridaySessionDeviceMapping(appSessionKey, normalizedDeviceId);
419
469
  sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
@@ -64,8 +64,30 @@ function resolveConfiguredModels(): ResolvedModels {
64
64
  }
65
65
  }
66
66
 
67
- const agentModel = agentDefaults?.model as Record<string, unknown> | undefined;
68
- const defaultModel = typeof agentModel?.primary === "string" ? agentModel.primary : "";
67
+ const agentModel = agentDefaults?.model;
68
+ let defaultModel =
69
+ typeof agentModel === "string" && agentModel.trim()
70
+ ? agentModel.trim()
71
+ : typeof (agentModel as Record<string, unknown> | undefined)?.primary === "string"
72
+ ? ((agentModel as Record<string, unknown>).primary as string)
73
+ : "";
74
+
75
+ if (!defaultModel && entries.length > 0) {
76
+ defaultModel = entries[0].id;
77
+ }
78
+
79
+ if (defaultModel && !seen.has(defaultModel)) {
80
+ const split = splitModelRef(defaultModel);
81
+ const meta = providerMeta.get(defaultModel);
82
+ entries.unshift({
83
+ id: defaultModel,
84
+ name: meta?.name ?? split.modelId,
85
+ provider: split.provider ?? "",
86
+ reasoning: meta?.reasoning,
87
+ contextWindow: meta?.contextWindow,
88
+ maxTokens: meta?.maxTokens,
89
+ });
90
+ }
69
91
 
70
92
  return { models: entries, defaultModel };
71
93
  }
@@ -0,0 +1,288 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { setMockRuntime } from "../../test-support/mock-runtime.js";
6
+
7
+ const mockExecImpl = vi.hoisted(() => vi.fn());
8
+ vi.mock("node:child_process", () => ({
9
+ exec: mockExecImpl,
10
+ }));
11
+
12
+ import { handleNodesApprove } from "./nodes-approve.js";
13
+
14
+ class MockRes extends EventEmitter {
15
+ statusCode = 0;
16
+ headers: Record<string, string> = {};
17
+ body = "";
18
+ setHeader(name: string, value: string): void {
19
+ this.headers[name.toLowerCase()] = value;
20
+ }
21
+ end(body?: string): void {
22
+ if (body) this.body += body;
23
+ }
24
+ }
25
+
26
+ function mockReq(method: string, headers: Record<string, string> = {}): PassThrough & { method: string; headers: Record<string, string> } {
27
+ const stream = new PassThrough() as unknown as PassThrough & { method: string; headers: Record<string, string> };
28
+ stream.method = method;
29
+ stream.headers = headers;
30
+ return stream;
31
+ }
32
+
33
+ function mockExecSuccess(stdout: string) {
34
+ const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
35
+ child.stdout = new EventEmitter();
36
+ child.stderr = new EventEmitter();
37
+ mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: null, stdout: string, stderr: string) => void) => {
38
+ cb(null, stdout, "");
39
+ return child;
40
+ });
41
+ }
42
+
43
+ function mockExecError(message: string, stderr?: string) {
44
+ const err = new Error(message) as Error & { stderr: string };
45
+ err.stderr = stderr ?? "";
46
+ const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
47
+ child.stdout = new EventEmitter();
48
+ child.stderr = new EventEmitter();
49
+ mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
50
+ cb(err);
51
+ return child;
52
+ });
53
+ }
54
+
55
+ function mockExecErrorWithStderr(err: Error & { stderr?: string }) {
56
+ const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
57
+ child.stdout = new EventEmitter();
58
+ child.stderr = new EventEmitter();
59
+ mockExecImpl.mockImplementationOnce((_cmd: string, _opts: unknown, cb: (error: Error) => void) => {
60
+ cb(err);
61
+ return child;
62
+ });
63
+ }
64
+
65
+ const NODE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
66
+ const REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
67
+
68
+ describe("handleNodesApprove", () => {
69
+ beforeEach(() => {
70
+ setMockRuntime();
71
+ mockExecImpl.mockReset();
72
+ });
73
+
74
+ it("returns 405 on non-POST", async () => {
75
+ const req = { method: "GET", headers: {} } as IncomingMessage;
76
+ const res = new MockRes() as unknown as ServerResponse;
77
+ await handleNodesApprove(req, res);
78
+ expect((res as unknown as MockRes).statusCode).toBe(405);
79
+ });
80
+
81
+ it("returns 401 for missing auth", async () => {
82
+ const req = mockReq("POST");
83
+ const res = new MockRes() as unknown as ServerResponse;
84
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
85
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
86
+ await p;
87
+ expect((res as unknown as MockRes).statusCode).toBe(401);
88
+ });
89
+
90
+ it("returns 400 for missing body", async () => {
91
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
92
+ const res = new MockRes() as unknown as ServerResponse;
93
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
94
+ req.end("");
95
+ await p;
96
+ expect((res as unknown as MockRes).statusCode).toBe(400);
97
+ });
98
+
99
+ it("returns 400 for missing nodeId", async () => {
100
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
101
+ const res = new MockRes() as unknown as ServerResponse;
102
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
103
+ req.end(JSON.stringify({}));
104
+ await p;
105
+ expect((res as unknown as MockRes).statusCode).toBe(400);
106
+ expect(JSON.parse((res as unknown as MockRes).body).error).toContain("nodeId");
107
+ });
108
+
109
+ it("returns 502 when nodes list CLI fails", async () => {
110
+ mockExecError("ENOENT");
111
+
112
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
113
+ const res = new MockRes() as unknown as ServerResponse;
114
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
115
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
116
+ await p;
117
+ expect((res as unknown as MockRes).statusCode).toBe(502);
118
+ expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Failed to list nodes");
119
+ });
120
+
121
+ it("returns 502 when nodes list returns invalid JSON", async () => {
122
+ mockExecSuccess("not valid json {{{");
123
+
124
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
125
+ const res = new MockRes() as unknown as ServerResponse;
126
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
127
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
128
+ await p;
129
+ expect((res as unknown as MockRes).statusCode).toBe(502);
130
+ expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response");
131
+ });
132
+
133
+ it("returns 404 when nodeId not in pending or paired with caps", async () => {
134
+ mockExecSuccess(JSON.stringify({
135
+ pending: [{ requestId: "uuid-1", nodeId: "OTHER_NODE" }],
136
+ paired: [{ nodeId: "ANOTHER", caps: ["canvas"], commands: ["canvas.navigate"] }],
137
+ }));
138
+
139
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
140
+ const res = new MockRes() as unknown as ServerResponse;
141
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
142
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
143
+ await p;
144
+ expect((res as unknown as MockRes).statusCode).toBe(404);
145
+ const body = JSON.parse((res as unknown as MockRes).body);
146
+ expect(body.error).toContain("No pending node found");
147
+ expect(body.nodeId).toBe(NODE_ID.toUpperCase());
148
+ });
149
+
150
+ it("returns 404 when pending is empty and paired has empty caps/commands", async () => {
151
+ mockExecSuccess(JSON.stringify({
152
+ pending: [],
153
+ paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: [], commands: [] }],
154
+ }));
155
+
156
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
157
+ const res = new MockRes() as unknown as ServerResponse;
158
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
159
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
160
+ await p;
161
+ expect((res as unknown as MockRes).statusCode).toBe(404);
162
+ });
163
+
164
+ it("returns 200 with alreadyApproved when node in paired with caps", async () => {
165
+ mockExecSuccess(JSON.stringify({
166
+ pending: [],
167
+ paired: [{ nodeId: NODE_ID, approvedAtMs: 1778571972361, caps: ["location", "canvas"], commands: ["canvas.navigate"] }],
168
+ }));
169
+
170
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
171
+ const res = new MockRes() as unknown as ServerResponse;
172
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
173
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
174
+ await p;
175
+
176
+ expect((res as unknown as MockRes).statusCode).toBe(200);
177
+ const body = JSON.parse((res as unknown as MockRes).body);
178
+ expect(body.ok).toBe(true);
179
+ expect(body.alreadyApproved).toBe(true);
180
+ expect(body.nodeId).toBe(NODE_ID.toUpperCase());
181
+ expect(body.approvedAtMs).toBe(1778571972361);
182
+ expect(body.caps).toEqual(["location", "canvas"]);
183
+ expect(body.commands).toEqual(["canvas.navigate"]);
184
+ expect(mockExecImpl).toHaveBeenCalledTimes(1); // no approve call
185
+ });
186
+
187
+ it("returns 502 when approve command fails", async () => {
188
+ mockExecSuccess(JSON.stringify({
189
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
190
+ paired: [],
191
+ }));
192
+ const approveErr = new Error("Command failed") as Error & { stderr: string };
193
+ approveErr.stderr = "unknown requestId";
194
+ mockExecErrorWithStderr(approveErr);
195
+
196
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
197
+ const res = new MockRes() as unknown as ServerResponse;
198
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
199
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
200
+ await p;
201
+ expect((res as unknown as MockRes).statusCode).toBe(502);
202
+ const body = JSON.parse((res as unknown as MockRes).body);
203
+ expect(body.error).toContain("Node approval command failed");
204
+ expect(body.detail).toBe("unknown requestId");
205
+ });
206
+
207
+ it("returns 502 when approve returns non-JSON", async () => {
208
+ mockExecSuccess(JSON.stringify({
209
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
210
+ paired: [],
211
+ }));
212
+ mockExecSuccess("No pending node pairing requests to approve");
213
+
214
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
215
+ const res = new MockRes() as unknown as ServerResponse;
216
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
217
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
218
+ await p;
219
+ expect((res as unknown as MockRes).statusCode).toBe(502);
220
+ expect(JSON.parse((res as unknown as MockRes).body).error).toContain("Unexpected response from node approval");
221
+ });
222
+
223
+ it("succeeds with complete flow", async () => {
224
+ mockExecSuccess(JSON.stringify({
225
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
226
+ paired: [],
227
+ }));
228
+ mockExecSuccess(JSON.stringify({
229
+ requestId: REQUEST_ID,
230
+ node: { nodeId: NODE_ID, approvedAtMs: 1778571972361 },
231
+ }));
232
+
233
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
234
+ const res = new MockRes() as unknown as ServerResponse;
235
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
236
+ req.end(JSON.stringify({ nodeId: NODE_ID }));
237
+ await p;
238
+
239
+ expect((res as unknown as MockRes).statusCode).toBe(200);
240
+ const body = JSON.parse((res as unknown as MockRes).body);
241
+ expect(body.ok).toBe(true);
242
+ expect(body.alreadyApproved).toBeUndefined();
243
+ expect(body.nodeId).toBe(NODE_ID.toUpperCase());
244
+ expect(body.requestId).toBe(REQUEST_ID);
245
+ expect(body.approvedAtMs).toBe(1778571972361);
246
+ expect(mockExecImpl).toHaveBeenCalledTimes(2);
247
+ });
248
+
249
+ it("normalizes nodeId case-insensitively in pending", async () => {
250
+ mockExecSuccess(JSON.stringify({
251
+ pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
252
+ paired: [],
253
+ }));
254
+ mockExecSuccess(JSON.stringify({
255
+ requestId: REQUEST_ID,
256
+ node: { nodeId: NODE_ID, approvedAtMs: 1 },
257
+ }));
258
+
259
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
260
+ const res = new MockRes() as unknown as ServerResponse;
261
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
262
+ req.end(JSON.stringify({ nodeId: NODE_ID.toLowerCase() }));
263
+ await p;
264
+
265
+ expect((res as unknown as MockRes).statusCode).toBe(200);
266
+ const body = JSON.parse((res as unknown as MockRes).body);
267
+ expect(body.ok).toBe(true);
268
+ expect(body.nodeId).toBe(NODE_ID.toUpperCase());
269
+ });
270
+
271
+ it("normalizes nodeId case-insensitively in paired", async () => {
272
+ mockExecSuccess(JSON.stringify({
273
+ pending: [],
274
+ paired: [{ nodeId: NODE_ID, approvedAtMs: 1, caps: ["canvas"], commands: [] }],
275
+ }));
276
+
277
+ const req = mockReq("POST", { authorization: "Bearer test-token" });
278
+ const res = new MockRes() as unknown as ServerResponse;
279
+ const p = handleNodesApprove(req as unknown as IncomingMessage, res);
280
+ req.end(JSON.stringify({ nodeId: NODE_ID.toLowerCase() }));
281
+ await p;
282
+
283
+ expect((res as unknown as MockRes).statusCode).toBe(200);
284
+ const body = JSON.parse((res as unknown as MockRes).body);
285
+ expect(body.ok).toBe(true);
286
+ expect(body.alreadyApproved).toBe(true);
287
+ });
288
+ });
@@ -0,0 +1,189 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { exec } from "node:child_process";
3
+ import { readJsonBody } from "../middleware/body.js";
4
+ import { extractBearerToken } from "../middleware/auth.js";
5
+ import { createFridayNextLogger } from "../../logging.js";
6
+
7
+ const EXEC_ENV = process.platform === "win32"
8
+ ? process.env
9
+ : { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:${process.env.PATH ?? ""}` };
10
+
11
+ interface PendingNode {
12
+ requestId: string;
13
+ nodeId: string;
14
+ }
15
+
16
+ interface PairedNode {
17
+ nodeId: string;
18
+ approvedAtMs?: number;
19
+ caps?: string[];
20
+ commands?: string[];
21
+ }
22
+
23
+ interface NodeListJson {
24
+ pending?: PendingNode[];
25
+ paired?: PairedNode[];
26
+ }
27
+
28
+ interface ApproveJson {
29
+ requestId: string;
30
+ node?: { nodeId: string; approvedAtMs?: number };
31
+ }
32
+
33
+ function execAsync(command: string, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
34
+ return new Promise((resolve, reject) => {
35
+ const child = exec(command, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024, env: EXEC_ENV }, (error, stdout, stderr) => {
36
+ if (error) {
37
+ reject(error);
38
+ } else {
39
+ resolve({ stdout, stderr });
40
+ }
41
+ });
42
+ child.stdout?.on("data", () => { /* drain */ });
43
+ child.stderr?.on("data", () => { /* drain */ });
44
+ });
45
+ }
46
+
47
+ export async function handleNodesApprove(
48
+ req: IncomingMessage,
49
+ res: ServerResponse,
50
+ ): Promise<boolean> {
51
+ const log = createFridayNextLogger("nodes-approve");
52
+
53
+ if (req.method !== "POST") {
54
+ res.statusCode = 405;
55
+ res.setHeader("Content-Type", "application/json");
56
+ res.end(JSON.stringify({ error: "Method Not Allowed" }));
57
+ return true;
58
+ }
59
+
60
+ const token = extractBearerToken(req);
61
+ if (!token) {
62
+ res.statusCode = 401;
63
+ res.setHeader("Content-Type", "application/json");
64
+ res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
65
+ return true;
66
+ }
67
+
68
+ const body = await readJsonBody(req);
69
+ if (!body) {
70
+ res.statusCode = 400;
71
+ res.setHeader("Content-Type", "application/json");
72
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
73
+ return true;
74
+ }
75
+
76
+ const rawNodeId = typeof body.nodeId === "string" ? body.nodeId : "";
77
+ if (!rawNodeId.trim()) {
78
+ res.statusCode = 400;
79
+ res.setHeader("Content-Type", "application/json");
80
+ res.end(JSON.stringify({ error: "Missing required field: nodeId" }));
81
+ return true;
82
+ }
83
+
84
+ const normalizedNodeId = rawNodeId.trim().toUpperCase();
85
+
86
+ let listStdout: string;
87
+ try {
88
+ const result = await execAsync("openclaw nodes list --json", 15000);
89
+ listStdout = result.stdout;
90
+ } catch (err) {
91
+ const stderr = (err as { stderr?: string })?.stderr?.trim();
92
+ log.error(`nodes list failed: ${err instanceof Error ? err.message : String(err)}`);
93
+ res.statusCode = 502;
94
+ res.setHeader("Content-Type", "application/json");
95
+ res.end(JSON.stringify({ error: "Failed to list nodes from gateway", detail: stderr || undefined }));
96
+ return true;
97
+ }
98
+
99
+ let listData: NodeListJson;
100
+ try {
101
+ listData = JSON.parse(listStdout) as NodeListJson;
102
+ } catch {
103
+ log.error(`nodes list returned invalid JSON: ${listStdout.slice(0, 200)}`);
104
+ res.statusCode = 502;
105
+ res.setHeader("Content-Type", "application/json");
106
+ res.end(JSON.stringify({ error: "Unexpected response from gateway node list" }));
107
+ return true;
108
+ }
109
+
110
+ const pending = listData.pending ?? [];
111
+ const pendingMatch = pending.find(
112
+ (entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
113
+ );
114
+
115
+ if (pendingMatch) {
116
+ const requestId = pendingMatch.requestId;
117
+ log.info(`approving nodeId=${normalizedNodeId} requestId=${requestId}`);
118
+
119
+ let approveStdout: string;
120
+ try {
121
+ const result = await execAsync(`openclaw nodes approve ${requestId} --json`, 15000);
122
+ approveStdout = result.stdout;
123
+ } catch (err) {
124
+ const stderr = (err as { stderr?: string })?.stderr?.trim();
125
+ log.error(`nodes approve failed: ${err instanceof Error ? err.message : String(err)}`);
126
+ res.statusCode = 502;
127
+ res.setHeader("Content-Type", "application/json");
128
+ res.end(JSON.stringify({
129
+ error: "Node approval command failed",
130
+ detail: stderr || (err instanceof Error ? err.message : "Unknown error"),
131
+ }));
132
+ return true;
133
+ }
134
+
135
+ let approveData: ApproveJson;
136
+ try {
137
+ approveData = JSON.parse(approveStdout) as ApproveJson;
138
+ } catch {
139
+ log.error(`nodes approve returned non-JSON: ${approveStdout.slice(0, 200)}`);
140
+ res.statusCode = 502;
141
+ res.setHeader("Content-Type", "application/json");
142
+ res.end(JSON.stringify({ error: "Unexpected response from node approval" }));
143
+ return true;
144
+ }
145
+
146
+ res.statusCode = 200;
147
+ res.setHeader("Content-Type", "application/json");
148
+ res.end(JSON.stringify({
149
+ ok: true,
150
+ nodeId: normalizedNodeId,
151
+ requestId: approveData.requestId,
152
+ approvedAtMs: approveData.node?.approvedAtMs,
153
+ }));
154
+ return true;
155
+ }
156
+
157
+ // Not in pending — check if already paired with non-empty caps/commands
158
+ const paired = listData.paired ?? [];
159
+ const pairedMatch = paired.find(
160
+ (entry) => entry.nodeId.trim().toUpperCase() === normalizedNodeId,
161
+ );
162
+
163
+ if (pairedMatch) {
164
+ const caps = pairedMatch.caps ?? [];
165
+ const commands = pairedMatch.commands ?? [];
166
+ if (caps.length > 0 || commands.length > 0) {
167
+ log.info(`nodeId=${normalizedNodeId} already paired with caps=${caps.length} commands=${commands.length}`);
168
+ res.statusCode = 200;
169
+ res.setHeader("Content-Type", "application/json");
170
+ res.end(JSON.stringify({
171
+ ok: true,
172
+ nodeId: normalizedNodeId,
173
+ alreadyApproved: true,
174
+ approvedAtMs: pairedMatch.approvedAtMs,
175
+ caps,
176
+ commands,
177
+ }));
178
+ return true;
179
+ }
180
+ }
181
+
182
+ res.statusCode = 404;
183
+ res.setHeader("Content-Type", "application/json");
184
+ res.end(JSON.stringify({
185
+ error: "No pending node found for this nodeId",
186
+ nodeId: normalizedNodeId,
187
+ }));
188
+ return true;
189
+ }