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