@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.
- package/dist/src/http/handlers/device-approve.js +14 -0
- package/dist/src/http/middleware/cors.js +1 -1
- package/dist/src/http/server.js +0 -4
- package/dist/src/session/session-manager.d.ts +0 -7
- package/dist/src/session/session-manager.js +1 -38
- package/install.js +2 -2
- package/package.json +1 -1
- package/src/e2e/auto-approve.integration.test.ts +415 -0
- package/src/http/handlers/device-approve.ts +17 -0
- package/src/http/middleware/cors.ts +1 -1
- package/src/http/server.ts +0 -5
- package/src/session/session-manager.ts +1 -45
- package/src/http/handlers/sessions-delete.ts +0 -59
|
@@ -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,
|
|
10
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
11
11
|
}
|
package/dist/src/http/server.js
CHANGED
|
@@ -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
|
|
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
|
@@ -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,
|
|
11
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
12
12
|
}
|
package/src/http/server.ts
CHANGED
|
@@ -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
|
|
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
|
-
}
|