toolfunnel 0.7.0 → 0.7.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.
Files changed (67) hide show
  1. package/README.md +228 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +451 -451
  4. package/docs/KNOWN_BUGS.md +80 -80
  5. package/docs/design.md +84 -84
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +39 -39
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -147
  12. package/mcp/servers/mock-upstream/server.js +380 -380
  13. package/package.json +67 -67
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +353 -353
  17. package/src/core/child-reaper.js +61 -61
  18. package/src/core/config-home.js +134 -134
  19. package/src/core/events.js +105 -105
  20. package/src/core/hook-engine.js +329 -329
  21. package/src/core/hook-loader.js +824 -824
  22. package/src/core/hook-runner.js +566 -566
  23. package/src/core/logger.js +297 -297
  24. package/src/core/matcher.js +100 -100
  25. package/src/core/metrics.js +75 -75
  26. package/src/core/rename-migration.js +163 -163
  27. package/src/core/requires.js +138 -138
  28. package/src/core/server-config.js +98 -98
  29. package/src/extend/add-hook.md +152 -152
  30. package/src/extend/add-mcp.md +119 -119
  31. package/src/extend/configure.md +88 -88
  32. package/src/extend/create-tool.md +168 -168
  33. package/src/extend/howto.js +102 -102
  34. package/src/extend/package.md +190 -190
  35. package/src/extend/wrap.md +99 -99
  36. package/src/mcp/aggregator.js +1719 -1674
  37. package/src/mcp/expose-store.js +577 -577
  38. package/src/mcp/gated-run.js +210 -210
  39. package/src/mcp/http-transport.js +1563 -1563
  40. package/src/mcp/mcp-client.js +1070 -1070
  41. package/src/mcp/modern.js +406 -406
  42. package/src/mcp/protocol.js +468 -468
  43. package/src/mcp/server.js +3112 -3112
  44. package/src/tools/registry.js +661 -661
  45. package/src/tools/tool-state.js +170 -170
  46. package/src/ui/public/app.js +1187 -1187
  47. package/src/ui/public/index.html +286 -286
  48. package/src/ui/public/styles.css +535 -535
  49. package/src/ui/server.js +1616 -1616
  50. package/tools/scripts/base64.js +139 -139
  51. package/tools/scripts/danger.js +61 -61
  52. package/tools/scripts/echo.js +39 -39
  53. package/tools/scripts/hash.js +101 -101
  54. package/tools/scripts/json.js +109 -109
  55. package/tools/scripts/text-stats.js +131 -131
  56. package/tools/scripts/tf-env.js +43 -43
  57. package/tools/scripts/tf-hook-add.js +151 -151
  58. package/tools/scripts/tf-hook-set.js +120 -120
  59. package/tools/scripts/tf-list.js +192 -192
  60. package/tools/scripts/tf-log.js +86 -86
  61. package/tools/scripts/tf-mcp-add.js +117 -117
  62. package/tools/scripts/tf-mcp-set.js +121 -121
  63. package/tools/scripts/tf-pack.js +237 -237
  64. package/tools/scripts/tf-tool-add.js +134 -134
  65. package/tools/scripts/tf-tool-set.js +142 -142
  66. package/tools/scripts/tf-wrap.js +103 -103
  67. package/tools/scripts/uuid.js +102 -102
