clay-server 4.7.0 → 4.7.1-beta.2
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 +6 -0
- package/lib/daemon.js +4 -0
- package/lib/network-origin-diagnostics.js +38 -0
- package/lib/network-origins.js +90 -0
- package/lib/public/app.js +1 -0
- package/lib/public/css/server-network-settings.css +41 -0
- package/lib/public/index.html +19 -0
- package/lib/public/modules/app-misc.js +11 -1
- package/lib/public/modules/network-origin-guard.js +56 -0
- package/lib/public/modules/server-network-settings.js +133 -0
- package/lib/public/modules/server-settings.js +6 -0
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +4 -3
- package/lib/server-network-settings.js +99 -0
- package/lib/server-speech.js +2 -2
- package/lib/server.js +26 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,12 @@ Clay Studio does not relay project traffic through a Clay-hosted cloud. Your cod
|
|
|
73
73
|
|
|
74
74
|
The `d.clay.studio` hostname used for local HTTPS is a DNS-only service: no application data passes through it. See [clay-dns](clay-dns/) for the implementation and threat model.
|
|
75
75
|
|
|
76
|
+
### Reverse proxies and tunnels
|
|
77
|
+
|
|
78
|
+
If your proxy rewrites the upstream `Host` header, open **Server Settings → Network → Reverse proxy & tunnels** and add your browser-facing origin, for example `https://clay.example.com`. Enter one HTTP or HTTPS origin per line, with a port when needed and no path or wildcard. Only server administrators can edit this setting in multi-user mode. Saving applies to new WebSocket connections without a restart; existing connections stay open until they reconnect, and authentication remains required. When Clay refuses a WebSocket because of its origin, the daemon log notes the refused origin (at most once a minute).
|
|
79
|
+
|
|
80
|
+
If the tunnel cannot connect yet, use a working local connection to configure it. Alternatively, stop the daemon, add `"allowedOrigins": ["https://clay.example.com"]` to the existing `~/.clay/daemon.json` (or the daemon config in your `CLAY_HOME`), preserving its other fields, and start Clay again. Direct connections and proxies that preserve `Host` normally need no additional origins. Forwarded headers are not trusted automatically.
|
|
81
|
+
|
|
76
82
|
## Common CLI commands
|
|
77
83
|
|
|
78
84
|
```bash
|
package/lib/daemon.js
CHANGED
|
@@ -789,6 +789,10 @@ var relay = createServer({
|
|
|
789
789
|
}
|
|
790
790
|
return { ok: false, error: "Project not found" };
|
|
791
791
|
},
|
|
792
|
+
onGetAllowedOrigins: function () { return config.allowedOrigins || []; },
|
|
793
|
+
onSetAllowedOrigins: function (origins) {
|
|
794
|
+
require("./network-origins").saveAllowedOrigins(config, origins, saveConfig);
|
|
795
|
+
},
|
|
792
796
|
onGetDaemonConfig: function () {
|
|
793
797
|
return {
|
|
794
798
|
port: config.port,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Rate-limited log hint for rejected WebSocket origins. State is fixed-size
|
|
2
|
+
// (one timestamp and one counter), so hostile traffic cannot grow memory or
|
|
3
|
+
// flood the daemon log. Only Origin and Host are logged, never cookies or tokens.
|
|
4
|
+
var DEFAULT_INTERVAL_MS = 60000;
|
|
5
|
+
var MAX_VALUE_LENGTH = 200;
|
|
6
|
+
|
|
7
|
+
function safeValue(value) {
|
|
8
|
+
if (typeof value !== "string") return "(none)";
|
|
9
|
+
var text = value.length > MAX_VALUE_LENGTH ? value.slice(0, MAX_VALUE_LENGTH) + "..." : value;
|
|
10
|
+
// JSON quoting escapes newlines and control characters, preventing log injection.
|
|
11
|
+
return JSON.stringify(text);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createOriginRejectionLogger(options) {
|
|
15
|
+
var opts = options || {};
|
|
16
|
+
var intervalMs = opts.intervalMs || DEFAULT_INTERVAL_MS;
|
|
17
|
+
var now = opts.now || Date.now;
|
|
18
|
+
var log = opts.log || function (message) { console.warn(message); };
|
|
19
|
+
var lastLoggedAt = null;
|
|
20
|
+
var suppressed = 0;
|
|
21
|
+
|
|
22
|
+
return function logRejectedOrigin(origin, host) {
|
|
23
|
+
var current = now();
|
|
24
|
+
if (lastLoggedAt !== null && current - lastLoggedAt < intervalMs) {
|
|
25
|
+
suppressed++;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
var message = "[network] Rejected WebSocket connection from origin " + safeValue(origin) +
|
|
29
|
+
" (Host " + safeValue(host) + "). If this is your reverse proxy or tunnel address, an administrator can add it in Server Settings > Network.";
|
|
30
|
+
if (suppressed > 0) message += " " + suppressed + " similar rejection(s) were not logged.";
|
|
31
|
+
lastLoggedAt = current;
|
|
32
|
+
suppressed = 0;
|
|
33
|
+
log(message);
|
|
34
|
+
return true;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { createOriginRejectionLogger: createOriginRejectionLogger };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Shared validation for configured browser origins and WebSocket handshakes.
|
|
2
|
+
var MAX_ORIGINS = 100;
|
|
3
|
+
var MAX_ORIGIN_LENGTH = 2048;
|
|
4
|
+
|
|
5
|
+
function invalid(reason) {
|
|
6
|
+
var error = new Error(reason);
|
|
7
|
+
error.reason = reason;
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeOrigin(value) {
|
|
12
|
+
if (typeof value !== "string") throw invalid("must be text, for example https://clay.example.com.");
|
|
13
|
+
if (value.length > MAX_ORIGIN_LENGTH) throw invalid("is longer than " + MAX_ORIGIN_LENGTH + " characters.");
|
|
14
|
+
if (value.indexOf("*") !== -1) throw invalid("cannot contain wildcards (*). List each address separately.");
|
|
15
|
+
if (!/^https?:\/\//i.test(value)) throw invalid("must start with http:// or https://.");
|
|
16
|
+
if (!/^https?:\/\/[^\s/?#\\]+\/?$/i.test(value)) {
|
|
17
|
+
throw invalid("cannot include a path, query, fragment, or spaces. Use only the scheme, host, and optional port.");
|
|
18
|
+
}
|
|
19
|
+
var url;
|
|
20
|
+
try { url = new URL(value); } catch (e) { throw invalid("is not a valid address. Check the host name and port."); }
|
|
21
|
+
if (url.username || url.password) throw invalid("cannot include a username or password.");
|
|
22
|
+
return url.origin;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeAllowedOrigins(values) {
|
|
26
|
+
if (!Array.isArray(values)) throw invalid("Allowed origins must be a list.");
|
|
27
|
+
if (values.length > MAX_ORIGINS) throw invalid("Enter at most " + MAX_ORIGINS + " allowed origins.");
|
|
28
|
+
var normalized = [];
|
|
29
|
+
for (var i = 0; i < values.length; i++) {
|
|
30
|
+
try {
|
|
31
|
+
normalized.push(normalizeOrigin(values[i]));
|
|
32
|
+
} catch (e) {
|
|
33
|
+
var error = invalid("Entry " + (i + 1) + " " + e.reason);
|
|
34
|
+
error.index = i;
|
|
35
|
+
error.entryReason = e.reason;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return Array.from(new Set(normalized));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Describes stored entries for the editor without dropping invalid manual edits.
|
|
43
|
+
function describeConfiguredOrigins(raw) {
|
|
44
|
+
if (raw === undefined || raw === null) return { allowedOrigins: [], invalid: [] };
|
|
45
|
+
var list = Array.isArray(raw) ? raw : [raw];
|
|
46
|
+
var entries = [];
|
|
47
|
+
var problems = [];
|
|
48
|
+
for (var i = 0; i < list.length; i++) {
|
|
49
|
+
var value = list[i];
|
|
50
|
+
entries.push(typeof value === "string" ? value : JSON.stringify(value) || String(value));
|
|
51
|
+
try { normalizeOrigin(value); } catch (e) { problems.push({ index: i, reason: e.reason }); }
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(raw)) problems.unshift({ index: 0, reason: "is not stored as a list. Save to store it correctly." });
|
|
54
|
+
return { allowedOrigins: entries, invalid: problems };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isAllowedOrigin(origin, host, allowedOrigins) {
|
|
58
|
+
// Non-browser clients may omit Origin; authentication is still required.
|
|
59
|
+
if (origin === undefined) return true;
|
|
60
|
+
try {
|
|
61
|
+
var normalized = normalizeOrigin(origin);
|
|
62
|
+
var configured = Array.isArray(allowedOrigins) ? allowedOrigins : [];
|
|
63
|
+
for (var i = 0; i < configured.length; i++) {
|
|
64
|
+
try {
|
|
65
|
+
if (normalizeOrigin(configured[i]) === normalized) return true;
|
|
66
|
+
} catch (e) { /* Invalid manual config entries never grant access. */ }
|
|
67
|
+
}
|
|
68
|
+
// Preserve Host-based access through TLS-terminating proxies. Forwarded
|
|
69
|
+
// headers are deliberately not trusted; rewritten hosts need an allowlist.
|
|
70
|
+
if (typeof host !== "string" || !host.trim()) return false;
|
|
71
|
+
var protocol = new URL(normalized).protocol;
|
|
72
|
+
return normalizeOrigin(protocol + "//" + host) === normalized;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveAllowedOrigins(config, origins, saveConfig) {
|
|
79
|
+
var normalized = normalizeAllowedOrigins(origins);
|
|
80
|
+
var nextConfig = Object.assign({}, config, { allowedOrigins: normalized });
|
|
81
|
+
saveConfig(nextConfig);
|
|
82
|
+
config.allowedOrigins = normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
normalizeAllowedOrigins: normalizeAllowedOrigins,
|
|
87
|
+
describeConfiguredOrigins: describeConfiguredOrigins,
|
|
88
|
+
isAllowedOrigin: isAllowedOrigin,
|
|
89
|
+
saveAllowedOrigins: saveAllowedOrigins,
|
|
90
|
+
};
|
package/lib/public/app.js
CHANGED
|
@@ -267,6 +267,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
|
|
|
267
267
|
|
|
268
268
|
// --- Initialize store (single source of truth for client state) ---
|
|
269
269
|
createStore({
|
|
270
|
+
networkSettings: { loaded: false, busy: false, confirming: false, draft: "", saved: "", status: "", errorKind: "", requestId: 0 },
|
|
270
271
|
// session / routing
|
|
271
272
|
basePath: basePath,
|
|
272
273
|
wsPath: wsPath,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
.settings-network-origins {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
width: 100%;
|
|
4
|
+
min-height: 128px;
|
|
5
|
+
margin-top: 12px;
|
|
6
|
+
padding: 12px;
|
|
7
|
+
resize: vertical;
|
|
8
|
+
border: 1px solid var(--border-subtle);
|
|
9
|
+
border-radius: 8px;
|
|
10
|
+
background: var(--bg-secondary);
|
|
11
|
+
color: var(--text);
|
|
12
|
+
font-family: var(--font-mono, monospace);
|
|
13
|
+
font-size: 13px;
|
|
14
|
+
line-height: 1.7;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.settings-network-origins:focus-visible {
|
|
18
|
+
outline: 2px solid var(--accent);
|
|
19
|
+
outline-offset: 2px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.settings-network-origins:disabled { opacity: 0.6; }
|
|
23
|
+
|
|
24
|
+
.settings-network-actions {
|
|
25
|
+
display: flex;
|
|
26
|
+
justify-content: space-between;
|
|
27
|
+
align-items: center;
|
|
28
|
+
flex-wrap: wrap;
|
|
29
|
+
gap: 12px;
|
|
30
|
+
margin-top: 12px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.settings-network-error { color: var(--error, #ef6464); }
|
|
34
|
+
|
|
35
|
+
.settings-network-actions .settings-btn-sm:disabled {
|
|
36
|
+
opacity: 0.45;
|
|
37
|
+
cursor: not-allowed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* The shared confirm modal normally sits below the Settings overlay. */
|
|
41
|
+
#confirm-modal.confirm-above-settings { z-index: 700; }
|
package/lib/public/index.html
CHANGED
|
@@ -1250,6 +1250,7 @@
|
|
|
1250
1250
|
<option value="overview" selected>Status</option>
|
|
1251
1251
|
<option value="notifications">Notifications</option>
|
|
1252
1252
|
<option value="security">Security</option>
|
|
1253
|
+
<option value="network">Network</option>
|
|
1253
1254
|
</optgroup>
|
|
1254
1255
|
<optgroup label="Config">
|
|
1255
1256
|
<option value="claudemd">Instructions</option>
|
|
@@ -1273,6 +1274,7 @@
|
|
|
1273
1274
|
<button class="settings-nav-item active" data-section="overview"><span>Status</span></button>
|
|
1274
1275
|
<button class="settings-nav-item" data-section="notifications"><span>Notifications</span></button>
|
|
1275
1276
|
<button class="settings-nav-item" data-section="security"><span>Security</span></button>
|
|
1277
|
+
<button class="settings-nav-item" data-section="network"><span>Network</span></button>
|
|
1276
1278
|
<div class="settings-nav-separator"></div>
|
|
1277
1279
|
<div class="settings-nav-category">Config</div>
|
|
1278
1280
|
<button class="settings-nav-item" data-section="claudemd"><span>Instructions</span></button>
|
|
@@ -1401,6 +1403,23 @@
|
|
|
1401
1403
|
</div>
|
|
1402
1404
|
</div>
|
|
1403
1405
|
</div>
|
|
1406
|
+
<div class="server-settings-section" data-section="network">
|
|
1407
|
+
<h2>Network</h2>
|
|
1408
|
+
<h3>Reverse proxy & tunnels</h3>
|
|
1409
|
+
<p class="settings-hint">Connect to Clay through an external address, even when your proxy changes the upstream host.</p>
|
|
1410
|
+
<form id="settings-network-form" class="settings-card">
|
|
1411
|
+
<div class="settings-field">
|
|
1412
|
+
<label class="settings-label" for="settings-network-origins">Allowed external origins</label>
|
|
1413
|
+
<p class="settings-hint" id="settings-network-help">Enter one address per line, including http:// or https:// and a port if needed. Do not include paths or wildcards. Leave empty if you connect directly.</p>
|
|
1414
|
+
<textarea id="settings-network-origins" class="settings-network-origins" rows="5" placeholder="https://clay.example.com" spellcheck="false" autocapitalize="off" autocomplete="off" aria-describedby="settings-network-help settings-network-status" disabled></textarea>
|
|
1415
|
+
<div class="settings-network-actions">
|
|
1416
|
+
<span id="settings-network-status" class="settings-hint" role="status" aria-live="polite"></span>
|
|
1417
|
+
<button type="submit" id="settings-network-save" class="settings-btn-sm" disabled>Save changes</button>
|
|
1418
|
+
</div>
|
|
1419
|
+
</div>
|
|
1420
|
+
</form>
|
|
1421
|
+
<p class="settings-hint">Applies to the entire server. Saved changes take effect on new connections without restarting; existing connections stay open until they reconnect. Login requirements stay unchanged.</p>
|
|
1422
|
+
</div>
|
|
1404
1423
|
<div class="server-settings-section" data-section="notifications">
|
|
1405
1424
|
<h2>Notifications</h2>
|
|
1406
1425
|
<div class="settings-card">
|
|
@@ -9,6 +9,7 @@ import { initPasteModal } from './paste-modal.js';
|
|
|
9
9
|
|
|
10
10
|
// --- Module-owned state ---
|
|
11
11
|
var confirmCallback = null;
|
|
12
|
+
var confirmCancelCallback = null;
|
|
12
13
|
var _extRequestCallbacks = {};
|
|
13
14
|
|
|
14
15
|
// Queue for extension messages that arrived before WS was ready
|
|
@@ -51,6 +52,7 @@ export function initMisc() {
|
|
|
51
52
|
var confirmCancel = document.getElementById("confirm-cancel");
|
|
52
53
|
|
|
53
54
|
confirmOk.addEventListener("click", function () {
|
|
55
|
+
confirmCancelCallback = null;
|
|
54
56
|
if (confirmCallback) confirmCallback();
|
|
55
57
|
hideConfirm();
|
|
56
58
|
});
|
|
@@ -259,12 +261,17 @@ export function closeImageModal() {
|
|
|
259
261
|
if (modal) modal.classList.add("hidden");
|
|
260
262
|
}
|
|
261
263
|
|
|
262
|
-
|
|
264
|
+
// onCancel (optional) runs when the dialog is dismissed or replaced without confirming.
|
|
265
|
+
export function showConfirm(text, onConfirm, okLabel, destructive, onCancel) {
|
|
263
266
|
var confirmText = document.getElementById("confirm-text");
|
|
264
267
|
var confirmOk = document.getElementById("confirm-ok");
|
|
265
268
|
var confirmModal = document.getElementById("confirm-modal");
|
|
269
|
+
var replaced = confirmCancelCallback;
|
|
270
|
+
confirmCancelCallback = null;
|
|
271
|
+
if (replaced) replaced();
|
|
266
272
|
confirmText.textContent = text;
|
|
267
273
|
confirmCallback = onConfirm;
|
|
274
|
+
confirmCancelCallback = onCancel || null;
|
|
268
275
|
confirmOk.textContent = okLabel || "Delete";
|
|
269
276
|
confirmOk.className = "confirm-btn " + (destructive === false ? "confirm-ok" : "confirm-delete");
|
|
270
277
|
confirmModal.classList.remove("hidden");
|
|
@@ -274,6 +281,9 @@ export function hideConfirm() {
|
|
|
274
281
|
var confirmModal = document.getElementById("confirm-modal");
|
|
275
282
|
confirmModal.classList.add("hidden");
|
|
276
283
|
confirmCallback = null;
|
|
284
|
+
var cancelled = confirmCancelCallback;
|
|
285
|
+
confirmCancelCallback = null;
|
|
286
|
+
if (cancelled) cancelled();
|
|
277
287
|
}
|
|
278
288
|
|
|
279
289
|
export function showForceChangePinOverlay() {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Pure helpers for the Network settings editor. Mirrors lib/network-origins.js
|
|
2
|
+
// closely enough to compare spellings; the server remains the authority.
|
|
3
|
+
|
|
4
|
+
export function normalizeOriginForCompare(value) {
|
|
5
|
+
if (typeof value !== 'string' || value.indexOf('*') !== -1 ||
|
|
6
|
+
!/^https?:\/\/[^\s/?#\\]+\/?$/i.test(value)) return null;
|
|
7
|
+
try {
|
|
8
|
+
var url = new URL(value);
|
|
9
|
+
if (url.username || url.password) return null;
|
|
10
|
+
return url.origin;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Returns trimmed non-empty entries plus the 1-based textarea line of each.
|
|
17
|
+
export function parseOriginDraft(draft) {
|
|
18
|
+
var origins = [];
|
|
19
|
+
var lines = [];
|
|
20
|
+
var rows = String(draft || '').split('\n');
|
|
21
|
+
for (var i = 0; i < rows.length; i++) {
|
|
22
|
+
var value = rows[i].trim();
|
|
23
|
+
if (!value) continue;
|
|
24
|
+
origins.push(value);
|
|
25
|
+
lines.push(i + 1);
|
|
26
|
+
}
|
|
27
|
+
return { origins: origins, lines: lines };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizedSet(values) {
|
|
31
|
+
var set = {};
|
|
32
|
+
for (var i = 0; i < values.length; i++) {
|
|
33
|
+
var origin = normalizeOriginForCompare(values[i]);
|
|
34
|
+
if (origin) set[origin] = true;
|
|
35
|
+
}
|
|
36
|
+
return set;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Returns the page's normalized origin when the saved list allowed it and the
|
|
40
|
+
// next list does not; otherwise null. Equivalent spellings count as kept.
|
|
41
|
+
export function removesCurrentOrigin(savedText, nextOrigins, currentOrigin) {
|
|
42
|
+
var current = normalizeOriginForCompare(currentOrigin);
|
|
43
|
+
if (!current) return null;
|
|
44
|
+
if (!normalizedSet(parseOriginDraft(savedText).origins)[current]) return null;
|
|
45
|
+
if (normalizedSet(nextOrigins)[current]) return null;
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Turns stored-entry problems into a status message using textarea line numbers.
|
|
50
|
+
export function describeInvalidEntries(invalid) {
|
|
51
|
+
if (!Array.isArray(invalid) || !invalid.length) return '';
|
|
52
|
+
var first = invalid[0];
|
|
53
|
+
var message = 'Line ' + (first.index + 1) + ' ' + first.reason;
|
|
54
|
+
if (invalid.length > 1) message += ' ' + (invalid.length - 1) + ' more line(s) also need correction.';
|
|
55
|
+
return message + ' Invalid lines are ignored until corrected.';
|
|
56
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
import { showConfirm, hideConfirm } from './app-misc.js';
|
|
3
|
+
import { parseOriginDraft, removesCurrentOrigin, describeInvalidEntries } from './network-origin-guard.js';
|
|
4
|
+
|
|
5
|
+
// errorKind: 'validation' marks the textarea invalid; 'request' covers
|
|
6
|
+
// network, authorization, and disk failures that are not the input's fault.
|
|
7
|
+
function updateNetworkState(values) {
|
|
8
|
+
store.set({ networkSettings: Object.assign({}, store.get('networkSettings'), values) });
|
|
9
|
+
renderNetworkSettings();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function renderNetworkSettings() {
|
|
13
|
+
var state = store.get('networkSettings');
|
|
14
|
+
var input = document.getElementById('settings-network-origins');
|
|
15
|
+
var button = document.getElementById('settings-network-save');
|
|
16
|
+
var status = document.getElementById('settings-network-status');
|
|
17
|
+
if (!input || !state) return;
|
|
18
|
+
var locked = state.busy || state.confirming || !state.loaded;
|
|
19
|
+
if (input.value !== state.draft) input.value = state.draft;
|
|
20
|
+
input.disabled = locked;
|
|
21
|
+
button.disabled = locked || state.draft === state.saved;
|
|
22
|
+
button.textContent = state.busy && state.loaded ? 'Saving...' : 'Save changes';
|
|
23
|
+
status.textContent = state.status;
|
|
24
|
+
status.classList.toggle('settings-network-error', !!state.errorKind);
|
|
25
|
+
input.setAttribute('aria-invalid', state.errorKind === 'validation' ? 'true' : 'false');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function requestNetworkSettings(options) {
|
|
29
|
+
var response = await fetch('/api/server/network', Object.assign({ credentials: 'same-origin' }, options));
|
|
30
|
+
var data;
|
|
31
|
+
try { data = await response.json(); } catch (e) { data = {}; }
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
var error = new Error(data.error || 'Could not reach network settings (HTTP ' + response.status + ').');
|
|
34
|
+
if (typeof data.index === 'number') error.index = data.index;
|
|
35
|
+
error.reason = data.reason;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadedState(data, extra) {
|
|
42
|
+
var value = (data.allowedOrigins || []).join('\n');
|
|
43
|
+
var invalidMessage = describeInvalidEntries(data.invalid);
|
|
44
|
+
return Object.assign({ loaded: true, busy: false, draft: value, saved: value,
|
|
45
|
+
status: invalidMessage, errorKind: invalidMessage ? 'validation' : '' }, extra || {});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function loadNetworkSettings() {
|
|
49
|
+
var current = store.get('networkSettings');
|
|
50
|
+
if (current.busy || current.confirming || (current.loaded && current.draft !== current.saved)) return;
|
|
51
|
+
var requestId = current.requestId + 1;
|
|
52
|
+
updateNetworkState({ busy: true, loaded: false, status: 'Loading...', errorKind: '', requestId: requestId });
|
|
53
|
+
try {
|
|
54
|
+
var data = await requestNetworkSettings();
|
|
55
|
+
if (store.get('networkSettings').requestId !== requestId) return;
|
|
56
|
+
updateNetworkState(loadedState(data));
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (store.get('networkSettings').requestId !== requestId) return;
|
|
59
|
+
updateNetworkState({ loaded: false, busy: false, draft: '', saved: '', status: e.message, errorKind: 'request' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function submitOrigins(parsed) {
|
|
64
|
+
updateNetworkState({ busy: true, status: 'Saving...', errorKind: '' });
|
|
65
|
+
try {
|
|
66
|
+
var data = await requestNetworkSettings({
|
|
67
|
+
method: 'PUT',
|
|
68
|
+
headers: { 'Content-Type': 'application/json', 'X-Clay-Network-Settings': '1' },
|
|
69
|
+
body: JSON.stringify({ allowedOrigins: parsed.origins }),
|
|
70
|
+
});
|
|
71
|
+
updateNetworkState(loadedState(data, { status: 'Saved. Applies to new connections; existing connections stay open.' }));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (typeof e.index === 'number' && e.reason) {
|
|
74
|
+
var line = parsed.lines[e.index] || e.index + 1;
|
|
75
|
+
updateNetworkState({ busy: false, status: 'Line ' + line + ' ' + e.reason, errorKind: 'validation' });
|
|
76
|
+
} else {
|
|
77
|
+
updateNetworkState({ busy: false, status: e.message, errorKind: 'request' });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function confirmCurrentOriginRemoval(origin, parsed) {
|
|
83
|
+
var modal = document.getElementById('confirm-modal');
|
|
84
|
+
function settle() { if (modal) modal.classList.remove('confirm-above-settings'); }
|
|
85
|
+
updateNetworkState({ confirming: true });
|
|
86
|
+
if (modal) modal.classList.add('confirm-above-settings');
|
|
87
|
+
showConfirm('This removes ' + origin + ', the address you are using now. Your current connection stays open, ' +
|
|
88
|
+
'but the next reconnect or page reload from this address may be refused, and you may need local access to Clay to add it back.',
|
|
89
|
+
function () {
|
|
90
|
+
settle();
|
|
91
|
+
updateNetworkState({ confirming: false });
|
|
92
|
+
submitOrigins(parsed);
|
|
93
|
+
}, 'Remove and save', true, function () {
|
|
94
|
+
settle();
|
|
95
|
+
updateNetworkState({ confirming: false, status: 'Not saved. Your edits are still here.', errorKind: '' });
|
|
96
|
+
var input = document.getElementById('settings-network-origins');
|
|
97
|
+
if (input) input.focus();
|
|
98
|
+
});
|
|
99
|
+
// Default keyboard focus to the safe choice.
|
|
100
|
+
var cancel = document.getElementById('confirm-cancel');
|
|
101
|
+
if (cancel) cancel.focus();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Escape cancels a pending removal instead of closing Settings underneath it.
|
|
105
|
+
function handleConfirmEscape(event) {
|
|
106
|
+
if (event.key !== 'Escape' || !store.get('networkSettings').confirming) return;
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
event.stopImmediatePropagation();
|
|
109
|
+
hideConfirm();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function saveNetworkSettings(event) {
|
|
113
|
+
if (event) event.preventDefault();
|
|
114
|
+
var state = store.get('networkSettings');
|
|
115
|
+
if (!state.loaded || state.busy || state.confirming) return;
|
|
116
|
+
var parsed = parseOriginDraft(state.draft);
|
|
117
|
+
var removed = removesCurrentOrigin(state.saved, parsed.origins, window.location.origin);
|
|
118
|
+
if (removed) {
|
|
119
|
+
confirmCurrentOriginRemoval(removed, parsed);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
return submitOrigins(parsed);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function initNetworkSettings() {
|
|
126
|
+
var form = document.getElementById('settings-network-form');
|
|
127
|
+
if (!form) return;
|
|
128
|
+
form.addEventListener('submit', saveNetworkSettings);
|
|
129
|
+
window.addEventListener('keydown', handleConfirmEscape, true);
|
|
130
|
+
document.getElementById('settings-network-origins').addEventListener('input', function () {
|
|
131
|
+
updateNetworkState({ draft: this.value, status: '', errorKind: '' });
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -4,6 +4,7 @@ import { showToast, copyToClipboard } from './utils.js';
|
|
|
4
4
|
import { parseEnvString, looksLikeEnv, serializeEnvValue } from './project-settings.js';
|
|
5
5
|
import { checkAdminAccess, loadAdminSection } from './admin.js';
|
|
6
6
|
import { closeFileViewer } from './filebrowser.js';
|
|
7
|
+
import { initNetworkSettings, loadNetworkSettings } from './server-network-settings.js';
|
|
7
8
|
|
|
8
9
|
var ctx = null;
|
|
9
10
|
var settingsEl = null;
|
|
@@ -24,6 +25,8 @@ export function initServerSettings(appCtx) {
|
|
|
24
25
|
navItems = settingsEl.querySelectorAll(".settings-nav-item");
|
|
25
26
|
sections = settingsEl.querySelectorAll(".server-settings-section");
|
|
26
27
|
|
|
28
|
+
initNetworkSettings();
|
|
29
|
+
|
|
27
30
|
// Open settings
|
|
28
31
|
settingsBtn.addEventListener("click", function () {
|
|
29
32
|
openSettings();
|
|
@@ -240,6 +243,8 @@ function switchSection(sectionName) {
|
|
|
240
243
|
sections[j].classList.toggle("active", isActive2);
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
if (sectionName === "network") loadNetworkSettings();
|
|
247
|
+
|
|
243
248
|
// Lazy-load section data
|
|
244
249
|
if (sectionName === "claudemd") loadGlobalClaudeMd();
|
|
245
250
|
if (sectionName === "environment") loadSharedEnv();
|
|
@@ -255,6 +260,7 @@ function openSettings() {
|
|
|
255
260
|
settingsBtn.classList.add("active");
|
|
256
261
|
refreshIcons(settingsEl);
|
|
257
262
|
populateSettings();
|
|
263
|
+
if (settingsEl.querySelector('.server-settings-section.active[data-section="network"]')) loadNetworkSettings();
|
|
258
264
|
requestDaemonConfig();
|
|
259
265
|
resetRestartButton();
|
|
260
266
|
resetShutdownForm();
|
package/lib/public/style.css
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
@import url("css/diff.css");
|
|
22
22
|
@import url("css/highlight.css");
|
|
23
23
|
@import url("css/server-settings.css");
|
|
24
|
+
@import url("css/server-network-settings.css");
|
|
24
25
|
@import url("css/sticky-notes.css");
|
|
25
26
|
@import url("css/skills.css");
|
|
26
27
|
@import url("css/mobile-nav.css?v=20260407");
|
package/lib/sdk-bridge.js
CHANGED
|
@@ -539,9 +539,10 @@ function createSDKBridge(opts) {
|
|
|
539
539
|
return { behavior: "allow", updatedInput: input };
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
-
// Session
|
|
543
|
-
if (
|
|
544
|
-
|
|
542
|
+
// Session discovery and reads enforce project and session access in their handlers.
|
|
543
|
+
if (["list_other_driver_sessions", "search_other_driver_sessions", "read_other_driver_session"].some(function (name) {
|
|
544
|
+
return toolName === name || toolName === "mcp__clay-handoff__" + name;
|
|
545
|
+
})) {
|
|
545
546
|
return { behavior: "allow", updatedInput: input };
|
|
546
547
|
}
|
|
547
548
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
var { normalizeAllowedOrigins, describeConfiguredOrigins } = require("./network-origins");
|
|
2
|
+
|
|
3
|
+
var MAX_BODY_BYTES = 220000;
|
|
4
|
+
|
|
5
|
+
function attachNetworkSettings(ctx) {
|
|
6
|
+
function reply(res, status, data) {
|
|
7
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
8
|
+
res.end(JSON.stringify(data));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function authorized(req, res) {
|
|
12
|
+
if (!ctx.isRequestAuthed(req)) {
|
|
13
|
+
reply(res, 401, { error: "Authentication required." });
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (ctx.users.isMultiUser()) {
|
|
17
|
+
var user = ctx.getMultiUserFromReq(req);
|
|
18
|
+
if (!user || user.role !== "admin") {
|
|
19
|
+
reply(res, 403, { error: "Only server administrators can manage network settings." });
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function handleRequest(req, res, fullUrl) {
|
|
27
|
+
if (fullUrl !== "/api/server/network") return false;
|
|
28
|
+
if (!authorized(req, res)) return true;
|
|
29
|
+
if (req.method === "GET") {
|
|
30
|
+
reply(res, 200, describeConfiguredOrigins(ctx.getConfiguredOrigins()));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (req.method !== "PUT") {
|
|
34
|
+
reply(res, 405, { error: "Method not allowed." });
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
// A custom header forces cross-origin browsers to preflight. OPTIONS never
|
|
38
|
+
// reaches this handler: the global preflight handler in server.js answers
|
|
39
|
+
// first and allows only GET and Content-Type, so a cross-origin PUT with
|
|
40
|
+
// this header is refused by the browser. Keep both checks below, and keep
|
|
41
|
+
// that handler from allowing PUT or custom headers (see the preflight test).
|
|
42
|
+
if (req.headers["x-clay-network-settings"] !== "1" ||
|
|
43
|
+
(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase() !== "application/json" ||
|
|
44
|
+
(req.headers["sec-fetch-site"] && ["same-origin", "none"].indexOf(req.headers["sec-fetch-site"]) === -1)) {
|
|
45
|
+
reply(res, 403, { error: "Save network settings from the Clay settings page." });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
var body = "";
|
|
49
|
+
var size = 0;
|
|
50
|
+
var oversized = false;
|
|
51
|
+
// Decode as a stream so multibyte characters split across chunks stay intact.
|
|
52
|
+
req.setEncoding("utf8");
|
|
53
|
+
req.on("data", function (chunk) {
|
|
54
|
+
size += Buffer.byteLength(chunk);
|
|
55
|
+
if (size > MAX_BODY_BYTES) {
|
|
56
|
+
if (!oversized) reply(res, 413, { error: "Network settings are too large." });
|
|
57
|
+
oversized = true;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
body += chunk;
|
|
61
|
+
});
|
|
62
|
+
req.on("end", function () {
|
|
63
|
+
if (oversized) return;
|
|
64
|
+
if (!authorized(req, res)) return;
|
|
65
|
+
var data;
|
|
66
|
+
try { data = JSON.parse(body); } catch (e) {
|
|
67
|
+
reply(res, 400, { error: "The request was not valid JSON." });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
var origins;
|
|
71
|
+
try {
|
|
72
|
+
origins = normalizeAllowedOrigins(data && data.allowedOrigins);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
var result = { error: e.message };
|
|
75
|
+
if (typeof e.index === "number") {
|
|
76
|
+
result.index = e.index;
|
|
77
|
+
result.reason = e.entryReason;
|
|
78
|
+
}
|
|
79
|
+
reply(res, 400, result);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
if (typeof ctx.opts.onSetAllowedOrigins !== "function") {
|
|
84
|
+
reply(res, 503, { error: "Network settings cannot be saved in this server runtime." });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
ctx.opts.onSetAllowedOrigins(origins);
|
|
88
|
+
reply(res, 200, { allowedOrigins: origins, invalid: [] });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
reply(res, 500, { error: "Could not save network settings. Your previous settings are unchanged." });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { handleRequest: handleRequest };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { attachNetworkSettings: attachNetworkSettings };
|
package/lib/server-speech.js
CHANGED
|
@@ -3,6 +3,7 @@ var catalog = require('./speech-catalog');
|
|
|
3
3
|
var attachSpeechStore = require('./speech-store').attachSpeechStore;
|
|
4
4
|
var transcribe = require('./speech-transcribe').transcribe;
|
|
5
5
|
var openSpeechStream = require('./speech-stream').openSpeechStream;
|
|
6
|
+
var isAllowedOrigin = require('./network-origins').isAllowedOrigin;
|
|
6
7
|
var MAX_AUDIO = 10 * 1024 * 1024;
|
|
7
8
|
|
|
8
9
|
function attachSpeech(ctx) {
|
|
@@ -18,8 +19,7 @@ function attachSpeech(ctx) {
|
|
|
18
19
|
}
|
|
19
20
|
function sameOrigin(req) {
|
|
20
21
|
if (req.headers['sec-fetch-site'] === 'cross-site') return false;
|
|
21
|
-
|
|
22
|
-
try { return new URL(req.headers.origin).host === req.headers.host; } catch (error) { return false; }
|
|
22
|
+
return isAllowedOrigin(req.headers.origin, req.headers.host, ctx.getAllowedOrigins ? ctx.getAllowedOrigins() : []);
|
|
23
23
|
}
|
|
24
24
|
function json(res, status, body) {
|
|
25
25
|
if (res.destroyed || res.writableEnded) return;
|
package/lib/server.js
CHANGED
|
@@ -23,6 +23,9 @@ var serverCursorSharing = require("./server-cursor-sharing");
|
|
|
23
23
|
var serverTools = require("./server-tools");
|
|
24
24
|
var serverAdmin = require("./server-admin");
|
|
25
25
|
var serverSettings = require("./server-settings");
|
|
26
|
+
var { attachNetworkSettings } = require("./server-network-settings");
|
|
27
|
+
var { isAllowedOrigin } = require("./network-origins");
|
|
28
|
+
var { createOriginRejectionLogger } = require("./network-origin-diagnostics");
|
|
26
29
|
var serverSpeech = require("./server-speech");
|
|
27
30
|
var serverPalette = require("./server-palette");
|
|
28
31
|
var workspaceQueryModule = require("./workspace-query-service");
|
|
@@ -441,7 +444,22 @@ function createServer(opts) {
|
|
|
441
444
|
});
|
|
442
445
|
|
|
443
446
|
var speech = serverSpeech.attachSpeech({ users: users, getMultiUserFromReq: getMultiUserFromReq,
|
|
444
|
-
isRequestAuthed: isRequestAuthed, CONFIG_DIR: CONFIG_DIR });
|
|
447
|
+
isRequestAuthed: isRequestAuthed, CONFIG_DIR: CONFIG_DIR, getAllowedOrigins: getAllowedOrigins });
|
|
448
|
+
|
|
449
|
+
function getConfiguredOrigins() {
|
|
450
|
+
return opts.onGetAllowedOrigins ? opts.onGetAllowedOrigins() : opts.allowedOrigins;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getAllowedOrigins() {
|
|
454
|
+
var origins = getConfiguredOrigins();
|
|
455
|
+
return Array.isArray(origins) ? origins : [];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
var networkSettings = attachNetworkSettings({
|
|
459
|
+
users: users, getMultiUserFromReq: getMultiUserFromReq,
|
|
460
|
+
isRequestAuthed: isRequestAuthed, opts: opts, getConfiguredOrigins: getConfiguredOrigins,
|
|
461
|
+
});
|
|
462
|
+
var logRejectedOrigin = createOriginRejectionLogger({ log: opts.onOriginRejectedLog });
|
|
445
463
|
|
|
446
464
|
var settings = serverSettings.attachSettings({
|
|
447
465
|
users: users,
|
|
@@ -644,6 +662,7 @@ function createServer(opts) {
|
|
|
644
662
|
}
|
|
645
663
|
|
|
646
664
|
// CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
|
|
665
|
+
// Network settings PUT relies on this never allowing PUT or custom headers.
|
|
647
666
|
if (req.method === "OPTIONS") {
|
|
648
667
|
res.writeHead(204, {
|
|
649
668
|
"Access-Control-Allow-Origin": "*",
|
|
@@ -780,6 +799,7 @@ function createServer(opts) {
|
|
|
780
799
|
}
|
|
781
800
|
|
|
782
801
|
if (speech.handleRequest(req, res, fullUrl)) return;
|
|
802
|
+
if (networkSettings.handleRequest(req, res, fullUrl)) return;
|
|
783
803
|
if (settings.handleRequest(req, res, fullUrl)) return;
|
|
784
804
|
|
|
785
805
|
// --- Admin API endpoints (multi-user mode only) ---
|
|
@@ -1114,33 +1134,11 @@ function createServer(opts) {
|
|
|
1114
1134
|
if (speech.handleUpgrade(req, socket, head)) return;
|
|
1115
1135
|
var wsRequest = parseWsRequestUrl(req.url);
|
|
1116
1136
|
var wsPathname = wsRequest.path;
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
var originPort = String(originUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
|
|
1123
|
-
// Extract port from Host header for reverse proxy support.
|
|
1124
|
-
// Use URL parser to correctly handle IPv6 addresses (e.g. [::1])
|
|
1125
|
-
// and infer default port from origin protocol (not backend tlsOptions)
|
|
1126
|
-
// so TLS-terminating proxies on :443 with HTTP backends work.
|
|
1127
|
-
var hostPort;
|
|
1128
|
-
try {
|
|
1129
|
-
var hostUrl = new URL(originUrl.protocol + "//" + (req.headers.host || ""));
|
|
1130
|
-
hostPort = String(hostUrl.port || (originUrl.protocol === "https:" ? "443" : "80"));
|
|
1131
|
-
} catch (e2) {
|
|
1132
|
-
hostPort = String(portNum);
|
|
1133
|
-
}
|
|
1134
|
-
if (originPort !== String(portNum) && originPort !== hostPort) {
|
|
1135
|
-
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
1136
|
-
socket.destroy();
|
|
1137
|
-
return;
|
|
1138
|
-
}
|
|
1139
|
-
} catch (e) {
|
|
1140
|
-
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
1141
|
-
socket.destroy();
|
|
1142
|
-
return;
|
|
1143
|
-
}
|
|
1137
|
+
if (!isAllowedOrigin(req.headers.origin, req.headers.host, getAllowedOrigins())) {
|
|
1138
|
+
logRejectedOrigin(req.headers.origin, req.headers.host);
|
|
1139
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
1140
|
+
socket.destroy();
|
|
1141
|
+
return;
|
|
1144
1142
|
}
|
|
1145
1143
|
|
|
1146
1144
|
if (!isRequestAuthed(req)) {
|
package/package.json
CHANGED