@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.
@@ -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
- reject(new Error("Provider returned non-JSON response (" + res.statusCode + "): " + data.slice(0, 500)));
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) : data;
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 + "): " + data.slice(0, 500)));
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) : data;
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
- let errData = "";
110
- res.on("data", chunk => { errData += chunk; });
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 = { type: "auto" };
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
- const data = response && Array.isArray(response.data) ? response.data : [];
332
- const models = data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
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
- reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
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) : data;
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
- reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
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) : data;
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
- const data = response && Array.isArray(response.data) ? response.data : [];
134
- const models = data
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
- let errData = "";
291
- res.on("data", chunk => { errData += chunk; });
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}): ${errData.slice(0, 500)}`));
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, 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,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-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.
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: 3,
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
- function saveSettings(settings) {
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 wins outright (Object.assign
222
- // already did this, but be explicit for clarity/safety).
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
- fs.appendFileSync(transcriptFile(conversationId), JSON.stringify(entry) + "\n", "utf8");
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 assembledPromptsFile = path.join(baseDir, "assembled-prompts.log");
414
+ const debugLogFile = path.join(baseDir, "debug.log");
307
415
 
308
- // W0.4: Append one JSON-lines entry to assembled-prompts.log.
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 appendAssembledPromptLog(entry) {
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(assembledPromptsFile, "a", 0o600);
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(assembledPromptsFile, 0o600);
434
+ fs.chmodSync(debugLogFile, 0o600);
327
435
  } catch (err) {
328
- console.error("[FlowPilot] assembled-prompts log write failed:", err.message);
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
- assembledPromptsFile,
448
+ debugLogFile,
341
449
  getSettings,
342
450
  saveSettings,
343
451
  getActiveProvider,
344
452
  getDefaultSystemPrompt,
345
453
  appendAudit,
346
- appendAssembledPromptLog,
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
- module.exports = createStorage;
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.5.2",
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",