@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 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.5.16",
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
- const log = (msg: string) =>
296
- process.stderr.write(`[ces-local] ${msg}\n`);
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
- process.stderr.write(`[ces-local] ${msg} ${args.map(String).join(" ")}\n`),
323
- warn: (msg: string, ...args: unknown[]) =>
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
- process.stderr.write(`[ces-local] Fatal: ${err}\n`);
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
  });
@@ -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 (managed always logs to stderr)
68
+ // Logging
66
69
  // ---------------------------------------------------------------------------
67
70
 
68
- const log = (msg: string) =>
69
- process.stderr.write(`[ces-managed] ${msg}\n`);
70
-
71
- const warn = (msg: string) =>
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
- log(`Starting CES v${CES_PROTOCOL_VERSION} (managed mode)`);
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
- process.stderr.write(`[ces-managed] ${msg} ${args.map(String).join(" ")}\n`),
582
- warn: (msg: string, ...args: unknown[]) =>
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(`Received assistant API key via handshake`);
598
+ log.info("Received assistant API key via handshake");
593
599
  }
594
600
  if (hsAssistantId) {
595
601
  assistantIdRef.current = hsAssistantId;
596
- log(`Received assistant ID via handshake`);
602
+ log.info("Received assistant ID via handshake");
597
603
  }
598
604
  },
599
605
  onApiKeyUpdate: (newKey, newAssistantId) => {
600
606
  apiKeyRef.current = newKey;
601
- log(`Assistant API key updated via RPC`);
607
+ log.info("Assistant API key updated via RPC");
602
608
  if (newAssistantId) {
603
609
  assistantIdRef.current = newAssistantId;
604
- log(`Assistant ID updated via RPC`);
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
- process.stderr.write(`[ces-managed] Fatal: ${err}\n`);
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
  // ---------------------------------------------------------------------------