@syengup/friday-channel-next 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/src/agent/dispatch-bridge.d.ts +1 -1
- package/dist/src/agent/node-pairing-bridge.d.ts +11 -8
- package/dist/src/agent/node-pairing-bridge.js +6 -2
- package/dist/src/agent/operator-scope.d.ts +19 -0
- package/dist/src/agent/operator-scope.js +54 -0
- package/dist/src/agent/subagent-registry.js +0 -3
- package/dist/src/channel-actions.js +3 -1
- package/dist/src/channel.js +0 -2
- package/dist/src/collect-message-media-paths.js +10 -1
- package/dist/src/friday-session.js +34 -10
- package/dist/src/history/normalize-message.js +22 -8
- package/dist/src/http/handlers/agent-config.js +10 -4
- package/dist/src/http/handlers/cancel.js +4 -2
- package/dist/src/http/handlers/device-approve.js +3 -1
- package/dist/src/http/handlers/files-download.js +6 -8
- package/dist/src/http/handlers/files.js +1 -1
- package/dist/src/http/handlers/health.js +18 -4
- package/dist/src/http/handlers/history-messages.js +1 -1
- package/dist/src/http/handlers/history-sessions.js +5 -3
- package/dist/src/http/handlers/messages.js +34 -11
- package/dist/src/http/handlers/models-list.js +1 -1
- package/dist/src/http/handlers/nodes-approve.js +1 -6
- package/dist/src/http/handlers/plugin-info.js +1 -1
- package/dist/src/http/server.js +4 -2
- package/dist/src/link-preview/og-parse.js +3 -1
- package/dist/src/plugin-install-info.js +4 -1
- package/dist/src/session/session-manager.js +9 -3
- package/dist/src/session-usage-store.js +3 -1
- package/dist/src/skills-discovery.d.ts +5 -4
- package/dist/src/skills-discovery.js +27 -22
- package/dist/src/sse/offline-queue.js +4 -1
- package/dist/src/tool-catalog.js +2 -3
- package/dist/src/upgrade-runtime.d.ts +1 -1
- package/dist/src/version.js +3 -1
- package/index.ts +43 -35
- package/install.js +131 -43
- package/package.json +10 -1
- package/src/agent/abort-run.ts +2 -3
- package/src/agent/dispatch-bridge.ts +2 -1
- package/src/agent/media-bridge.ts +9 -2
- package/src/agent/node-pairing-bridge.ts +29 -15
- package/src/agent/operator-scope.test.ts +66 -0
- package/src/agent/operator-scope.ts +63 -0
- package/src/agent/run-usage-accumulator.ts +4 -2
- package/src/agent/subagent-registry.ts +0 -4
- package/src/agent-run-context-bridge.ts +3 -1
- package/src/channel-actions.test.ts +10 -4
- package/src/channel-actions.ts +3 -1
- package/src/channel.outbound.test.ts +18 -4
- package/src/channel.ts +121 -123
- package/src/collect-message-media-paths.ts +15 -6
- package/src/config.ts +1 -4
- package/src/e2e/agents-list.e2e.test.ts +9 -2
- package/src/e2e/attachments-inbound.e2e.test.ts +5 -1
- package/src/e2e/attachments-outbound.e2e.test.ts +7 -2
- package/src/e2e/auto-approve.integration.test.ts +13 -7
- package/src/e2e/cancel-reconnect-errors.e2e.test.ts +18 -3
- package/src/e2e/connect-and-connected.e2e.test.ts +5 -1
- package/src/e2e/offline-replay.e2e.test.ts +17 -3
- package/src/e2e/send-text.e2e.test.ts +11 -2
- package/src/e2e/slash-commands.e2e.test.ts +5 -1
- package/src/e2e/status-cors-auth.e2e.test.ts +11 -2
- package/src/e2e/subagent-smoke.e2e.test.ts +68 -28
- package/src/e2e/subagent.e2e.test.ts +136 -53
- package/src/e2e/tool-lifecycle.e2e.test.ts +5 -1
- package/src/friday-session.forward-agent.test.ts +44 -12
- package/src/friday-session.ts +44 -20
- package/src/history/normalize-message.test.ts +35 -8
- package/src/history/normalize-message.ts +24 -12
- package/src/history/read-transcript.ts +1 -4
- package/src/http/handlers/agent-config.test.ts +10 -3
- package/src/http/handlers/agent-config.ts +22 -8
- package/src/http/handlers/agents-list.test.ts +1 -5
- package/src/http/handlers/cancel.test.ts +12 -3
- package/src/http/handlers/cancel.ts +4 -2
- package/src/http/handlers/device-approve.test.ts +12 -3
- package/src/http/handlers/device-approve.ts +33 -21
- package/src/http/handlers/files-download.ts +17 -13
- package/src/http/handlers/files.test.ts +8 -2
- package/src/http/handlers/files.ts +21 -7
- package/src/http/handlers/health.test.ts +43 -11
- package/src/http/handlers/health.ts +22 -6
- package/src/http/handlers/history-messages.test.ts +51 -9
- package/src/http/handlers/history-messages.ts +4 -1
- package/src/http/handlers/history-sessions.test.ts +46 -9
- package/src/http/handlers/history-sessions.ts +5 -3
- package/src/http/handlers/history-set-title.test.ts +14 -5
- package/src/http/handlers/link-preview.test.ts +57 -16
- package/src/http/handlers/link-preview.ts +4 -1
- package/src/http/handlers/messages.test.ts +12 -8
- package/src/http/handlers/messages.ts +67 -19
- package/src/http/handlers/models-list.ts +14 -8
- package/src/http/handlers/nodes-approve.test.ts +15 -4
- package/src/http/handlers/nodes-approve.ts +38 -40
- package/src/http/handlers/plugin-info.ts +5 -6
- package/src/http/handlers/plugin-upgrade.ts +4 -1
- package/src/http/handlers/sse.ts +3 -1
- package/src/http/server.ts +9 -6
- package/src/link-preview/og-parse.test.ts +6 -2
- package/src/link-preview/og-parse.ts +10 -3
- package/src/link-preview/preview-service.ts +4 -1
- package/src/link-preview/ssrf-guard.test.ts +72 -15
- package/src/link-preview/ssrf-guard.ts +2 -1
- package/src/media-fetch.test.ts +7 -2
- package/src/media-fetch.ts +1 -2
- package/src/openclaw.d.ts +26 -9
- package/src/plugin-install-info.ts +20 -9
- package/src/run-metadata.ts +2 -1
- package/src/session/session-manager.ts +19 -11
- package/src/session-usage-snapshot.ts +3 -1
- package/src/session-usage-store.ts +3 -1
- package/src/skills-discovery.test.ts +14 -10
- package/src/skills-discovery.ts +43 -27
- package/src/sse/emitter.test.ts +1 -1
- package/src/sse/emitter.ts +9 -3
- package/src/sse/offline-queue.ts +17 -8
- package/src/test-support/app-simulator.ts +17 -3
- package/src/test-support/mock-dispatch.ts +17 -4
- package/src/thinking-levels.ts +3 -1
- package/src/tool-catalog.ts +16 -7
- package/src/upgrade-runtime.ts +4 -2
- package/src/version.ts +5 -1
- package/tsconfig.json +1 -1
|
@@ -67,21 +67,25 @@ export async function handleDeviceApprove(
|
|
|
67
67
|
if (pairedDevice) {
|
|
68
68
|
res.statusCode = 200;
|
|
69
69
|
res.setHeader("Content-Type", "application/json");
|
|
70
|
-
res.end(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
res.end(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
ok: true,
|
|
73
|
+
deviceId: normalizedDeviceId,
|
|
74
|
+
alreadyApproved: true,
|
|
75
|
+
approvedAtMs: (pairedDevice as any).approvedAtMs,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
76
78
|
return true;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
res.statusCode = 404;
|
|
80
82
|
res.setHeader("Content-Type", "application/json");
|
|
81
|
-
res.end(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
res.end(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
error: "No pending device found for this deviceId",
|
|
86
|
+
deviceId: normalizedDeviceId,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
85
89
|
return true;
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -95,10 +99,12 @@ export async function handleDeviceApprove(
|
|
|
95
99
|
log.error(`approveDevicePairing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
96
100
|
res.statusCode = 502;
|
|
97
101
|
res.setHeader("Content-Type", "application/json");
|
|
98
|
-
res.end(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
res.end(
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
error: "Device approval failed",
|
|
105
|
+
detail: err instanceof Error ? err.message : "Unknown error",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
102
108
|
return true;
|
|
103
109
|
}
|
|
104
110
|
|
|
@@ -112,17 +118,23 @@ export async function handleDeviceApprove(
|
|
|
112
118
|
if (approved.status === "forbidden") {
|
|
113
119
|
res.statusCode = 403;
|
|
114
120
|
res.setHeader("Content-Type", "application/json");
|
|
115
|
-
res.end(
|
|
121
|
+
res.end(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
error: `Device approval forbidden: ${(approved as any).reason ?? "unknown"}`,
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
116
126
|
return true;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
res.statusCode = 200;
|
|
120
130
|
res.setHeader("Content-Type", "application/json");
|
|
121
|
-
res.end(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
res.end(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
ok: true,
|
|
134
|
+
deviceId: normalizedDeviceId,
|
|
135
|
+
requestId: approved.requestId,
|
|
136
|
+
approvedAtMs: (approved as any).device?.approvedAtMs,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
127
139
|
return true;
|
|
128
140
|
}
|
|
@@ -63,7 +63,10 @@ function tryDecodeURIComponent(segment: string): string | null {
|
|
|
63
63
|
*/
|
|
64
64
|
function contentDispositionInline(filename: string): string {
|
|
65
65
|
const base =
|
|
66
|
-
path
|
|
66
|
+
path
|
|
67
|
+
.basename(filename)
|
|
68
|
+
.replace(/[\r\n"]/g, "_")
|
|
69
|
+
.replace(/\\/g, "_") || "file";
|
|
67
70
|
const ascii = /^[\x20-\x7E]*$/.test(base) ? base : "file";
|
|
68
71
|
return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base)}`;
|
|
69
72
|
}
|
|
@@ -82,9 +85,7 @@ function sendBuffer(
|
|
|
82
85
|
const disposition = contentDispositionInline(filename);
|
|
83
86
|
const rangeRaw = req.headers.range;
|
|
84
87
|
const range =
|
|
85
|
-
typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim())
|
|
86
|
-
? rangeRaw.trim()
|
|
87
|
-
: undefined;
|
|
88
|
+
typeof rangeRaw === "string" && /^bytes=/i.test(rangeRaw.trim()) ? rangeRaw.trim() : undefined;
|
|
88
89
|
|
|
89
90
|
res.setHeader("Accept-Ranges", "bytes");
|
|
90
91
|
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
@@ -110,7 +111,7 @@ function sendBuffer(
|
|
|
110
111
|
let end = total - 1;
|
|
111
112
|
|
|
112
113
|
if (m[1] === "" && m[2] !== "") {
|
|
113
|
-
const suffixLen = parseInt(m[2]
|
|
114
|
+
const suffixLen = parseInt(m[2], 10);
|
|
114
115
|
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
|
|
115
116
|
res.statusCode = 200;
|
|
116
117
|
res.setHeader("Content-Length", String(total));
|
|
@@ -120,11 +121,11 @@ function sendBuffer(
|
|
|
120
121
|
start = Math.max(0, total - suffixLen);
|
|
121
122
|
end = total - 1;
|
|
122
123
|
} else if (m[1] !== "" && m[2] === "") {
|
|
123
|
-
start = parseInt(m[1]
|
|
124
|
+
start = parseInt(m[1], 10);
|
|
124
125
|
end = total - 1;
|
|
125
126
|
} else if (m[1] !== "" && m[2] !== "") {
|
|
126
|
-
start = parseInt(m[1]
|
|
127
|
-
end = parseInt(m[2]
|
|
127
|
+
start = parseInt(m[1], 10);
|
|
128
|
+
end = parseInt(m[2], 10);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
|
|
@@ -190,7 +191,13 @@ export async function handleFilesDownload(
|
|
|
190
191
|
// 1.2 Plugin-root attachments/ (survives gateway restarts; basename = URL token)
|
|
191
192
|
const fromAttachments = readAttachmentFileFromDisk(fileToken);
|
|
192
193
|
if (fromAttachments) {
|
|
193
|
-
sendBuffer(
|
|
194
|
+
sendBuffer(
|
|
195
|
+
req,
|
|
196
|
+
res,
|
|
197
|
+
fromAttachments.buffer,
|
|
198
|
+
fromAttachments.mimeType,
|
|
199
|
+
fromAttachments.filename,
|
|
200
|
+
);
|
|
194
201
|
return true;
|
|
195
202
|
}
|
|
196
203
|
|
|
@@ -211,10 +218,7 @@ export async function handleFilesDownload(
|
|
|
211
218
|
// fileId may include an extension (e.g. "uuid.png") — strip it to get the base id
|
|
212
219
|
const baseId = fileToken.replace(/\.[^.]+$/, "");
|
|
213
220
|
const mediaDir = path.join(os.homedir(), ".openclaw", "media", "inbound");
|
|
214
|
-
const candidates = [
|
|
215
|
-
path.join(mediaDir, baseId),
|
|
216
|
-
path.join(mediaDir, fileToken),
|
|
217
|
-
];
|
|
221
|
+
const candidates = [path.join(mediaDir, baseId), path.join(mediaDir, fileToken)];
|
|
218
222
|
|
|
219
223
|
for (const filePath of candidates) {
|
|
220
224
|
if (fs.existsSync(filePath)) {
|
|
@@ -28,7 +28,11 @@ afterEach(() => {
|
|
|
28
28
|
|
|
29
29
|
describe("attachment original filename survives a gateway restart", () => {
|
|
30
30
|
it("stores under a uuid token but recovers the original filename from disk", () => {
|
|
31
|
-
const stored = storeFile(
|
|
31
|
+
const stored = storeFile(
|
|
32
|
+
Buffer.from("%PDF-1.4 fake"),
|
|
33
|
+
"Quarterly Report.pdf",
|
|
34
|
+
"application/pdf",
|
|
35
|
+
);
|
|
32
36
|
expect(stored.urlToken).not.toBe("Quarterly Report.pdf");
|
|
33
37
|
|
|
34
38
|
const disk = readAttachmentFileFromDisk(stored.urlToken);
|
|
@@ -57,7 +61,9 @@ describe("attachment original filename survives a gateway restart", () => {
|
|
|
57
61
|
const stored = storeFile(Buffer.from("x"), "doc.docx", "application/octet-stream");
|
|
58
62
|
clearFileIndexForTest();
|
|
59
63
|
|
|
60
|
-
const publicUrl = fridayFilesPublicUrl(
|
|
64
|
+
const publicUrl = fridayFilesPublicUrl(
|
|
65
|
+
`/friday-next/files/${encodeURIComponent(stored.urlToken)}`,
|
|
66
|
+
);
|
|
61
67
|
expect(publicUrl).toBe(`/friday-next/files/${encodeURIComponent(stored.urlToken)}`);
|
|
62
68
|
});
|
|
63
69
|
|
|
@@ -25,7 +25,9 @@ export function setAttachmentsDirForTest(dir: string | null): void {
|
|
|
25
25
|
/** Resolve `<historyDir>/../attachments`, mirroring the offline-queue layout. */
|
|
26
26
|
function resolveAttachmentsDir(): string {
|
|
27
27
|
try {
|
|
28
|
-
const cfg = resolveFridayNextConfig(
|
|
28
|
+
const cfg = resolveFridayNextConfig(
|
|
29
|
+
getHostOpenClawConfigSnapshot(getFridayNextRuntime().config),
|
|
30
|
+
);
|
|
29
31
|
return path.join(path.dirname(cfg.historyDir), "attachments");
|
|
30
32
|
} catch {
|
|
31
33
|
return path.join(os.homedir(), ".openclaw", "friday-next", "attachments");
|
|
@@ -112,7 +114,11 @@ function writeAttachmentMetaSidecar(urlToken: string, filename: string, mimeType
|
|
|
112
114
|
* is unrecoverable. We stash it here (keyed by the inbound basename, reusing the sidecar
|
|
113
115
|
* scheme but inside our own attachments dir) at send time, while we still know it.
|
|
114
116
|
*/
|
|
115
|
-
export function rememberInboundMediaName(
|
|
117
|
+
export function rememberInboundMediaName(
|
|
118
|
+
inboundPath: string,
|
|
119
|
+
filename: string,
|
|
120
|
+
mimeType: string,
|
|
121
|
+
): void {
|
|
116
122
|
const key = path.basename(inboundPath);
|
|
117
123
|
const name = filename.trim();
|
|
118
124
|
if (!key || !name) return;
|
|
@@ -191,7 +197,10 @@ export function normalizeAgentMediaPath(raw: string): string {
|
|
|
191
197
|
return s;
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
function copyLocalFileToAttachments(
|
|
200
|
+
function copyLocalFileToAttachments(
|
|
201
|
+
sourcePath: string,
|
|
202
|
+
originalFilename?: string,
|
|
203
|
+
): StoredFile | null {
|
|
195
204
|
const resolvedPath = normalizeAgentMediaPath(sourcePath);
|
|
196
205
|
const diskBasename = path.basename(resolvedPath);
|
|
197
206
|
// Prefer the caller-supplied original name (recovered from an inbound sidecar); fall
|
|
@@ -211,7 +220,9 @@ function copyLocalFileToAttachments(sourcePath: string, originalFilename?: strin
|
|
|
211
220
|
// Fallback to read+write so attachment persistence still works.
|
|
212
221
|
const raw = fs.readFileSync(resolvedPath);
|
|
213
222
|
fs.writeFileSync(storedPath, raw);
|
|
214
|
-
logger.warn(
|
|
223
|
+
logger.warn(
|
|
224
|
+
`copyLocalFileToAttachments copy fallback used for "${resolvedPath}": ${String(copyErr)}`,
|
|
225
|
+
);
|
|
215
226
|
}
|
|
216
227
|
const stat = fs.statSync(storedPath);
|
|
217
228
|
const mimeType = guessMimeType(filename);
|
|
@@ -246,7 +257,7 @@ export function storeFile(buffer: Buffer, filename: string, mimeType: string): S
|
|
|
246
257
|
try {
|
|
247
258
|
fs.writeFileSync(storedPath, buffer);
|
|
248
259
|
} catch (err) {
|
|
249
|
-
throw new Error(`Failed to store file: ${String(err)}
|
|
260
|
+
throw new Error(`Failed to store file: ${String(err)}`, { cause: err });
|
|
250
261
|
}
|
|
251
262
|
|
|
252
263
|
const file: StoredFile = {
|
|
@@ -314,7 +325,11 @@ export function getExternalFileSourceByUrlToken(token: string): string | undefin
|
|
|
314
325
|
/**
|
|
315
326
|
* Read a file as a Buffer with its MIME type (by id or urlToken).
|
|
316
327
|
*/
|
|
317
|
-
export function readFile(id: string): {
|
|
328
|
+
export function readFile(id: string): {
|
|
329
|
+
buffer: Buffer | null;
|
|
330
|
+
mimeType: string;
|
|
331
|
+
filename?: string;
|
|
332
|
+
} {
|
|
318
333
|
const file = resolveStoredFile(id);
|
|
319
334
|
if (!file) return { buffer: null, mimeType: "application/octet-stream" };
|
|
320
335
|
try {
|
|
@@ -400,7 +415,6 @@ export function resolveMediaAttachment(localPath: string): ResolvedAttachment |
|
|
|
400
415
|
};
|
|
401
416
|
}
|
|
402
417
|
|
|
403
|
-
|
|
404
418
|
/**
|
|
405
419
|
* Guess MIME type from filename extension.
|
|
406
420
|
*/
|
|
@@ -23,7 +23,11 @@ class MockRes extends EventEmitter {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function mockReq(
|
|
26
|
+
function mockReq(
|
|
27
|
+
method: string,
|
|
28
|
+
url: string,
|
|
29
|
+
headers: Record<string, string> = {},
|
|
30
|
+
): IncomingMessage {
|
|
27
31
|
return { method, url, headers } as IncomingMessage;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -82,7 +86,17 @@ describe("handleHealth", () => {
|
|
|
82
86
|
{
|
|
83
87
|
nodeId: NODE_ID,
|
|
84
88
|
caps: ["location", "canvas"],
|
|
85
|
-
commands: [
|
|
89
|
+
commands: [
|
|
90
|
+
"location.get",
|
|
91
|
+
"canvas.present",
|
|
92
|
+
"canvas.hide",
|
|
93
|
+
"canvas.navigate",
|
|
94
|
+
"canvas.eval",
|
|
95
|
+
"canvas.snapshot",
|
|
96
|
+
"canvas.a2ui.push",
|
|
97
|
+
"canvas.a2ui.pushJSONL",
|
|
98
|
+
"canvas.a2ui.reset",
|
|
99
|
+
],
|
|
86
100
|
},
|
|
87
101
|
],
|
|
88
102
|
});
|
|
@@ -104,9 +118,7 @@ describe("handleHealth", () => {
|
|
|
104
118
|
it("returns degraded when node is missing required caps", async () => {
|
|
105
119
|
mockListNodePairing.mockResolvedValueOnce({
|
|
106
120
|
pending: [],
|
|
107
|
-
paired: [
|
|
108
|
-
{ nodeId: NODE_ID, caps: ["canvas"], commands: ["canvas.present"] },
|
|
109
|
-
],
|
|
121
|
+
paired: [{ nodeId: NODE_ID, caps: ["canvas"], commands: ["canvas.present"] }],
|
|
110
122
|
});
|
|
111
123
|
|
|
112
124
|
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}`, {
|
|
@@ -127,7 +139,10 @@ describe("handleHealth", () => {
|
|
|
127
139
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
128
140
|
paired: [],
|
|
129
141
|
});
|
|
130
|
-
mockApproveNodePairing.mockResolvedValueOnce({
|
|
142
|
+
mockApproveNodePairing.mockResolvedValueOnce({
|
|
143
|
+
requestId: REQUEST_ID,
|
|
144
|
+
node: { nodeId: NODE_ID },
|
|
145
|
+
});
|
|
131
146
|
|
|
132
147
|
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
133
148
|
authorization: "Bearer test-token",
|
|
@@ -186,7 +201,10 @@ describe("handleHealth", () => {
|
|
|
186
201
|
pending: [{ requestId: REQUEST_ID, nodeId: NODE_ID }],
|
|
187
202
|
paired: [],
|
|
188
203
|
});
|
|
189
|
-
mockApproveNodePairing.mockResolvedValueOnce({
|
|
204
|
+
mockApproveNodePairing.mockResolvedValueOnce({
|
|
205
|
+
status: "forbidden",
|
|
206
|
+
missingScope: "operator.admin",
|
|
207
|
+
});
|
|
190
208
|
|
|
191
209
|
const req = mockReq("GET", `/friday-next/health?nodeDeviceId=${NODE_ID}&selfHeal=true`, {
|
|
192
210
|
authorization: "Bearer test-token",
|
|
@@ -236,14 +254,28 @@ describe("handleHealth", () => {
|
|
|
236
254
|
{
|
|
237
255
|
nodeId: NODE_ID,
|
|
238
256
|
caps: ["location", "canvas"],
|
|
239
|
-
commands: [
|
|
257
|
+
commands: [
|
|
258
|
+
"location.get",
|
|
259
|
+
"canvas.present",
|
|
260
|
+
"canvas.hide",
|
|
261
|
+
"canvas.navigate",
|
|
262
|
+
"canvas.eval",
|
|
263
|
+
"canvas.snapshot",
|
|
264
|
+
"canvas.a2ui.push",
|
|
265
|
+
"canvas.a2ui.pushJSONL",
|
|
266
|
+
"canvas.a2ui.reset",
|
|
267
|
+
],
|
|
240
268
|
},
|
|
241
269
|
],
|
|
242
270
|
});
|
|
243
271
|
|
|
244
|
-
const req = mockReq(
|
|
245
|
-
|
|
246
|
-
|
|
272
|
+
const req = mockReq(
|
|
273
|
+
"GET",
|
|
274
|
+
`/friday-next/health?deviceId=${DEVICE_ID}&nodeDeviceId=${NODE_ID}`,
|
|
275
|
+
{
|
|
276
|
+
authorization: "Bearer test-token",
|
|
277
|
+
},
|
|
278
|
+
);
|
|
247
279
|
const res = new MockRes() as unknown as ServerResponse;
|
|
248
280
|
await handleHealth(req, res);
|
|
249
281
|
const body = JSON.parse((res as unknown as MockRes).body);
|
|
@@ -75,7 +75,10 @@ export async function handleHealth(req: IncomingMessage, res: ServerResponse): P
|
|
|
75
75
|
result.nodePairing = await checkNodePairing(nodeDeviceId, selfHeal, result, log);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
result.ok =
|
|
78
|
+
result.ok =
|
|
79
|
+
!result.nodePairing ||
|
|
80
|
+
result.nodePairing.status === "ok" ||
|
|
81
|
+
result.nodePairing.status === "pending";
|
|
79
82
|
|
|
80
83
|
res.statusCode = 200;
|
|
81
84
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -113,7 +116,8 @@ async function checkNodePairing(
|
|
|
113
116
|
};
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
const pairedNodes: Array<{ nodeId: string; caps?: string[]; commands?: string[] }> =
|
|
119
|
+
const pairedNodes: Array<{ nodeId: string; caps?: string[]; commands?: string[] }> =
|
|
120
|
+
listData?.paired ?? [];
|
|
117
121
|
const pairedMatch = pairedNodes.find(
|
|
118
122
|
(entry) => entry.nodeId?.trim().toUpperCase() === normalizedNodeId,
|
|
119
123
|
);
|
|
@@ -156,16 +160,24 @@ async function checkNodePairing(
|
|
|
156
160
|
|
|
157
161
|
if (pendingMatch && selfHeal) {
|
|
158
162
|
try {
|
|
159
|
-
const callerScopes = [
|
|
163
|
+
const callerScopes = [
|
|
164
|
+
"operator.admin",
|
|
165
|
+
"operator.pairing",
|
|
166
|
+
"operator.read",
|
|
167
|
+
"operator.write",
|
|
168
|
+
];
|
|
160
169
|
const approved = await approveNodePairing(pendingMatch.requestId, { callerScopes });
|
|
161
|
-
const succeeded =
|
|
170
|
+
const succeeded =
|
|
171
|
+
approved != null &&
|
|
172
|
+
!("status" in approved && approved.status === "forbidden") &&
|
|
173
|
+
"requestId" in approved;
|
|
162
174
|
(result.repairActions ??= []).push({
|
|
163
175
|
component: "nodePairing",
|
|
164
176
|
action: "approveNodePairing",
|
|
165
177
|
result: succeeded ? "ok" : "failed",
|
|
166
178
|
detail: succeeded
|
|
167
179
|
? `Auto-approved node ${normalizedNodeId}`
|
|
168
|
-
: `approveNodePairing returned status=${
|
|
180
|
+
: `approveNodePairing returned status=${approved?.status ?? "null"}`,
|
|
169
181
|
});
|
|
170
182
|
if (succeeded) {
|
|
171
183
|
log.info(`Auto-approved node ${normalizedNodeId}`);
|
|
@@ -191,5 +203,9 @@ async function checkNodePairing(
|
|
|
191
203
|
return { status: "pending", detail: "Node is pending approval", nodePaired: false };
|
|
192
204
|
}
|
|
193
205
|
|
|
194
|
-
return {
|
|
206
|
+
return {
|
|
207
|
+
status: "not_found",
|
|
208
|
+
detail: `Node ${normalizedNodeId} not registered`,
|
|
209
|
+
nodePaired: false,
|
|
210
|
+
};
|
|
195
211
|
}
|
|
@@ -35,7 +35,12 @@ const CFG = {
|
|
|
35
35
|
let tmpDir = "";
|
|
36
36
|
|
|
37
37
|
/** Auth config + optional subagent fallback. */
|
|
38
|
-
function setRuntime(
|
|
38
|
+
function setRuntime(
|
|
39
|
+
getSessionMessages?: (params: {
|
|
40
|
+
sessionKey: string;
|
|
41
|
+
limit?: number;
|
|
42
|
+
}) => Promise<{ messages?: unknown[] }>,
|
|
43
|
+
): void {
|
|
39
44
|
setFridayNextRuntime({
|
|
40
45
|
config: { loadConfig: () => CFG },
|
|
41
46
|
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
@@ -100,13 +105,30 @@ describe("handleHistoryMessages", () => {
|
|
|
100
105
|
it("reads the transcript file from disk including user + assistant messages", async () => {
|
|
101
106
|
const file = writeTranscript("sess.jsonl", [
|
|
102
107
|
{ type: "session", version: 1, sessionId: "s" },
|
|
103
|
-
{
|
|
104
|
-
|
|
108
|
+
{
|
|
109
|
+
type: "message",
|
|
110
|
+
id: "u1",
|
|
111
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
112
|
+
message: { role: "user", content: "hi there" },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: "message",
|
|
116
|
+
id: "a1",
|
|
117
|
+
timestamp: "2026-01-01T00:00:01.000Z",
|
|
118
|
+
message: {
|
|
119
|
+
role: "assistant",
|
|
120
|
+
content: [{ type: "text", text: "hello" }],
|
|
121
|
+
model: "openai/gpt-4",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
105
124
|
]);
|
|
106
125
|
setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
|
|
107
126
|
|
|
108
127
|
const res = new MockRes();
|
|
109
|
-
await handleHistoryMessages(
|
|
128
|
+
await handleHistoryMessages(
|
|
129
|
+
makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
|
|
130
|
+
res as any,
|
|
131
|
+
);
|
|
110
132
|
expect(res.statusCode).toBe(200);
|
|
111
133
|
const body = JSON.parse(res.body);
|
|
112
134
|
expect(body.messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
|
|
@@ -117,7 +139,15 @@ describe("handleHistoryMessages", () => {
|
|
|
117
139
|
it("returns the cumulative sessionUsage snapshot from the store", async () => {
|
|
118
140
|
const file = writeTranscript("usage.jsonl", [
|
|
119
141
|
{ type: "message", id: "u1", message: { role: "user", content: "hi" } },
|
|
120
|
-
{
|
|
142
|
+
{
|
|
143
|
+
type: "message",
|
|
144
|
+
id: "a1",
|
|
145
|
+
message: {
|
|
146
|
+
role: "assistant",
|
|
147
|
+
content: [{ type: "text", text: "yo" }],
|
|
148
|
+
model: "openai/gpt-4",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
121
151
|
]);
|
|
122
152
|
setForward({
|
|
123
153
|
"agent:main:main": {
|
|
@@ -132,7 +162,10 @@ describe("handleHistoryMessages", () => {
|
|
|
132
162
|
});
|
|
133
163
|
|
|
134
164
|
const res = new MockRes();
|
|
135
|
-
await handleHistoryMessages(
|
|
165
|
+
await handleHistoryMessages(
|
|
166
|
+
makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
|
|
167
|
+
res as any,
|
|
168
|
+
);
|
|
136
169
|
expect(res.statusCode).toBe(200);
|
|
137
170
|
const body = JSON.parse(res.body);
|
|
138
171
|
expect(body.sessionUsage).toBeDefined();
|
|
@@ -148,7 +181,10 @@ describe("handleHistoryMessages", () => {
|
|
|
148
181
|
setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
|
|
149
182
|
|
|
150
183
|
const res = new MockRes();
|
|
151
|
-
await handleHistoryMessages(
|
|
184
|
+
await handleHistoryMessages(
|
|
185
|
+
makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
|
|
186
|
+
res as any,
|
|
187
|
+
);
|
|
152
188
|
const body = JSON.parse(res.body);
|
|
153
189
|
expect(body.sessionUsage).toBeUndefined();
|
|
154
190
|
});
|
|
@@ -162,7 +198,10 @@ describe("handleHistoryMessages", () => {
|
|
|
162
198
|
|
|
163
199
|
const res = new MockRes();
|
|
164
200
|
await handleHistoryMessages(
|
|
165
|
-
makeReq(
|
|
201
|
+
makeReq(
|
|
202
|
+
"/friday-next/history/messages?sessionKey=agent:main:friday:direct:ABCD-1234:9",
|
|
203
|
+
AUTH,
|
|
204
|
+
),
|
|
166
205
|
res as any,
|
|
167
206
|
);
|
|
168
207
|
const body = JSON.parse(res.body);
|
|
@@ -209,7 +248,10 @@ describe("handleHistoryMessages", () => {
|
|
|
209
248
|
messages: [{ role: "assistant", content: "fallback", __openclaw: { id: "a1", seq: 1 } }],
|
|
210
249
|
}));
|
|
211
250
|
const res = new MockRes();
|
|
212
|
-
await handleHistoryMessages(
|
|
251
|
+
await handleHistoryMessages(
|
|
252
|
+
makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
|
|
253
|
+
res as any,
|
|
254
|
+
);
|
|
213
255
|
const body = JSON.parse(res.body);
|
|
214
256
|
expect(body.messages.map((m: any) => m.id)).toEqual(["a1"]);
|
|
215
257
|
});
|
|
@@ -15,7 +15,10 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
16
16
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
17
17
|
import { normalizeHistoryMessages } from "../../history/normalize-message.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
readSessionTranscriptRawMessages,
|
|
20
|
+
resolveSessionId,
|
|
21
|
+
} from "../../history/read-transcript.js";
|
|
19
22
|
import { resolveMediaAttachment } from "./files.js";
|
|
20
23
|
import { readSessionUsageSnapshotFromStore } from "../../session-usage-store.js";
|
|
21
24
|
|
|
@@ -33,7 +33,11 @@ let tmpDir = "";
|
|
|
33
33
|
/** Write a non-empty transcript file and return its absolute path. */
|
|
34
34
|
function transcript(name: string): string {
|
|
35
35
|
const file = path.join(tmpDir, name);
|
|
36
|
-
fs.writeFileSync(
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
file,
|
|
38
|
+
`${JSON.stringify({ type: "message", id: "m", message: { role: "user", content: "hi" } })}\n`,
|
|
39
|
+
"utf-8",
|
|
40
|
+
);
|
|
37
41
|
return file;
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -86,7 +90,11 @@ describe("handleHistorySessions", () => {
|
|
|
86
90
|
{ agents: { list: [{ id: "main" }] } },
|
|
87
91
|
{
|
|
88
92
|
main: {
|
|
89
|
-
"agent:main:main": {
|
|
93
|
+
"agent:main:main": {
|
|
94
|
+
sessionId: "s-main",
|
|
95
|
+
updatedAt: 100,
|
|
96
|
+
sessionFile: transcript("main.jsonl"),
|
|
97
|
+
},
|
|
90
98
|
"agent:main:friday:direct:dev:1": {
|
|
91
99
|
sessionId: "s-fd",
|
|
92
100
|
updatedAt: 300,
|
|
@@ -112,8 +120,16 @@ describe("handleHistorySessions", () => {
|
|
|
112
120
|
{ agents: { list: [{ id: "main" }] } },
|
|
113
121
|
{
|
|
114
122
|
main: {
|
|
115
|
-
"agent:main:live": {
|
|
116
|
-
|
|
123
|
+
"agent:main:live": {
|
|
124
|
+
sessionId: "a",
|
|
125
|
+
updatedAt: 1,
|
|
126
|
+
sessionFile: transcript("live.jsonl"),
|
|
127
|
+
},
|
|
128
|
+
"agent:main:archived": {
|
|
129
|
+
sessionId: "b",
|
|
130
|
+
updatedAt: 2,
|
|
131
|
+
sessionFile: path.join(tmpDir, "gone.jsonl"),
|
|
132
|
+
},
|
|
117
133
|
},
|
|
118
134
|
},
|
|
119
135
|
);
|
|
@@ -129,11 +145,32 @@ describe("handleHistorySessions", () => {
|
|
|
129
145
|
{
|
|
130
146
|
main: {
|
|
131
147
|
"agent:main:main": { sessionId: "ok", updatedAt: 5, sessionFile: transcript("ok.jsonl") },
|
|
132
|
-
"agent:main:main:heartbeat": {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
148
|
+
"agent:main:main:heartbeat": {
|
|
149
|
+
sessionId: "hb",
|
|
150
|
+
updatedAt: 4,
|
|
151
|
+
sessionFile: transcript("hb.jsonl"),
|
|
152
|
+
},
|
|
153
|
+
"agent:main:cron:abc": {
|
|
154
|
+
sessionId: "c",
|
|
155
|
+
updatedAt: 3,
|
|
156
|
+
sessionFile: transcript("c.jsonl"),
|
|
157
|
+
},
|
|
158
|
+
"agent:main:subagent:xyz": {
|
|
159
|
+
sessionId: "sa",
|
|
160
|
+
updatedAt: 2,
|
|
161
|
+
sessionFile: transcript("sa.jsonl"),
|
|
162
|
+
},
|
|
163
|
+
"agent:main:dreaming-narrative-rem-1": {
|
|
164
|
+
sessionId: "d",
|
|
165
|
+
updatedAt: 1,
|
|
166
|
+
sessionFile: transcript("d.jsonl"),
|
|
167
|
+
},
|
|
168
|
+
"agent:main:child": {
|
|
169
|
+
sessionId: "ch",
|
|
170
|
+
updatedAt: 6,
|
|
171
|
+
spawnedBy: "agent:main:main",
|
|
172
|
+
sessionFile: transcript("ch.jsonl"),
|
|
173
|
+
},
|
|
137
174
|
global: { sessionId: "g", updatedAt: 7, sessionFile: transcript("g.jsonl") },
|
|
138
175
|
},
|
|
139
176
|
},
|
|
@@ -139,12 +139,14 @@ function readAgentSessions(agentId: string): FridayHistorySessionSummary[] {
|
|
|
139
139
|
sessionKey: canonicalKey,
|
|
140
140
|
agentId,
|
|
141
141
|
...(readString(entry.sessionId) ? { sessionId: readString(entry.sessionId) } : {}),
|
|
142
|
-
...(readNumber(entry.updatedAt) !== undefined
|
|
143
|
-
|
|
142
|
+
...(readNumber(entry.updatedAt) !== undefined
|
|
143
|
+
? { updatedAt: readNumber(entry.updatedAt) }
|
|
144
|
+
: {}),
|
|
145
|
+
...((readString(entry.model) ?? readString(entry.modelOverride))
|
|
144
146
|
? { model: readString(entry.model) ?? readString(entry.modelOverride) }
|
|
145
147
|
: {}),
|
|
146
148
|
// Server-side session display name (matches OpenClaw's resolution order).
|
|
147
|
-
...(readString(entry.displayName) ?? readString(entry.label)
|
|
149
|
+
...((readString(entry.displayName) ?? readString(entry.label))
|
|
148
150
|
? { title: readString(entry.displayName) ?? readString(entry.label) }
|
|
149
151
|
: {}),
|
|
150
152
|
});
|