@manny-est/node-red-flowpilot 0.5.2 → 0.6.0-beta.1

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.
@@ -33,12 +33,18 @@ function postJson(urlString, headers, body, timeoutMs) {
33
33
  try {
34
34
  parsed = data ? JSON.parse(data) : null;
35
35
  } catch (err) {
36
- reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
36
+ // Never echo the raw upstream body — a security boundary, not just
37
+ // tidiness. This request may be the provider-confirmation check
38
+ // hitting a baseUrl for the first time (SSRF mitigation, ADR-007);
39
+ // an attacker-controlled target (internal service, cloud metadata)
40
+ // must not be able to get its response body reflected back to the
41
+ // caller through a FlowPilot error message.
42
+ reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
37
43
  return;
38
44
  }
39
45
 
40
46
  if (res.statusCode < 200 || res.statusCode >= 300) {
41
- const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
47
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
42
48
  reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
43
49
  return;
44
50
  }
@@ -86,12 +92,13 @@ function getJson(urlString, headers, timeoutMs) {
86
92
  try {
87
93
  parsed = data ? JSON.parse(data) : null;
88
94
  } catch (err) {
89
- reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
95
+ // See postJson above never echo the raw upstream body.
96
+ reject(new Error(`Provider returned a non-JSON response (status ${res.statusCode}).`));
90
97
  return;
91
98
  }
92
99
 
93
100
  if (res.statusCode < 200 || res.statusCode >= 300) {
94
- const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
101
+ const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : `status ${res.statusCode}`;
95
102
  reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
96
103
  return;
97
104
  }
@@ -172,6 +179,9 @@ async function chat(settings, messages, options) {
172
179
  if (options && options.responseFormat) {
173
180
  body.response_format = options.responseFormat;
174
181
  }
182
+ if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
183
+ body.max_tokens = options.maxTokens;
184
+ }
175
185
 
176
186
  const startedAt = Date.now();
177
187
  const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
@@ -187,6 +197,8 @@ async function chat(settings, messages, options) {
187
197
  raw: response,
188
198
  content: content || (toolCalls ? "" : "[No assistant message returned by provider]"),
189
199
  toolCalls: toolCalls,
200
+ finishReason: (response && response.choices && response.choices[0] &&
201
+ response.choices[0].finish_reason) || null,
190
202
  timing: { totalMs },
191
203
  usage: (response && response.usage) || null
192
204
  };
@@ -282,20 +294,22 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
282
294
  const startedAt = Date.now();
283
295
  let firstTokenAt = null;
284
296
  let usage = null;
297
+ let finishReason = null;
285
298
 
286
299
  const req = transport.request(options, (res) => {
287
300
  res.setEncoding("utf8");
288
301
 
289
302
  if (res.statusCode < 200 || res.statusCode >= 300) {
290
- let errData = "";
291
- res.on("data", chunk => { errData += chunk; });
303
+ // Drain the body but never reflect it — see postJson's comment above.
304
+ res.on("data", () => {});
292
305
  res.on("end", () => {
293
- reject(new Error(`Provider request failed (${res.statusCode}): ${errData.slice(0, 500)}`));
306
+ reject(new Error(`Provider request failed (status ${res.statusCode}).`));
294
307
  });
295
308
  return;
296
309
  }
297
310
 
298
311
  let sseBuf = "";
312
+ let sawValidSseData = false;
299
313
  let full = "";
300
314
  // When onReasoningDelta is provided, intercept <think>...</think> from
301
315
  // delta.content in addition to the dedicated delta.reasoning_content field
@@ -325,7 +339,12 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
325
339
  return; // ignore malformed/partial SSE chunk
326
340
  }
327
341
 
342
+ sawValidSseData = true;
343
+
328
344
  if (evt && evt.usage) { usage = evt.usage; }
345
+ if (evt && evt.choices && evt.choices[0] && evt.choices[0].finish_reason) {
346
+ finishReason = evt.choices[0].finish_reason;
347
+ }
329
348
 
330
349
  const deltaObj = evt && evt.choices && evt.choices[0] && evt.choices[0].delta;
331
350
  if (deltaObj) {
@@ -350,12 +369,17 @@ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDel
350
369
  });
351
370
  res.on("end", () => {
352
371
  if (thinkSplitter) { thinkSplitter.finish(); }
372
+ if (!sawValidSseData) {
373
+ reject(new Error(`Provider returned a non-SSE response (status ${res.statusCode}).`));
374
+ return;
375
+ }
353
376
  const endedAt = Date.now();
354
377
  resolve({
355
378
  content: full,
356
379
  ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null,
357
380
  totalMs: endedAt - startedAt,
358
- usage: usage
381
+ usage: usage,
382
+ finishReason: finishReason
359
383
  });
360
384
  });
361
385
  });
@@ -397,6 +421,9 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta, options
397
421
  if (options && options.responseFormat) {
398
422
  body.response_format = options.responseFormat;
399
423
  }
