shadow-claw 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +2 -1
  2. package/bin/cli.mjs +111 -2
  3. package/bin/commands/webrtc-listen.mjs +21 -1
  4. package/bin/utils/control-client.mjs +44 -30
  5. package/bin/utils/control-client.test.mjs +10 -0
  6. package/bin/utils/webrtc-control-client.mjs +23 -4
  7. package/dist/public/AGENTS.md +8 -1
  8. package/dist/public/README.md +2 -1
  9. package/dist/public/agent.worker.js +49 -49
  10. package/dist/public/backup-controller-CvzDOq4T.js +1 -0
  11. package/dist/public/{configurePeerJs-CxpZeDJJ.js → configurePeerJs-UfQ6HUlv.js} +1 -1
  12. package/dist/public/docs/README.md +1 -1
  13. package/dist/public/docs/example/article/index.html +1 -1
  14. package/dist/public/docs/guides/server-development-configuration.md +28 -0
  15. package/dist/public/docs/publishing/index.html +1 -1
  16. package/dist/public/docs/skill-creator/index.html +1 -1
  17. package/dist/public/docs/subsystems/cli.md +25 -19
  18. package/dist/public/docs/subsystems/control-plane.md +11 -0
  19. package/dist/public/{downloadGroupFile-By0GR6eV.js → downloadGroupFile-9niHBj9n.js} +1 -1
  20. package/dist/public/{e2e-bridge-IGWsRl4i.js → e2e-bridge-D28tOggc.js} +1 -1
  21. package/dist/public/{file-viewer-D0eflgt6.js → file-viewer-BVg3B_JH.js} +1 -1
  22. package/dist/public/index.html +1 -1
  23. package/dist/public/index.js +1 -1
  24. package/dist/public/initControlPlane-D5kHKcB5.js +1 -0
  25. package/dist/public/llms.txt +1 -1
  26. package/dist/public/main/index.html +1 -1
  27. package/dist/public/main/memory/index.html +1 -1
  28. package/dist/public/{markdown-BnpKxSxl.js → markdown-SiFq0PTy.js} +1 -1
  29. package/dist/public/{orchestrator-nJj59RqD.js → orchestrator-CnwkoBpe.js} +1 -1
  30. package/dist/public/{orchestrator-3h9xxHUm.js → orchestrator-tHWrqi_F.js} +5 -5
  31. package/dist/public/{peerjs-BP4laMXj.js → peerjs-CuSJUH-r.js} +1 -1
  32. package/dist/public/push-client-D0lkwrK0.js +1 -0
  33. package/dist/public/service-worker/fetch-proxy.js +1 -1
  34. package/dist/public/service-worker.js +1 -1
  35. package/dist/public/{shadow-claw-CbwETBWf.js → shadow-claw-CUbhMmcB.js} +2 -2
  36. package/dist/public/{shadow-claw-channels-BDWG5Qa3.js → shadow-claw-channels-mY-JMvib.js} +1 -1
  37. package/dist/public/{shadow-claw-chat-Crv0DTCY.js → shadow-claw-chat-BN85a1xk.js} +1 -1
  38. package/dist/public/{shadow-claw-control-plane-CX1IGbh9.js → shadow-claw-control-plane-C0lSxLpW.js} +2 -2
  39. package/dist/public/{shadow-claw-conversations-_9qAaRXk.js → shadow-claw-conversations-0Q-VC_7N.js} +1 -1
  40. package/dist/public/{shadow-claw-file-viewer-Blf6yGVD.js → shadow-claw-file-viewer-AZfG4D-u.js} +1 -1
  41. package/dist/public/{shadow-claw-files-CYySBDCl.js → shadow-claw-files-Bx4v0Ggp.js} +1 -1
  42. package/dist/public/{shadow-claw-git-v8S0JjuR.js → shadow-claw-git-DD7iQRxq.js} +2 -2
  43. package/dist/public/{shadow-claw-llm-GWgoVfhI.js → shadow-claw-llm-CfeDBZKB.js} +1 -1
  44. package/dist/public/{shadow-claw-networking-qHbP95z9.js → shadow-claw-networking-e0EarDVs.js} +1 -1
  45. package/dist/public/{shadow-claw-notifications-YyHOK2Fr.js → shadow-claw-notifications-BkxuDEQI.js} +3 -3
  46. package/dist/public/{shadow-claw-pages-DFZHhzFZ.js → shadow-claw-pages-BWSE91I1.js} +1 -1
  47. package/dist/public/{shadow-claw-settings-DhxrKIfA.js → shadow-claw-settings-BZWrAoQA.js} +2 -2
  48. package/dist/public/{shadow-claw-storage-BFynbP0j.js → shadow-claw-storage-Crs6LY15.js} +1 -1
  49. package/dist/public/{shadow-claw-task-server-3BIeb5oZ.js → shadow-claw-task-server-CKm_Ennl.js} +1 -1
  50. package/dist/public/{shadow-claw-tasks-Caxc9atL.js → shadow-claw-tasks-Czx6Ucf5.js} +1 -1
  51. package/dist/public/{shadow-claw-terminal-Beb5qF-U.js → shadow-claw-terminal-Dmtr9KY1.js} +1 -1
  52. package/dist/public/{shadow-claw-tools-BfJx3A9W.js → shadow-claw-tools-t3Qq4TDO.js} +1 -1
  53. package/dist/public/{shadow-claw-webvm-BCS8ykeP.js → shadow-claw-webvm-CIuSywD_.js} +1 -1
  54. package/dist/public/{syncWebMcpRegistration-B_M2fCjH.js → syncWebMcpRegistration-DzC80wVG.js} +1 -1
  55. package/dist/server.js +203 -203
  56. package/package.json +1 -1
  57. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +3 -0
  58. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.ts +43 -9
  59. package/src/core/backup-controller.ts +33 -4
  60. package/src/core/control-plane-client.test.ts +9 -2
  61. package/src/core/control-plane-client.ts +18 -2
  62. package/src/core/orchestrator/utils/operations/task.ts +46 -15
  63. package/src/core/utils/initControlPlane.ts +2 -0
  64. package/src/server/app.ts +2 -0
  65. package/src/server/config.test.ts +49 -0
  66. package/src/server/config.ts +47 -0
  67. package/src/server/control-plane.test.ts +36 -0
  68. package/src/server/control-plane.ts +41 -14
  69. package/src/server/middleware/pna.test.ts +15 -3
  70. package/src/server/middleware/pna.ts +6 -2
  71. package/src/server/peer.ts +6 -2
  72. package/src/server/routes/backup.test.ts +14 -0
  73. package/src/server/routes/backup.ts +44 -15
  74. package/src/server/server.test.ts +118 -0
  75. package/src/server/server.ts +22 -5
  76. package/src/server/tls.test.ts +266 -0
  77. package/src/server/tls.ts +178 -0
  78. package/src/service-worker/fetch-proxy-rules.test.ts +41 -0
  79. package/src/service-worker/fetch-proxy-rules.ts +15 -8
  80. package/src/service-worker/fetch-proxy.ts +18 -2
  81. package/src/subsystems/notifications/push-client.test.ts +5 -1
  82. package/src/subsystems/notifications/push-client.ts +50 -12
  83. package/dist/public/backup-controller-C_s9in2t.js +0 -1
  84. package/dist/public/initControlPlane-C9jpnWev.js +0 -1
  85. package/dist/public/push-client-CrjEXK3U.js +0 -1
