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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadow-claw",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0",
6
6
  "description": "Browser-native personal AI assistant.",
@@ -11,6 +11,9 @@ jest.unstable_mockModule(
11
11
  getPushUrl: jest
12
12
  .fn<any>()
13
13
  .mockImplementation((path: string) => Promise.resolve(path)),
14
+ getPushFetchOptions: jest
15
+ .fn<any>()
16
+ .mockImplementation((_url: string, opts?: any) => opts || {}),
14
17
  }),
15
18
  );
16
19
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getCurrentSubscription,
3
+ getPushFetchOptions,
3
4
  getPushUrl,
4
5
  subscribeToPush,
5
6
  unsubscribeFromPush,
@@ -186,9 +187,12 @@ export class ShadowClawNotifications extends ShadowClawElement {
186
187
 
187
188
  try {
188
189
  const url = await getPushUrl(`/push/subscription/${this._selectedId}`);
189
- const res = await fetch(url, {
190
- method: "DELETE",
191
- });
190
+ const res = await fetch(
191
+ url,
192
+ getPushFetchOptions(url, {
193
+ method: "DELETE",
194
+ }),
195
+ );
192
196
 
193
197
  if (res.ok) {
194
198
  showSuccess("Subscription deleted.");
@@ -237,11 +241,14 @@ export class ShadowClawNotifications extends ShadowClawElement {
237
241
 
238
242
  try {
239
243
  const url = await getPushUrl("/push/send");
240
- const res = await fetch(url, {
241
- method: "POST",
242
- headers: { "Content-Type": "application/json" },
243
- body: JSON.stringify({ endpoint: sub.endpoint, payload }),
244
- });
244
+ const res = await fetch(
245
+ url,
246
+ getPushFetchOptions(url, {
247
+ method: "POST",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ endpoint: sub.endpoint, payload }),
250
+ }),
251
+ );
245
252
 
246
253
  if (res.ok) {
247
254
  showSuccess("Notification sent!");
@@ -303,10 +310,37 @@ export class ShadowClawNotifications extends ShadowClawElement {
303
310
  }
304
311
 
305
312
  try {
313
+ const isStaticHost =
314
+ typeof location !== "undefined" &&
315
+ (location.protocol === "file:" ||
316
+ location.hostname.endsWith(".github.io") ||
317
+ location.hostname.endsWith(".pages.dev"));
318
+
319
+ const db = await getDb();
320
+ const proxyUrl =
321
+ (await getConfig(db, CONFIG_KEYS.PUSH_PROXY_URL)) ||
322
+ (typeof localStorage !== "undefined"
323
+ ? localStorage.getItem(CONFIG_KEYS.PUSH_PROXY_URL)
324
+ : null);
325
+
326
+ if (isStaticHost && !proxyUrl) {
327
+ this._subscriptions = [];
328
+ this._backendAvailable = false;
329
+ this._selectedId = null;
330
+ this.renderSubscriptionList();
331
+ this.updateActionButtons();
332
+ return;
333
+ }
334
+
306
335
  const url = await getPushUrl("/push/subscriptions");
307
- const res = await fetch(url);
336
+ const res = await fetch(url, getPushFetchOptions(url));
308
337
 
309
338
  if (!res.ok) {
339
+ this._subscriptions = [];
340
+ this._backendAvailable = false;
341
+ this._selectedId = null;
342
+ this.renderSubscriptionList();
343
+ this.updateActionButtons();
310
344
  return;
311
345
  }
312
346
 
@@ -29,6 +29,25 @@ export interface BackupInitiateResult {
29
29
  totalBytes: number;
30
30
  }
31
31
 
32
+ function getTargetAddressSpace(urlStr: string): string | undefined {
33
+ try {
34
+ const u = new URL(urlStr);
35
+ const host = u.hostname.toLowerCase();
36
+ if (
37
+ host === "localhost" ||
38
+ host === "127.0.0.1" ||
39
+ host === "::1" ||
40
+ host === "[::1]"
41
+ ) {
42
+ return "loopback";
43
+ }
44
+ if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) {
45
+ return "private";
46
+ }
47
+ } catch {}
48
+ return undefined;
49
+ }
50
+
32
51
  export class BackupController {
33
52
  private _clientId: string;
34
53
  private _token?: string;
@@ -80,7 +99,7 @@ export class BackupController {
80
99
  }
81
100
 
82
101
  const uploadUrl = `${this._serverBaseUrl}/api/backup/upload`;
83
- const res = await fetch(uploadUrl, {
102
+ const uploadOpts: any = {
84
103
  method: "POST",
85
104
  headers,
86
105
  body: JSON.stringify({
@@ -90,7 +109,12 @@ export class BackupController {
90
109
  content: contentStr,
91
110
  encoding,
92
111
  }),
93
- });
112
+ };
113
+ const uploadAddressSpace = getTargetAddressSpace(uploadUrl);
114
+ if (uploadAddressSpace) {
115
+ uploadOpts.targetAddressSpace = uploadAddressSpace;
116
+ }
117
+ const res = await fetch(uploadUrl, uploadOpts);
94
118
 
95
119
  if (!res.ok) {
96
120
  const errorText = await res.text().catch(() => res.statusText);
@@ -109,7 +133,7 @@ export class BackupController {
109
133
 
110
134
  // Finalize backup
111
135
  const completeUrl = `${this._serverBaseUrl}/api/backup/complete`;
112
- const completeRes = await fetch(completeUrl, {
136
+ const completeOpts: any = {
113
137
  method: "POST",
114
138
  headers,
115
139
  body: JSON.stringify({
@@ -118,7 +142,12 @@ export class BackupController {
118
142
  fileCount: uploadedCount,
119
143
  totalBytes,
120
144
  }),
121
- });
145
+ };
146
+ const completeAddressSpace = getTargetAddressSpace(completeUrl);
147
+ if (completeAddressSpace) {
148
+ completeOpts.targetAddressSpace = completeAddressSpace;
149
+ }
150
+ const completeRes = await fetch(completeUrl, completeOpts);
122
151
 
123
152
  if (!completeRes.ok) {
124
153
  const errorText = await completeRes
@@ -97,7 +97,12 @@ class MockEventSource {
97
97
 
98
98
  describe("control-plane-client", () => {
99
99
  let mockFetch: jest.Mock<any>;
100
- let postedRequests: Array<{ url: string; body: any; headers: any }>;
100
+ let postedRequests: Array<{
101
+ url: string;
102
+ body: any;
103
+ headers: any;
104
+ targetAddressSpace?: string;
105
+ }>;
101
106
 
102
107
  beforeEach(() => {
103
108
  MockClientWebSocket.instances = [];
@@ -108,6 +113,7 @@ describe("control-plane-client", () => {
108
113
  url,
109
114
  body: JSON.parse(opts.body),
110
115
  headers: opts.headers,
116
+ targetAddressSpace: opts.targetAddressSpace,
111
117
  });
112
118
  return { ok: true, json: async () => ({ status: "received" }) };
113
119
  }) as any;
@@ -119,7 +125,7 @@ describe("control-plane-client", () => {
119
125
  });
120
126
 
121
127
  describe("SSE Transport (Default)", () => {
122
- it("connects via EventSource and sends registration over HTTP POST", async () => {
128
+ it("connects via EventSource and sends registration over HTTP POST with targetAddressSpace", async () => {
123
129
  const client = new ControlPlaneClient({
124
130
  clientId: "sse-client-1",
125
131
  deviceLabel: "SSE Device",
@@ -145,6 +151,7 @@ describe("control-plane-client", () => {
145
151
  expect(postedRequests[0].body.type).toBe("client:register");
146
152
  expect(postedRequests[0].body.payload.clientId).toBe("sse-client-1");
147
153
  expect(postedRequests[0].body.payload.transport).toBe("sse");
154
+ expect(postedRequests[0].targetAddressSpace).toBe("loopback");
148
155
 
149
156
  client.disconnect();
150
157
  expect(client.getState()).toBe("disconnected");
@@ -353,11 +353,27 @@ export class ControlPlaneClient {
353
353
  headers["Authorization"] = `Bearer ${this._token}`;
354
354
  }
355
355
 
356
- this._fetchFn(messagesUrl.toString(), {
356
+ const fetchOptions: any = {
357
357
  method: "POST",
358
358
  headers,
359
359
  body: JSON.stringify(msg),
360
- }).catch((err) => {
360
+ };
361
+
362
+ const host = messagesUrl.hostname.toLowerCase();
363
+ const isLoopback =
364
+ host === "localhost" ||
365
+ host === "127.0.0.1" ||
366
+ host === "::1" ||
367
+ host === "[::1]";
368
+ const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host);
369
+
370
+ if (isLoopback) {
371
+ fetchOptions.targetAddressSpace = "loopback";
372
+ } else if (isPrivate) {
373
+ fetchOptions.targetAddressSpace = "private";
374
+ }
375
+
376
+ this._fetchFn(messagesUrl.toString(), fetchOptions).catch((err) => {
361
377
  console.warn(
362
378
  "[ControlPlaneClient] Failed to send message via HTTP POST:",
363
379
  err,
@@ -4,6 +4,32 @@ import { showToast } from "../../../../ui/toast.js";
4
4
  import type { Task } from "../../../../db/types.js";
5
5
  import type { OrchestratorState } from "../../orchestrator-state.js";
6
6
 
7
+ function getTaskFetchOptions(
8
+ url: string,
9
+ baseOptions: RequestInit = {},
10
+ ): RequestInit {
11
+ const opts: any = { ...baseOptions };
12
+ try {
13
+ const locOrigin =
14
+ typeof location !== "undefined"
15
+ ? location.origin
16
+ : "http://127.0.0.1:8888";
17
+ const u = new URL(url, locOrigin);
18
+ const host = u.hostname.toLowerCase();
19
+ if (
20
+ host === "localhost" ||
21
+ host === "127.0.0.1" ||
22
+ host === "::1" ||
23
+ host === "[::1]"
24
+ ) {
25
+ opts.targetAddressSpace = "loopback";
26
+ } else if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) {
27
+ opts.targetAddressSpace = "private";
28
+ }
29
+ } catch (_) {}
30
+ return opts;
31
+ }
32
+
7
33
  export async function syncTaskToServer(
8
34
  state: Pick<OrchestratorState, "taskServerUrl" | "taskServerEnabled">,
9
35
  task: Task,
@@ -20,18 +46,22 @@ export async function syncTaskToServer(
20
46
  }
21
47
  try {
22
48
  const base = state.taskServerUrl.replace(/\/$/, "");
23
- const res = await fetch(`${base}/tasks`, {
24
- body: JSON.stringify(
25
- subscriberId
26
- ? {
27
- ...task,
28
- subscriberId,
29
- }
30
- : task,
31
- ),
32
- headers: { "Content-Type": "application/json" },
33
- method: "POST",
34
- });
49
+ const url = `${base}/tasks`;
50
+ const res = await fetch(
51
+ url,
52
+ getTaskFetchOptions(url, {
53
+ body: JSON.stringify(
54
+ subscriberId
55
+ ? {
56
+ ...task,
57
+ subscriberId,
58
+ }
59
+ : task,
60
+ ),
61
+ headers: { "Content-Type": "application/json" },
62
+ method: "POST",
63
+ }),
64
+ );
35
65
  if (!res.ok) {
36
66
  console.error("Server rejected task sync:", res.status);
37
67
  return false;
@@ -58,11 +88,12 @@ export async function deleteTaskFromServer(
58
88
  const suffix = subscriberId
59
89
  ? `?subscriberId=${encodeURIComponent(subscriberId)}`
60
90
  : "";
91
+ const url = `${base}/tasks/${encodeURIComponent(id)}${suffix}`;
61
92
  const res = await fetch(
62
- `${base}/tasks/${encodeURIComponent(id)}${suffix}`,
63
- {
93
+ url,
94
+ getTaskFetchOptions(url, {
64
95
  method: "DELETE",
65
- },
96
+ }),
66
97
  );
67
98
  if (!res.ok) {
68
99
  console.error("Server rejected task deletion:", res.status);
@@ -334,9 +334,11 @@ export async function executeClientControlCommand(
334
334
  }
335
335
 
336
336
  const { BackupController } = await import("../backup-controller.js");
337
+
337
338
  const backupCtrl = new BackupController({
338
339
  clientId,
339
340
  token: args.token,
341
+ serverBaseUrl: getControlPlaneServerUrl().httpUrl,
340
342
  fileEnumerator: () => collectFiles(groupDir),
341
343
  fileReader: (filePath) => readFile(filePath),
342
344
  });
package/src/server/app.ts CHANGED
@@ -96,6 +96,8 @@ export function createApp(config: ServerConfig): {
96
96
  registerBackupRoutes(app, {
97
97
  backupsDir: path.resolve(config.databaseDir, "..", ".cache", "backups"),
98
98
  token: config.controlToken,
99
+ allowedOrigins: config.allowedOrigins,
100
+ corsMode: config.corsMode,
99
101
  });
100
102
 
101
103
  // ---------------- CSP REPORT ROUTES ----------------
@@ -111,4 +111,53 @@ describe("config", () => {
111
111
 
112
112
  expect(config.rootPath).toContain("public");
113
113
  });
114
+
115
+ it("defaults https to false and provides default sslDir", async () => {
116
+ const { parseConfig } = await import("./config.js");
117
+ const config = parseConfig();
118
+
119
+ expect(config.https).toBe(false);
120
+ expect(config.certPath).toBeUndefined();
121
+ expect(config.keyPath).toBeUndefined();
122
+ expect(config.sslDir).toContain(".cache/tls");
123
+ });
124
+
125
+ it("parses HTTPS options from CLI flags", async () => {
126
+ commanderMock.Command().opts.mockReturnValue({
127
+ https: true,
128
+ cert: "/custom/cert.pem",
129
+ key: "/custom/key.pem",
130
+ sslDir: "/custom/ssl",
131
+ corsAllowOrigin: [],
132
+ });
133
+
134
+ const { parseConfig } = await import("./config.js");
135
+ const config = parseConfig();
136
+
137
+ expect(config.https).toBe(true);
138
+ expect(config.certPath).toBe("/custom/cert.pem");
139
+ expect(config.keyPath).toBe("/custom/key.pem");
140
+ expect(config.sslDir).toBe("/custom/ssl");
141
+ });
142
+
143
+ it("parses HTTPS options from environment variables", async () => {
144
+ processMock.env.SHADOWCLAW_HTTPS = "1";
145
+ processMock.env.SHADOWCLAW_TLS_CERT = "/env/cert.pem";
146
+ processMock.env.SHADOWCLAW_TLS_KEY = "/env/key.pem";
147
+ processMock.env.SHADOWCLAW_SSL_DIR = "env-ssl";
148
+
149
+ const { parseConfig } = await import("./config.js");
150
+ const config = parseConfig();
151
+
152
+ expect(config.https).toBe(true);
153
+ expect(config.certPath).toBe("/env/cert.pem");
154
+ expect(config.keyPath).toBe("/env/key.pem");
155
+ expect(config.sslDir).toBe(
156
+ (await import("node:path")).default.resolve(
157
+ config.rootPath,
158
+ "..",
159
+ "env-ssl",
160
+ ),
161
+ );
162
+ });
114
163
  });
@@ -27,6 +27,10 @@ export interface ServerConfig {
27
27
  */
28
28
  controlToken?: string;
29
29
  allowPrivateProxy: boolean;
30
+ https: boolean;
31
+ certPath?: string;
32
+ keyPath?: string;
33
+ sslDir: string;
30
34
  }
31
35
 
32
36
  export function parseConfig(): ServerConfig {
@@ -67,6 +71,13 @@ export function parseConfig(): ServerConfig {
67
71
  .option(
68
72
  "--control-token <token>",
69
73
  "Secret token for control-plane authentication",
74
+ )
75
+ .option("--https", "Enable HTTPS dev server", false)
76
+ .option("--cert <path>", "Path to existing TLS certificate file")
77
+ .option("--key <path>", "Path to existing TLS private key file")
78
+ .option(
79
+ "--ssl-dir <path>",
80
+ "Directory for TLS certificate generation/storage",
70
81
  );
71
82
 
72
83
  program.parse();
@@ -178,6 +189,38 @@ export function parseConfig(): ServerConfig {
178
189
  ? options.controlToken.trim()
179
190
  : envControlToken || undefined;
180
191
 
192
+ // HTTPS & TLS logic
193
+ const https =
194
+ Boolean(options.https) ||
195
+ ["1", "true", "yes"].includes(
196
+ (env.SHADOWCLAW_HTTPS || "").toLowerCase().trim(),
197
+ );
198
+
199
+ const envCert = (env.SHADOWCLAW_TLS_CERT || env.SHADOWCLAW_CERT || "").trim();
200
+ const certPath = options.cert
201
+ ? path.resolve(options.cert)
202
+ : envCert
203
+ ? path.resolve(envCert)
204
+ : undefined;
205
+
206
+ const envKey = (env.SHADOWCLAW_TLS_KEY || env.SHADOWCLAW_KEY || "").trim();
207
+ const keyPath = options.key
208
+ ? path.resolve(options.key)
209
+ : envKey
210
+ ? path.resolve(envKey)
211
+ : undefined;
212
+
213
+ const envSslDir = (
214
+ env.SHADOWCLAW_SSL_DIR ||
215
+ env.SHADOWCLAW_TLS_DIR ||
216
+ ""
217
+ ).trim();
218
+ const sslDir = options.sslDir
219
+ ? path.resolve(options.sslDir)
220
+ : envSslDir
221
+ ? path.resolve(projectRoot, envSslDir)
222
+ : path.resolve(databaseDir, "..", ".cache", "tls");
223
+
181
224
  return {
182
225
  port,
183
226
  bindHost,
@@ -189,5 +232,9 @@ export function parseConfig(): ServerConfig {
189
232
  databaseDir,
190
233
  controlToken,
191
234
  allowPrivateProxy,
235
+ https,
236
+ certPath,
237
+ keyPath,
238
+ sslDir,
192
239
  };
193
240
  }
@@ -136,6 +136,19 @@ describe("control-plane", () => {
136
136
  expect(ws.readyState).toBe(WebSocket.OPEN);
137
137
  ws.close();
138
138
  });
139
+
140
+ it("accepts GitHub Pages origin connection without token", async () => {
141
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/control`, {
142
+ headers: { Origin: "https://xt-ml.github.io" },
143
+ });
144
+ await new Promise<void>((resolve, reject) => {
145
+ ws.on("open", () => resolve());
146
+ ws.on("error", reject);
147
+ });
148
+
149
+ expect(ws.readyState).toBe(WebSocket.OPEN);
150
+ ws.close();
151
+ });
139
152
  });
140
153
 
141
154
  describe("Client registration and heartbeat", () => {
@@ -353,6 +366,29 @@ describe("control-plane", () => {
353
366
  expect(res.data.reply?.type).toBe("server:registered");
354
367
  });
355
368
 
369
+ it("POST /api/control/messages processes message from GitHub Pages origin without token", async () => {
370
+ const res = await makeHttpRequest({
371
+ port,
372
+ path: "/api/control/messages",
373
+ method: "POST",
374
+ headers: { Origin: "https://xt-ml.github.io" },
375
+ body: {
376
+ id: "http-msg-gh-1",
377
+ type: "client:register",
378
+ payload: {
379
+ clientId: "http-client-gh",
380
+ deviceLabel: "GitHub Pages Client",
381
+ capabilities: ["opfs"],
382
+ version: "1.0.0",
383
+ },
384
+ },
385
+ });
386
+
387
+ expect(res.status).toBe(200);
388
+ expect(res.data.status).toBe("received");
389
+ expect(res.data.reply?.type).toBe("server:registered");
390
+ });
391
+
356
392
  it("dispatches command to SSE client and resolves when client POSTs result", async () => {
357
393
  // 1. Establish SSE connection
358
394
  let sseReq: http.ClientRequest;
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import http from "node:http";
10
+ import https from "node:https";
10
11
  import { URL } from "node:url";
11
12
  import { WebSocketServer, WebSocket } from "ws";
12
13
  import type { Express, Request, Response, NextFunction } from "express";
@@ -29,11 +30,13 @@ import type {
29
30
  } from "./control-plane-types.js";
30
31
 
31
32
  export interface ControlPlaneOptions {
32
- httpServer: http.Server;
33
+ httpServer: http.Server | https.Server;
33
34
  app?: Express;
34
35
  token?: string;
35
36
  heartbeatTimeoutMs?: number;
36
37
  verbose?: boolean;
38
+ allowedOrigins?: Set<string>;
39
+ corsMode?: "localhost" | "private" | "all";
37
40
  }
38
41
 
39
42
  export interface PendingCommand {
@@ -115,34 +118,58 @@ export function createControlPlane(options: ControlPlaneOptions): ControlPlane {
115
118
  const origin = req.headers.origin;
116
119
  const referer = req.headers.referer;
117
120
 
118
- if (origin) {
121
+ const isTrustedUrl = (urlStr: string): boolean => {
119
122
  try {
120
- const originUrl = new URL(origin);
121
- if (originUrl.host === host) {
123
+ const parsed = new URL(urlStr);
124
+ if (parsed.host === host) {
122
125
  return true;
123
126
  }
127
+
128
+ const hostname = parsed.hostname.toLowerCase();
124
129
  if (
125
- originUrl.hostname === "127.0.0.1" ||
126
- originUrl.hostname === "localhost"
130
+ hostname === "127.0.0.1" ||
131
+ hostname === "localhost" ||
132
+ hostname === "::1" ||
133
+ hostname === "[::1]"
127
134
  ) {
128
135
  return true;
129
136
  }
130
- } catch (_) {}
131
- }
132
137
 
133
- if (referer) {
134
- try {
135
- const refererUrl = new URL(referer);
136
- if (refererUrl.host === host) {
138
+ if (
139
+ hostname === "github.com" ||
140
+ hostname.endsWith(".github.io") ||
141
+ hostname.endsWith(".pages.dev")
142
+ ) {
143
+ return true;
144
+ }
145
+
146
+ if (options.corsMode === "all") {
147
+ return true;
148
+ }
149
+
150
+ if (
151
+ options.allowedOrigins &&
152
+ options.allowedOrigins.has(parsed.origin)
153
+ ) {
137
154
  return true;
138
155
  }
156
+
139
157
  if (
140
- refererUrl.hostname === "127.0.0.1" ||
141
- refererUrl.hostname === "localhost"
158
+ options.corsMode === "private" &&
159
+ /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname)
142
160
  ) {
143
161
  return true;
144
162
  }
145
163
  } catch (_) {}
164
+ return false;
165
+ };
166
+
167
+ if (origin && isTrustedUrl(origin)) {
168
+ return true;
169
+ }
170
+
171
+ if (referer && isTrustedUrl(referer)) {
172
+ return true;
146
173
  }
147
174
 
148
175
  const secFetchSite = req.headers["sec-fetch-site"];
@@ -15,7 +15,7 @@ describe("pna-middleware", () => {
15
15
  next = jest.fn();
16
16
  });
17
17
 
18
- it("sets Access-Control-Allow-Private-Network header if requested", () => {
18
+ it("sets Access-Control-Allow-Private-Network header if requested explicitly", () => {
19
19
  req.headers["access-control-request-private-network"] = "true";
20
20
  const middleware = createPnaMiddleware(logger, false);
21
21
  middleware(req, res, next);
@@ -27,7 +27,19 @@ describe("pna-middleware", () => {
27
27
  expect(next).toHaveBeenCalled();
28
28
  });
29
29
 
30
- it("does not set header if not requested", () => {
30
+ it("sets Access-Control-Allow-Private-Network header for cross-origin requests", () => {
31
+ req.headers.origin = "https://xt-ml.github.io";
32
+ const middleware = createPnaMiddleware(logger, false);
33
+ middleware(req, res, next);
34
+
35
+ expect(res.setHeader).toHaveBeenCalledWith(
36
+ "Access-Control-Allow-Private-Network",
37
+ "true",
38
+ );
39
+ expect(next).toHaveBeenCalled();
40
+ });
41
+
42
+ it("does not set header if neither requested nor origin present", () => {
31
43
  const middleware = createPnaMiddleware(logger, false);
32
44
  middleware(req, res, next);
33
45
 
@@ -35,7 +47,7 @@ describe("pna-middleware", () => {
35
47
  expect(next).toHaveBeenCalled();
36
48
  });
37
49
 
38
- it("logs if verbose is true", () => {
50
+ it("logs if verbose is true and header is explicitly requested", () => {
39
51
  req.headers["access-control-request-private-network"] = "true";
40
52
  const middleware = createPnaMiddleware(logger, true);
41
53
  middleware(req, res, next);
@@ -3,8 +3,12 @@ import type { Logger } from "../logger.js";
3
3
 
4
4
  export function createPnaMiddleware(logger: Logger, verbose: boolean) {
5
5
  return (req: Request, res: Response, next: NextFunction) => {
6
- if (req.headers["access-control-request-private-network"] === "true") {
7
- if (verbose) {
6
+ const isPnaExplicit =
7
+ req.headers["access-control-request-private-network"] === "true";
8
+ const hasOrigin = Boolean(req.headers.origin);
9
+
10
+ if (isPnaExplicit || hasOrigin) {
11
+ if (verbose && isPnaExplicit) {
8
12
  logger.log("VERBOSE", `[PNA] Allowing Private Network Access`);
9
13
  }
10
14