424
+ if (options && Number.isInteger(options.maxTokens) && options.maxTokens > 0) {
425
+ body.max_tokens = options.maxTokens;
426
+ }
400
427
 
401
428
  const result = await postStream(`${baseUrl}/v1/chat/completions`, headers,
402
429
  body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
@@ -404,7 +431,8 @@ async function chatStream(settings, messages, onDelta, onReasoningDelta, options
404
431
  return {
405
432
  content: result.content || "",
406
433
  timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
407
- usage: result.usage
434
+ usage: result.usage,
435
+ finishReason: result.finishReason
408
436
  };
409
437
  }
410
438
 
@@ -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 };
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, each assembled prompt (post-redaction messages array) plus the
42
- // raw provider response and parse outcome is appended to assembled-prompts.log
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
- logAssembledPrompts: false,
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,9 @@ 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,
62
138
  // Max build->deploy->test->fix cycles the /build agentic loop will run
63
139
  // before stopping with an honest "couldn't fully verify" instead of
64
140
  // proposing another fix. Bounds against a non-converging loop burning
@@ -75,6 +151,10 @@ function createStorage(userDir) {
75
151
  // verification after import). The legacy path remains available when a
76
152
  // user explicitly disables this setting.
77
153
  enableStepQueue: true,
154
+ // W7 WRITE-tool loop. Default-off until the server/client Round 1
155
+ // plumbing has passed its integration and mandatory human live-test
156
+ // gates. This is separate from enableStepQueue (Generate checklist UI).
157
+ enableAgentWrite: false,
78
158
  // Lets the user silence the recurring secrets/size reminder bar after
79
159
  // typing an explicit acknowledgement in settings.
80
160
  suppressContextWarnings: false,
@@ -84,12 +164,15 @@ function createStorage(userDir) {
84
164
  // dedicated Node-RED credentials field is dropped by the frontend
85
165
  // regardless of this setting, via a different, always-on mechanism.
86
166
  redactionEnabled: true,
87
- // Chat-only persona slider, 1-10: 1 is a plain Node-RED engineer, 10 is
88
- // a comically over-the-top airline captain who happens to be a Node-RED
89
- // expert. 3 matches the original "subtle co-pilot" voice this replaced.
167
+ // Chat-only persona slider, 1-5 (CLAUDE-032: was 1-10, collapsed to 5
168
+ // discrete levels after live testing found the old scale's
169
+ // interpolated-between-anchors design produced no discernible voice
170
+ // difference across most of its range): 1 is a plain Node-RED engineer,
171
+ // 5 is a comically over-the-top airline captain who happens to be a
172
+ // Node-RED expert. 2 ("subtle co-pilot") is the default.
90
173
  // See lib/persona-prompt.js — generated fresh per request, never baked
91
174
  // into the persisted systemPrompt below.
92
- personaIntensity: 3,
175
+ personaIntensity: 2,
93
176
  // User-defined intent buttons: array of { label, text }.
94
177
  customIntents: [],
95
178
  systemPrompt: require("./default-system-prompt")
@@ -176,6 +259,7 @@ function createStorage(userDir) {
176
259
 
177
260
  if (!fs.existsSync(settingsFile)) {
178
261
  fs.writeFileSync(settingsFile, JSON.stringify(defaultSettings, null, 2), "utf8");
262
+ try { fs.chmodSync(settingsFile, 0o600); } catch (e) { /* best-effort */ }
179
263
  }
180
264
 
181
265
  if (!fs.existsSync(auditFile)) {
@@ -204,7 +288,13 @@ function createStorage(userDir) {
204
288
  }
205
289
  }
206
290
 
207
- function saveSettings(settings) {
291
+ // options.trustConfirmation: pass true ONLY from /flowpilot/test or
292
+ // /flowpilot/probe's own internal saveSettings call, right after a real
293
+ // passing provider check — see reconcileProviderSecrets's own comment.
294
+ // Every other caller (in particular the public POST /settings route)
295
+ // omits this, so confirmedBaseUrl/confirmedAt stay strictly
296
+ // server-computed and can never be set via a settings save.
297
+ function saveSettings(settings, options) {
208
298
  init();
209
299
 
210
300
  let current = {};
@@ -218,16 +308,19 @@ function createStorage(userDir) {
218
308
  const merged = Object.assign({}, defaultSettings, current, settings || {});
219
309
  merged.systemPrompt = fixStaleSystemPrompt(merged.systemPrompt);
220
310
  delete merged._error;
221
- // If the caller sent a providers list, it wins outright (Object.assign
222
- // already did this, but be explicit for clarity/safety).
311
+ // If the caller sent a providers list, reconcile it against what's
312
+ // actually stored (real apiKey/confirmedBaseUrl never come from the
313
+ // client — see reconcileProviderSecrets above) rather than trusting it
314
+ // outright the way Object.assign would.
223
315
  if (settings && Array.isArray(settings.providers)) {
224
- merged.providers = settings.providers;
316
+ merged.providers = reconcileProviderSecrets(settings.providers, current.providers, !!(options && options.trustConfirmation));
225
317
  }
226
318
  // Saving settings is an explicit user action; mark first-run complete so
227
319
  // the welcome/warning stops showing.
228
320
  merged.firstRunAcknowledged = true;
229
321
 
230
322
  fs.writeFileSync(settingsFile, JSON.stringify(merged, null, 2), "utf8");
323
+ try { fs.chmodSync(settingsFile, 0o600); } catch (e) { /* best-effort */ }
231
324
 
232
325
  return merged;
233
326
  }
@@ -253,7 +346,16 @@ function createStorage(userDir) {
253
346
 
254
347
  function appendTranscript(conversationId, entry) {
255
348
  init();
256
- fs.appendFileSync(transcriptFile(conversationId), JSON.stringify(entry) + "\n", "utf8");
349
+ const file = transcriptFile(conversationId);
350
+ const isNewFile = !fs.existsSync(file);
351
+ // Same 0600-on-append pattern as appendDebugLog below — transcripts hold
352
+ // full conversation content, never world-readable even on first write.
353
+ const fd = fs.openSync(file, "a", 0o600);
354
+ fs.writeSync(fd, JSON.stringify(entry) + "\n");
355
+ fs.closeSync(fd);
356
+ if (isNewFile) {
357
+ try { fs.chmodSync(file, 0o600); } catch (e) { /* best-effort */ }
358
+ }
257
359
  }
258
360
 
259
361
  // Removes a conversation's transcript file (e.g. user deletes it from the
@@ -303,14 +405,14 @@ function createStorage(userDir) {
303
405
  return defaultSettings.systemPrompt;
304
406
  }
305
407
 
306
- const assembledPromptsFile = path.join(baseDir, "assembled-prompts.log");
408
+ const debugLogFile = path.join(baseDir, "debug.log");
307
409
 
308
- // W0.4: Append one JSON-lines entry to assembled-prompts.log.
410
+ // Append one JSON-lines entry to debug.log.
309
411
  // Written 0600 — diagnostic data, never world-readable.
310
412
  // Auth keys are NOT included (only baseUrl + model from the provider
311
413
  // profile, never apiKey). The bytes logged are post-redaction: the same
312
414
  // content the provider actually received.
313
- function appendAssembledPromptLog(entry) {
415
+ function appendDebugLog(entry) {
314
416
  init();
315
417
  const line = JSON.stringify({
316
418
  timestamp: new Date().toISOString(),
@@ -319,13 +421,13 @@ function createStorage(userDir) {
319
421
  try {
320
422
  // Open with O_APPEND | O_CREAT, mode 0600 so secrets never land
321
423
  // in a world-readable file even on first write.
322
- const fd = fs.openSync(assembledPromptsFile, "a", 0o600);
424
+ const fd = fs.openSync(debugLogFile, "a", 0o600);
323
425
  fs.writeSync(fd, line + "\n");
324
426
  fs.closeSync(fd);
325
427
  // Ensure 0600 regardless of umask on subsequent opens.
326
- fs.chmodSync(assembledPromptsFile, 0o600);
428
+ fs.chmodSync(debugLogFile, 0o600);
327
429
  } catch (err) {
328
- console.error("[FlowPilot] assembled-prompts log write failed:", err.message);
430
+ console.error("[FlowPilot] debug log write failed:", err.message);
329
431
  }
330
432
  }
331
433
 
@@ -337,13 +439,13 @@ function createStorage(userDir) {
337
439
  backupsDir,
338
440
  settingsFile,
339
441
  auditFile,
340
- assembledPromptsFile,
442
+ debugLogFile,
341
443
  getSettings,
342
444
  saveSettings,
343
445
  getActiveProvider,
344
446
  getDefaultSystemPrompt,
345
447
  appendAudit,
346
- appendAssembledPromptLog,
448
+ appendDebugLog,
347
449
  appendTranscript,
348
450
  readTranscript,
349
451
  deleteTranscript,
@@ -351,4 +453,9 @@ function createStorage(userDir) {
351
453
  };
352
454
  }
353
455
 
354
- module.exports = createStorage;
456
+ // Static, instance-independent — flowpilot.js's route handlers need the
457
+ // exact same sentinel string that reconcileProviderSecrets checks against
458
+ // above, without needing a storage instance to get it.
459
+ createStorage.API_KEY_UNCHANGED = API_KEY_UNCHANGED;
460
+
461
+ module.exports = createStorage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manny-est/node-red-flowpilot",
3
- "version": "0.5.2",
3
+ "version": "0.6.0-beta.1",
4
4
  "description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
5
5
  "main": "flowpilot.js",
6
6
  "keywords": [