@syengup/friday-channel-next 0.1.4 → 0.1.6

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.
@@ -45,6 +45,20 @@ export async function handleDeviceApprove(req, res) {
45
45
  }
46
46
  const match = pairing.pending.find((entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId);
47
47
  if (!match) {
48
+ // Gateway may have already auto-approved the device (e.g. mode="local").
49
+ // Check the paired list before returning 404.
50
+ const pairedDevice = (pairing.paired ?? []).find((entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId);
51
+ if (pairedDevice) {
52
+ res.statusCode = 200;
53
+ res.setHeader("Content-Type", "application/json");
54
+ res.end(JSON.stringify({
55
+ ok: true,
56
+ deviceId: normalizedDeviceId,
57
+ alreadyApproved: true,
58
+ approvedAtMs: pairedDevice.approvedAtMs,
59
+ }));
60
+ return true;
61
+ }
48
62
  res.statusCode = 404;
49
63
  res.setHeader("Content-Type", "application/json");
50
64
  res.end(JSON.stringify({
@@ -7,5 +7,5 @@ export function applyCorsHeaders(res) {
7
7
  return;
8
8
  res.setHeader("Access-Control-Allow-Origin", cfg.corsAllowOrigin || "*");
9
9
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID");
10
- res.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
10
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
11
11
  }
@@ -11,7 +11,6 @@ import { handleFilesDownload } from "./handlers/files-download.js";
11
11
  import { handleCancel } from "./handlers/cancel.js";
12
12
  import { handleDeviceApprove } from "./handlers/device-approve.js";
13
13
  import { handleNodesApprove } from "./handlers/nodes-approve.js";
14
- import { handleSessionsDelete } from "./handlers/sessions-delete.js";
15
14
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
16
15
  import { handleModelsList } from "./handlers/models-list.js";
17
16
  import { handleStatus } from "./handlers/status.js";
@@ -56,9 +55,6 @@ async function handleFridayNextRoute(req, res) {
56
55
  if (req.method === "POST" && pathname === "/friday-next/nodes-approve") {
57
56
  return await handleNodesApprove(req, res);
58
57
  }
59
- if (req.method === "DELETE" && pathname === "/friday-next/sessions") {
60
- return await handleSessionsDelete(req, res);
61
- }
62
58
  if ((req.method === "PUT" || req.method === "GET") && pathname === "/friday-next/sessions/settings") {
63
59
  return await handleSessionsSettings(req, res);
64
60
  }
@@ -4,13 +4,6 @@ export declare function splitModelRef(modelRef: string): {
4
4
  };
5
5
  export declare function toSessionStoreKey(rawSessionKey: string): string;
6
6
  export declare function ensureSessionLevels(sessionKey: string, reasoningLevel: string, thinkingLevel: string, historyDir?: string): void;
7
- export declare function resolveSessionsDir(historyDir?: string): string;
8
- export interface DeleteSessionResult {
9
- sessionKey: string;
10
- sessionId?: string;
11
- transcriptDeleted?: boolean;
12
- }
13
- export declare function deleteFridaySession(sessionKey: string, historyDir?: string): DeleteSessionResult;
14
7
  export interface FridaySessionSettings {
15
8
  reasoningLevel?: string;
16
9
  thinkingLevel?: string;
@@ -1,6 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import os from "node:os";
3
- import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
4
  const FRIDAY_AGENT_ID = "main";
5
5
  const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
6
6
  function deriveOpenClawBaseDir(historyDir) {
@@ -96,43 +96,6 @@ function upsertSessionEntry(data, fileKey, sessionKey) {
96
96
  export function ensureSessionLevels(sessionKey, reasoningLevel, thinkingLevel, historyDir) {
97
97
  setSessionSettings(sessionKey, { reasoningLevel, thinkingLevel }, historyDir);
98
98
  }
99
- export function resolveSessionsDir(historyDir) {
100
- const base = deriveOpenClawBaseDir(historyDir);
101
- return join(base, "agents/main/sessions");
102
- }
103
- export function deleteFridaySession(sessionKey, historyDir) {
104
- const result = { sessionKey };
105
- const sessionsFile = resolveSessionsFilePath(historyDir);
106
- const data = readSessionsData(sessionsFile);
107
- if (!data)
108
- return result;
109
- const fileKey = toSessionStoreKey(sessionKey);
110
- const entry = data[fileKey];
111
- if (!entry)
112
- return result;
113
- const sessionId = typeof entry["sessionId"] === "string" ? entry["sessionId"] : undefined;
114
- const sessionFilePath = typeof entry["sessionFile"] === "string" ? entry["sessionFile"] : undefined;
115
- result.sessionId = sessionId;
116
- if (sessionFilePath) {
117
- try {
118
- unlinkSync(sessionFilePath);
119
- result.transcriptDeleted = true;
120
- }
121
- catch { /* gone already */ }
122
- }
123
- if (sessionId) {
124
- const dir = resolveSessionsDir(historyDir);
125
- for (const suffix of [".trajectory.jsonl", ".trajectory-path.json"]) {
126
- try {
127
- unlinkSync(join(dir, `${sessionId}${suffix}`));
128
- }
129
- catch { /* optional */ }
130
- }
131
- }
132
- delete data[fileKey];
133
- writeSessionsData(sessionsFile, data);
134
- return result;
135
- }
136
99
  export function setSessionSettings(sessionKey, settings, historyDir) {
137
100
  try {
138
101
  const sessionsFile = resolveSessionsFilePath(historyDir);
package/install.js CHANGED
@@ -96,7 +96,7 @@ let installed = false;
96
96
 
97
97
  try {
98
98
  const out = execSync(
99
- `${openclawCmd} plugins install @syengup/friday-channel-next@latest`,
99
+ `${openclawCmd} plugins install @syengup/friday-channel-next@latest --force`,
100
100
  { encoding: "utf8", stdio: "pipe", timeout: 120000 }
101
101
  );
102
102
  if (out.trim()) console.log(out.trim());
@@ -124,7 +124,7 @@ if (!installed) {
124
124
  process.exit(1);
125
125
  }
126
126
  warn("Manual install complete, but auto-upgrade is NOT available.");
127
- warn("To enable auto-upgrade later, run: openclaw plugins install @syengup/friday-channel-next");
127
+ warn("To enable auto-upgrade later, run: openclaw plugins install @syengup/friday-channel-next --force");
128
128
  // Clean up legacy dir even in fallback to avoid duplicate warnings
129
129
  if (existsSync(join(USER_HOME, ".openclaw", "extensions", "friday-channel-next"))) {
130
130
  warn("Legacy install detected. Remove it to avoid duplicate warnings:");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,415 @@
1
+ /**
2
+ * E2E test: 模拟 iOS 两步自动批准流程 (device-approve → nodes-approve)
3
+ *
4
+ * iOS 端 connect 成功后会调用 performPostConnectHealthCheckIfNeeded():
5
+ * 1. GET /friday-next/health?deviceId=...&nodeDeviceId=... (selfHeal 默认 true)
6
+ * 2. 如果 devicePairing.status == "pending" → POST /friday-next/device-approve
7
+ * 3. 如果 nodePairing.status == "pending" → POST /friday-next/nodes-approve
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { createAppSimulator } from "../test-support/app-simulator.js";
11
+ import { createTempHistoryDir, removeTempHistoryDir, setMockRuntime } from "../test-support/mock-runtime.js";
12
+ import { __setMockNodePairingForTests } from "../agent/node-pairing-bridge.js";
13
+
14
+ const FAKE_DEVICE_ID = "a80b8c4b305fb02c5772c409c6dfcbacde691b61557f7779511ad1a5be8fdf06";
15
+ const DEVICE_REQUEST_ID = "12f150e8-b1bc-4688-be23-e3a7fa8b9e51";
16
+ const NODE_REQUEST_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
17
+
18
+ const { mockListDevices, mockApproveDevice } = vi.hoisted(() => ({
19
+ mockListDevices: vi.fn(),
20
+ mockApproveDevice: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("openclaw/plugin-sdk/device-bootstrap", () => ({
24
+ listDevicePairing: mockListDevices,
25
+ approveDevicePairing: mockApproveDevice,
26
+ }));
27
+
28
+ describe("e2e two-step auto-approval", () => {
29
+ let historyDir = "";
30
+
31
+ beforeEach(() => {
32
+ historyDir = createTempHistoryDir();
33
+ setMockRuntime({ historyDir, authToken: "test-token" });
34
+ vi.clearAllMocks();
35
+ // 默认:设备和节点都不在 pending/paired 中
36
+ mockListDevices.mockResolvedValue({ pending: [], paired: [] });
37
+ mockApproveDevice.mockResolvedValue({ status: "approved", requestId: DEVICE_REQUEST_ID });
38
+ });
39
+
40
+ afterEach(() => {
41
+ removeTempHistoryDir(historyDir);
42
+ });
43
+
44
+ // ── Step 1: Device Approve ───────────────────────────────────────
45
+
46
+ it("Step 1: POST /friday-next/device-approve 成功批准设备", async () => {
47
+ mockListDevices.mockResolvedValueOnce({
48
+ pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
49
+ paired: [],
50
+ });
51
+ mockApproveDevice.mockResolvedValueOnce({
52
+ status: "approved",
53
+ requestId: DEVICE_REQUEST_ID,
54
+ device: { deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
55
+ });
56
+
57
+ const app = createAppSimulator({ token: "test-token" });
58
+ const res = await app.rawRequest({
59
+ method: "POST",
60
+ path: "/friday-next/device-approve",
61
+ headers: { "content-type": "application/json" },
62
+ body: JSON.stringify({ deviceId: FAKE_DEVICE_ID }),
63
+ });
64
+
65
+ expect(res.status).toBe(200);
66
+ const body = JSON.parse(res.body);
67
+ expect(body.ok).toBe(true);
68
+ expect(body.deviceId).toBe(FAKE_DEVICE_ID.toUpperCase());
69
+ expect(body.requestId).toBe(DEVICE_REQUEST_ID);
70
+ expect(mockApproveDevice).toHaveBeenCalledWith(DEVICE_REQUEST_ID);
71
+ });
72
+
73
+ it("Step 1: device 已在 paired 列表中时返回 alreadyApproved", async () => {
74
+ mockListDevices.mockResolvedValueOnce({
75
+ pending: [],
76
+ paired: [{ deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 }],
77
+ });
78
+
79
+ const app = createAppSimulator({ token: "test-token" });
80
+ const res = await app.rawRequest({
81
+ method: "POST",
82
+ path: "/friday-next/device-approve",
83
+ headers: { "content-type": "application/json" },
84
+ body: JSON.stringify({ deviceId: FAKE_DEVICE_ID }),
85
+ });
86
+
87
+ expect(res.status).toBe(200);
88
+ const body = JSON.parse(res.body);
89
+ expect(body.ok).toBe(true);
90
+ expect(body.alreadyApproved).toBe(true);
91
+ expect(mockApproveDevice).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it("Step 1: device 不在 pending/paired 中时返回 404", async () => {
95
+ mockListDevices.mockResolvedValueOnce({ pending: [], paired: [] });
96
+
97
+ const app = createAppSimulator({ token: "test-token" });
98
+ const res = await app.rawRequest({
99
+ method: "POST",
100
+ path: "/friday-next/device-approve",
101
+ headers: { "content-type": "application/json" },
102
+ body: JSON.stringify({ deviceId: FAKE_DEVICE_ID }),
103
+ });
104
+
105
+ expect(res.status).toBe(404);
106
+ const body = JSON.parse(res.body);
107
+ expect(body.error).toContain("No pending device found");
108
+ expect(body.deviceId).toBe(FAKE_DEVICE_ID.toUpperCase());
109
+ });
110
+
111
+ // ── Step 2: Node Approve ─────────────────────────────────────────
112
+
113
+ it("Step 2: POST /friday-next/nodes-approve 成功批准节点", async () => {
114
+ const mockListNodePairing = vi.fn().mockResolvedValueOnce({
115
+ pending: [{ requestId: NODE_REQUEST_ID, nodeId: FAKE_DEVICE_ID }],
116
+ paired: [],
117
+ });
118
+ const mockApproveNodePairing = vi.fn().mockResolvedValueOnce({
119
+ requestId: NODE_REQUEST_ID,
120
+ node: { nodeId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
121
+ });
122
+
123
+ __setMockNodePairingForTests({
124
+ listNodePairing: mockListNodePairing,
125
+ approveNodePairing: mockApproveNodePairing,
126
+ });
127
+
128
+ const app = createAppSimulator({ token: "test-token" });
129
+ const res = await app.rawRequest({
130
+ method: "POST",
131
+ path: "/friday-next/nodes-approve",
132
+ headers: { "content-type": "application/json" },
133
+ body: JSON.stringify({ nodeId: FAKE_DEVICE_ID }),
134
+ });
135
+
136
+ expect(res.status).toBe(200);
137
+ const body = JSON.parse(res.body);
138
+ expect(body.ok).toBe(true);
139
+ expect(body.nodeId).toBe(FAKE_DEVICE_ID.toUpperCase());
140
+ expect(body.requestId).toBe(NODE_REQUEST_ID);
141
+ expect(mockApproveNodePairing).toHaveBeenCalledWith(NODE_REQUEST_ID, {
142
+ callerScopes: ["operator.admin", "operator.pairing", "operator.read", "operator.write"],
143
+ });
144
+ });
145
+
146
+ it("Step 2: node 已在 paired 中且有 caps 时返回 alreadyApproved", async () => {
147
+ const mockListNodePairing = vi.fn().mockResolvedValueOnce({
148
+ pending: [],
149
+ paired: [{
150
+ nodeId: FAKE_DEVICE_ID,
151
+ approvedAtMs: 1700000000000,
152
+ caps: ["location", "canvas"],
153
+ commands: ["canvas.present"],
154
+ }],
155
+ });
156
+ const mockApproveNodePairing = vi.fn();
157
+
158
+ __setMockNodePairingForTests({
159
+ listNodePairing: mockListNodePairing,
160
+ approveNodePairing: mockApproveNodePairing,
161
+ });
162
+
163
+ const app = createAppSimulator({ token: "test-token" });
164
+ const res = await app.rawRequest({
165
+ method: "POST",
166
+ path: "/friday-next/nodes-approve",
167
+ headers: { "content-type": "application/json" },
168
+ body: JSON.stringify({ nodeId: FAKE_DEVICE_ID }),
169
+ });
170
+
171
+ expect(res.status).toBe(200);
172
+ const body = JSON.parse(res.body);
173
+ expect(body.ok).toBe(true);
174
+ expect(body.alreadyApproved).toBe(true);
175
+ expect(body.caps).toEqual(["location", "canvas"]);
176
+ expect(mockApproveNodePairing).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it("Step 2: node 不在 pending/paired 中时返回 404", async () => {
180
+ const mockListNodePairing = vi.fn().mockResolvedValueOnce({
181
+ pending: [],
182
+ paired: [],
183
+ });
184
+ const mockApproveNodePairing = vi.fn();
185
+
186
+ __setMockNodePairingForTests({
187
+ listNodePairing: mockListNodePairing,
188
+ approveNodePairing: mockApproveNodePairing,
189
+ });
190
+
191
+ const app = createAppSimulator({ token: "test-token" });
192
+ const res = await app.rawRequest({
193
+ method: "POST",
194
+ path: "/friday-next/nodes-approve",
195
+ headers: { "content-type": "application/json" },
196
+ body: JSON.stringify({ nodeId: FAKE_DEVICE_ID }),
197
+ });
198
+
199
+ expect(res.status).toBe(404);
200
+ const body = JSON.parse(res.body);
201
+ expect(body.error).toContain("No pending node found");
202
+ });
203
+
204
+ // ── Combined: full iOS flow simulation ───────────────────────────
205
+
206
+ it("完整两步流程: device-approve → nodes-approve 串行成功", async () => {
207
+ // Step 1 mock: 设备在 pending 中
208
+ mockListDevices.mockResolvedValueOnce({
209
+ pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
210
+ paired: [],
211
+ });
212
+ mockApproveDevice.mockResolvedValueOnce({
213
+ status: "approved",
214
+ requestId: DEVICE_REQUEST_ID,
215
+ device: { deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
216
+ });
217
+
218
+ // Step 2 mock: 节点在 pending 中
219
+ const mockListNodePairing = vi.fn().mockResolvedValueOnce({
220
+ pending: [{ requestId: NODE_REQUEST_ID, nodeId: FAKE_DEVICE_ID }],
221
+ paired: [],
222
+ });
223
+ const mockApproveNodePairing = vi.fn().mockResolvedValueOnce({
224
+ requestId: NODE_REQUEST_ID,
225
+ node: { nodeId: FAKE_DEVICE_ID, approvedAtMs: 1700000000001 },
226
+ });
227
+ __setMockNodePairingForTests({
228
+ listNodePairing: mockListNodePairing,
229
+ approveNodePairing: mockApproveNodePairing,
230
+ });
231
+
232
+ const app = createAppSimulator({ token: "test-token" });
233
+
234
+ // Step 1: 批准设备
235
+ const step1 = await app.rawRequest({
236
+ method: "POST",
237
+ path: "/friday-next/device-approve",
238
+ headers: { "content-type": "application/json" },
239
+ body: JSON.stringify({ deviceId: FAKE_DEVICE_ID }),
240
+ });
241
+ expect(step1.status).toBe(200);
242
+ expect(JSON.parse(step1.body).ok).toBe(true);
243
+ expect(mockApproveDevice).toHaveBeenCalledTimes(1);
244
+
245
+ // Step 2: 批准节点
246
+ const step2 = await app.rawRequest({
247
+ method: "POST",
248
+ path: "/friday-next/nodes-approve",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({ nodeId: FAKE_DEVICE_ID }),
251
+ });
252
+ expect(step2.status).toBe(200);
253
+ expect(JSON.parse(step2.body).ok).toBe(true);
254
+ expect(mockApproveNodePairing).toHaveBeenCalledTimes(1);
255
+ });
256
+
257
+ // ── Auth tests ───────────────────────────────────────────────────
258
+
259
+ it("device-approve: 正确 token 正常批准", async () => {
260
+ mockListDevices.mockResolvedValueOnce({
261
+ pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
262
+ paired: [],
263
+ });
264
+ mockApproveDevice.mockResolvedValueOnce({
265
+ status: "approved",
266
+ requestId: DEVICE_REQUEST_ID,
267
+ device: { deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
268
+ });
269
+
270
+ const app = createAppSimulator({ token: "test-token" });
271
+ const res = await app.rawRequest({
272
+ method: "POST",
273
+ path: "/friday-next/device-approve",
274
+ headers: { "content-type": "application/json" },
275
+ body: JSON.stringify({ deviceId: FAKE_DEVICE_ID }),
276
+ });
277
+ expect(res.status).toBe(200);
278
+ expect(JSON.parse(res.body).ok).toBe(true);
279
+ });
280
+
281
+ it("device-approve: 错误 token 返回 401", async () => {
282
+ const app = createAppSimulator({ token: "wrong-token" });
283
+ const res = await app.rawRequest({
284
+ method: "POST",
285
+ path: "/friday-next/device-approve",
286
+ headers: { "content-type": "application/json" },
287
+ body: JSON.stringify({ deviceId: FAKE_DEVICE_ID }),
288
+ });
289
+ expect(res.status).toBe(401);
290
+ });
291
+
292
+ // ── Health endpoint with selfHeal ────────────────────────────────
293
+
294
+ it("GET /friday-next/health?selfHeal=true 自动批准 pending device", async () => {
295
+ mockListDevices.mockResolvedValueOnce({
296
+ pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
297
+ paired: [],
298
+ });
299
+ mockApproveDevice.mockResolvedValueOnce({
300
+ status: "approved",
301
+ requestId: DEVICE_REQUEST_ID,
302
+ device: { deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
303
+ });
304
+
305
+ const app = createAppSimulator({ token: "test-token" });
306
+ const res = await app.rawRequest({
307
+ method: "GET",
308
+ path: `/friday-next/health?deviceId=${FAKE_DEVICE_ID}&selfHeal=true`,
309
+ });
310
+
311
+ expect(res.status).toBe(200);
312
+ const body = JSON.parse(res.body);
313
+ expect(body.ok).toBe(true);
314
+ expect(body.devicePairing.status).toBe("ok");
315
+ expect(body.devicePairing.detail).toContain("auto-approved");
316
+ expect(body.repairActions).toHaveLength(1);
317
+ expect(body.repairActions[0].component).toBe("devicePairing");
318
+ expect(body.repairActions[0].result).toBe("ok");
319
+ expect(mockApproveDevice).toHaveBeenCalledWith(DEVICE_REQUEST_ID);
320
+ });
321
+
322
+ it("GET /friday-next/health 不带 selfHeal 时不自动批准 (只返回 pending)", async () => {
323
+ mockListDevices.mockResolvedValueOnce({
324
+ pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
325
+ paired: [],
326
+ });
327
+
328
+ const app = createAppSimulator({ token: "test-token" });
329
+ const res = await app.rawRequest({
330
+ method: "GET",
331
+ path: `/friday-next/health?deviceId=${FAKE_DEVICE_ID}`,
332
+ });
333
+
334
+ expect(res.status).toBe(200);
335
+ const body = JSON.parse(res.body);
336
+ expect(body.devicePairing.status).toBe("pending");
337
+ expect(body.devicePairing.detail).toContain("pending approval");
338
+ expect(mockApproveDevice).not.toHaveBeenCalled();
339
+ // ok 仍为 true,因为 pending 也视为 ok
340
+ expect(body.ok).toBe(true);
341
+ });
342
+
343
+ it("GET /friday-next/health?selfHeal=true 自动批准 pending node", async () => {
344
+ mockListDevices.mockResolvedValue({ pending: [], paired: [] }); // device 不相关
345
+
346
+ const mockListNodePairing = vi.fn().mockResolvedValueOnce({
347
+ pending: [{ requestId: NODE_REQUEST_ID, nodeId: FAKE_DEVICE_ID }],
348
+ paired: [],
349
+ });
350
+ const mockApproveNodePairing = vi.fn().mockResolvedValueOnce({
351
+ requestId: NODE_REQUEST_ID,
352
+ node: { nodeId: FAKE_DEVICE_ID },
353
+ });
354
+ __setMockNodePairingForTests({
355
+ listNodePairing: mockListNodePairing,
356
+ approveNodePairing: mockApproveNodePairing,
357
+ });
358
+
359
+ const app = createAppSimulator({ token: "test-token" });
360
+ const res = await app.rawRequest({
361
+ method: "GET",
362
+ path: `/friday-next/health?nodeDeviceId=${FAKE_DEVICE_ID}&selfHeal=true`,
363
+ });
364
+
365
+ expect(res.status).toBe(200);
366
+ const body = JSON.parse(res.body);
367
+ expect(body.nodePairing.status).toBe("ok");
368
+ expect(body.nodePairing.detail).toContain("auto-approved");
369
+ expect(body.repairActions).toHaveLength(1);
370
+ expect(body.repairActions[0].component).toBe("nodePairing");
371
+ expect(body.repairActions[0].result).toBe("ok");
372
+ });
373
+
374
+ it("GET /friday-next/health?selfHeal=true 同时自动批准 device 和 node", async () => {
375
+ // Device is pending
376
+ mockListDevices.mockResolvedValueOnce({
377
+ pending: [{ requestId: DEVICE_REQUEST_ID, deviceId: FAKE_DEVICE_ID }],
378
+ paired: [],
379
+ });
380
+ mockApproveDevice.mockResolvedValueOnce({
381
+ status: "approved",
382
+ requestId: DEVICE_REQUEST_ID,
383
+ device: { deviceId: FAKE_DEVICE_ID, approvedAtMs: 1700000000000 },
384
+ });
385
+
386
+ // Node is pending
387
+ const mockListNodePairing = vi.fn().mockResolvedValueOnce({
388
+ pending: [{ requestId: NODE_REQUEST_ID, nodeId: FAKE_DEVICE_ID }],
389
+ paired: [],
390
+ });
391
+ const mockApproveNodePairing = vi.fn().mockResolvedValueOnce({
392
+ requestId: NODE_REQUEST_ID,
393
+ node: { nodeId: FAKE_DEVICE_ID },
394
+ });
395
+ __setMockNodePairingForTests({
396
+ listNodePairing: mockListNodePairing,
397
+ approveNodePairing: mockApproveNodePairing,
398
+ });
399
+
400
+ const app = createAppSimulator({ token: "test-token" });
401
+ const res = await app.rawRequest({
402
+ method: "GET",
403
+ path: `/friday-next/health?deviceId=${FAKE_DEVICE_ID}&nodeDeviceId=${FAKE_DEVICE_ID}&selfHeal=true`,
404
+ });
405
+
406
+ expect(res.status).toBe(200);
407
+ const body = JSON.parse(res.body);
408
+ expect(body.ok).toBe(true);
409
+ expect(body.devicePairing.status).toBe("ok");
410
+ expect(body.nodePairing.status).toBe("ok");
411
+ expect(body.repairActions).toHaveLength(2);
412
+ expect(body.repairActions[0].component).toBe("devicePairing");
413
+ expect(body.repairActions[1].component).toBe("nodePairing");
414
+ });
415
+ });
@@ -59,6 +59,23 @@ export async function handleDeviceApprove(
59
59
  );
60
60
 
61
61
  if (!match) {
62
+ // Gateway may have already auto-approved the device (e.g. mode="local").
63
+ // Check the paired list before returning 404.
64
+ const pairedDevice = (pairing.paired ?? []).find(
65
+ (entry) => entry.deviceId.trim().toUpperCase() === normalizedDeviceId,
66
+ );
67
+ if (pairedDevice) {
68
+ res.statusCode = 200;
69
+ res.setHeader("Content-Type", "application/json");
70
+ res.end(JSON.stringify({
71
+ ok: true,
72
+ deviceId: normalizedDeviceId,
73
+ alreadyApproved: true,
74
+ approvedAtMs: (pairedDevice as any).approvedAtMs,
75
+ }));
76
+ return true;
77
+ }
78
+
62
79
  res.statusCode = 404;
63
80
  res.setHeader("Content-Type", "application/json");
64
81
  res.end(JSON.stringify({
@@ -8,5 +8,5 @@ export function applyCorsHeaders(res: ServerResponse): void {
8
8
  if (!cfg.corsEnabled) return;
9
9
  res.setHeader("Access-Control-Allow-Origin", cfg.corsAllowOrigin || "*");
10
10
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, Last-Event-ID");
11
- res.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
11
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
12
12
  }
@@ -13,7 +13,6 @@ import { handleFilesDownload } from "./handlers/files-download.js";
13
13
  import { handleCancel } from "./handlers/cancel.js";
14
14
  import { handleDeviceApprove } from "./handlers/device-approve.js";
15
15
  import { handleNodesApprove } from "./handlers/nodes-approve.js";
16
- import { handleSessionsDelete } from "./handlers/sessions-delete.js";
17
16
  import { handleSessionsSettings } from "./handlers/sessions-settings.js";
18
17
  import { handleModelsList } from "./handlers/models-list.js";
19
18
  import { handleStatus } from "./handlers/status.js";
@@ -70,10 +69,6 @@ async function handleFridayNextRoute(
70
69
  return await handleNodesApprove(req, res);
71
70
  }
72
71
 
73
- if (req.method === "DELETE" && pathname === "/friday-next/sessions") {
74
- return await handleSessionsDelete(req, res);
75
- }
76
-
77
72
  if ((req.method === "PUT" || req.method === "GET") && pathname === "/friday-next/sessions/settings") {
78
73
  return await handleSessionsSettings(req, res);
79
74
  }
@@ -1,6 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import os from "node:os";
3
- import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
4
 
5
5
  const FRIDAY_AGENT_ID = "main";
6
6
  const SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
@@ -111,50 +111,6 @@ export function ensureSessionLevels(
111
111
  setSessionSettings(sessionKey, { reasoningLevel, thinkingLevel }, historyDir);
112
112
  }
113
113
 
114
- export function resolveSessionsDir(historyDir?: string): string {
115
- const base = deriveOpenClawBaseDir(historyDir);
116
- return join(base, "agents/main/sessions");
117
- }
118
-
119
- export interface DeleteSessionResult {
120
- sessionKey: string;
121
- sessionId?: string;
122
- transcriptDeleted?: boolean;
123
- }
124
-
125
- export function deleteFridaySession(
126
- sessionKey: string,
127
- historyDir?: string,
128
- ): DeleteSessionResult {
129
- const result: DeleteSessionResult = { sessionKey };
130
- const sessionsFile = resolveSessionsFilePath(historyDir);
131
- const data = readSessionsData(sessionsFile);
132
- if (!data) return result;
133
-
134
- const fileKey = toSessionStoreKey(sessionKey);
135
- const entry = data[fileKey];
136
- if (!entry) return result;
137
-
138
- const sessionId = typeof entry["sessionId"] === "string" ? entry["sessionId"] : undefined;
139
- const sessionFilePath = typeof entry["sessionFile"] === "string" ? entry["sessionFile"] : undefined;
140
- result.sessionId = sessionId;
141
-
142
- if (sessionFilePath) {
143
- try { unlinkSync(sessionFilePath); result.transcriptDeleted = true; } catch { /* gone already */ }
144
- }
145
-
146
- if (sessionId) {
147
- const dir = resolveSessionsDir(historyDir);
148
- for (const suffix of [".trajectory.jsonl", ".trajectory-path.json"]) {
149
- try { unlinkSync(join(dir, `${sessionId}${suffix}`)); } catch { /* optional */ }
150
- }
151
- }
152
-
153
- delete data[fileKey];
154
- writeSessionsData(sessionsFile, data);
155
- return result;
156
- }
157
-
158
114
  export interface FridaySessionSettings {
159
115
  reasoningLevel?: string;
160
116
  thinkingLevel?: string;
@@ -1,59 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { deleteFridaySession, toSessionStoreKey } from "../../session/session-manager.js";
3
- import { getActiveRunIds } from "../../agent/active-runs.js";
4
- import { abortRun } from "../../agent/abort-run.js";
5
- import { getRunRoute } from "../../run-metadata.js";
6
- import { sseEmitter } from "../../sse/emitter.js";
7
- import { readJsonBody } from "../middleware/body.js";
8
- import { extractBearerToken } from "../middleware/auth.js";
9
-
10
- async function cancelActiveRunsForSession(sessionKey: string): Promise<string[]> {
11
- const storeKey = toSessionStoreKey(sessionKey);
12
- const cancelled: string[] = [];
13
- for (const runId of getActiveRunIds()) {
14
- const route = getRunRoute(runId);
15
- if (route?.sessionKey === storeKey) {
16
- await abortRun(runId);
17
- sseEmitter.untrackRun(runId);
18
- cancelled.push(runId);
19
- }
20
- }
21
- return cancelled;
22
- }
23
-
24
- export async function handleSessionsDelete(
25
- req: IncomingMessage,
26
- res: ServerResponse,
27
- ): Promise<boolean> {
28
- if (req.method !== "DELETE") {
29
- res.statusCode = 405;
30
- res.setHeader("Content-Type", "application/json");
31
- res.end(JSON.stringify({ error: "Method Not Allowed" }));
32
- return true;
33
- }
34
-
35
- const token = extractBearerToken(req);
36
- if (!token) {
37
- res.statusCode = 401;
38
- res.setHeader("Content-Type", "application/json");
39
- res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
40
- return true;
41
- }
42
-
43
- const body = await readJsonBody(req);
44
- const sessionKey = typeof body?.sessionKey === "string" ? body.sessionKey.trim() : "";
45
- if (!sessionKey) {
46
- res.statusCode = 400;
47
- res.setHeader("Content-Type", "application/json");
48
- res.end(JSON.stringify({ error: "Missing required field: sessionKey" }));
49
- return true;
50
- }
51
-
52
- const cancelledRuns = await cancelActiveRunsForSession(sessionKey);
53
- const result = deleteFridaySession(sessionKey);
54
-
55
- res.statusCode = 200;
56
- res.setHeader("Content-Type", "application/json");
57
- res.end(JSON.stringify({ ok: true, ...result, cancelledRuns }));
58
- return true;
59
- }