@@ -1,4 +1,5 @@
1
1
  import http from "node:http";
2
+ import https from "node:https";
2
3
  import { ExpressPeerServer } from "peer";
3
4
  import type { Express } from "express";
4
5
 
@@ -10,10 +11,13 @@ import type { Express } from "express";
10
11
  * We mount the middleware at "/" and set internal path to "/" so routes land
11
12
  * at /peerjs/* as expected.
12
13
  *
13
- * @param httpServer - The underlying http.Server (needed for WebSocket upgrade).
14
+ * @param httpServer - The underlying http.Server or https.Server (needed for WebSocket upgrade).
14
15
  * @param app - The Express app to mount the PeerJS REST routes on.
15
16
  */
16
- export function attachPeerServer(httpServer: http.Server, app: Express): void {
17
+ export function attachPeerServer(
18
+ httpServer: http.Server | https.Server,
19
+ app: Express,
20
+ ): void {
17
21
  const peerServer = ExpressPeerServer(httpServer, {
18
22
  path: "/",
19
23
  allow_discovery: false,
@@ -121,6 +121,20 @@ describe("backup routes", () => {
121
121
  expect(res.data.backups).toBeDefined();
122
122
  });
123
123
 
124
+ it("allows GitHub Pages origin requests without explicit token header", async () => {
125
+ const res = await makeHttpRequest({
126
+ port,
127
+ path: "/api/backup/list",
128
+ headers: {
129
+ host: `127.0.0.1:${port}`,
130
+ origin: "https://xt-ml.github.io",
131
+ },
132
+ });
133
+
134
+ expect(res.status).toBe(200);
135
+ expect(res.data.backups).toBeDefined();
136
+ });
137
+
124
138
  it("uploads a file and verifies on disk", async () => {
125
139
  const res = await makeHttpRequest({
126
140
  port,
@@ -20,6 +20,8 @@ import {
20
20
  export interface BackupRoutesOptions {
21
21
  backupsDir: string;
22
22
  token?: string;
23
+ allowedOrigins?: Set<string>;
24
+ corsMode?: "localhost" | "private" | "all";
23
25
  }
24
26
 
25
27
  function isSafeRelativePath(relPath: string): boolean {
@@ -37,39 +39,66 @@ function isSafeRelativePath(relPath: string): boolean {
37
39
  return true;
38
40
  }
39
41
 
40
- function isSameOriginBrowser(req: Request): boolean {
42
+ function isSameOriginBrowser(
43
+ req: Request,
44
+ options?: BackupRoutesOptions,
45
+ ): boolean {
41
46
  const host = req.headers.host || "127.0.0.1";
42
47
  const origin = req.headers.origin;
43
48
  const referer = req.headers.referer;
44
49
 
45
- if (origin) {
50
+ const isTrustedUrl = (urlStr: string): boolean => {
46
51
  try {
47
- const originUrl = new URL(origin);
48
- if (originUrl.host === host) {
52
+ const parsed = new URL(urlStr);
53
+ if (parsed.host === host) {
49
54
  return true;
50
55
  }
56
+
57
+ const hostname = parsed.hostname.toLowerCase();
51
58
  if (
52
- originUrl.hostname === "127.0.0.1" ||
53
- originUrl.hostname === "localhost"
59
+ hostname === "127.0.0.1" ||
60
+ hostname === "localhost" ||
61
+ hostname === "::1" ||
62
+ hostname === "[::1]"
54
63
  ) {
55
64
  return true;
56
65
  }
57
- } catch (_) {}
58
- }
59
66
 
60
- if (referer) {
61
- try {
62
- const refererUrl = new URL(referer);
63
- if (refererUrl.host === host) {
67
+ if (
68
+ hostname === "github.com" ||
69
+ hostname.endsWith(".github.io") ||
70
+ hostname.endsWith(".pages.dev")
71
+ ) {
72
+ return true;
73
+ }
74
+
75
+ if (options?.corsMode === "all") {
76
+ return true;
77
+ }
78
+
79
+ if (
80
+ options?.allowedOrigins &&
81
+ options.allowedOrigins.has(parsed.origin)
82
+ ) {
64
83
  return true;
65
84
  }
85
+
66
86
  if (
67
- refererUrl.hostname === "127.0.0.1" ||
68
- refererUrl.hostname === "localhost"
87
+ options?.corsMode === "private" &&
88
+ /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname)
69
89
  ) {
70
90
  return true;
71
91
  }
72
92
  } catch (_) {}
93
+ return false;
94
+ };
95
+
96
+ if (origin && isTrustedUrl(origin)) {
97
+ return true;
98
+ }
99
+
100
+ if (referer && isTrustedUrl(referer)) {
101
+ return true;
73
102
  }
74
103
 
75
104
  const secFetchSite = req.headers["sec-fetch-site"];
@@ -103,7 +132,7 @@ export function registerBackupRoutes(
103
132
 
104
133
  const isAuthValid = providedToken
105
134
  ? providedToken === token
106
- : isSameOriginBrowser(req);
135
+ : isSameOriginBrowser(req, options);
107
136
 
108
137
  if (!isAuthValid) {
109
138
  res.status(401).json({ error: "Unauthorized: Invalid control token" });
@@ -147,3 +147,121 @@ describe("src/server/server.ts proxy simulation", () => {
147
147
  expect(headers.authorization).toBe("Bearer server-key");
148
148
  });
149
149
  });
150
+
151
+ describe("startServer HTTP / HTTPS selection", () => {
152
+ let httpMock: any;
153
+ let httpsMock: any;
154
+ let appMock: any;
155
+ let tlsMock: any;
156
+ let tcpPortUsedMock: any;
157
+ let controlPlaneMock: any;
158
+
159
+ beforeEach(() => {
160
+ jest.resetModules();
161
+
162
+ const fakeServer = {
163
+ listen: jest.fn((_port: number, _host: string, cb: any) => {
164
+ if (cb) cb();
165
+ return fakeServer;
166
+ }),
167
+ };
168
+
169
+ httpMock = {
170
+ createServer: jest.fn(() => fakeServer),
171
+ };
172
+
173
+ httpsMock = {
174
+ createServer: jest.fn(() => fakeServer),
175
+ };
176
+
177
+ appMock = {
178
+ createApp: jest.fn(() => ({
179
+ app: {},
180
+ scheduler: { start: jest.fn() },
181
+ })),
182
+ };
183
+
184
+ tlsMock = {
185
+ ensureTlsCredentials: jest.fn(() => ({
186
+ key: Buffer.from("fake-key"),
187
+ cert: Buffer.from("fake-cert"),
188
+ })),
189
+ };
190
+
191
+ tcpPortUsedMock = {
192
+ check: jest.fn(() => Promise.resolve(false)),
193
+ };
194
+
195
+ controlPlaneMock = {
196
+ createControlPlane: jest.fn(() => ({
197
+ getToken: jest.fn(() => "test-token"),
198
+ })),
199
+ };
200
+
201
+ jest.spyOn(console, "log").mockImplementation(() => {});
202
+
203
+ jest.unstable_mockModule("node:http", () => ({
204
+ default: httpMock,
205
+ ...httpMock,
206
+ }));
207
+ jest.unstable_mockModule("node:https", () => ({
208
+ default: httpsMock,
209
+ ...httpsMock,
210
+ }));
211
+ jest.unstable_mockModule("./app.js", () => appMock);
212
+ jest.unstable_mockModule("./tls.js", () => tlsMock);
213
+ jest.unstable_mockModule("tcp-port-used", () => ({
214
+ default: tcpPortUsedMock,
215
+ ...tcpPortUsedMock,
216
+ }));
217
+ jest.unstable_mockModule("./control-plane.js", () => controlPlaneMock);
218
+ jest.unstable_mockModule("./peer.js", () => ({
219
+ attachPeerServer: jest.fn(),
220
+ }));
221
+ });
222
+
223
+ it("creates http.Server when config.https is false", async () => {
224
+ const { startServer } = await import("./server.js");
225
+ await startServer({
226
+ port: 8888,
227
+ bindHost: "127.0.0.1",
228
+ corsMode: "localhost",
229
+ allowedOrigins: new Set(),
230
+ verbose: false,
231
+ peerjs: false,
232
+ rootPath: "/root",
233
+ databaseDir: "/db",
234
+ allowPrivateProxy: false,
235
+ https: false,
236
+ sslDir: "/ssl",
237
+ });
238
+
239
+ expect(httpMock.createServer).toHaveBeenCalledTimes(1);
240
+ expect(httpsMock.createServer).not.toHaveBeenCalled();
241
+ expect(tlsMock.ensureTlsCredentials).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it("creates https.Server with TLS credentials when config.https is true", async () => {
245
+ const { startServer } = await import("./server.js");
246
+ await startServer({
247
+ port: 8888,
248
+ bindHost: "127.0.0.1",
249
+ corsMode: "localhost",
250
+ allowedOrigins: new Set(),
251
+ verbose: false,
252
+ peerjs: false,
253
+ rootPath: "/root",
254
+ databaseDir: "/db",
255
+ allowPrivateProxy: false,
256
+ https: true,
257
+ sslDir: "/ssl",
258
+ });
259
+
260
+ expect(tlsMock.ensureTlsCredentials).toHaveBeenCalledTimes(1);
261
+ expect(httpsMock.createServer).toHaveBeenCalledWith(
262
+ { key: Buffer.from("fake-key"), cert: Buffer.from("fake-cert") },
263
+ expect.anything(),
264
+ );
265
+ expect(httpMock.createServer).not.toHaveBeenCalled();
266
+ });
267
+ });
@@ -1,5 +1,6 @@
1
1
  import { exit } from "node:process";
2
2
  import http from "node:http";
3
+ import https from "node:https";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import tcpPortUsed from "tcp-port-used";
@@ -10,10 +11,11 @@ import { attachPeerServer } from "./peer.js";
10
11
  import { createControlPlane } from "./control-plane.js";
11
12
  import { ServerPeer } from "./server-peer.js";
12
13
  import { parseConfig, ServerConfig } from "./config.js";
14
+ import { ensureTlsCredentials } from "./tls.js";
13
15
 
14
16
  export async function startServer(
15
17
  customConfig?: ServerConfig,
16
- ): Promise<http.Server> {
18
+ ): Promise<http.Server | https.Server> {
17
19
  const config = customConfig || parseConfig();
18
20
  const { app, scheduler } = createApp(config);
19
21
 
@@ -27,13 +29,17 @@ export async function startServer(
27
29
 
28
30
  scheduler.start();
29
31
 
30
- const httpServer = http.createServer(app);
32
+ const httpServer: http.Server | https.Server = config.https
33
+ ? https.createServer(ensureTlsCredentials(config), app)
34
+ : http.createServer(app);
31
35
 
32
36
  const controlPlane = createControlPlane({
33
37
  httpServer,
34
38
  app,
35
39
  token: config.controlToken,
36
40
  verbose: config.verbose,
41
+ allowedOrigins: config.allowedOrigins,
42
+ corsMode: config.corsMode,
37
43
  });
38
44
 
39
45
  let serverPeer: ServerPeer | null = null;
@@ -47,9 +53,20 @@ export async function startServer(
47
53
  });
48
54
  }
49
55
 
50
- return new Promise<http.Server>((resolve) => {
56
+ return new Promise<http.Server | https.Server>((resolve) => {
51
57
  httpServer.listen(config.port, config.bindHost, () => {
52
- console.log(`Server running at http://${config.bindHost}:${config.port}`);
58
+ const protocol = config.https ? "https" : "http";
59
+ const wsProtocol = config.https ? "wss" : "ws";
60
+
61
+ console.log(
62
+ `Server running at ${protocol}://${config.bindHost}:${config.port}`,
63
+ );
64
+
65
+ if (config.https) {
66
+ const certLocation =
67
+ config.certPath || path.join(config.sslDir, "cert.pem");
68
+ console.log(`TLS enabled (cert: ${certLocation})`);
69
+ }
53
70
 
54
71
  if (config.bindHost === DEFAULT_DEV_IP) {
55
72
  console.log(
@@ -70,7 +87,7 @@ export async function startServer(
70
87
  }
71
88
 
72
89
  console.log(
73
- `Control plane active at http://${config.bindHost}:${config.port}/api/control/events (SSE) and ws://${config.bindHost}:${config.port}/ws/control (WebSocket)`,
90
+ `Control plane active at ${protocol}://${config.bindHost}:${config.port}/api/control/events (SSE) and ${wsProtocol}://${config.bindHost}:${config.port}/ws/control (WebSocket)`,
74
91
  );
75
92
  console.log(`Control token: ${controlPlane.getToken()}`);
76
93
 
@@ -0,0 +1,266 @@
1
+ import { jest } from "@jest/globals";
2
+ import type { ServerConfig } from "./config.js";
3
+
4
+ describe("tls credentials", () => {
5
+ let fsMock: any;
6
+ let childProcessMock: any;
7
+ let processMock: any;
8
+ let osMock: any;
9
+ let baseConfig: ServerConfig;
10
+
11
+ beforeEach(() => {
12
+ jest.resetModules();
13
+
14
+ fsMock = {
15
+ existsSync: jest.fn(),
16
+ readFileSync: jest.fn(),
17
+ mkdirSync: jest.fn(),
18
+ };
19
+
20
+ childProcessMock = {
21
+ execFileSync: jest.fn(),
22
+ };
23
+
24
+ processMock = {
25
+ exit: jest.fn(),
26
+ };
27
+
28
+ osMock = {
29
+ networkInterfaces: jest.fn(() => ({
30
+ eth0: [{ address: "192.168.1.50" }],
31
+ })),
32
+ };
33
+
34
+ baseConfig = {
35
+ port: 8888,
36
+ bindHost: "127.0.0.1",
37
+ corsMode: "localhost",
38
+ allowedOrigins: new Set(),
39
+ verbose: false,
40
+ peerjs: false,
41
+ rootPath: "/app/public",
42
+ databaseDir: "/app/database",
43
+ allowPrivateProxy: false,
44
+ https: true,
45
+ sslDir: "/app/.cache/tls",
46
+ };
47
+
48
+ jest.spyOn(console, "error").mockImplementation(() => {});
49
+ jest.spyOn(console, "warn").mockImplementation(() => {});
50
+
51
+ jest.unstable_mockModule("node:fs", () => ({
52
+ default: fsMock,
53
+ ...fsMock,
54
+ }));
55
+ jest.unstable_mockModule("node:os", () => ({
56
+ default: osMock,
57
+ ...osMock,
58
+ }));
59
+ jest.unstable_mockModule("node:child_process", () => ({
60
+ default: childProcessMock,
61
+ ...childProcessMock,
62
+ }));
63
+ jest.unstable_mockModule("node:process", () => ({
64
+ exit: processMock.exit,
65
+ default: processMock,
66
+ }));
67
+ });
68
+
69
+ it("reads explicit cert and key files when both are provided and exist", async () => {
70
+ fsMock.existsSync.mockReturnValue(true);
71
+ fsMock.readFileSync.mockImplementation((file: string) =>
72
+ Buffer.from(`content of ${file}`),
73
+ );
74
+
75
+ const { ensureTlsCredentials } = await import("./tls.js");
76
+ const credentials = ensureTlsCredentials({
77
+ ...baseConfig,
78
+ certPath: "/custom/cert.pem",
79
+ keyPath: "/custom/key.pem",
80
+ });
81
+
82
+ expect(credentials.cert.toString()).toBe("content of /custom/cert.pem");
83
+ expect(credentials.key.toString()).toBe("content of /custom/key.pem");
84
+ expect(childProcessMock.execFileSync).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("exits when certPath is provided without keyPath", async () => {
88
+ const { ensureTlsCredentials } = await import("./tls.js");
89
+ ensureTlsCredentials({
90
+ ...baseConfig,
91
+ certPath: "/custom/cert.pem",
92
+ keyPath: undefined,
93
+ });
94
+
95
+ expect(processMock.exit).toHaveBeenCalledWith(1);
96
+ expect(console.error).toHaveBeenCalledWith(
97
+ expect.stringContaining("provided without --key"),
98
+ );
99
+ });
100
+
101
+ it("exits when keyPath is provided without certPath", async () => {
102
+ const { ensureTlsCredentials } = await import("./tls.js");
103
+ ensureTlsCredentials({
104
+ ...baseConfig,
105
+ certPath: undefined,
106
+ keyPath: "/custom/key.pem",
107
+ });
108
+
109
+ expect(processMock.exit).toHaveBeenCalledWith(1);
110
+ expect(console.error).toHaveBeenCalledWith(
111
+ expect.stringContaining("provided without --cert"),
112
+ );
113
+ });
114
+
115
+ it("exits when provided cert file does not exist", async () => {
116
+ fsMock.existsSync.mockImplementation(
117
+ (file: string) => file !== "/custom/cert.pem",
118
+ );
119
+
120
+ const { ensureTlsCredentials } = await import("./tls.js");
121
+ ensureTlsCredentials({
122
+ ...baseConfig,
123
+ certPath: "/custom/cert.pem",
124
+ keyPath: "/custom/key.pem",
125
+ });
126
+
127
+ expect(processMock.exit).toHaveBeenCalledWith(1);
128
+ expect(console.error).toHaveBeenCalledWith(
129
+ expect.stringContaining("TLS certificate not found"),
130
+ );
131
+ });
132
+
133
+ it("exits when provided key file does not exist", async () => {
134
+ fsMock.existsSync.mockImplementation(
135
+ (file: string) => file !== "/custom/key.pem",
136
+ );
137
+
138
+ const { ensureTlsCredentials } = await import("./tls.js");
139
+ ensureTlsCredentials({
140
+ ...baseConfig,
141
+ certPath: "/custom/cert.pem",
142
+ keyPath: "/custom/key.pem",
143
+ });
144
+
145
+ expect(processMock.exit).toHaveBeenCalledWith(1);
146
+ expect(console.error).toHaveBeenCalledWith(
147
+ expect.stringContaining("TLS private key not found"),
148
+ );
149
+ });
150
+
151
+ it("returns cached cert and key if already present in sslDir", async () => {
152
+ fsMock.existsSync.mockReturnValue(true);
153
+ fsMock.readFileSync.mockImplementation((file: string) =>
154
+ Buffer.from(`cached ${file}`),
155
+ );
156
+
157
+ const { ensureTlsCredentials } = await import("./tls.js");
158
+ const credentials = ensureTlsCredentials(baseConfig);
159
+
160
+ expect(credentials.cert.toString()).toBe("cached /app/.cache/tls/cert.pem");
161
+ expect(credentials.key.toString()).toBe("cached /app/.cache/tls/key.pem");
162
+ expect(childProcessMock.execFileSync).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it("generates self-signed cert and key via openssl when not in sslDir", async () => {
166
+ fsMock.existsSync.mockReturnValue(false);
167
+ fsMock.readFileSync.mockImplementation((file: string) =>
168
+ Buffer.from(`generated ${file}`),
169
+ );
170
+
171
+ const { ensureTlsCredentials } = await import("./tls.js");
172
+ const credentials = ensureTlsCredentials(baseConfig);
173
+
174
+ expect(fsMock.mkdirSync).toHaveBeenCalledWith("/app/.cache/tls", {
175
+ recursive: true,
176
+ });
177
+ expect(childProcessMock.execFileSync).toHaveBeenCalledWith(
178
+ "openssl",
179
+ expect.arrayContaining([
180
+ "-addext",
181
+ expect.stringMatching(/subjectAltName=.*IP:192\.168\.1\.50/),
182
+ ]),
183
+ { stdio: "pipe" },
184
+ );
185
+ expect(console.warn).toHaveBeenCalledWith(
186
+ expect.stringContaining("Generated self-signed TLS certificate"),
187
+ );
188
+ expect(credentials.cert.toString()).toBe(
189
+ "generated /app/.cache/tls/cert.pem",
190
+ );
191
+ expect(credentials.key.toString()).toBe(
192
+ "generated /app/.cache/tls/key.pem",
193
+ );
194
+ });
195
+
196
+ it("resolves SAN entries including loopback, local interfaces, and bindHost", async () => {
197
+ const { resolveSanList } = await import("./tls.js");
198
+ const sans = resolveSanList("10.9.8.226");
199
+
200
+ expect(sans).toContain("DNS:localhost");
201
+ expect(sans).toContain("IP:127.0.0.1");
202
+ expect(sans).toContain("IP:::1");
203
+ expect(sans).toContain("IP:192.168.1.50");
204
+ expect(sans).toContain("IP:10.9.8.226");
205
+ });
206
+
207
+ it("resolves DNS hostname for bindHost if bindHost is a domain", async () => {
208
+ const { resolveSanList } = await import("./tls.js");
209
+ const sans = resolveSanList("my-laptop.local");
210
+
211
+ expect(sans).toContain("DNS:my-laptop.local");
212
+ });
213
+
214
+ it("falls back to standard openssl args when -addext fails", async () => {
215
+ fsMock.existsSync.mockReturnValue(false);
216
+ fsMock.readFileSync.mockImplementation((file: string) =>
217
+ Buffer.from(`generated ${file}`),
218
+ );
219
+
220
+ childProcessMock.execFileSync
221
+ .mockImplementationOnce(() => {
222
+ throw new Error("unknown option -addext");
223
+ })
224
+ .mockImplementationOnce(() => Buffer.from(""));
225
+
226
+ const { ensureTlsCredentials } = await import("./tls.js");
227
+ const credentials = ensureTlsCredentials(baseConfig);
228
+
229
+ expect(childProcessMock.execFileSync).toHaveBeenCalledTimes(2);
230
+ expect(credentials.cert.toString()).toBe(
231
+ "generated /app/.cache/tls/cert.pem",
232
+ );
233
+ });
234
+
235
+ it("handles ENOENT when OpenSSL binary is missing", async () => {
236
+ fsMock.existsSync.mockReturnValue(false);
237
+ const enoentError: any = new Error("spawnSync openssl ENOENT");
238
+ enoentError.code = "ENOENT";
239
+ childProcessMock.execFileSync.mockImplementation(() => {
240
+ throw enoentError;
241
+ });
242
+
243
+ const { ensureTlsCredentials } = await import("./tls.js");
244
+ ensureTlsCredentials(baseConfig);
245
+
246
+ expect(processMock.exit).toHaveBeenCalledWith(1);
247
+ expect(console.error).toHaveBeenCalledWith(
248
+ expect.stringContaining("OpenSSL must be installed"),
249
+ );
250
+ });
251
+
252
+ it("handles generic openssl execution error", async () => {
253
+ fsMock.existsSync.mockReturnValue(false);
254
+ childProcessMock.execFileSync.mockImplementation(() => {
255
+ throw new Error("Command failed");
256
+ });
257
+
258
+ const { ensureTlsCredentials } = await import("./tls.js");
259
+ ensureTlsCredentials(baseConfig);
260
+
261
+ expect(processMock.exit).toHaveBeenCalledWith(1);
262
+ expect(console.error).toHaveBeenCalledWith(
263
+ expect.stringContaining("Error generating TLS certificate with OpenSSL"),
264
+ );
265
+ });
266
+ });