@@ -1,297 +1,297 @@
1
- 'use strict';
2
-
3
- /**
4
- * logger.js - a toggleable JSONL activity/audit log for the gateway.
5
- *
6
- * DEFAULT OFF (privacy + lean). Nothing is written, and no files are created, unless
7
- * logging has been explicitly enabled via setConfig(). A MISSING config file means
8
- * disabled - the safe default - so a fresh checkout logs nothing.
9
- *
10
- * Design rules:
11
- * - Zero dependencies. Node built-ins only (fs, path). No transport, no SDK.
12
- * - Config is read FRESH on every call (no caching), so a toggle takes effect for the
13
- * very next event without a reconnect or restart.
14
- * - log() must NEVER throw. A logging failure (bad config, unwritable disk, full FS)
15
- * must never break a tool call or the gate. Everything is wrapped; failures are
16
- * swallowed silently.
17
- *
18
- * Paths are resolved against the config home, and the resolved path MUST stay inside
19
- * <home>/logs/ (0.7.0; logging.test.js step 5 is the acceptance). The log path is
20
- * settable from the tf_log tool surface and the UI's POST /api/logs/config, and every
21
- * other file-writing path in the repo is confined to its root (hook-loader's scripts-dir
22
- * assert, registry.writeScript, sendStatic) - this one now matches. setConfig REFUSES an
23
- * out-of-home path loudly; getConfig treats one already in the file as unset (default),
24
- * so log() and tail() stay in-home even against a hand-edited config. An operator who
25
- * wants the log on another disk can make <home>/logs a symlink/junction - containment is
26
- * checked on the unfollowed path.
27
- *
28
- * Config file: <root>/logs/log.config.json
29
- * { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
30
- *
31
- * Both paths are BOUNDED (0.7.0, log-rotation.test.js): the live file rotates to `<path>.1`
32
- * at MAX_LOG_BYTES (one older generation kept), and tail(n) reads a trailing slice rather
33
- * than the whole file - with a whole-file fallback so bounding never changes an answer.
34
- *
35
- * CommonJS only.
36
- */
37
-
38
- const fs = require('node:fs');
39
- const path = require('node:path');
40
-
41
- /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
42
- * config-home.js). Logs + their toggle are user-state, so they live with the home. */
43
- const { resolveConfigHome } = require('./config-home');
44
- const ROOT = resolveConfigHome();
45
-
46
- /** The toggle/config file. NOT created until setConfig() writes it. */
47
- const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
48
-
49
- /** Safe defaults - used whenever the config file is absent or unreadable. */
50
- const DEFAULT_ENABLED = false;
51
- const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
52
-
53
- /**
54
- * The live file's size cap. An append that finds the file at/over it first renames the
55
- * file to `<path>.1` (ONE older generation, overwritten by the next rotation), so disk
56
- * use is bounded at ~2x the cap and the no-argument tail() (the documented all-lines
57
- * mode, tf_log status's count) can never be asked to parse an unbounded file again.
58
- * Rotation is best-effort: if the rename is refused (e.g. a reader holds `.1` open on
59
- * win32) the append proceeds on the oversized live file and rotation retries next call -
60
- * a record is never dropped for the sake of the cap.
61
- */
62
- const MAX_LOG_BYTES = 5 * 1024 * 1024;
63
-
64
- /** tail(n)'s bounded read: bytes budgeted per requested line (generous - records run
65
- * ~100-200 bytes), with a floor so tiny n still reads one decent chunk. */
66
- const TAIL_BYTES_PER_LINE = 1024;
67
- const TAIL_READ_MIN_BYTES = 64 * 1024;
68
-
69
- /** The directory every log path must resolve inside. */
70
- const LOG_DIR = path.join(ROOT, 'logs');
71
-
72
- /**
73
- * Resolve a (possibly relative) log path against the config home, REQUIRING the result
74
- * to stay inside <home>/logs/. Returns the resolved absolute path, or null when it
75
- * resolves outside (same refusal shape as registry.js defaultRunScript: '' would be the
76
- * dir itself, '..'-prefixed left it, absolute rel = another drive on win32).
77
- * @param {string} p
78
- * @returns {string|null}
79
- */
80
- function resolveLogPath(p) {
81
- const resolved = path.resolve(ROOT, String(p));
82
- const rel = path.relative(LOG_DIR, resolved);
83
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
84
- return resolved;
85
- }
86
-
87
- /**
88
- * getConfig - the resolved { enabled, path }.
89
- *
90
- * Reads the config file fresh. A missing/unreadable/malformed file resolves to the
91
- * safe defaults (disabled). Never throws.
92
- *
93
- * @returns {{ enabled: boolean, path: string }}
94
- */
95
- function getConfig() {
96
- try {
97
- const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
98
- const parsed = JSON.parse(raw);
99
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
100
- return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
101
- }
102
- return {
103
- enabled: parsed.enabled === true,
104
- // A path that resolves outside <home>/logs/ is treated as UNSET, not honoured: the
105
- // config file can be edited directly, and the readers (log/tail) must stay in-home
106
- // regardless of how the value got there. setConfig additionally refuses one loudly.
107
- path:
108
- typeof parsed.path === 'string' && parsed.path.length > 0 && resolveLogPath(parsed.path) !== null
109
- ? parsed.path
110
- : DEFAULT_PATH,
111
- };
112
- } catch (_err) {
113
- // Missing file = disabled (the safe default); also covers unreadable/malformed.
114
- return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
115
- }
116
- }
117
-
118
- /**
119
- * log - append one JSONL record IF logging is enabled, else a silent no-op.
120
- *
121
- * The record is the event fields plus a logger-stamped ISO-8601 "ts" timestamp.
122
- * Resolves the configured path against root, mkdir -p its directory, and appends one
123
- * line with fs.appendFileSync. NEVER throws - any failure is swallowed.
124
- *
125
- * @param {object} event arbitrary serialisable fields (e.g. { type, tool, decision }).
126
- */
127
- function log(event) {
128
- try {
129
- const cfg = getConfig();
130
- if (!cfg.enabled) return; // default-off: no-op, no file created.
131
-
132
- const record = Object.assign(
133
- { ts: new Date().toISOString() },
134
- event && typeof event === 'object' && !Array.isArray(event) ? event : {}
135
- );
136
-
137
- const logPath = resolveLogPath(cfg.path);
138
- if (logPath === null) return; // cannot happen via getConfig (it sanitises); belt-and-braces
139
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
140
-
141
- // Rotate BEFORE the append when the live file has reached the cap. Its own try/catch:
142
- // a refused stat/rename must never cost the record below. `logPath + '.1'` inherits
143
- // logPath's containment, and rename replaces an existing `.1` (one older generation).
144
- try {
145
- if (fs.statSync(logPath).size >= MAX_LOG_BYTES) {
146
- fs.renameSync(logPath, logPath + '.1');
147
- }
148
- } catch (_e) {
149
- // Missing file (nothing to rotate) or a refused rename - append regardless.
150
- }
151
-
152
- fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
153
- } catch (_err) {
154
- // Logging must NEVER break the caller. Swallow everything.
155
- }
156
- }
157
-
158
- /**
159
- * setConfig - atomically merge a patch into logs/log.config.json (temp + rename).
160
- *
161
- * Merges with the current resolved config so a partial patch (e.g. { enabled: true })
162
- * preserves the existing path. Creates the logs/ dir and the config file if absent -
163
- * this is the ONLY function that creates the config file.
164
- *
165
- * @param {{ enabled?: boolean, path?: string }} patch
166
- * @returns {{ enabled: boolean, path: string }} the merged, written config.
167
- */
168
- function setConfig(patch) {
169
- const current = getConfig();
170
- const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
171
-
172
- // Refuse an out-of-home path LOUDLY before anything is written. Reaches every setter
173
- // surface (tf_log's { ok:false }, the UI's error response) with nothing persisted.
174
- // `current.path` needs no re-check - getConfig only ever returns a contained path.
175
- // The code lets callers tell "you sent a bad path" (client input -> the UI's 400) apart
176
- // from a genuine write failure (disk, perms -> 500) without matching on message prose.
177
- if (typeof p.path === 'string' && p.path.length > 0 && resolveLogPath(p.path) === null) {
178
- const err = new Error(
179
- `log path refused: "${p.path}" resolves outside the config home's logs directory - ` +
180
- 'the activity log lives inside <home>/logs'
181
- );
182
- err.code = 'TF_LOG_PATH_REFUSED';
183
- throw err;
184
- }
185
-
186
- const next = {
187
- enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
188
- path:
189
- typeof p.path === 'string' && p.path.length > 0 ? p.path : current.path,
190
- };
191
-
192
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
193
- // Atomic write: write a unique temp file, then rename over the target.
194
- const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
195
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
196
- fs.renameSync(tmp, CONFIG_PATH);
197
-
198
- return next;
199
- }
200
-
201
- /**
202
- * Read the trailing slice of a file that is expected to contain the last `wanted` lines:
203
- * the final min(size, max(64 KB, wanted KB)) bytes. When the read starts mid-file the
204
- * first segment may be a PARTIAL line, so it is dropped (mid-character utf8 starts land
205
- * in that dropped segment too). Returns the complete lines it can vouch for, plus whether
206
- * the whole file was covered - the caller falls back to a whole-file read on a shortfall
207
- * (e.g. one line longer than the window), so the bounded path can never change an answer,
208
- * only the bytes it took to produce it.
209
- * @param {string} logPath
210
- * @param {number} wanted
211
- * @returns {{ lines: string[], wholeFile: boolean }}
212
- */
213
- function readTrailingLines(logPath, wanted) {
214
- const fd = fs.openSync(logPath, 'r');
215
- try {
216
- const size = fs.fstatSync(fd).size;
217
- const budget = Math.max(TAIL_READ_MIN_BYTES, wanted * TAIL_BYTES_PER_LINE);
218
- const readLen = Math.min(size, budget);
219
- const position = size - readLen;
220
- const buf = Buffer.alloc(readLen);
221
- let got = 0;
222
- while (got < readLen) {
223
- const r = fs.readSync(fd, buf, got, readLen - got, position + got);
224
- if (r <= 0) break; // shrank underneath us (rotation) - use what we have
225
- got += r;
226
- }
227
- let text = buf.toString('utf8', 0, got);
228
- if (position > 0) {
229
- const firstNl = text.indexOf('\n');
230
- text = firstNl === -1 ? '' : text.slice(firstNl + 1);
231
- }
232
- const lines = text.split('\n').filter(function (l) { return l.length > 0; });
233
- return { lines, wholeFile: position === 0 };
234
- } finally {
235
- try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
236
- }
237
- }
238
-
239
- /**
240
- * tail - return the last n parsed JSON lines from the configured log.
241
- *
242
- * Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
243
- * n is not a positive finite number, all lines are returned. Never throws.
244
- *
245
- * A finite n reads a BOUNDED trailing slice (~1 KB budgeted per requested line), not the
246
- * whole file - with a whole-file fallback when the slice holds fewer than n lines, so the
247
- * answer is byte-identical to the unbounded engine's in every case. The no-argument mode
248
- * still reads everything by design; rotation (MAX_LOG_BYTES) bounds that file for it.
249
- *
250
- * @param {number} [n] how many trailing lines to parse and return.
251
- * @returns {object[]}
252
- */
253
- function tail(n) {
254
- try {
255
- const cfg = getConfig();
256
- const logPath = resolveLogPath(cfg.path);
257
- if (logPath === null) return []; // cannot happen via getConfig (it sanitises); belt-and-braces
258
-
259
- const bounded = typeof n === 'number' && isFinite(n) && n > 0;
260
- let lines;
261
- let wanted;
262
- if (bounded) {
263
- wanted = Math.floor(n);
264
- const sliceRead = readTrailingLines(logPath, wanted);
265
- if (sliceRead.lines.length >= wanted || sliceRead.wholeFile) {
266
- lines = sliceRead.lines;
267
- } else {
268
- // Shortfall on a partial window (unusually long lines): take the exact answer
269
- // over the byte bound.
270
- lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
271
- return l.length > 0;
272
- });
273
- }
274
- } else {
275
- lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
276
- return l.length > 0;
277
- });
278
- wanted = lines.length;
279
- }
280
-
281
- const slice = lines.slice(-wanted);
282
-
283
- const out = [];
284
- for (let i = 0; i < slice.length; i++) {
285
- try {
286
- out.push(JSON.parse(slice[i]));
287
- } catch (_e) {
288
- // Skip a corrupt/partial line rather than failing the whole tail.
289
- }
290
- }
291
- return out;
292
- } catch (_err) {
293
- return [];
294
- }
295
- }
296
-
297
- module.exports = { log, setConfig, getConfig, tail };
1
+ 'use strict';
2
+
3
+ /**
4
+ * logger.js - a toggleable JSONL activity/audit log for the gateway.
5
+ *
6
+ * DEFAULT OFF (privacy + lean). Nothing is written, and no files are created, unless
7
+ * logging has been explicitly enabled via setConfig(). A MISSING config file means
8
+ * disabled - the safe default - so a fresh checkout logs nothing.
9
+ *
10
+ * Design rules:
11
+ * - Zero dependencies. Node built-ins only (fs, path). No transport, no SDK.
12
+ * - Config is read FRESH on every call (no caching), so a toggle takes effect for the
13
+ * very next event without a reconnect or restart.
14
+ * - log() must NEVER throw. A logging failure (bad config, unwritable disk, full FS)
15
+ * must never break a tool call or the gate. Everything is wrapped; failures are
16
+ * swallowed silently.
17
+ *
18
+ * Paths are resolved against the config home, and the resolved path MUST stay inside
19
+ * <home>/logs/ (0.7.0; logging.test.js step 5 is the acceptance). The log path is
20
+ * settable from the tf_log tool surface and the UI's POST /api/logs/config, and every
21
+ * other file-writing path in the repo is confined to its root (hook-loader's scripts-dir
22
+ * assert, registry.writeScript, sendStatic) - this one now matches. setConfig REFUSES an
23
+ * out-of-home path loudly; getConfig treats one already in the file as unset (default),
24
+ * so log() and tail() stay in-home even against a hand-edited config. An operator who
25
+ * wants the log on another disk can make <home>/logs a symlink/junction - containment is
26
+ * checked on the unfollowed path.
27
+ *
28
+ * Config file: <root>/logs/log.config.json
29
+ * { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
30
+ *
31
+ * Both paths are BOUNDED (0.7.0, log-rotation.test.js): the live file rotates to `<path>.1`
32
+ * at MAX_LOG_BYTES (one older generation kept), and tail(n) reads a trailing slice rather
33
+ * than the whole file - with a whole-file fallback so bounding never changes an answer.
34
+ *
35
+ * CommonJS only.
36
+ */
37
+
38
+ const fs = require('node:fs');
39
+ const path = require('node:path');
40
+
41
+ /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
42
+ * config-home.js). Logs + their toggle are user-state, so they live with the home. */
43
+ const { resolveConfigHome } = require('./config-home');
44
+ const ROOT = resolveConfigHome();
45
+
46
+ /** The toggle/config file. NOT created until setConfig() writes it. */
47
+ const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
48
+
49
+ /** Safe defaults - used whenever the config file is absent or unreadable. */
50
+ const DEFAULT_ENABLED = false;
51
+ const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
52
+
53
+ /**
54
+ * The live file's size cap. An append that finds the file at/over it first renames the
55
+ * file to `<path>.1` (ONE older generation, overwritten by the next rotation), so disk
56
+ * use is bounded at ~2x the cap and the no-argument tail() (the documented all-lines
57
+ * mode, tf_log status's count) can never be asked to parse an unbounded file again.
58
+ * Rotation is best-effort: if the rename is refused (e.g. a reader holds `.1` open on
59
+ * win32) the append proceeds on the oversized live file and rotation retries next call -
60
+ * a record is never dropped for the sake of the cap.
61
+ */
62
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
63
+
64
+ /** tail(n)'s bounded read: bytes budgeted per requested line (generous - records run
65
+ * ~100-200 bytes), with a floor so tiny n still reads one decent chunk. */
66
+ const TAIL_BYTES_PER_LINE = 1024;
67
+ const TAIL_READ_MIN_BYTES = 64 * 1024;
68
+
69
+ /** The directory every log path must resolve inside. */
70
+ const LOG_DIR = path.join(ROOT, 'logs');
71
+
72
+ /**
73
+ * Resolve a (possibly relative) log path against the config home, REQUIRING the result
74
+ * to stay inside <home>/logs/. Returns the resolved absolute path, or null when it
75
+ * resolves outside (same refusal shape as registry.js defaultRunScript: '' would be the
76
+ * dir itself, '..'-prefixed left it, absolute rel = another drive on win32).
77
+ * @param {string} p
78
+ * @returns {string|null}
79
+ */
80
+ function resolveLogPath(p) {
81
+ const resolved = path.resolve(ROOT, String(p));
82
+ const rel = path.relative(LOG_DIR, resolved);
83
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
84
+ return resolved;
85
+ }
86
+
87
+ /**
88
+ * getConfig - the resolved { enabled, path }.
89
+ *
90
+ * Reads the config file fresh. A missing/unreadable/malformed file resolves to the
91
+ * safe defaults (disabled). Never throws.
92
+ *
93
+ * @returns {{ enabled: boolean, path: string }}
94
+ */
95
+ function getConfig() {
96
+ try {
97
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
98
+ const parsed = JSON.parse(raw);
99
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
100
+ return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
101
+ }
102
+ return {
103
+ enabled: parsed.enabled === true,
104
+ // A path that resolves outside <home>/logs/ is treated as UNSET, not honoured: the
105
+ // config file can be edited directly, and the readers (log/tail) must stay in-home
106
+ // regardless of how the value got there. setConfig additionally refuses one loudly.
107
+ path:
108
+ typeof parsed.path === 'string' && parsed.path.length > 0 && resolveLogPath(parsed.path) !== null
109
+ ? parsed.path
110
+ : DEFAULT_PATH,
111
+ };
112
+ } catch (_err) {
113
+ // Missing file = disabled (the safe default); also covers unreadable/malformed.
114
+ return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * log - append one JSONL record IF logging is enabled, else a silent no-op.
120
+ *
121
+ * The record is the event fields plus a logger-stamped ISO-8601 "ts" timestamp.
122
+ * Resolves the configured path against root, mkdir -p its directory, and appends one
123
+ * line with fs.appendFileSync. NEVER throws - any failure is swallowed.
124
+ *
125
+ * @param {object} event arbitrary serialisable fields (e.g. { type, tool, decision }).
126
+ */
127
+ function log(event) {
128
+ try {
129
+ const cfg = getConfig();
130
+ if (!cfg.enabled) return; // default-off: no-op, no file created.
131
+
132
+ const record = Object.assign(
133
+ { ts: new Date().toISOString() },
134
+ event && typeof event === 'object' && !Array.isArray(event) ? event : {}
135
+ );
136
+
137
+ const logPath = resolveLogPath(cfg.path);
138
+ if (logPath === null) return; // cannot happen via getConfig (it sanitises); belt-and-braces
139
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
140
+
141
+ // Rotate BEFORE the append when the live file has reached the cap. Its own try/catch:
142
+ // a refused stat/rename must never cost the record below. `logPath + '.1'` inherits
143
+ // logPath's containment, and rename replaces an existing `.1` (one older generation).
144
+ try {
145
+ if (fs.statSync(logPath).size >= MAX_LOG_BYTES) {
146
+ fs.renameSync(logPath, logPath + '.1');
147
+ }
148
+ } catch (_e) {
149
+ // Missing file (nothing to rotate) or a refused rename - append regardless.
150
+ }
151
+
152
+ fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
153
+ } catch (_err) {
154
+ // Logging must NEVER break the caller. Swallow everything.
155
+ }
156
+ }
157
+
158
+ /**
159
+ * setConfig - atomically merge a patch into logs/log.config.json (temp + rename).
160
+ *
161
+ * Merges with the current resolved config so a partial patch (e.g. { enabled: true })
162
+ * preserves the existing path. Creates the logs/ dir and the config file if absent -
163
+ * this is the ONLY function that creates the config file.
164
+ *
165
+ * @param {{ enabled?: boolean, path?: string }} patch
166
+ * @returns {{ enabled: boolean, path: string }} the merged, written config.
167
+ */
168
+ function setConfig(patch) {
169
+ const current = getConfig();
170
+ const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
171
+
172
+ // Refuse an out-of-home path LOUDLY before anything is written. Reaches every setter
173
+ // surface (tf_log's { ok:false }, the UI's error response) with nothing persisted.
174
+ // `current.path` needs no re-check - getConfig only ever returns a contained path.
175
+ // The code lets callers tell "you sent a bad path" (client input -> the UI's 400) apart
176
+ // from a genuine write failure (disk, perms -> 500) without matching on message prose.
177
+ if (typeof p.path === 'string' && p.path.length > 0 && resolveLogPath(p.path) === null) {
178
+ const err = new Error(
179
+ `log path refused: "${p.path}" resolves outside the config home's logs directory - ` +
180
+ 'the activity log lives inside <home>/logs'
181
+ );
182
+ err.code = 'TF_LOG_PATH_REFUSED';
183
+ throw err;
184
+ }
185
+
186
+ const next = {
187
+ enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
188
+ path:
189
+ typeof p.path === 'string' && p.path.length > 0 ? p.path : current.path,
190
+ };
191
+
192
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
193
+ // Atomic write: write a unique temp file, then rename over the target.
194
+ const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
195
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
196
+ fs.renameSync(tmp, CONFIG_PATH);
197
+
198
+ return next;
199
+ }
200
+
201
+ /**
202
+ * Read the trailing slice of a file that is expected to contain the last `wanted` lines:
203
+ * the final min(size, max(64 KB, wanted KB)) bytes. When the read starts mid-file the
204
+ * first segment may be a PARTIAL line, so it is dropped (mid-character utf8 starts land
205
+ * in that dropped segment too). Returns the complete lines it can vouch for, plus whether
206
+ * the whole file was covered - the caller falls back to a whole-file read on a shortfall
207
+ * (e.g. one line longer than the window), so the bounded path can never change an answer,
208
+ * only the bytes it took to produce it.
209
+ * @param {string} logPath
210
+ * @param {number} wanted
211
+ * @returns {{ lines: string[], wholeFile: boolean }}
212
+ */
213
+ function readTrailingLines(logPath, wanted) {
214
+ const fd = fs.openSync(logPath, 'r');
215
+ try {
216
+ const size = fs.fstatSync(fd).size;
217
+ const budget = Math.max(TAIL_READ_MIN_BYTES, wanted * TAIL_BYTES_PER_LINE);
218
+ const readLen = Math.min(size, budget);
219
+ const position = size - readLen;
220
+ const buf = Buffer.alloc(readLen);
221
+ let got = 0;
222
+ while (got < readLen) {
223
+ const r = fs.readSync(fd, buf, got, readLen - got, position + got);
224
+ if (r <= 0) break; // shrank underneath us (rotation) - use what we have
225
+ got += r;
226
+ }
227
+ let text = buf.toString('utf8', 0, got);
228
+ if (position > 0) {
229
+ const firstNl = text.indexOf('\n');
230
+ text = firstNl === -1 ? '' : text.slice(firstNl + 1);
231
+ }
232
+ const lines = text.split('\n').filter(function (l) { return l.length > 0; });
233
+ return { lines, wholeFile: position === 0 };
234
+ } finally {
235
+ try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * tail - return the last n parsed JSON lines from the configured log.
241
+ *
242
+ * Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
243
+ * n is not a positive finite number, all lines are returned. Never throws.
244
+ *
245
+ * A finite n reads a BOUNDED trailing slice (~1 KB budgeted per requested line), not the
246
+ * whole file - with a whole-file fallback when the slice holds fewer than n lines, so the
247
+ * answer is byte-identical to the unbounded engine's in every case. The no-argument mode
248
+ * still reads everything by design; rotation (MAX_LOG_BYTES) bounds that file for it.
249
+ *
250
+ * @param {number} [n] how many trailing lines to parse and return.
251
+ * @returns {object[]}
252
+ */
253
+ function tail(n) {
254
+ try {
255
+ const cfg = getConfig();
256
+ const logPath = resolveLogPath(cfg.path);
257
+ if (logPath === null) return []; // cannot happen via getConfig (it sanitises); belt-and-braces
258
+
259
+ const bounded = typeof n === 'number' && isFinite(n) && n > 0;
260
+ let lines;
261
+ let wanted;
262
+ if (bounded) {
263
+ wanted = Math.floor(n);
264
+ const sliceRead = readTrailingLines(logPath, wanted);
265
+ if (sliceRead.lines.length >= wanted || sliceRead.wholeFile) {
266
+ lines = sliceRead.lines;
267
+ } else {
268
+ // Shortfall on a partial window (unusually long lines): take the exact answer
269
+ // over the byte bound.
270
+ lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
271
+ return l.length > 0;
272
+ });
273
+ }
274
+ } else {
275
+ lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(function (l) {
276
+ return l.length > 0;
277
+ });
278
+ wanted = lines.length;
279
+ }
280
+
281
+ const slice = lines.slice(-wanted);
282
+
283
+ const out = [];
284
+ for (let i = 0; i < slice.length; i++) {
285
+ try {
286
+ out.push(JSON.parse(slice[i]));
287
+ } catch (_e) {
288
+ // Skip a corrupt/partial line rather than failing the whole tail.
289
+ }
290
+ }
291
+ return out;
292
+ } catch (_err) {
293
+ return [];
294
+ }
295
+ }
296
+
297
+ module.exports = { log, setConfig, getConfig, tail };