@vellumai/credential-executor 0.5.16 → 0.6.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/Dockerfile +2 -2
- package/bun.lock +58 -0
- package/package.json +4 -2
- package/src/http/log-export-routes.test.ts +249 -0
- package/src/http/log-export-routes.ts +219 -0
- package/src/log-redact.ts +133 -0
- package/src/logger.ts +143 -0
- package/src/main.ts +16 -12
- package/src/managed-main.ts +39 -29
- package/src/paths.ts +5 -0
package/Dockerfile
CHANGED
|
@@ -9,8 +9,8 @@ RUN apt-get update && apt-get install -y \
|
|
|
9
9
|
unzip \
|
|
10
10
|
&& rm -rf /var/lib/apt/lists/*
|
|
11
11
|
|
|
12
|
-
# Install bun
|
|
13
|
-
RUN curl -fsSL https://bun.sh/install | bash
|
|
12
|
+
# Install bun (pinned version)
|
|
13
|
+
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.11"
|
|
14
14
|
ENV PATH="/root/.bun/bin:${PATH}"
|
|
15
15
|
|
|
16
16
|
# Copy shared packages first (needed for repo-local dependencies)
|
package/bun.lock
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"@vellumai/ces-contracts": "file:../packages/ces-contracts",
|
|
9
9
|
"@vellumai/credential-storage": "file:../packages/credential-storage",
|
|
10
10
|
"@vellumai/egress-proxy": "file:../packages/egress-proxy",
|
|
11
|
+
"pino": "^9.6.0",
|
|
12
|
+
"pino-pretty": "^13.1.3",
|
|
11
13
|
},
|
|
12
14
|
"devDependencies": {
|
|
13
15
|
"@types/bun": "^1.2.4",
|
|
@@ -16,6 +18,8 @@
|
|
|
16
18
|
},
|
|
17
19
|
},
|
|
18
20
|
"packages": {
|
|
21
|
+
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
|
22
|
+
|
|
19
23
|
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
|
20
24
|
|
|
21
25
|
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
|
@@ -26,12 +30,66 @@
|
|
|
26
30
|
|
|
27
31
|
"@vellumai/egress-proxy": ["@vellumai/egress-proxy@file:../packages/egress-proxy", { "devDependencies": { "@types/bun": "^1.2.4", "typescript": "^5.7.3" } }],
|
|
28
32
|
|
|
33
|
+
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
|
34
|
+
|
|
29
35
|
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
|
30
36
|
|
|
37
|
+
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
|
38
|
+
|
|
39
|
+
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
|
40
|
+
|
|
41
|
+
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
|
42
|
+
|
|
43
|
+
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
|
|
44
|
+
|
|
45
|
+
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
|
46
|
+
|
|
47
|
+
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
|
48
|
+
|
|
49
|
+
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
|
50
|
+
|
|
51
|
+
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
|
52
|
+
|
|
53
|
+
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
|
54
|
+
|
|
55
|
+
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
|
56
|
+
|
|
57
|
+
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
|
|
58
|
+
|
|
59
|
+
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
|
60
|
+
|
|
61
|
+
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
|
|
62
|
+
|
|
63
|
+
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
|
64
|
+
|
|
65
|
+
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
|
66
|
+
|
|
67
|
+
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
|
68
|
+
|
|
69
|
+
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
|
70
|
+
|
|
71
|
+
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
|
72
|
+
|
|
73
|
+
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
|
74
|
+
|
|
75
|
+
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
|
76
|
+
|
|
77
|
+
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
|
78
|
+
|
|
79
|
+
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
|
80
|
+
|
|
81
|
+
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
|
82
|
+
|
|
83
|
+
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
|
84
|
+
|
|
31
85
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
32
86
|
|
|
33
87
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
|
34
88
|
|
|
89
|
+
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
|
90
|
+
|
|
35
91
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
|
92
|
+
|
|
93
|
+
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
|
36
94
|
}
|
|
37
95
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/credential-executor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@vellumai/ces-contracts": "file:../packages/ces-contracts",
|
|
22
22
|
"@vellumai/credential-storage": "file:../packages/credential-storage",
|
|
23
|
-
"@vellumai/egress-proxy": "file:../packages/egress-proxy"
|
|
23
|
+
"@vellumai/egress-proxy": "file:../packages/egress-proxy",
|
|
24
|
+
"pino": "^9.6.0",
|
|
25
|
+
"pino-pretty": "^13.1.3"
|
|
24
26
|
},
|
|
25
27
|
"bundledDependencies": [
|
|
26
28
|
"@vellumai/ces-contracts",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the CES log export route handler.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Returns a valid tar.gz archive containing CES log files
|
|
6
|
+
* - Filters log files by startTime query param
|
|
7
|
+
* - Filters log files by endTime query param
|
|
8
|
+
* - Returns an empty archive (manifest only) when no logs exist
|
|
9
|
+
* - Returns 401/403 without a valid service token
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
import { handleLogExportRoute } from "./log-export-routes.js";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const SERVICE_TOKEN = "test-ces-service-token-12345";
|
|
32
|
+
|
|
33
|
+
let tmpLogDir: string;
|
|
34
|
+
|
|
35
|
+
function makeRequest(
|
|
36
|
+
opts: {
|
|
37
|
+
startTime?: number;
|
|
38
|
+
endTime?: number;
|
|
39
|
+
token?: string | null;
|
|
40
|
+
method?: string;
|
|
41
|
+
} = {},
|
|
42
|
+
): Request {
|
|
43
|
+
const params = new URLSearchParams();
|
|
44
|
+
if (opts.startTime !== undefined)
|
|
45
|
+
params.set("startTime", String(opts.startTime));
|
|
46
|
+
if (opts.endTime !== undefined) params.set("endTime", String(opts.endTime));
|
|
47
|
+
|
|
48
|
+
const qs = params.toString();
|
|
49
|
+
const url = `http://localhost:8090/v1/logs/export${qs ? `?${qs}` : ""}`;
|
|
50
|
+
|
|
51
|
+
const headers: Record<string, string> = {};
|
|
52
|
+
if (opts.token !== null) {
|
|
53
|
+
headers["Authorization"] = `Bearer ${opts.token ?? SERVICE_TOKEN}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new Request(url, { method: opts.method ?? "GET", headers });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract a tar.gz buffer and return the list of file paths inside.
|
|
61
|
+
*/
|
|
62
|
+
function extractTarGzEntries(buf: ArrayBuffer): string[] {
|
|
63
|
+
const staging = mkdtempSync(join(tmpdir(), "ces-test-extract-"));
|
|
64
|
+
try {
|
|
65
|
+
const tarGzPath = join(staging, "archive.tar.gz");
|
|
66
|
+
writeFileSync(tarGzPath, Buffer.from(buf));
|
|
67
|
+
|
|
68
|
+
const extractDir = join(staging, "out");
|
|
69
|
+
mkdirSync(extractDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
const proc = spawnSync("tar", ["xzf", tarGzPath, "-C", extractDir]);
|
|
72
|
+
if (proc.status !== 0) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`tar extraction failed: ${proc.stderr?.toString() ?? "unknown"}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Recursively list all files
|
|
79
|
+
const files: string[] = [];
|
|
80
|
+
function walk(dir: string, prefix: string) {
|
|
81
|
+
for (const entry of readdirSync(dir)) {
|
|
82
|
+
const full = join(dir, entry);
|
|
83
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
84
|
+
if (statSync(full).isDirectory()) {
|
|
85
|
+
walk(full, rel);
|
|
86
|
+
} else {
|
|
87
|
+
files.push(rel);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
walk(extractDir, "");
|
|
92
|
+
return files.sort();
|
|
93
|
+
} finally {
|
|
94
|
+
rmSync(staging, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Setup / Teardown
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
tmpLogDir = mkdtempSync(join(tmpdir(), "ces-log-export-test-"));
|
|
104
|
+
process.env["CES_SERVICE_TOKEN"] = SERVICE_TOKEN;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
rmSync(tmpLogDir, { recursive: true, force: true });
|
|
109
|
+
delete process.env["CES_SERVICE_TOKEN"];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Tests
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
describe("CES log export route", () => {
|
|
117
|
+
it("returns tar.gz with CES log files", async () => {
|
|
118
|
+
// Create test log files
|
|
119
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-15.log"), "log line 1\n");
|
|
120
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-16.log"), "log line 2\n");
|
|
121
|
+
|
|
122
|
+
const res = await handleLogExportRoute(makeRequest(), tmpLogDir);
|
|
123
|
+
expect(res).not.toBeNull();
|
|
124
|
+
expect(res!.status).toBe(200);
|
|
125
|
+
expect(res!.headers.get("Content-Type")).toBe("application/gzip");
|
|
126
|
+
|
|
127
|
+
const buf = await res!.arrayBuffer();
|
|
128
|
+
const entries = extractTarGzEntries(buf);
|
|
129
|
+
|
|
130
|
+
// Should contain the manifest and the two log files
|
|
131
|
+
expect(entries).toContain("ces-export-manifest.json");
|
|
132
|
+
expect(entries).toContain("ces-logs/ces-2025-01-15.log");
|
|
133
|
+
expect(entries).toContain("ces-logs/ces-2025-01-16.log");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("filters by startTime query param", async () => {
|
|
137
|
+
// 2025-01-15 = 1736899200000 (start of day UTC)
|
|
138
|
+
// 2025-01-16 = 1736985600000 (start of day UTC)
|
|
139
|
+
// 2025-01-17 = 1737072000000 (start of day UTC)
|
|
140
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-15.log"), "old log\n");
|
|
141
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-16.log"), "recent log\n");
|
|
142
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-17.log"), "newest log\n");
|
|
143
|
+
|
|
144
|
+
// startTime at 2025-01-16 12:00:00 UTC — should include 01-16 and 01-17
|
|
145
|
+
// because 01-16 day end (23:59:59.999) >= startTime
|
|
146
|
+
const startTime = new Date("2025-01-16T12:00:00Z").getTime();
|
|
147
|
+
const res = await handleLogExportRoute(
|
|
148
|
+
makeRequest({ startTime }),
|
|
149
|
+
tmpLogDir,
|
|
150
|
+
);
|
|
151
|
+
expect(res).not.toBeNull();
|
|
152
|
+
expect(res!.status).toBe(200);
|
|
153
|
+
|
|
154
|
+
const entries = extractTarGzEntries(await res!.arrayBuffer());
|
|
155
|
+
expect(entries).not.toContain("ces-logs/ces-2025-01-15.log");
|
|
156
|
+
expect(entries).toContain("ces-logs/ces-2025-01-16.log");
|
|
157
|
+
expect(entries).toContain("ces-logs/ces-2025-01-17.log");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("filters by endTime query param", async () => {
|
|
161
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-15.log"), "old log\n");
|
|
162
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-16.log"), "recent log\n");
|
|
163
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-17.log"), "newest log\n");
|
|
164
|
+
|
|
165
|
+
// endTime at 2025-01-16 00:00:00 UTC — should include 01-15 and 01-16
|
|
166
|
+
// because 01-16 day start (00:00:00) <= endTime, but 01-17 day start > endTime
|
|
167
|
+
const endTime = new Date("2025-01-16T00:00:00Z").getTime();
|
|
168
|
+
const res = await handleLogExportRoute(makeRequest({ endTime }), tmpLogDir);
|
|
169
|
+
expect(res).not.toBeNull();
|
|
170
|
+
expect(res!.status).toBe(200);
|
|
171
|
+
|
|
172
|
+
const entries = extractTarGzEntries(await res!.arrayBuffer());
|
|
173
|
+
expect(entries).toContain("ces-logs/ces-2025-01-15.log");
|
|
174
|
+
expect(entries).toContain("ces-logs/ces-2025-01-16.log");
|
|
175
|
+
expect(entries).not.toContain("ces-logs/ces-2025-01-17.log");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns empty archive when no logs exist", async () => {
|
|
179
|
+
// tmpLogDir exists but has no log files
|
|
180
|
+
const res = await handleLogExportRoute(makeRequest(), tmpLogDir);
|
|
181
|
+
expect(res).not.toBeNull();
|
|
182
|
+
expect(res!.status).toBe(200);
|
|
183
|
+
|
|
184
|
+
const entries = extractTarGzEntries(await res!.arrayBuffer());
|
|
185
|
+
// Should still contain the manifest
|
|
186
|
+
expect(entries).toContain("ces-export-manifest.json");
|
|
187
|
+
// No log files in ces-logs/
|
|
188
|
+
const logFiles = entries.filter((e) => e.startsWith("ces-logs/"));
|
|
189
|
+
expect(logFiles.length).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns 401 without Authorization header", async () => {
|
|
193
|
+
const res = await handleLogExportRoute(
|
|
194
|
+
makeRequest({ token: null }),
|
|
195
|
+
tmpLogDir,
|
|
196
|
+
);
|
|
197
|
+
expect(res).not.toBeNull();
|
|
198
|
+
expect(res!.status).toBe(401);
|
|
199
|
+
|
|
200
|
+
const body = await res!.json();
|
|
201
|
+
expect(body.error).toMatch(/Missing Authorization/i);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns 403 with wrong service token", async () => {
|
|
205
|
+
const res = await handleLogExportRoute(
|
|
206
|
+
makeRequest({ token: "wrong-token-value" }),
|
|
207
|
+
tmpLogDir,
|
|
208
|
+
);
|
|
209
|
+
expect(res).not.toBeNull();
|
|
210
|
+
expect(res!.status).toBe(403);
|
|
211
|
+
|
|
212
|
+
const body = await res!.json();
|
|
213
|
+
expect(body.error).toMatch(/Invalid service token/i);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns null for non-matching paths", async () => {
|
|
217
|
+
const req = new Request("http://localhost:8090/v1/other", {
|
|
218
|
+
method: "GET",
|
|
219
|
+
headers: { Authorization: `Bearer ${SERVICE_TOKEN}` },
|
|
220
|
+
});
|
|
221
|
+
const res = await handleLogExportRoute(req, tmpLogDir);
|
|
222
|
+
expect(res).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns 405 for non-GET methods on the export path", async () => {
|
|
226
|
+
const res = await handleLogExportRoute(
|
|
227
|
+
makeRequest({ method: "POST" }),
|
|
228
|
+
tmpLogDir,
|
|
229
|
+
);
|
|
230
|
+
expect(res).not.toBeNull();
|
|
231
|
+
expect(res!.status).toBe(405);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("ignores non-log files in the directory", async () => {
|
|
235
|
+
writeFileSync(join(tmpLogDir, "ces-2025-01-15.log"), "real log\n");
|
|
236
|
+
writeFileSync(join(tmpLogDir, "random-file.txt"), "not a log\n");
|
|
237
|
+
writeFileSync(join(tmpLogDir, "ces-bad-date.log"), "bad pattern\n");
|
|
238
|
+
|
|
239
|
+
const res = await handleLogExportRoute(makeRequest(), tmpLogDir);
|
|
240
|
+
expect(res).not.toBeNull();
|
|
241
|
+
expect(res!.status).toBe(200);
|
|
242
|
+
|
|
243
|
+
const entries = extractTarGzEntries(await res!.arrayBuffer());
|
|
244
|
+
expect(entries).toContain("ces-logs/ces-2025-01-15.log");
|
|
245
|
+
// Non-matching files should not be included
|
|
246
|
+
const logFiles = entries.filter((e) => e.startsWith("ces-logs/"));
|
|
247
|
+
expect(logFiles.length).toBe(1);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log export HTTP endpoint for the CES managed service.
|
|
3
|
+
*
|
|
4
|
+
* Exposes a single `GET /v1/logs/export` endpoint that collects CES log
|
|
5
|
+
* files, archives them as a tar.gz, and returns the archive. The gateway
|
|
6
|
+
* calls this endpoint to collect CES logs alongside daemon and gateway
|
|
7
|
+
* logs during a diagnostic log export.
|
|
8
|
+
*
|
|
9
|
+
* Auth: Requires a `CES_SERVICE_TOKEN` bearer token in the
|
|
10
|
+
* `Authorization` header (same token used for credential CRUD).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { timingSafeEqual } from "node:crypto";
|
|
26
|
+
|
|
27
|
+
import { LOG_FILE_PATTERN } from "../logger.js";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Constants
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Maximum cumulative size of collected log files (5 MB). */
|
|
34
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Auth
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate the Authorization header against the configured service token.
|
|
42
|
+
* Returns an error Response if auth fails, or null if auth succeeds.
|
|
43
|
+
*/
|
|
44
|
+
function checkAuth(req: Request, serviceToken: string): Response | null {
|
|
45
|
+
const authHeader = req.headers.get("authorization");
|
|
46
|
+
if (!authHeader) {
|
|
47
|
+
return new Response(
|
|
48
|
+
JSON.stringify({ error: "Missing Authorization header" }),
|
|
49
|
+
{ status: 401, headers: { "Content-Type": "application/json" } },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parts = authHeader.split(" ");
|
|
54
|
+
if (parts.length !== 2 || parts[0]!.toLowerCase() !== "bearer") {
|
|
55
|
+
return new Response(
|
|
56
|
+
JSON.stringify({ error: "Invalid Authorization header format. Expected: Bearer <token>" }),
|
|
57
|
+
{ status: 401, headers: { "Content-Type": "application/json" } },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const provided = Buffer.from(parts[1]!);
|
|
62
|
+
const expected = Buffer.from(serviceToken);
|
|
63
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
64
|
+
return new Response(
|
|
65
|
+
JSON.stringify({ error: "Invalid service token" }),
|
|
66
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Route handler
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Try to handle a log export request. Returns a Response if the request
|
|
79
|
+
* matches `GET /v1/logs/export`, or null if it doesn't match (allowing the
|
|
80
|
+
* caller to fall through to other routes).
|
|
81
|
+
*/
|
|
82
|
+
export async function handleLogExportRoute(
|
|
83
|
+
req: Request,
|
|
84
|
+
logDir: string,
|
|
85
|
+
): Promise<Response | null> {
|
|
86
|
+
const url = new URL(req.url);
|
|
87
|
+
|
|
88
|
+
// Only handle GET /v1/logs/export
|
|
89
|
+
if (url.pathname !== "/v1/logs/export") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (req.method !== "GET") {
|
|
94
|
+
return new Response(
|
|
95
|
+
JSON.stringify({ error: "Method not allowed" }),
|
|
96
|
+
{ status: 405, headers: { "Content-Type": "application/json" } },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Auth check
|
|
101
|
+
const serviceToken = process.env["CES_SERVICE_TOKEN"] ?? "";
|
|
102
|
+
if (!serviceToken) {
|
|
103
|
+
return new Response(
|
|
104
|
+
JSON.stringify({ error: "CES_SERVICE_TOKEN not configured" }),
|
|
105
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const authError = checkAuth(req, serviceToken);
|
|
110
|
+
if (authError) return authError;
|
|
111
|
+
|
|
112
|
+
// Parse optional time range query params
|
|
113
|
+
const startTimeParam = url.searchParams.get("startTime");
|
|
114
|
+
const endTimeParam = url.searchParams.get("endTime");
|
|
115
|
+
const startTime = startTimeParam ? Number(startTimeParam) : undefined;
|
|
116
|
+
const endTime = endTimeParam ? Number(endTimeParam) : undefined;
|
|
117
|
+
|
|
118
|
+
const staging = mkdtempSync(join(tmpdir(), "ces-log-export-"));
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const logsStaging = join(staging, "ces-logs");
|
|
122
|
+
mkdirSync(logsStaging, { recursive: true });
|
|
123
|
+
|
|
124
|
+
let totalBytes = 0;
|
|
125
|
+
let filesCollected = 0;
|
|
126
|
+
|
|
127
|
+
if (existsSync(logDir)) {
|
|
128
|
+
const entries = readdirSync(logDir);
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
const dateMatch = LOG_FILE_PATTERN.exec(entry);
|
|
131
|
+
if (!dateMatch) continue;
|
|
132
|
+
|
|
133
|
+
// Filter by date when startTime/endTime are provided.
|
|
134
|
+
// Parse the date from the filename and compare start-of-day / end-of-day
|
|
135
|
+
// against the time bounds.
|
|
136
|
+
const fileDateStr = dateMatch[1]!;
|
|
137
|
+
const fileDayStartMs = new Date(fileDateStr + "T00:00:00.000Z").getTime();
|
|
138
|
+
const fileDayEndMs = new Date(fileDateStr + "T23:59:59.999Z").getTime();
|
|
139
|
+
|
|
140
|
+
if (startTime !== undefined && fileDayEndMs < startTime) continue; // entire day before range
|
|
141
|
+
if (endTime !== undefined && fileDayStartMs > endTime) continue; // entire day after range
|
|
142
|
+
|
|
143
|
+
const filePath = join(logDir, entry);
|
|
144
|
+
try {
|
|
145
|
+
const stat = statSync(filePath);
|
|
146
|
+
if (!stat.isFile()) continue;
|
|
147
|
+
if (totalBytes + stat.size > MAX_LOG_BYTES) continue;
|
|
148
|
+
|
|
149
|
+
// Copy the file to the staging directory via Bun.file for efficiency,
|
|
150
|
+
// but fall back to sync fs for portability in the spawnSync-based flow.
|
|
151
|
+
const content = await Bun.file(filePath).arrayBuffer();
|
|
152
|
+
writeFileSync(join(logsStaging, entry), Buffer.from(content));
|
|
153
|
+
totalBytes += stat.size;
|
|
154
|
+
filesCollected++;
|
|
155
|
+
} catch {
|
|
156
|
+
// Skip unreadable files
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Always write a manifest so the consumer knows what was collected
|
|
162
|
+
const manifest = {
|
|
163
|
+
type: "ces-log-export",
|
|
164
|
+
exportedAt: new Date().toISOString(),
|
|
165
|
+
filesCollected,
|
|
166
|
+
totalBytes,
|
|
167
|
+
...(startTime !== undefined ? { startTime } : {}),
|
|
168
|
+
...(endTime !== undefined ? { endTime } : {}),
|
|
169
|
+
};
|
|
170
|
+
writeFileSync(
|
|
171
|
+
join(staging, "ces-export-manifest.json"),
|
|
172
|
+
JSON.stringify(manifest, null, 2),
|
|
173
|
+
"utf-8",
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Create tar.gz archive of the staging directory
|
|
177
|
+
const proc = spawnSync("tar", ["czf", "-", "-C", staging, "."], {
|
|
178
|
+
maxBuffer: MAX_LOG_BYTES * 2, // allow headroom for tar overhead
|
|
179
|
+
timeout: 30_000,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (proc.status !== 0) {
|
|
183
|
+
const stderr = proc.stderr
|
|
184
|
+
? Buffer.isBuffer(proc.stderr)
|
|
185
|
+
? proc.stderr.toString("utf-8")
|
|
186
|
+
: String(proc.stderr)
|
|
187
|
+
: "unknown error";
|
|
188
|
+
return new Response(
|
|
189
|
+
JSON.stringify({ error: "Failed to create archive", detail: stderr }),
|
|
190
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const archiveBuffer = Buffer.isBuffer(proc.stdout)
|
|
195
|
+
? proc.stdout
|
|
196
|
+
: Buffer.from(proc.stdout);
|
|
197
|
+
|
|
198
|
+
return new Response(
|
|
199
|
+
archiveBuffer.buffer.slice(
|
|
200
|
+
archiveBuffer.byteOffset,
|
|
201
|
+
archiveBuffer.byteOffset + archiveBuffer.byteLength,
|
|
202
|
+
),
|
|
203
|
+
{
|
|
204
|
+
status: 200,
|
|
205
|
+
headers: {
|
|
206
|
+
"Content-Type": "application/gzip",
|
|
207
|
+
"Content-Disposition": 'attachment; filename="ces-logs.tar.gz"',
|
|
208
|
+
"Content-Length": String(archiveBuffer.byteLength),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
} finally {
|
|
213
|
+
try {
|
|
214
|
+
rmSync(staging, { recursive: true, force: true });
|
|
215
|
+
} catch {
|
|
216
|
+
// Best-effort cleanup
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pino log serializers that scrub sensitive data (bearer tokens, API keys,
|
|
3
|
+
* authorization headers) from logged values.
|
|
4
|
+
*
|
|
5
|
+
* Standalone copy for the credential-executor package — kept in sync with
|
|
6
|
+
* gateway/src/log-redact.ts and assistant/src/util/log-redact.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Sensitive-value patterns
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const BEARER_RE = /Bearer [A-Za-z0-9._\-]+/g;
|
|
14
|
+
|
|
15
|
+
const API_KEY_PATTERNS: RegExp[] = [
|
|
16
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
17
|
+
/gh[pousr]_[A-Za-z0-9_]{36,255}/g,
|
|
18
|
+
/github_pat_[A-Za-z0-9_]{22,255}/g,
|
|
19
|
+
/glpat-[A-Za-z0-9\-_]{20,}/g,
|
|
20
|
+
/sk_live_[A-Za-z0-9]{24,}/g,
|
|
21
|
+
/rk_live_[A-Za-z0-9]{24,}/g,
|
|
22
|
+
/xoxb-[0-9]{10,}-[0-9]{10,}-[A-Za-z0-9]{24,}/g,
|
|
23
|
+
/xoxp-[0-9]{10,}-[0-9]{10,}-[0-9]{10,}-[a-f0-9]{32}/g,
|
|
24
|
+
/sk-ant-[A-Za-z0-9\-_]{80,}/g,
|
|
25
|
+
/sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}/g,
|
|
26
|
+
/sk-proj-[A-Za-z0-9\-_]{40,}/g,
|
|
27
|
+
/AIza[A-Za-z0-9\-_]{35}/g,
|
|
28
|
+
/GOCSPX-[A-Za-z0-9\-_]{28}/g,
|
|
29
|
+
/SG\.[A-Za-z0-9\-_]{22}\.[A-Za-z0-9\-_]{43}/g,
|
|
30
|
+
/[0-9]{8,10}:[A-Za-z0-9_-]{35}/g,
|
|
31
|
+
/npm_[A-Za-z0-9]{36}/g,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const SENSITIVE_HEADERS = new Set([
|
|
35
|
+
"authorization",
|
|
36
|
+
"proxy-authorization",
|
|
37
|
+
"cookie",
|
|
38
|
+
"set-cookie",
|
|
39
|
+
"x-api-key",
|
|
40
|
+
"x-auth-token",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// String redaction
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function redactString(value: string): string {
|
|
48
|
+
let result = value;
|
|
49
|
+
result = result.replace(BEARER_RE, "Bearer [REDACTED]");
|
|
50
|
+
for (const pattern of API_KEY_PATTERNS) {
|
|
51
|
+
pattern.lastIndex = 0;
|
|
52
|
+
result = result.replace(pattern, "[REDACTED]");
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Deep value redaction
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function redactValue(value: unknown, depth: number): unknown {
|
|
62
|
+
if (depth > 8) return value;
|
|
63
|
+
|
|
64
|
+
if (typeof value === "string") {
|
|
65
|
+
return redactString(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return value.map((item) => redactValue(item, depth + 1));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (value !== null && typeof value === "object") {
|
|
73
|
+
const result: Record<string, unknown> = {};
|
|
74
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
75
|
+
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
|
|
76
|
+
result[key] = "[REDACTED]";
|
|
77
|
+
} else {
|
|
78
|
+
result[key] = redactValue(val, depth + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Error serialization — extracts non-enumerable Error fields and cause chain
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function serializeError(err: unknown, depth: number): unknown {
|
|
92
|
+
if (depth > 8 || err == null) return err;
|
|
93
|
+
|
|
94
|
+
if (!(err instanceof Error)) {
|
|
95
|
+
return err;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const serialized: Record<string, unknown> = {
|
|
99
|
+
name: err.name,
|
|
100
|
+
message: err.message,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if ("code" in err && typeof (err as { code: unknown }).code === "string") {
|
|
104
|
+
serialized.code = (err as { code: string }).code;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (err.stack) {
|
|
108
|
+
serialized.stack = err.stack;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (err.cause !== undefined) {
|
|
112
|
+
serialized.cause = serializeError(err.cause, depth + 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Preserve any additional enumerable properties
|
|
116
|
+
for (const [key, val] of Object.entries(err)) {
|
|
117
|
+
if (!(key in serialized)) {
|
|
118
|
+
serialized[key] = val;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return serialized;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Pino serializers
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export const logSerializers: Record<string, (value: unknown) => unknown> = {
|
|
130
|
+
err: (err) => redactValue(serializeError(err, 0), 0),
|
|
131
|
+
req: (req) => redactValue(req, 0),
|
|
132
|
+
res: (res) => redactValue(res, 0),
|
|
133
|
+
};
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import pino from "pino";
|
|
10
|
+
import pinoPretty from "pino-pretty";
|
|
11
|
+
import { logSerializers } from "./log-redact.js";
|
|
12
|
+
|
|
13
|
+
export type LogFileConfig = {
|
|
14
|
+
dir: string | undefined;
|
|
15
|
+
retentionDays: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const LOG_FILE_PREFIX = "ces-";
|
|
19
|
+
const LOG_FILE_SUFFIX = ".log";
|
|
20
|
+
export const LOG_FILE_PATTERN = /^ces-(\d{4}-\d{2}-\d{2})\.log$/;
|
|
21
|
+
|
|
22
|
+
function formatDate(date: Date): string {
|
|
23
|
+
const y = date.getUTCFullYear();
|
|
24
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
25
|
+
const d = String(date.getUTCDate()).padStart(2, "0");
|
|
26
|
+
return `${y}-${m}-${d}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function logFilePathForDate(dir: string, date: Date): string {
|
|
30
|
+
return join(dir, `${LOG_FILE_PREFIX}${formatDate(date)}${LOG_FILE_SUFFIX}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function pruneOldLogFiles(dir: string, retentionDays: number): number {
|
|
34
|
+
if (!existsSync(dir)) return 0;
|
|
35
|
+
|
|
36
|
+
const cutoff = new Date();
|
|
37
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
|
|
38
|
+
cutoff.setUTCHours(0, 0, 0, 0);
|
|
39
|
+
|
|
40
|
+
let removed = 0;
|
|
41
|
+
for (const name of readdirSync(dir)) {
|
|
42
|
+
const match = LOG_FILE_PATTERN.exec(name);
|
|
43
|
+
if (!match) continue;
|
|
44
|
+
const fileDate = new Date(match[1] + "T00:00:00Z");
|
|
45
|
+
if (fileDate < cutoff) {
|
|
46
|
+
try {
|
|
47
|
+
unlinkSync(join(dir, name));
|
|
48
|
+
removed++;
|
|
49
|
+
} catch {
|
|
50
|
+
// best-effort
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return removed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let rootLogger: pino.Logger | null = null;
|
|
58
|
+
let activeLogDate: string | null = null;
|
|
59
|
+
let activeConfig: LogFileConfig | null = null;
|
|
60
|
+
|
|
61
|
+
function buildLogger(config: LogFileConfig | null): pino.Logger {
|
|
62
|
+
if (!config?.dir) {
|
|
63
|
+
return pino(
|
|
64
|
+
{ name: "ces", serializers: logSerializers },
|
|
65
|
+
pinoPretty({ destination: 2 }),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!existsSync(config.dir)) {
|
|
70
|
+
mkdirSync(config.dir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const today = formatDate(new Date());
|
|
74
|
+
const filePath = logFilePathForDate(config.dir, new Date());
|
|
75
|
+
const fileStream = pino.destination({
|
|
76
|
+
dest: filePath,
|
|
77
|
+
sync: false,
|
|
78
|
+
mkdir: true,
|
|
79
|
+
mode: 0o600,
|
|
80
|
+
});
|
|
81
|
+
// Tighten permissions on pre-existing log files that may have been created with looser modes
|
|
82
|
+
try {
|
|
83
|
+
chmodSync(filePath, 0o600);
|
|
84
|
+
} catch {
|
|
85
|
+
/* best-effort */
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
activeLogDate = today;
|
|
89
|
+
activeConfig = config;
|
|
90
|
+
|
|
91
|
+
return pino(
|
|
92
|
+
{ name: "ces", serializers: logSerializers },
|
|
93
|
+
pino.multistream([
|
|
94
|
+
{ stream: fileStream, level: "info" as const },
|
|
95
|
+
{ stream: pinoPretty({ destination: 2 }), level: "info" as const },
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ensureCurrentDate(): void {
|
|
101
|
+
if (!activeConfig?.dir || !activeLogDate) return;
|
|
102
|
+
const today = formatDate(new Date());
|
|
103
|
+
if (today !== activeLogDate) {
|
|
104
|
+
rootLogger = buildLogger(activeConfig);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function initLogger(config: LogFileConfig): void {
|
|
109
|
+
rootLogger = buildLogger(config);
|
|
110
|
+
|
|
111
|
+
if (config.dir && config.retentionDays > 0) {
|
|
112
|
+
const removed = pruneOldLogFiles(config.dir, config.retentionDays);
|
|
113
|
+
if (removed > 0) {
|
|
114
|
+
rootLogger.info(
|
|
115
|
+
{ removed, retentionDays: config.retentionDays },
|
|
116
|
+
"Pruned old log files",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns a lazy proxy logger that always delegates to the **current**
|
|
124
|
+
* rootLogger. This is critical because module-level `const log = getLogger(...)`
|
|
125
|
+
* calls execute before `initLogger()` runs. Without the proxy, those early
|
|
126
|
+
* child loggers would permanently hold the fallback stderr-only stream and
|
|
127
|
+
* never write to log files.
|
|
128
|
+
*/
|
|
129
|
+
export function getLogger(name: string): pino.Logger {
|
|
130
|
+
const handler: ProxyHandler<pino.Logger> = {
|
|
131
|
+
get(_target, prop, receiver) {
|
|
132
|
+
ensureCurrentDate();
|
|
133
|
+
if (!rootLogger) {
|
|
134
|
+
rootLogger = buildLogger(null);
|
|
135
|
+
}
|
|
136
|
+
const child = rootLogger.child({ module: name });
|
|
137
|
+
const value = Reflect.get(child, prop, receiver);
|
|
138
|
+
return typeof value === "function" ? value.bind(child) : value;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
// The proxy target is a throwaway logger — all access is intercepted.
|
|
142
|
+
return new Proxy({} as pino.Logger, handler);
|
|
143
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -41,10 +41,12 @@ import { createLocalOAuthLookup } from "./materializers/local-oauth-lookup.js";
|
|
|
41
41
|
import { createLocalTokenRefreshFn } from "./materializers/local-token-refresh.js";
|
|
42
42
|
import { resolveLocalSubject } from "./subjects/local.js";
|
|
43
43
|
import { checkCredentialPolicy } from "./subjects/policy.js";
|
|
44
|
+
import { initLogger, getLogger } from "./logger.js";
|
|
44
45
|
import {
|
|
45
46
|
getCesAuditDir,
|
|
46
47
|
getCesDataRoot,
|
|
47
48
|
getCesGrantsDir,
|
|
49
|
+
getCesLogDir,
|
|
48
50
|
getCesToolStoreDir,
|
|
49
51
|
} from "./paths.js";
|
|
50
52
|
import {
|
|
@@ -292,16 +294,16 @@ function buildHandlers(sessionIdRef: SessionIdRef): RpcHandlerRegistry {
|
|
|
292
294
|
async function main(): Promise<void> {
|
|
293
295
|
ensureDataDirs();
|
|
294
296
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
+
initLogger({ dir: getCesLogDir(), retentionDays: 30 });
|
|
298
|
+
const log = getLogger("main");
|
|
297
299
|
|
|
298
|
-
log(`Starting CES v${CES_PROTOCOL_VERSION} (local mode, stdio transport)`);
|
|
300
|
+
log.info(`Starting CES v${CES_PROTOCOL_VERSION} (local mode, stdio transport)`);
|
|
299
301
|
|
|
300
302
|
const controller = new AbortController();
|
|
301
303
|
|
|
302
304
|
// Graceful shutdown on SIGTERM / SIGINT
|
|
303
305
|
const shutdown = () => {
|
|
304
|
-
log("Shutting down...");
|
|
306
|
+
log.info("Shutting down...");
|
|
305
307
|
controller.abort();
|
|
306
308
|
};
|
|
307
309
|
process.on("SIGTERM", shutdown);
|
|
@@ -313,17 +315,15 @@ async function main(): Promise<void> {
|
|
|
313
315
|
const sessionIdRef: SessionIdRef = { current: `ces-local-${Date.now()}` };
|
|
314
316
|
const handlers = buildHandlers(sessionIdRef);
|
|
315
317
|
|
|
318
|
+
const rpcLog = getLogger("rpc");
|
|
316
319
|
const server = new CesRpcServer({
|
|
317
320
|
input: process.stdin,
|
|
318
321
|
output: process.stdout,
|
|
319
322
|
handlers,
|
|
320
323
|
logger: {
|
|
321
|
-
log: (msg: string, ...args: unknown[]) =>
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
process.stderr.write(`[ces-local] WARN: ${msg} ${args.map(String).join(" ")}\n`),
|
|
325
|
-
error: (msg: string, ...args: unknown[]) =>
|
|
326
|
-
process.stderr.write(`[ces-local] ERROR: ${msg} ${args.map(String).join(" ")}\n`),
|
|
324
|
+
log: (msg: string, ...args: unknown[]) => rpcLog.info({ args }, msg),
|
|
325
|
+
warn: (msg: string, ...args: unknown[]) => rpcLog.warn({ args }, msg),
|
|
326
|
+
error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
|
|
327
327
|
},
|
|
328
328
|
signal: controller.signal,
|
|
329
329
|
onHandshakeComplete: (hsSessionId) => {
|
|
@@ -334,10 +334,14 @@ async function main(): Promise<void> {
|
|
|
334
334
|
});
|
|
335
335
|
|
|
336
336
|
await server.serve();
|
|
337
|
-
log("Server stopped.");
|
|
337
|
+
log.info("Server stopped.");
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
main().catch((err) => {
|
|
341
|
-
|
|
341
|
+
try {
|
|
342
|
+
getLogger("main").fatal({ err }, "Fatal error");
|
|
343
|
+
} catch {
|
|
344
|
+
process.stderr.write(`[ces-local] Fatal: ${err}\n`);
|
|
345
|
+
}
|
|
342
346
|
process.exit(1);
|
|
343
347
|
});
|
package/src/managed-main.ts
CHANGED
|
@@ -33,11 +33,13 @@ import {
|
|
|
33
33
|
createRevokeGrantHandler,
|
|
34
34
|
} from "./grants/rpc-handlers.js";
|
|
35
35
|
import { TemporaryGrantStore } from "./grants/temporary-store.js";
|
|
36
|
+
import { initLogger, getLogger } from "./logger.js";
|
|
36
37
|
import {
|
|
37
38
|
getBootstrapSocketPath,
|
|
38
39
|
getCesAuditDir,
|
|
39
40
|
getCesDataRoot,
|
|
40
41
|
getCesGrantsDir,
|
|
42
|
+
getCesLogDir,
|
|
41
43
|
getCesToolStoreDir,
|
|
42
44
|
getHealthPort,
|
|
43
45
|
} from "./paths.js";
|
|
@@ -60,16 +62,16 @@ import { MANAGED_LOCAL_STATIC_REJECTION_ERROR } from "./managed-errors.js";
|
|
|
60
62
|
import type { SecureKeyBackend } from "@vellumai/credential-storage";
|
|
61
63
|
import { createLocalSecureKeyBackend } from "./materializers/local-secure-key-backend.js";
|
|
62
64
|
import { handleCredentialRoute, type CredentialRouteDeps } from "./http/credential-routes.js";
|
|
65
|
+
import { handleLogExportRoute } from "./http/log-export-routes.js";
|
|
63
66
|
|
|
64
67
|
// ---------------------------------------------------------------------------
|
|
65
|
-
// Logging
|
|
68
|
+
// Logging
|
|
66
69
|
// ---------------------------------------------------------------------------
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
process.stderr.write(`[ces-managed] WARN: ${msg}\n`);
|
|
71
|
+
// Module-level logger used before initLogger() runs (early bootstrap) and
|
|
72
|
+
// after it runs (structured file + stderr). Before initLogger() the fallback
|
|
73
|
+
// inside getLogger() writes to stderr only, so early messages still appear.
|
|
74
|
+
const log = getLogger("main");
|
|
73
75
|
|
|
74
76
|
// ---------------------------------------------------------------------------
|
|
75
77
|
// Data directory bootstrap
|
|
@@ -122,7 +124,7 @@ function buildHandlers(sessionIdRef: SessionIdRef, apiKeyRef: ApiKeyRef, assista
|
|
|
122
124
|
});
|
|
123
125
|
|
|
124
126
|
if (!platformBaseUrl) {
|
|
125
|
-
warn(
|
|
127
|
+
log.warn(
|
|
126
128
|
"VELLUM_PLATFORM_URL not set. " +
|
|
127
129
|
"Managed credential materialisation will depend on the handshake-provided values.",
|
|
128
130
|
);
|
|
@@ -388,6 +390,10 @@ function startHealthServer(
|
|
|
388
390
|
if (credentialResponse) return credentialResponse;
|
|
389
391
|
}
|
|
390
392
|
|
|
393
|
+
// Log export route
|
|
394
|
+
const logExportResponse = await handleLogExportRoute(req, getCesLogDir("managed"));
|
|
395
|
+
if (logExportResponse) return logExportResponse;
|
|
396
|
+
|
|
391
397
|
return new Response("Not Found", { status: 404 });
|
|
392
398
|
},
|
|
393
399
|
});
|
|
@@ -450,20 +456,20 @@ function acceptOneConnection(
|
|
|
450
456
|
});
|
|
451
457
|
|
|
452
458
|
netServer.listen(socketPath, () => {
|
|
453
|
-
log(`Bootstrap socket listening at ${socketPath}`);
|
|
459
|
+
log.info(`Bootstrap socket listening at ${socketPath}`);
|
|
454
460
|
});
|
|
455
461
|
|
|
456
462
|
netServer.on("connection", (socket: Socket) => {
|
|
457
463
|
// Accept exactly one connection, then close the listener and
|
|
458
464
|
// unlink the socket path so no other process can connect.
|
|
459
|
-
log("Assistant connected via bootstrap socket");
|
|
465
|
+
log.info("Assistant connected via bootstrap socket");
|
|
460
466
|
netServer.close();
|
|
461
467
|
try {
|
|
462
468
|
unlinkSync(socketPath);
|
|
463
469
|
} catch {
|
|
464
470
|
// Already unlinked
|
|
465
471
|
}
|
|
466
|
-
log("Bootstrap socket unlinked (single-connection enforced)");
|
|
472
|
+
log.info("Bootstrap socket unlinked (single-connection enforced)");
|
|
467
473
|
|
|
468
474
|
const readable = new Readable({
|
|
469
475
|
read() {
|
|
@@ -506,13 +512,15 @@ function acceptOneConnection(
|
|
|
506
512
|
async function main(): Promise<void> {
|
|
507
513
|
ensureDataDirs();
|
|
508
514
|
|
|
509
|
-
|
|
515
|
+
initLogger({ dir: getCesLogDir("managed"), retentionDays: 30 });
|
|
516
|
+
|
|
517
|
+
log.info(`Starting CES v${CES_PROTOCOL_VERSION} (managed mode)`);
|
|
510
518
|
|
|
511
519
|
const controller = new AbortController();
|
|
512
520
|
|
|
513
521
|
// Graceful shutdown
|
|
514
522
|
const shutdown = () => {
|
|
515
|
-
log("Shutting down...");
|
|
523
|
+
log.info("Shutting down...");
|
|
516
524
|
controller.abort();
|
|
517
525
|
};
|
|
518
526
|
process.on("SIGTERM", shutdown);
|
|
@@ -534,9 +542,9 @@ async function main(): Promise<void> {
|
|
|
534
542
|
|
|
535
543
|
if (serviceToken) {
|
|
536
544
|
credentialDeps = { backend: secureKeyBackend, serviceToken };
|
|
537
|
-
log("Credential CRUD routes enabled (CES_SERVICE_TOKEN configured)");
|
|
545
|
+
log.info("Credential CRUD routes enabled (CES_SERVICE_TOKEN configured)");
|
|
538
546
|
} else {
|
|
539
|
-
warn(
|
|
547
|
+
log.warn(
|
|
540
548
|
"CES_SERVICE_TOKEN not set — credential CRUD HTTP routes are disabled. " +
|
|
541
549
|
"Set CES_SERVICE_TOKEN to enable credential management over HTTP.",
|
|
542
550
|
);
|
|
@@ -545,18 +553,18 @@ async function main(): Promise<void> {
|
|
|
545
553
|
// Start health server on dedicated port
|
|
546
554
|
const healthPort = getHealthPort();
|
|
547
555
|
const healthServer = startHealthServer(healthPort, controller.signal, credentialDeps);
|
|
548
|
-
log(`Health server listening on port ${healthPort}`);
|
|
556
|
+
log.info(`Health server listening on port ${healthPort}`);
|
|
549
557
|
|
|
550
558
|
// Wait for exactly one assistant connection on the bootstrap socket
|
|
551
559
|
const socketPath = getBootstrapSocketPath();
|
|
552
|
-
log(`Waiting for assistant connection on ${socketPath}...`);
|
|
560
|
+
log.info(`Waiting for assistant connection on ${socketPath}...`);
|
|
553
561
|
|
|
554
562
|
let connection: Awaited<ReturnType<typeof acceptOneConnection>>;
|
|
555
563
|
try {
|
|
556
564
|
connection = await acceptOneConnection(socketPath, controller.signal);
|
|
557
565
|
} catch (err) {
|
|
558
566
|
if (controller.signal.aborted) {
|
|
559
|
-
log("Shutdown before assistant connected.");
|
|
567
|
+
log.info("Shutdown before assistant connected.");
|
|
560
568
|
return;
|
|
561
569
|
}
|
|
562
570
|
throw err;
|
|
@@ -572,36 +580,34 @@ async function main(): Promise<void> {
|
|
|
572
580
|
const assistantIdRef: AssistantIdRef = { current: process.env["PLATFORM_ASSISTANT_ID"] ?? "" };
|
|
573
581
|
const handlers = buildHandlers(sessionIdRef, apiKeyRef, assistantIdRef, secureKeyBackend);
|
|
574
582
|
|
|
583
|
+
const rpcLog = getLogger("rpc");
|
|
575
584
|
const server = new CesRpcServer({
|
|
576
585
|
input: connection.readable,
|
|
577
586
|
output: connection.writable,
|
|
578
587
|
handlers,
|
|
579
588
|
logger: {
|
|
580
|
-
log: (msg: string, ...args: unknown[]) =>
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
process.stderr.write(`[ces-managed] WARN: ${msg} ${args.map(String).join(" ")}\n`),
|
|
584
|
-
error: (msg: string, ...args: unknown[]) =>
|
|
585
|
-
process.stderr.write(`[ces-managed] ERROR: ${msg} ${args.map(String).join(" ")}\n`),
|
|
589
|
+
log: (msg: string, ...args: unknown[]) => rpcLog.info({ args }, msg),
|
|
590
|
+
warn: (msg: string, ...args: unknown[]) => rpcLog.warn({ args }, msg),
|
|
591
|
+
error: (msg: string, ...args: unknown[]) => rpcLog.error({ args }, msg),
|
|
586
592
|
},
|
|
587
593
|
signal: controller.signal,
|
|
588
594
|
onHandshakeComplete: (hsSessionId, hsApiKey, hsAssistantId) => {
|
|
589
595
|
sessionIdRef.current = hsSessionId;
|
|
590
596
|
if (hsApiKey) {
|
|
591
597
|
apiKeyRef.current = hsApiKey;
|
|
592
|
-
log(
|
|
598
|
+
log.info("Received assistant API key via handshake");
|
|
593
599
|
}
|
|
594
600
|
if (hsAssistantId) {
|
|
595
601
|
assistantIdRef.current = hsAssistantId;
|
|
596
|
-
log(
|
|
602
|
+
log.info("Received assistant ID via handshake");
|
|
597
603
|
}
|
|
598
604
|
},
|
|
599
605
|
onApiKeyUpdate: (newKey, newAssistantId) => {
|
|
600
606
|
apiKeyRef.current = newKey;
|
|
601
|
-
log(
|
|
607
|
+
log.info("Assistant API key updated via RPC");
|
|
602
608
|
if (newAssistantId) {
|
|
603
609
|
assistantIdRef.current = newAssistantId;
|
|
604
|
-
log(
|
|
610
|
+
log.info("Assistant ID updated via RPC");
|
|
605
611
|
}
|
|
606
612
|
},
|
|
607
613
|
});
|
|
@@ -609,11 +615,15 @@ async function main(): Promise<void> {
|
|
|
609
615
|
await server.serve();
|
|
610
616
|
|
|
611
617
|
rpcConnected = false;
|
|
612
|
-
log("RPC session ended. Shutting down...");
|
|
618
|
+
log.info("RPC session ended. Shutting down...");
|
|
613
619
|
controller.abort();
|
|
614
620
|
}
|
|
615
621
|
|
|
616
622
|
main().catch((err) => {
|
|
617
|
-
|
|
623
|
+
try {
|
|
624
|
+
getLogger("main").fatal({ err }, "Fatal error");
|
|
625
|
+
} catch {
|
|
626
|
+
process.stderr.write(`[ces-managed] Fatal: ${err}\n`);
|
|
627
|
+
}
|
|
618
628
|
process.exit(1);
|
|
619
629
|
});
|
package/src/paths.ts
CHANGED
|
@@ -95,6 +95,11 @@ export function getCesToolStoreDir(mode?: CesMode): string {
|
|
|
95
95
|
return join(getCesDataRoot(mode), "toolstore");
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Directory for CES log files. */
|
|
99
|
+
export function getCesLogDir(mode?: CesMode): string {
|
|
100
|
+
return join(getCesDataRoot(mode), "logs");
|
|
101
|
+
}
|
|
102
|
+
|
|
98
103
|
// ---------------------------------------------------------------------------
|
|
99
104
|
// Bootstrap socket path (managed mode only)
|
|
100
105
|
// ---------------------------------------------------------------------------
|