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.
- package/README.md +2 -1
- package/bin/cli.mjs +111 -2
- package/bin/commands/webrtc-listen.mjs +21 -1
- package/bin/utils/control-client.mjs +44 -30
- package/bin/utils/control-client.test.mjs +10 -0
- package/bin/utils/webrtc-control-client.mjs +23 -4
- package/dist/public/AGENTS.md +8 -1
- package/dist/public/README.md +2 -1
- package/dist/public/agent.worker.js +49 -49
- package/dist/public/backup-controller-CvzDOq4T.js +1 -0
- package/dist/public/{configurePeerJs-CxpZeDJJ.js → configurePeerJs-UfQ6HUlv.js} +1 -1
- package/dist/public/docs/README.md +1 -1
- package/dist/public/docs/example/article/index.html +1 -1
- package/dist/public/docs/guides/server-development-configuration.md +28 -0
- package/dist/public/docs/publishing/index.html +1 -1
- package/dist/public/docs/skill-creator/index.html +1 -1
- package/dist/public/docs/subsystems/cli.md +25 -19
- package/dist/public/docs/subsystems/control-plane.md +11 -0
- package/dist/public/{downloadGroupFile-By0GR6eV.js → downloadGroupFile-9niHBj9n.js} +1 -1
- package/dist/public/{e2e-bridge-IGWsRl4i.js → e2e-bridge-D28tOggc.js} +1 -1
- package/dist/public/{file-viewer-D0eflgt6.js → file-viewer-BVg3B_JH.js} +1 -1
- package/dist/public/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/initControlPlane-D5kHKcB5.js +1 -0
- package/dist/public/llms.txt +1 -1
- package/dist/public/main/index.html +1 -1
- package/dist/public/main/memory/index.html +1 -1
- package/dist/public/{markdown-BnpKxSxl.js → markdown-SiFq0PTy.js} +1 -1
- package/dist/public/{orchestrator-nJj59RqD.js → orchestrator-CnwkoBpe.js} +1 -1
- package/dist/public/{orchestrator-3h9xxHUm.js → orchestrator-tHWrqi_F.js} +5 -5
- package/dist/public/{peerjs-BP4laMXj.js → peerjs-CuSJUH-r.js} +1 -1
- package/dist/public/push-client-D0lkwrK0.js +1 -0
- package/dist/public/service-worker/fetch-proxy.js +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-CbwETBWf.js → shadow-claw-CUbhMmcB.js} +2 -2
- package/dist/public/{shadow-claw-channels-BDWG5Qa3.js → shadow-claw-channels-mY-JMvib.js} +1 -1
- package/dist/public/{shadow-claw-chat-Crv0DTCY.js → shadow-claw-chat-BN85a1xk.js} +1 -1
- package/dist/public/{shadow-claw-control-plane-CX1IGbh9.js → shadow-claw-control-plane-C0lSxLpW.js} +2 -2
- package/dist/public/{shadow-claw-conversations-_9qAaRXk.js → shadow-claw-conversations-0Q-VC_7N.js} +1 -1
- package/dist/public/{shadow-claw-file-viewer-Blf6yGVD.js → shadow-claw-file-viewer-AZfG4D-u.js} +1 -1
- package/dist/public/{shadow-claw-files-CYySBDCl.js → shadow-claw-files-Bx4v0Ggp.js} +1 -1
- package/dist/public/{shadow-claw-git-v8S0JjuR.js → shadow-claw-git-DD7iQRxq.js} +2 -2
- package/dist/public/{shadow-claw-llm-GWgoVfhI.js → shadow-claw-llm-CfeDBZKB.js} +1 -1
- package/dist/public/{shadow-claw-networking-qHbP95z9.js → shadow-claw-networking-e0EarDVs.js} +1 -1
- package/dist/public/{shadow-claw-notifications-YyHOK2Fr.js → shadow-claw-notifications-BkxuDEQI.js} +3 -3
- package/dist/public/{shadow-claw-pages-DFZHhzFZ.js → shadow-claw-pages-BWSE91I1.js} +1 -1
- package/dist/public/{shadow-claw-settings-DhxrKIfA.js → shadow-claw-settings-BZWrAoQA.js} +2 -2
- package/dist/public/{shadow-claw-storage-BFynbP0j.js → shadow-claw-storage-Crs6LY15.js} +1 -1
- package/dist/public/{shadow-claw-task-server-3BIeb5oZ.js → shadow-claw-task-server-CKm_Ennl.js} +1 -1
- package/dist/public/{shadow-claw-tasks-Caxc9atL.js → shadow-claw-tasks-Czx6Ucf5.js} +1 -1
- package/dist/public/{shadow-claw-terminal-Beb5qF-U.js → shadow-claw-terminal-Dmtr9KY1.js} +1 -1
- package/dist/public/{shadow-claw-tools-BfJx3A9W.js → shadow-claw-tools-t3Qq4TDO.js} +1 -1
- package/dist/public/{shadow-claw-webvm-BCS8ykeP.js → shadow-claw-webvm-CIuSywD_.js} +1 -1
- package/dist/public/{syncWebMcpRegistration-B_M2fCjH.js → syncWebMcpRegistration-DzC80wVG.js} +1 -1
- package/dist/server.js +203 -203
- package/package.json +1 -1
- package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +3 -0
- package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.ts +43 -9
- package/src/core/backup-controller.ts +33 -4
- package/src/core/control-plane-client.test.ts +9 -2
- package/src/core/control-plane-client.ts +18 -2
- package/src/core/orchestrator/utils/operations/task.ts +46 -15
- package/src/core/utils/initControlPlane.ts +2 -0
- package/src/server/app.ts +2 -0
- package/src/server/config.test.ts +49 -0
- package/src/server/config.ts +47 -0
- package/src/server/control-plane.test.ts +36 -0
- package/src/server/control-plane.ts +41 -14
- package/src/server/middleware/pna.test.ts +15 -3
- package/src/server/middleware/pna.ts +6 -2
- package/src/server/peer.ts +6 -2
- package/src/server/routes/backup.test.ts +14 -0
- package/src/server/routes/backup.ts +44 -15
- package/src/server/server.test.ts +118 -0
- package/src/server/server.ts +22 -5
- package/src/server/tls.test.ts +266 -0
- package/src/server/tls.ts +178 -0
- package/src/service-worker/fetch-proxy-rules.test.ts +41 -0
- package/src/service-worker/fetch-proxy-rules.ts +15 -8
- package/src/service-worker/fetch-proxy.ts +18 -2
- package/src/subsystems/notifications/push-client.test.ts +5 -1
- package/src/subsystems/notifications/push-client.ts +50 -12
- package/dist/public/backup-controller-C_s9in2t.js +0 -1
- package/dist/public/initControlPlane-C9jpnWev.js +0 -1
- package/dist/public/push-client-CrjEXK3U.js +0 -1
package/package.json
CHANGED
|
@@ -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(
|
|
190
|
-
|
|
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(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
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
|
|
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<{
|
|
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
|
-
|
|
356
|
+
const fetchOptions: any = {
|
|
357
357
|
method: "POST",
|
|
358
358
|
headers,
|
|
359
359
|
body: JSON.stringify(msg),
|
|
360
|
-
}
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/server/config.ts
CHANGED
|
@@ -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
|
-
|
|
121
|
+
const isTrustedUrl = (urlStr: string): boolean => {
|
|
119
122
|
try {
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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("
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|