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.
- package/README.md +228 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +451 -451
- package/docs/KNOWN_BUGS.md +80 -80
- package/docs/design.md +84 -84
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -380
- package/package.json +67 -67
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -353
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -329
- package/src/core/hook-loader.js +824 -824
- package/src/core/hook-runner.js +566 -566
- package/src/core/logger.js +297 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1674
- package/src/mcp/expose-store.js +577 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1563 -1563
- package/src/mcp/mcp-client.js +1070 -1070
- package/src/mcp/modern.js +406 -406
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -3112
- package/src/tools/registry.js +661 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
package/src/core/logger.js
CHANGED
|
@@ -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 };
|