@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.
- package/CHANGELOG.md +53 -74
- package/USER-GUIDE.md +5 -1
- package/flowpilot-core.css +38 -10
- package/flowpilot.js +1007 -181
- package/lib/agent-contract.js +45 -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 +10 -1
- package/lib/core/init.js +138 -5
- package/lib/core/main.js +664 -16
- package/lib/core/modes.js +882 -100
- package/lib/core/selection-context.js +27 -24
- package/lib/default-system-prompt.js +4 -3
- package/lib/document-system-prompt.js +3 -2
- 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 +13 -2
- package/lib/provider-anthropic.js +11 -8
- package/lib/provider-openai-compatible.js +37 -9
- package/lib/provider-shape-check.js +34 -0
- package/lib/storage.js +128 -21
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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) :
|
|
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
|
-
|
|
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) :
|
|
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
|
-
|
|
291
|
-
res.on("data",
|
|
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})
|
|
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,
|
|
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,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-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
222
|
-
//
|
|
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
|
-
|
|
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
|
|
408
|
+
const debugLogFile = path.join(baseDir, "debug.log");
|
|
307
409
|
|
|
308
|
-
//
|
|
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
|
|
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(
|
|
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(
|
|
428
|
+
fs.chmodSync(debugLogFile, 0o600);
|
|
327
429
|
} catch (err) {
|
|
328
|
-
console.error("[FlowPilot]
|
|
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
|
-
|
|
442
|
+
debugLogFile,
|
|
341
443
|
getSettings,
|
|
342
444
|
saveSettings,
|
|
343
445
|
getActiveProvider,
|
|
344
446
|
getDefaultSystemPrompt,
|
|
345
447
|
appendAudit,
|
|
346
|
-
|
|
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
|
-
|
|
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;
|