@manny-est/node-red-flowpilot 0.5.2 → 0.6.0
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/CHANGELOG.md +85 -26
- package/README.md +10 -1
- package/USER-GUIDE.md +5 -1
- package/flowpilot-core.css +53 -10
- package/flowpilot-node-entry.js +15 -0
- package/flowpilot.js +1207 -187
- package/lib/agent-contract.js +50 -0
- package/lib/build-core-script.js +1 -0
- package/lib/chat-data.js +106 -0
- package/lib/core/apply-review.js +34 -11
- package/lib/core/graph-truth.js +63 -0
- package/lib/core/history.js +157 -6
- package/lib/core/init.js +223 -21
- package/lib/core/main.js +780 -38
- package/lib/core/modes.js +1098 -112
- package/lib/core/selection-context.js +44 -24
- package/lib/default-system-prompt.js +4 -3
- package/lib/document-system-prompt.js +7 -4
- package/lib/envelope.js +13 -7
- package/lib/generation-system-prompt.js +5 -4
- package/lib/modify-system-prompt.js +64 -12
- package/lib/persona-prompt.js +81 -54
- package/lib/prompt-fragments.js +17 -2
- package/lib/provider-anthropic.js +23 -10
- package/lib/provider-openai-compatible.js +51 -11
- package/lib/provider-shape-check.js +34 -0
- package/lib/storage.js +134 -21
- package/package.json +3 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const https = require("https");
|
|
2
2
|
const http = require("http");
|
|
3
|
+
const { isModelsListShaped } = require("./provider-shape-check");
|
|
3
4
|
|
|
4
5
|
const ANTHROPIC_API_BASE = "https://api.anthropic.com";
|
|
5
6
|
const ANTHROPIC_VERSION = "2023-06-01";
|
|
@@ -27,11 +28,13 @@ function postJson(urlString, headers, body, timeoutMs) {
|
|
|
27
28
|
res.on("end", () => {
|
|
28
29
|
let parsed = null;
|
|
29
30
|
try { parsed = data ? JSON.parse(data) : null; } catch (err) {
|
|
30
|
-
|
|
31
|
+
// Never echo the raw upstream body — see the matching comment in
|
|
32
|
+
// provider-openai-compatible.js (ADR-007, the SSRF mitigation).
|
|
33
|
+
reject(new Error("Provider returned a non-JSON response (status " + res.statusCode + ")."));
|
|
31
34
|
return;
|
|
32
35
|
}
|
|
33
36
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
34
|
-
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) :
|
|
37
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
|
|
35
38
|
reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
|
|
36
39
|
return;
|
|
37
40
|
}
|
|
@@ -66,11 +69,11 @@ function getJson(urlString, headers, timeoutMs) {
|
|
|
66
69
|
res.on("end", () => {
|
|
67
70
|
let parsed = null;
|
|
68
71
|
try { parsed = data ? JSON.parse(data) : null; } catch (err) {
|
|
69
|
-
reject(new Error("Provider returned non-JSON response (" + res.statusCode + ")
|
|
72
|
+
reject(new Error("Provider returned a non-JSON response (status " + res.statusCode + ")."));
|
|
70
73
|
return;
|
|
71
74
|
}
|
|
72
75
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
73
|
-
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) :
|
|
76
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : ("status " + res.statusCode);
|
|
74
77
|
reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
|
|
75
78
|
return;
|
|
76
79
|
}
|
|
@@ -106,9 +109,8 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
106
109
|
}, (res) => {
|
|
107
110
|
res.setEncoding("utf8");
|
|
108
111
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
109
|
-
|
|
110
|
-
res.on("
|
|
111
|
-
res.on("end", () => { reject(new Error("Provider request failed (" + res.statusCode + "): " + errData.slice(0, 500))); });
|
|
112
|
+
res.on("data", () => {});
|
|
113
|
+
res.on("end", () => { reject(new Error("Provider request failed (status " + res.statusCode + ").")); });
|
|
112
114
|
return;
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -271,7 +273,9 @@ async function chat(settings, messages, options) {
|
|
|
271
273
|
if (system) { body.system = system; }
|
|
272
274
|
if (options && Array.isArray(options.tools) && options.tools.length) {
|
|
273
275
|
body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
|
|
274
|
-
body.tool_choice = {
|
|
276
|
+
body.tool_choice = {
|
|
277
|
+
type: options.toolChoice === "required" ? "any" : "auto"
|
|
278
|
+
};
|
|
275
279
|
}
|
|
276
280
|
|
|
277
281
|
const startedAt = Date.now();
|
|
@@ -324,12 +328,21 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
|
|
|
324
328
|
// ---- listModels ----
|
|
325
329
|
// Tries GET /v1/models; falls back to a hardcoded list if that endpoint
|
|
326
330
|
// is unavailable (non-standard proxy) or returns an error.
|
|
331
|
+
//
|
|
332
|
+
// Allowed to run against an UNCONFIRMED provider (ADR-007), same as the
|
|
333
|
+
// OpenAI-compatible provider's listModels — see its comment for the full
|
|
334
|
+
// rationale. isModelsListShaped gates the success path here too: a
|
|
335
|
+
// non-provider target's response never gets its ids reflected back, it
|
|
336
|
+
// just falls through to the safe hardcoded fallback below like any other
|
|
337
|
+
// failure.
|
|
327
338
|
async function listModels(settings) {
|
|
328
339
|
const baseUrl = resolveBaseUrl(settings);
|
|
329
340
|
try {
|
|
330
341
|
const response = await getJson(baseUrl + "/v1/models", anthropicHeaders(settings), settings.requestTimeoutMs || 30000);
|
|
331
|
-
|
|
332
|
-
|
|
342
|
+
if (!isModelsListShaped(response)) {
|
|
343
|
+
throw new Error("Not a valid provider endpoint (no FlowPilot-compatible response).");
|
|
344
|
+
}
|
|
345
|
+
const models = response.data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
|
|
333
346
|
if (models.length) { return { models: models }; }
|
|
334
347
|
throw new Error("Empty model list from provider");
|
|
335
348
|
} catch (err) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const http = require("http");
|
|
2
2
|
const https = require("https");
|
|
3
|
+
const { isModelsListShaped } = require("./provider-shape-check");
|
|
3
4
|
|
|
4
5
|
function postJson(urlString, headers, body, timeoutMs) {
|
|
5
6
|
return new Promise((resolve, reject) => {
|
|
@@ -33,12 +34,18 @@ function postJson(urlString, headers, body, timeoutMs) {
|
|
|
33
34
|
try {
|
|
34
35
|
parsed = data ? JSON.parse(data) : null;
|
|
35
36
|
} catch (err) {
|
|
36
|
-
|
|
37
|
+
// Never echo the raw upstream body — a security boundary, not just
|
|
38
|
+
// tidiness. This request may be the provider-confirmation check
|
|
39
|
+
// hitting a baseUrl for the first time (SSRF mitigation, ADR-007);
|
|
40
|
+
// an attacker-controlled target (internal service, cloud metadata)
|
|
41
|
+
// must not be able to get its response body reflected back to the
|
|
42
|
+
// caller through a FlowPilot error message.
|
|
43
|
+
reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
|
|
37
44
|
return;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
41
|
-
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) :
|
|
48
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
|
|
42
49
|
reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
|
|
43
50
|
return;
|
|
44
51
|
}
|
|
@@ -86,12 +93,13 @@ function getJson(urlString, headers, timeoutMs) {
|
|
|
86
93
|
try {
|
|
87
94
|
parsed = data ? JSON.parse(data) : null;
|
|
88
95
|
} catch (err) {
|
|
89
|
-
|
|
96
|
+
// See postJson above — never echo the raw upstream body.
|
|
97
|
+
reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
|
|
90
98
|
return;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
94
|
-
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) :
|
|
102
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
|
|
95
103
|
reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
|
|
96
104
|
return;
|
|
97
105
|
}
|
|
@@ -118,6 +126,15 @@ function getJson(urlString, headers, timeoutMs) {
|
|
|
118
126
|
// the user picks one. Never throws: a provider without /v1/models (or any
|
|
119
127
|
// other failure) just means an empty list with an explanatory error, which
|
|
120
128
|
// the UI shows as a hint while leaving the model field free-text.
|
|
129
|
+
//
|
|
130
|
+
// Allowed to run against an UNCONFIRMED provider (ADR-007) — same blind
|
|
131
|
+
// treatment as /flowpilot/test and /flowpilot/probe: getJson's error path
|
|
132
|
+
// is already generic (never reflects the upstream body), and a "successful"
|
|
133
|
+
// response is only trusted if it's genuinely models-list-shaped
|
|
134
|
+
// (isModelsListShaped) — a non-provider target returning some unrelated
|
|
135
|
+
// JSON blob with data[].id-shaped entries doesn't get those ids reflected
|
|
136
|
+
// back to the client. This route never writes confirmedBaseUrl/confirmedAt;
|
|
137
|
+
// only /flowpilot/test performs the deliberate confirming action.
|
|
121
138
|
// ---------------------------------------------------------------------
|
|
122
139
|
async function listModels(settings) {
|
|
123
140
|
const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
|
|
@@ -130,8 +147,10 @@ async function listModels(settings) {
|
|
|
130
147
|
|
|
131
148
|
try {
|
|
132
149
|
const response = await getJson(`${baseUrl}/v1/models`, headers, settings.requestTimeoutMs || 30000);
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
if (!isModelsListShaped(response)) {
|
|
151
|
+
return { models: [], error: "Not a valid provider endpoint (no FlowPilot-compatible response)." };
|
|
152
|
+
}
|
|
153
|
+
const models = response.data
|
|
135
154
|
.map(function (m) { return m && m.id; })
|
|
136
155
|
.filter(function (id) { return typeof id === "string" && id; });
|
|
137
156
|
return { models: models };
|
|
@@ -172,6 +191,9 @@ async function chat(settings, messages, options) {
|
|
|
172
191
|
if (options && options.responseFormat) {
|
|
173
192
|
body.response_format = options.responseFormat;
|
|
174
193
|
}
|
|
194
|
+
if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
|
|
195
|
+
body.max_tokens = options.maxTokens;
|
|
196
|
+
}
|
|
175
197
|
|
|
176
198
|
const startedAt = Date.now();
|
|
177
199
|
const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
|
|
@@ -187,6 +209,8 @@ async function chat(settings, messages, options) {
|
|
|
187
209
|
raw: response,
|
|
188
210
|
content: content || (toolCalls ? "" : "[No assistant message returned by provider]"),
|
|
189
211
|
toolCalls: toolCalls,
|
|
212
|
+
finishReason: (response && response.choices && response.choices[0] &&
|
|
213
|
+
response.choices[0].finish_reason) || null,
|
|
190
214
|
timing: { totalMs },
|
|
191
215
|
usage: (response && response.usage) || null
|
|
192
216
|
};
|
|
@@ -282,20 +306,22 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
282
306
|
const startedAt = Date.now();
|
|
283
307
|
let firstTokenAt = null;
|
|
284
308
|
let usage = null;
|
|
309
|
+
let finishReason = null;
|
|
285
310
|
|
|
286
311
|
const req = transport.request(options, (res) => {
|
|
287
312
|
res.setEncoding("utf8");
|
|
288
313
|
|
|
289
314
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
290
|
-
|
|
291
|
-
res.on("data",
|
|
315
|
+
// Drain the body but never reflect it — see postJson's comment above.
|
|
316
|
+
res.on("data", () => {});
|
|
292
317
|
res.on("end", () => {
|
|
293
|
-
reject(new Error(`Provider request failed (${res.statusCode})
|
|
318
|
+
reject(new Error(`Provider request failed (status ${res.statusCode}).`));
|
|
294
319
|
});
|
|
295
320
|
return;
|
|
296
321
|
}
|
|
297
322
|
|
|
298
323
|
let sseBuf = "";
|
|
324
|
+
let sawValidSseData = false;
|
|
299
325
|
let full = "";
|
|
300
326
|
// When onReasoningDelta is provided, intercept <think>...</think> from
|
|
301
327
|
// delta.content in addition to the dedicated delta.reasoning_content field
|
|
@@ -325,7 +351,12 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
325
351
|
return; // ignore malformed/partial SSE chunk
|
|
326
352
|
}
|
|
327
353
|
|
|
354
|
+
sawValidSseData = true;
|
|
355
|
+
|
|
328
356
|
if (evt && evt.usage) { usage = evt.usage; }
|
|
357
|
+
if (evt && evt.choices && evt.choices[0] && evt.choices[0].finish_reason) {
|
|
358
|
+
finishReason = evt.choices[0].finish_reason;
|
|
359
|
+
}
|
|
329
360
|
|
|
330
361
|
const deltaObj = evt && evt.choices && evt.choices[0] && evt.choices[0].delta;
|
|
331
362
|
if (deltaObj) {
|
|
@@ -350,12 +381,17 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
|
|
|
350
381
|
});
|
|
351
382
|
res.on("end", () => {
|
|
352
383
|
if (thinkSplitter) { thinkSplitter.finish(); }
|
|
384
|
+
if (!sawValidSseData) {
|
|
385
|
+
reject(new Error(`Provider returned a non-SSE response (status ${res.statusCode}).`));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
353
388
|
const endedAt = Date.now();
|
|
354
389
|
resolve({
|
|
355
390
|
content: full,
|
|
356
391
|
ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null,
|
|
357
392
|
totalMs: endedAt - startedAt,
|
|
358
|
-
usage: usage
|
|
393
|
+
usage: usage,
|
|
394
|
+
finishReason: finishReason
|
|
359
395
|
});
|
|
360
396
|
});
|
|
361
397
|
});
|
|
@@ -397,6 +433,9 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta, options
|
|
|
397
433
|
if (options && options.responseFormat) {
|
|
398
434
|
body.response_format = options.responseFormat;
|
|
399
435
|
}
|
|
436
|
+
if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
|
|
437
|
+
body.max_tokens = options.maxTokens;
|
|
438
|
+
}
|
|
400
439
|
|
|
401
440
|
const result = await postStream(`${baseUrl}/v1/chat/completions`, headers,
|
|
402
441
|
body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
|
|
@@ -404,7 +443,8 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta, options
|
|
|
404
443
|
return {
|
|
405
444
|
content: result.content || "",
|
|
406
445
|
timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
|
|
407
|
-
usage: result.usage
|
|
446
|
+
usage: result.usage,
|
|
447
|
+
finishReason: result.finishReason
|
|
408
448
|
};
|
|
409
449
|
}
|
|
410
450
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------
|
|
4
|
+
// The provider-confirmation gate's own pass/fail criterion (ADR-007, the
|
|
5
|
+
// SSRF mitigation). Deliberately NOT "HTTP 200 with a JSON body" — an
|
|
6
|
+
// internal admin panel or a cloud metadata endpoint can trivially return
|
|
7
|
+
// that. Requires an actually provider-shaped response: a well-formed
|
|
8
|
+
// OpenAI-compatible chat-completion object (choices[].message) or Anthropic
|
|
9
|
+
// message (content[]), or a valid OpenAI-style /v1/models list (data[] of
|
|
10
|
+
// {id}). Anything else — including a bare 200, an HTML error page, or JSON
|
|
11
|
+
// that merely happens to parse but isn't shaped like either — fails the
|
|
12
|
+
// check, and the provider stays unconfirmed.
|
|
13
|
+
// ---------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function isChatShaped(providerType, raw) {
|
|
16
|
+
if (providerType === "anthropic") {
|
|
17
|
+
return Array.isArray(raw.content);
|
|
18
|
+
}
|
|
19
|
+
return Array.isArray(raw.choices) && raw.choices.length > 0 &&
|
|
20
|
+
raw.choices[0] && typeof raw.choices[0] === "object" &&
|
|
21
|
+
raw.choices[0].message && typeof raw.choices[0].message === "object";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isModelsListShaped(raw) {
|
|
25
|
+
return Array.isArray(raw.data) && raw.data.length > 0 &&
|
|
26
|
+
raw.data.every(function (m) { return m && typeof m.id === "string" && m.id; });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isProviderShapedResponse(providerType, raw) {
|
|
30
|
+
if (!raw || typeof raw !== "object") { return false; }
|
|
31
|
+
return isChatShaped(providerType, raw) || isModelsListShaped(raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { isProviderShapedResponse, isModelsListShaped };
|
package/lib/storage.js
CHANGED
|
@@ -1,10 +1,83 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
|
|
4
|
+
// Sentinel the client sends back for an apiKey field it never actually saw
|
|
5
|
+
// (see maskProviderSecrets in flowpilot.js) to mean "leave the stored key
|
|
6
|
+
// alone". Shared between the GET/POST /flowpilot/settings routes (masking)
|
|
7
|
+
// and reconcileProviderSecrets below (unmasking on save) — both must agree
|
|
8
|
+
// on the exact string or a real key could get silently overwritten.
|
|
9
|
+
const API_KEY_UNCHANGED = "__FP_KEY_UNCHANGED__";
|
|
10
|
+
|
|
4
11
|
function ensureDir(dir) {
|
|
5
12
|
fs.mkdirSync(dir, { recursive: true });
|
|
6
13
|
}
|
|
7
14
|
|
|
15
|
+
// Strips control characters (CR/LF and friends) from a freshly-typed API
|
|
16
|
+
// key before it's persisted — cheap insurance against a pasted value
|
|
17
|
+
// corrupting a future header or a log line.
|
|
18
|
+
function sanitizeApiKey(raw) {
|
|
19
|
+
return String(raw).replace(/[\x00-\x1F\x7F]/g, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Reconciles an incoming providers list (as submitted by POST /flowpilot/
|
|
23
|
+
// settings, where the client only ever sees the masked sentinel/"" for
|
|
24
|
+
// apiKey — never a real key) against the previously-stored list, matched by
|
|
25
|
+
// provider id. A sentinel or missing apiKey keeps the stored key; an empty
|
|
26
|
+
// string clears it; anything else is a real retyped key. Also enforces the
|
|
27
|
+
// server-writes-only discipline for confirmedBaseUrl/confirmedAt (the
|
|
28
|
+
// provider-confirmation gate, B1): a client can never set or forge these,
|
|
29
|
+
// and confirmation is dropped whenever baseUrl or apiKey actually changed
|
|
30
|
+
// from what was last confirmed.
|
|
31
|
+
// trustedConfirmation: true ONLY when called from saveSettings' own internal
|
|
32
|
+
// callers (/flowpilot/test, /flowpilot/probe, right after a real passing
|
|
33
|
+
// check) — never from the public POST /settings path. Those two routes
|
|
34
|
+
// compute confirmedBaseUrl themselves (== the exact URL they just verified),
|
|
35
|
+
// so it's server-computed data at that point, not client input; every OTHER
|
|
36
|
+
// provider in the same save (anything not freshly (re)confirmed this call)
|
|
37
|
+
// still only keeps its OWN prior confirmation, and only while baseUrl/apiKey
|
|
38
|
+
// still match what was actually confirmed.
|
|
39
|
+
function reconcileProviderSecrets(incoming, existing, trustedConfirmation) {
|
|
40
|
+
const existingById = {};
|
|
41
|
+
(Array.isArray(existing) ? existing : []).forEach(function (p) {
|
|
42
|
+
if (p && p.id) { existingById[p.id] = p; }
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return (Array.isArray(incoming) ? incoming : []).map(function (p) {
|
|
46
|
+
const prior = existingById[p.id];
|
|
47
|
+
const next = Object.assign({}, p);
|
|
48
|
+
delete next.hasApiKey;
|
|
49
|
+
|
|
50
|
+
if (next.apiKey === API_KEY_UNCHANGED || next.apiKey === undefined) {
|
|
51
|
+
next.apiKey = prior ? (prior.apiKey || "") : "";
|
|
52
|
+
} else if (typeof next.apiKey === "string" && next.apiKey !== "") {
|
|
53
|
+
next.apiKey = sanitizeApiKey(next.apiKey);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// typeof, not truthiness: baseUrl "" is a real, documented, supported
|
|
57
|
+
// value (Anthropic's "leave blank for api.anthropic.com" convention),
|
|
58
|
+
// so confirmedBaseUrl can legitimately BE "" too — `"" && ...` would
|
|
59
|
+
// silently evaluate false and lock that configuration out of
|
|
60
|
+
// confirmation forever. Only an actually-absent field should fail.
|
|
61
|
+
if (trustedConfirmation && typeof p.confirmedBaseUrl === "string") {
|
|
62
|
+
next.confirmedBaseUrl = p.confirmedBaseUrl;
|
|
63
|
+
next.confirmedAt = p.confirmedAt;
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Untrusted path (public POST /settings): never trust what the client
|
|
68
|
+
// sent, start from the prior stored state, then drop it if baseUrl or
|
|
69
|
+
// the (now-reconciled) apiKey no longer matches what was confirmed.
|
|
70
|
+
if (prior && typeof prior.confirmedBaseUrl === "string" && next.baseUrl === prior.baseUrl && next.apiKey === prior.apiKey) {
|
|
71
|
+
next.confirmedBaseUrl = prior.confirmedBaseUrl;
|
|
72
|
+
next.confirmedAt = prior.confirmedAt;
|
|
73
|
+
} else {
|
|
74
|
+
delete next.confirmedBaseUrl;
|
|
75
|
+
delete next.confirmedAt;
|
|
76
|
+
}
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
8
81
|
function createStorage(userDir) {
|
|
9
82
|
const baseDir = path.join(userDir, "flowpilot");
|
|
10
83
|
const chatsDir = path.join(baseDir, "chats");
|
|
@@ -38,11 +111,11 @@ function createStorage(userDir) {
|
|
|
38
111
|
maxContextChars: 12000,
|
|
39
112
|
defaultContextMode: "selected",
|
|
40
113
|
allowConfigContext: false,
|
|
41
|
-
// When true,
|
|
42
|
-
//
|
|
114
|
+
// When true, provider turns (post-redaction messages, replies, and tool calls)
|
|
115
|
+
// are appended to debug.log
|
|
43
116
|
// (0600 perms). Auth headers/keys are never logged — only the content bytes
|
|
44
117
|
// that the provider actually received. Off by default (diagnostic tool).
|
|
45
|
-
|
|
118
|
+
debugLogging: false,
|
|
46
119
|
streamingEnabled: true,
|
|
47
120
|
// First-run welcome/warning shows until the user saves settings once.
|
|
48
121
|
firstRunAcknowledged: false,
|
|
@@ -59,6 +132,15 @@ function createStorage(userDir) {
|
|
|
59
132
|
// than cloud providers; users on that hardware raise this in Behavior
|
|
60
133
|
// settings rather than living with a hardcoded ceiling.
|
|
61
134
|
requestTimeoutMs: 180000,
|
|
135
|
+
// Hard output bound for each tool-capable agent turn. Classic completions
|
|
136
|
+
// remain uncapped so ordinary flow envelopes are never silently clipped.
|
|
137
|
+
agentTurnMaxTokens: 4096,
|
|
138
|
+
// Cumulative token budget across one read/write tool-calling turn
|
|
139
|
+
// before stopping with an honest error instead of continuing forever.
|
|
140
|
+
// User-configurable since "reasonable" varies by provider context
|
|
141
|
+
// window. Unrelated to agentTurnMaxTokens above, which caps one
|
|
142
|
+
// individual model response's output length, not the running total.
|
|
143
|
+
agentLoopTokenCeiling: 50000,
|
|
62
144
|
// Max build->deploy->test->fix cycles the /build agentic loop will run
|
|
63
145
|
// before stopping with an honest "couldn't fully verify" instead of
|
|
64
146
|
// proposing another fix. Bounds against a non-converging loop burning
|
|
@@ -75,6 +157,10 @@ function createStorage(userDir) {
|
|
|
75
157
|
// verification after import). The legacy path remains available when a
|
|
76
158
|
// user explicitly disables this setting.
|
|
77
159
|
enableStepQueue: true,
|
|
160
|
+
// W7 WRITE-tool loop. Default-off until the server/client Round 1
|
|
161
|
+
// plumbing has passed its integration and mandatory human live-test
|
|
162
|
+
// gates. This is separate from enableStepQueue (Generate checklist UI).
|
|
163
|
+
enableAgentWrite: false,
|
|
78
164
|
// Lets the user silence the recurring secrets/size reminder bar after
|
|
79
165
|
// typing an explicit acknowledgement in settings.
|
|
80
166
|
suppressContextWarnings: false,
|
|
@@ -84,12 +170,15 @@ function createStorage(userDir) {
|
|
|
84
170
|
// dedicated Node-RED credentials field is dropped by the frontend
|
|
85
171
|
// regardless of this setting, via a different, always-on mechanism.
|
|
86
172
|
redactionEnabled: true,
|
|
87
|
-
// Chat-only persona slider, 1-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
173
|
+
// Chat-only persona slider, 1-5 (CLAUDE-032: was 1-10, collapsed to 5
|
|
174
|
+
// discrete levels after live testing found the old scale's
|
|
175
|
+
// interpolated-between-anchors design produced no discernible voice
|
|
176
|
+
// difference across most of its range): 1 is a plain Node-RED engineer,
|
|
177
|
+
// 5 is a comically over-the-top airline captain who happens to be a
|
|
178
|
+
// Node-RED expert. 2 ("subtle co-pilot") is the default.
|
|
90
179
|
// See lib/persona-prompt.js — generated fresh per request, never baked
|
|
91
180
|
// into the persisted systemPrompt below.
|
|
92
|
-
personaIntensity:
|
|
181
|
+
personaIntensity: 2,
|
|
93
182
|
// User-defined intent buttons: array of { label, text }.
|
|
94
183
|
customIntents: [],
|
|
95
184
|
systemPrompt: require("./default-system-prompt")
|
|
@@ -176,6 +265,7 @@ function createStorage(userDir) {
|
|
|
176
265
|
|
|
177
266
|
if (!fs.existsSync(settingsFile)) {
|
|
178
267
|
fs.writeFileSync(settingsFile, JSON.stringify(defaultSettings, null, 2), "utf8");
|
|
268
|
+
try { fs.chmodSync(settingsFile, 0o600); } catch (e) { /* best-effort */ }
|
|
179
269
|
}
|
|
180
270
|
|
|
181
271
|
if (!fs.existsSync(auditFile)) {
|
|
@@ -204,7 +294,13 @@ function createStorage(userDir) {
|
|
|
204
294
|
}
|
|
205
295
|
}
|
|
206
296
|
|
|
207
|
-
|
|
297
|
+
// options.trustConfirmation: pass true ONLY from /flowpilot/test or
|
|
298
|
+
// /flowpilot/probe's own internal saveSettings call, right after a real
|
|
299
|
+
// passing provider check — see reconcileProviderSecrets's own comment.
|
|
300
|
+
// Every other caller (in particular the public POST /settings route)
|
|
301
|
+
// omits this, so confirmedBaseUrl/confirmedAt stay strictly
|
|
302
|
+
// server-computed and can never be set via a settings save.
|
|
303
|
+
function saveSettings(settings, options) {
|
|
208
304
|
init();
|
|
209
305
|
|
|
210
306
|
let current = {};
|
|
@@ -218,16 +314,19 @@ function createStorage(userDir) {
|
|
|
218
314
|
const merged = Object.assign({}, defaultSettings, current, settings || {});
|
|
219
315
|
merged.systemPrompt = fixStaleSystemPrompt(merged.systemPrompt);
|
|
220
316
|
delete merged._error;
|
|
221
|
-
// If the caller sent a providers list, it
|
|
222
|
-
//
|
|
317
|
+
// If the caller sent a providers list, reconcile it against what's
|
|
318
|
+
// actually stored (real apiKey/confirmedBaseUrl never come from the
|
|
319
|
+
// client — see reconcileProviderSecrets above) rather than trusting it
|
|
320
|
+
// outright the way Object.assign would.
|
|
223
321
|
if (settings && Array.isArray(settings.providers)) {
|
|
224
|
-
merged.providers = settings.providers;
|
|
322
|
+
merged.providers = reconcileProviderSecrets(settings.providers, current.providers, !!(options && options.trustConfirmation));
|
|
225
323
|
}
|
|
226
324
|
// Saving settings is an explicit user action; mark first-run complete so
|
|
227
325
|
// the welcome/warning stops showing.
|
|
228
326
|
merged.firstRunAcknowledged = true;
|
|
229
327
|
|
|
230
328
|
fs.writeFileSync(settingsFile, JSON.stringify(merged, null, 2), "utf8");
|
|
329
|
+
try { fs.chmodSync(settingsFile, 0o600); } catch (e) { /* best-effort */ }
|
|
231
330
|
|
|
232
331
|
return merged;
|
|
233
332
|
}
|
|
@@ -253,7 +352,16 @@ function createStorage(userDir) {
|
|
|
253
352
|
|
|
254
353
|
function appendTranscript(conversationId, entry) {
|
|
255
354
|
init();
|
|
256
|
-
|
|
355
|
+
const file = transcriptFile(conversationId);
|
|
356
|
+
const isNewFile = !fs.existsSync(file);
|
|
357
|
+
// Same 0600-on-append pattern as appendDebugLog below — transcripts hold
|
|
358
|
+
// full conversation content, never world-readable even on first write.
|
|
359
|
+
const fd = fs.openSync(file, "a", 0o600);
|
|
360
|
+
fs.writeSync(fd, JSON.stringify(entry) + "\n");
|
|
361
|
+
fs.closeSync(fd);
|
|
362
|
+
if (isNewFile) {
|
|
363
|
+
try { fs.chmodSync(file, 0o600); } catch (e) { /* best-effort */ }
|
|
364
|
+
}
|
|
257
365
|
}
|
|
258
366
|
|
|
259
367
|
// Removes a conversation's transcript file (e.g. user deletes it from the
|
|
@@ -303,14 +411,14 @@ function createStorage(userDir) {
|
|
|
303
411
|
return defaultSettings.systemPrompt;
|
|
304
412
|
}
|
|
305
413
|
|
|
306
|
-
const
|
|
414
|
+
const debugLogFile = path.join(baseDir, "debug.log");
|
|
307
415
|
|
|
308
|
-
//
|
|
416
|
+
// Append one JSON-lines entry to debug.log.
|
|
309
417
|
// Written 0600 — diagnostic data, never world-readable.
|
|
310
418
|
// Auth keys are NOT included (only baseUrl + model from the provider
|
|
311
419
|
// profile, never apiKey). The bytes logged are post-redaction: the same
|
|
312
420
|
// content the provider actually received.
|
|
313
|
-
function
|
|
421
|
+
function appendDebugLog(entry) {
|
|
314
422
|
init();
|
|
315
423
|
const line = JSON.stringify({
|
|
316
424
|
timestamp: new Date().toISOString(),
|
|
@@ -319,13 +427,13 @@ function createStorage(userDir) {
|
|
|
319
427
|
try {
|
|
320
428
|
// Open with O_APPEND | O_CREAT, mode 0600 so secrets never land
|
|
321
429
|
// in a world-readable file even on first write.
|
|
322
|
-
const fd = fs.openSync(
|
|
430
|
+
const fd = fs.openSync(debugLogFile, "a", 0o600);
|
|
323
431
|
fs.writeSync(fd, line + "\n");
|
|
324
432
|
fs.closeSync(fd);
|
|
325
433
|
// Ensure 0600 regardless of umask on subsequent opens.
|
|
326
|
-
fs.chmodSync(
|
|
434
|
+
fs.chmodSync(debugLogFile, 0o600);
|
|
327
435
|
} catch (err) {
|
|
328
|
-
console.error("[FlowPilot]
|
|
436
|
+
console.error("[FlowPilot] debug log write failed:", err.message);
|
|
329
437
|
}
|
|
330
438
|
}
|
|
331
439
|
|
|
@@ -337,13 +445,13 @@ function createStorage(userDir) {
|
|
|
337
445
|
backupsDir,
|
|
338
446
|
settingsFile,
|
|
339
447
|
auditFile,
|
|
340
|
-
|
|
448
|
+
debugLogFile,
|
|
341
449
|
getSettings,
|
|
342
450
|
saveSettings,
|
|
343
451
|
getActiveProvider,
|
|
344
452
|
getDefaultSystemPrompt,
|
|
345
453
|
appendAudit,
|
|
346
|
-
|
|
454
|
+
appendDebugLog,
|
|
347
455
|
appendTranscript,
|
|
348
456
|
readTranscript,
|
|
349
457
|
deleteTranscript,
|
|
@@ -351,4 +459,9 @@ function createStorage(userDir) {
|
|
|
351
459
|
};
|
|
352
460
|
}
|
|
353
461
|
|
|
354
|
-
|
|
462
|
+
// Static, instance-independent — flowpilot.js's route handlers need the
|
|
463
|
+
// exact same sentinel string that reconcileProviderSecrets checks against
|
|
464
|
+
// above, without needing a storage instance to get it.
|
|
465
|
+
createStorage.API_KEY_UNCHANGED = API_KEY_UNCHANGED;
|
|
466
|
+
|
|
467
|
+
module.exports = createStorage;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manny-est/node-red-flowpilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
|
|
5
5
|
"main": "flowpilot.js",
|
|
6
6
|
"keywords": [
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"node-red": {
|
|
27
27
|
"version": ">=4.0.0",
|
|
28
28
|
"nodes": {
|
|
29
|
-
"flowpilot": "flowpilot.js"
|
|
29
|
+
"flowpilot": "flowpilot-node-entry.js"
|
|
30
30
|
},
|
|
31
31
|
"plugins": {
|
|
32
32
|
"flowpilot": "flowpilot.html"
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
39
|
"flowpilot.js",
|
|
40
|
+
"flowpilot-node-entry.js",
|
|
40
41
|
"flowpilot.html",
|
|
41
42
|
"flowpilot-core.css",
|
|
42
43
|
"lib",
|