@epiphytic/claudecodeui 1.0.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/LICENSE +675 -0
- package/README.md +414 -0
- package/dist/api-docs.html +879 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/index-DfR9xEkp.css +32 -0
- package/dist/assets/index-DvlVn6Eb.js +1231 -0
- package/dist/assets/vendor-codemirror-CJLzwpLB.js +39 -0
- package/dist/assets/vendor-react-DcyRfQm3.js +59 -0
- package/dist/assets/vendor-xterm-DfaPXD3y.js +66 -0
- package/dist/clear-cache.html +85 -0
- package/dist/convert-icons.md +53 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +9 -0
- package/dist/generate-icons.js +49 -0
- package/dist/icons/claude-ai-icon.svg +1 -0
- package/dist/icons/codex-white.svg +3 -0
- package/dist/icons/codex.svg +3 -0
- package/dist/icons/cursor-white.svg +12 -0
- package/dist/icons/cursor.svg +1 -0
- package/dist/icons/generate-icons.md +19 -0
- package/dist/icons/icon-128x128.png +0 -0
- package/dist/icons/icon-128x128.svg +12 -0
- package/dist/icons/icon-144x144.png +0 -0
- package/dist/icons/icon-144x144.svg +12 -0
- package/dist/icons/icon-152x152.png +0 -0
- package/dist/icons/icon-152x152.svg +12 -0
- package/dist/icons/icon-192x192.png +0 -0
- package/dist/icons/icon-192x192.svg +12 -0
- package/dist/icons/icon-384x384.png +0 -0
- package/dist/icons/icon-384x384.svg +12 -0
- package/dist/icons/icon-512x512.png +0 -0
- package/dist/icons/icon-512x512.svg +12 -0
- package/dist/icons/icon-72x72.png +0 -0
- package/dist/icons/icon-72x72.svg +12 -0
- package/dist/icons/icon-96x96.png +0 -0
- package/dist/icons/icon-96x96.svg +12 -0
- package/dist/icons/icon-template.svg +12 -0
- package/dist/index.html +52 -0
- package/dist/logo-128.png +0 -0
- package/dist/logo-256.png +0 -0
- package/dist/logo-32.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-64.png +0 -0
- package/dist/logo.svg +17 -0
- package/dist/manifest.json +61 -0
- package/dist/screenshots/cli-selection.png +0 -0
- package/dist/screenshots/desktop-main.png +0 -0
- package/dist/screenshots/mobile-chat.png +0 -0
- package/dist/screenshots/tools-modal.png +0 -0
- package/dist/sw.js +107 -0
- package/package.json +120 -0
- package/server/claude-sdk.js +721 -0
- package/server/cli.js +469 -0
- package/server/cursor-cli.js +267 -0
- package/server/database/db.js +554 -0
- package/server/database/init.sql +54 -0
- package/server/index.js +2120 -0
- package/server/middleware/auth.js +161 -0
- package/server/openai-codex.js +389 -0
- package/server/orchestrator/client.js +989 -0
- package/server/orchestrator/github-auth.js +308 -0
- package/server/orchestrator/index.js +216 -0
- package/server/orchestrator/protocol.js +299 -0
- package/server/orchestrator/proxy.js +364 -0
- package/server/orchestrator/status-tracker.js +226 -0
- package/server/projects.js +1604 -0
- package/server/routes/agent.js +1230 -0
- package/server/routes/auth.js +135 -0
- package/server/routes/cli-auth.js +341 -0
- package/server/routes/codex.js +345 -0
- package/server/routes/commands.js +521 -0
- package/server/routes/cursor.js +795 -0
- package/server/routes/git.js +1128 -0
- package/server/routes/mcp-utils.js +48 -0
- package/server/routes/mcp.js +650 -0
- package/server/routes/projects.js +378 -0
- package/server/routes/settings.js +178 -0
- package/server/routes/taskmaster.js +1963 -0
- package/server/routes/user.js +106 -0
- package/server/utils/commandParser.js +303 -0
- package/server/utils/gitConfig.js +24 -0
- package/server/utils/mcp-detector.js +198 -0
- package/server/utils/taskmaster-websocket.js +129 -0
- package/shared/modelConstants.js +65 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrchestratorClient
|
|
3
|
+
*
|
|
4
|
+
* WebSocket client that connects claudecodeui to a central orchestrator server.
|
|
5
|
+
* Handles connection management, authentication, heartbeats, and message routing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from "events";
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import { userDb } from "../database/db.js";
|
|
12
|
+
import { generateToken } from "../middleware/auth.js";
|
|
13
|
+
import {
|
|
14
|
+
createRegisterMessage,
|
|
15
|
+
createStatusUpdateMessage,
|
|
16
|
+
createPingMessage,
|
|
17
|
+
createResponseChunkMessage,
|
|
18
|
+
createResponseCompleteMessage,
|
|
19
|
+
createErrorMessage,
|
|
20
|
+
createHttpProxyResponseMessage,
|
|
21
|
+
serialize,
|
|
22
|
+
parse,
|
|
23
|
+
validateInboundMessage,
|
|
24
|
+
InboundMessageTypes,
|
|
25
|
+
StatusValues,
|
|
26
|
+
CommandTypes,
|
|
27
|
+
} from "./protocol.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default configuration values
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULTS = {
|
|
33
|
+
reconnectInterval: 5000,
|
|
34
|
+
heartbeatInterval: 30000,
|
|
35
|
+
heartbeatTimeout: 10000,
|
|
36
|
+
maxReconnectAttempts: 10,
|
|
37
|
+
reconnectBackoffMultiplier: 1.5,
|
|
38
|
+
maxReconnectInterval: 60000,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* OrchestratorClient class
|
|
43
|
+
*
|
|
44
|
+
* Manages the WebSocket connection to the orchestrator server.
|
|
45
|
+
* Emits events: 'connected', 'disconnected', 'error', 'command', 'user_request'
|
|
46
|
+
*/
|
|
47
|
+
export class OrchestratorClient extends EventEmitter {
|
|
48
|
+
/**
|
|
49
|
+
* Creates a new OrchestratorClient
|
|
50
|
+
* @param {Object} config - Configuration options
|
|
51
|
+
* @param {string} config.url - Orchestrator WebSocket URL
|
|
52
|
+
* @param {string} config.token - Authentication token
|
|
53
|
+
* @param {string} [config.clientId] - Custom client ID (defaults to hostname-pid)
|
|
54
|
+
* @param {number} [config.reconnectInterval] - Base reconnect interval in ms
|
|
55
|
+
* @param {number} [config.heartbeatInterval] - Heartbeat interval in ms
|
|
56
|
+
* @param {Object} [config.metadata] - Additional metadata to send on register
|
|
57
|
+
* @param {string} [config.callbackUrl] - HTTP callback URL for proxying (e.g., http://localhost:3010)
|
|
58
|
+
*/
|
|
59
|
+
constructor(config) {
|
|
60
|
+
super();
|
|
61
|
+
|
|
62
|
+
if (!config.url) {
|
|
63
|
+
throw new Error("Orchestrator URL is required");
|
|
64
|
+
}
|
|
65
|
+
if (!config.token) {
|
|
66
|
+
throw new Error("Orchestrator token is required");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.config = {
|
|
70
|
+
url: config.url,
|
|
71
|
+
token: config.token,
|
|
72
|
+
clientId: config.clientId || `${os.hostname()}-${process.pid}`,
|
|
73
|
+
reconnectInterval: config.reconnectInterval || DEFAULTS.reconnectInterval,
|
|
74
|
+
heartbeatInterval: config.heartbeatInterval || DEFAULTS.heartbeatInterval,
|
|
75
|
+
maxReconnectAttempts:
|
|
76
|
+
config.maxReconnectAttempts || DEFAULTS.maxReconnectAttempts,
|
|
77
|
+
metadata: config.metadata || {},
|
|
78
|
+
callbackUrl: config.callbackUrl || null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
this.ws = null;
|
|
82
|
+
this.status = StatusValues.IDLE;
|
|
83
|
+
this.reconnectAttempts = 0;
|
|
84
|
+
this.currentReconnectInterval = this.config.reconnectInterval;
|
|
85
|
+
this.reconnectTimer = null;
|
|
86
|
+
this.heartbeatTimer = null;
|
|
87
|
+
this.heartbeatTimeoutTimer = null;
|
|
88
|
+
this.isConnected = false;
|
|
89
|
+
this.isRegistered = false;
|
|
90
|
+
this.shouldReconnect = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Connects to the orchestrator server
|
|
95
|
+
* @returns {Promise<void>} Resolves when connected and registered
|
|
96
|
+
*/
|
|
97
|
+
async connect() {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
100
|
+
resolve();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.shouldReconnect = true;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Build connection URL with token and client_id
|
|
108
|
+
// This allows ORCHESTRATOR_URL to just be the base URL (e.g., wss://host/ws/connect)
|
|
109
|
+
// and the token from ORCHESTRATOR_TOKEN is automatically appended
|
|
110
|
+
const connectionUrl = new URL(this.config.url);
|
|
111
|
+
connectionUrl.searchParams.set("token", this.config.token);
|
|
112
|
+
connectionUrl.searchParams.set("client_id", this.config.clientId);
|
|
113
|
+
const urlString = connectionUrl.toString();
|
|
114
|
+
|
|
115
|
+
// Log connection without exposing token
|
|
116
|
+
console.log(
|
|
117
|
+
`[ORCHESTRATOR] Connecting to ${this.config.url} as ${this.config.clientId}`,
|
|
118
|
+
);
|
|
119
|
+
this.ws = new WebSocket(urlString);
|
|
120
|
+
|
|
121
|
+
const connectTimeout = setTimeout(() => {
|
|
122
|
+
if (!this.isConnected) {
|
|
123
|
+
this.ws.terminate();
|
|
124
|
+
reject(new Error("Connection timeout"));
|
|
125
|
+
}
|
|
126
|
+
}, 30000);
|
|
127
|
+
|
|
128
|
+
this.ws.on("open", () => {
|
|
129
|
+
clearTimeout(connectTimeout);
|
|
130
|
+
console.log("[ORCHESTRATOR] WebSocket connection established");
|
|
131
|
+
this.isConnected = true;
|
|
132
|
+
this.reconnectAttempts = 0;
|
|
133
|
+
this.currentReconnectInterval = this.config.reconnectInterval;
|
|
134
|
+
|
|
135
|
+
// Send registration message
|
|
136
|
+
this.sendRegister();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
this.ws.on("message", (data) => {
|
|
140
|
+
this.handleMessage(data.toString());
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
this.ws.on("close", (code, reason) => {
|
|
144
|
+
clearTimeout(connectTimeout);
|
|
145
|
+
const wasConnected = this.isConnected;
|
|
146
|
+
this.isConnected = false;
|
|
147
|
+
this.isRegistered = false;
|
|
148
|
+
this.stopHeartbeat();
|
|
149
|
+
|
|
150
|
+
console.log(
|
|
151
|
+
`[ORCHESTRATOR] Connection closed: ${code} ${reason || ""}`,
|
|
152
|
+
);
|
|
153
|
+
this.emit("disconnected", { code, reason: reason?.toString() });
|
|
154
|
+
|
|
155
|
+
if (this.shouldReconnect) {
|
|
156
|
+
this.scheduleReconnect();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!wasConnected) {
|
|
160
|
+
reject(new Error(`Connection failed: ${code}`));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
this.ws.on("error", (error) => {
|
|
165
|
+
console.error("[ORCHESTRATOR] WebSocket error:", error.message);
|
|
166
|
+
this.emit("error", error);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Wait for registration before resolving
|
|
170
|
+
const onRegistered = () => {
|
|
171
|
+
clearTimeout(connectTimeout);
|
|
172
|
+
this.removeListener("error", onError);
|
|
173
|
+
resolve();
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const onError = (error) => {
|
|
177
|
+
clearTimeout(connectTimeout);
|
|
178
|
+
this.removeListener("registered", onRegistered);
|
|
179
|
+
reject(error);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
this.once("registered", onRegistered);
|
|
183
|
+
this.once("error", onError);
|
|
184
|
+
|
|
185
|
+
// Clean up listeners if socket closes before registration
|
|
186
|
+
this.ws.once("close", () => {
|
|
187
|
+
this.removeListener("registered", onRegistered);
|
|
188
|
+
this.removeListener("error", onError);
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error("[ORCHESTRATOR] Connection error:", error.message);
|
|
192
|
+
reject(error);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Disconnects from the orchestrator server
|
|
199
|
+
*/
|
|
200
|
+
disconnect() {
|
|
201
|
+
console.log("[ORCHESTRATOR] Disconnecting...");
|
|
202
|
+
this.shouldReconnect = false;
|
|
203
|
+
this.stopHeartbeat();
|
|
204
|
+
this.clearReconnectTimer();
|
|
205
|
+
|
|
206
|
+
if (this.ws) {
|
|
207
|
+
this.ws.close(1000, "Client disconnect");
|
|
208
|
+
this.ws = null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.isConnected = false;
|
|
212
|
+
this.isRegistered = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Sends registration message to orchestrator
|
|
217
|
+
*/
|
|
218
|
+
sendRegister() {
|
|
219
|
+
const metadata = {
|
|
220
|
+
hostname: os.hostname(),
|
|
221
|
+
platform: os.platform(),
|
|
222
|
+
project: process.cwd(),
|
|
223
|
+
status: this.status,
|
|
224
|
+
version: process.env.npm_package_version || "1.0.0",
|
|
225
|
+
// Include callback URL for HTTP proxy support
|
|
226
|
+
// This allows the orchestrator to proxy HTTP requests to this instance
|
|
227
|
+
callback_url: this.config.callbackUrl || null,
|
|
228
|
+
...this.config.metadata,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const message = createRegisterMessage(
|
|
232
|
+
this.config.clientId,
|
|
233
|
+
this.config.token,
|
|
234
|
+
metadata,
|
|
235
|
+
);
|
|
236
|
+
this.sendMessage(message);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sends a status update to the orchestrator
|
|
241
|
+
* @param {string} status - New status (idle, active, busy)
|
|
242
|
+
*/
|
|
243
|
+
sendStatusUpdate(status) {
|
|
244
|
+
if (!Object.values(StatusValues).includes(status)) {
|
|
245
|
+
console.warn(`[ORCHESTRATOR] Invalid status: ${status}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.status = status;
|
|
250
|
+
|
|
251
|
+
if (!this.isRegistered) {
|
|
252
|
+
console.log(
|
|
253
|
+
"[ORCHESTRATOR] Not registered, queuing status update:",
|
|
254
|
+
status,
|
|
255
|
+
);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const message = createStatusUpdateMessage(this.config.clientId, status);
|
|
260
|
+
this.sendMessage(message);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Sends a ping message for heartbeat
|
|
265
|
+
*/
|
|
266
|
+
sendPing() {
|
|
267
|
+
const message = createPingMessage(this.config.clientId);
|
|
268
|
+
this.sendMessage(message);
|
|
269
|
+
|
|
270
|
+
// Clear any existing heartbeat timeout before setting a new one
|
|
271
|
+
// This prevents stale timers from firing on healthy connections
|
|
272
|
+
if (this.heartbeatTimeoutTimer) {
|
|
273
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
274
|
+
this.heartbeatTimeoutTimer = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Set timeout for pong response
|
|
278
|
+
this.heartbeatTimeoutTimer = setTimeout(() => {
|
|
279
|
+
console.warn("[ORCHESTRATOR] Heartbeat timeout, reconnecting...");
|
|
280
|
+
this.ws?.terminate();
|
|
281
|
+
}, DEFAULTS.heartbeatTimeout);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Sends a response chunk for a proxied request
|
|
286
|
+
* @param {string} requestId - Request ID
|
|
287
|
+
* @param {Object} data - Chunk data
|
|
288
|
+
*/
|
|
289
|
+
sendResponseChunk(requestId, data) {
|
|
290
|
+
const message = createResponseChunkMessage(requestId, data);
|
|
291
|
+
this.sendMessage(message);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Sends a response complete message for a proxied request
|
|
296
|
+
* @param {string} requestId - Request ID
|
|
297
|
+
* @param {Object} [data] - Final data
|
|
298
|
+
*/
|
|
299
|
+
sendResponseComplete(requestId, data = null) {
|
|
300
|
+
const message = createResponseCompleteMessage(requestId, data);
|
|
301
|
+
this.sendMessage(message);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Sends an error message
|
|
306
|
+
* @param {string} requestId - Request ID (optional)
|
|
307
|
+
* @param {string} errorMessage - Error message
|
|
308
|
+
*/
|
|
309
|
+
sendError(requestId, errorMessage) {
|
|
310
|
+
const message = createErrorMessage(requestId, errorMessage);
|
|
311
|
+
this.sendMessage(message);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sends a message to the orchestrator
|
|
316
|
+
* @param {Object} message - Message object
|
|
317
|
+
*/
|
|
318
|
+
sendMessage(message) {
|
|
319
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
320
|
+
console.warn("[ORCHESTRATOR] Cannot send message, not connected");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
this.ws.send(serialize(message));
|
|
326
|
+
} catch (error) {
|
|
327
|
+
console.error("[ORCHESTRATOR] Failed to send message:", error.message);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Handles incoming messages from the orchestrator
|
|
333
|
+
* @param {string} data - Raw message data
|
|
334
|
+
*/
|
|
335
|
+
handleMessage(data) {
|
|
336
|
+
const message = parse(data);
|
|
337
|
+
if (!message) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!validateInboundMessage(message)) {
|
|
342
|
+
console.warn("[ORCHESTRATOR] Invalid message received:", message.type);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
switch (message.type) {
|
|
347
|
+
case InboundMessageTypes.REGISTERED:
|
|
348
|
+
this.handleRegistered(message);
|
|
349
|
+
break;
|
|
350
|
+
|
|
351
|
+
case InboundMessageTypes.PONG:
|
|
352
|
+
this.handlePong();
|
|
353
|
+
break;
|
|
354
|
+
|
|
355
|
+
case InboundMessageTypes.COMMAND:
|
|
356
|
+
this.handleCommand(message);
|
|
357
|
+
break;
|
|
358
|
+
|
|
359
|
+
case InboundMessageTypes.ERROR:
|
|
360
|
+
console.error("[ORCHESTRATOR] Error from server:", message.message);
|
|
361
|
+
this.emit("error", new Error(message.message));
|
|
362
|
+
break;
|
|
363
|
+
|
|
364
|
+
case InboundMessageTypes.USER_REQUEST:
|
|
365
|
+
this.handleUserRequest(message);
|
|
366
|
+
break;
|
|
367
|
+
|
|
368
|
+
case InboundMessageTypes.USER_REQUEST_FOLLOW_UP:
|
|
369
|
+
// Emit follow-up messages for proxy handler to process
|
|
370
|
+
this.emit(InboundMessageTypes.USER_REQUEST_FOLLOW_UP, message);
|
|
371
|
+
break;
|
|
372
|
+
|
|
373
|
+
case InboundMessageTypes.HTTP_PROXY_REQUEST:
|
|
374
|
+
this.handleHttpProxyRequest(message);
|
|
375
|
+
break;
|
|
376
|
+
|
|
377
|
+
default:
|
|
378
|
+
console.log("[ORCHESTRATOR] Unknown message type:", message.type);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Handles registration response
|
|
384
|
+
* @param {Object} message - Registration response message
|
|
385
|
+
*/
|
|
386
|
+
handleRegistered(message) {
|
|
387
|
+
if (message.success) {
|
|
388
|
+
console.log("[ORCHESTRATOR] Successfully registered with orchestrator");
|
|
389
|
+
this.isRegistered = true;
|
|
390
|
+
this.startHeartbeat();
|
|
391
|
+
this.emit("registered");
|
|
392
|
+
this.emit("connected");
|
|
393
|
+
|
|
394
|
+
// Send current status if not idle
|
|
395
|
+
if (this.status !== StatusValues.IDLE) {
|
|
396
|
+
this.sendStatusUpdate(this.status);
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
console.error(
|
|
400
|
+
"[ORCHESTRATOR] Registration failed:",
|
|
401
|
+
message.message || "Unknown error",
|
|
402
|
+
);
|
|
403
|
+
this.emit("error", new Error(message.message || "Registration failed"));
|
|
404
|
+
this.disconnect();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Handles pong response
|
|
410
|
+
*/
|
|
411
|
+
handlePong() {
|
|
412
|
+
if (this.heartbeatTimeoutTimer) {
|
|
413
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
414
|
+
this.heartbeatTimeoutTimer = null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Handles command from orchestrator
|
|
420
|
+
* @param {Object} message - Command message
|
|
421
|
+
*/
|
|
422
|
+
handleCommand(message) {
|
|
423
|
+
console.log("[ORCHESTRATOR] Received command:", message.command);
|
|
424
|
+
|
|
425
|
+
switch (message.command) {
|
|
426
|
+
case CommandTypes.DISCONNECT:
|
|
427
|
+
console.log("[ORCHESTRATOR] Server requested disconnect");
|
|
428
|
+
this.shouldReconnect = false;
|
|
429
|
+
this.disconnect();
|
|
430
|
+
break;
|
|
431
|
+
|
|
432
|
+
case CommandTypes.REFRESH_STATUS:
|
|
433
|
+
this.sendStatusUpdate(this.status);
|
|
434
|
+
break;
|
|
435
|
+
|
|
436
|
+
default:
|
|
437
|
+
this.emit("command", message);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Handles user request from orchestrator (proxy mode)
|
|
443
|
+
* @param {Object} message - User request message
|
|
444
|
+
*/
|
|
445
|
+
handleUserRequest(message) {
|
|
446
|
+
console.log(
|
|
447
|
+
"[ORCHESTRATOR] Received user request:",
|
|
448
|
+
message.request_id,
|
|
449
|
+
message.action,
|
|
450
|
+
);
|
|
451
|
+
this.emit("user_request", message);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Handles HTTP proxy request from orchestrator
|
|
456
|
+
* Makes a local HTTP request and sends the response back
|
|
457
|
+
* @param {Object} message - HTTP proxy request message
|
|
458
|
+
*/
|
|
459
|
+
async handleHttpProxyRequest(message) {
|
|
460
|
+
const { request_id, method, path, headers, body, query, proxy_base } =
|
|
461
|
+
message;
|
|
462
|
+
console.log(`[ORCHESTRATOR] HTTP proxy request: ${method} ${path}`);
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
// Extract orchestrator user info from headers for auto-authentication
|
|
466
|
+
let orchestratorUserId = null;
|
|
467
|
+
let orchestratorUsername = null;
|
|
468
|
+
|
|
469
|
+
if (headers && Array.isArray(headers)) {
|
|
470
|
+
for (const [key, value] of headers) {
|
|
471
|
+
if (key.toLowerCase() === "x-orchestrator-user-id") {
|
|
472
|
+
orchestratorUserId = value;
|
|
473
|
+
} else if (key.toLowerCase() === "x-orchestrator-username") {
|
|
474
|
+
orchestratorUsername = value;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// If we have orchestrator user info, generate a token for auto-authentication
|
|
480
|
+
let orchestratorToken = null;
|
|
481
|
+
if (orchestratorUserId && orchestratorUsername) {
|
|
482
|
+
try {
|
|
483
|
+
orchestratorToken = await this.getOrCreateOrchestratorToken(
|
|
484
|
+
orchestratorUserId,
|
|
485
|
+
orchestratorUsername,
|
|
486
|
+
);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
console.error(
|
|
489
|
+
"[ORCHESTRATOR] Failed to create orchestrator token:",
|
|
490
|
+
err,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Build the local URL - use the configured local server port
|
|
496
|
+
const port = process.env.PORT || 3010;
|
|
497
|
+
let url = `http://localhost:${port}${path}`;
|
|
498
|
+
if (query) {
|
|
499
|
+
url += `?${query}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Build fetch options
|
|
503
|
+
const fetchOptions = {
|
|
504
|
+
method: method || "GET",
|
|
505
|
+
headers: {},
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// Add headers (skip orchestrator-specific headers and authorization)
|
|
509
|
+
// We'll set our own Authorization header if we have an orchestrator token
|
|
510
|
+
if (headers && Array.isArray(headers)) {
|
|
511
|
+
for (const [key, value] of headers) {
|
|
512
|
+
const keyLower = key.toLowerCase();
|
|
513
|
+
// Skip host header, orchestrator headers, and authorization (we'll set it ourselves)
|
|
514
|
+
if (
|
|
515
|
+
keyLower !== "host" &&
|
|
516
|
+
keyLower !== "authorization" &&
|
|
517
|
+
!keyLower.startsWith("x-orchestrator-")
|
|
518
|
+
) {
|
|
519
|
+
fetchOptions.headers[key] = value;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// If we have an orchestrator token, add it as Authorization header
|
|
525
|
+
// This auto-authenticates the request to claudecodeui
|
|
526
|
+
// We always skip the original Authorization header to avoid case-sensitivity issues
|
|
527
|
+
if (orchestratorToken) {
|
|
528
|
+
// Validate token format before setting (should have 3 parts separated by dots)
|
|
529
|
+
const tokenParts = orchestratorToken.split(".");
|
|
530
|
+
if (tokenParts.length !== 3) {
|
|
531
|
+
console.error(
|
|
532
|
+
`[ORCHESTRATOR] Invalid token format - expected 3 parts, got ${tokenParts.length}`,
|
|
533
|
+
);
|
|
534
|
+
} else {
|
|
535
|
+
fetchOptions.headers["authorization"] = `Bearer ${orchestratorToken}`;
|
|
536
|
+
console.log(
|
|
537
|
+
`[ORCHESTRATOR] Setting auth header for user: ${orchestratorUsername || "[unknown]"} (token: [REDACTED])`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Add body for non-GET/HEAD requests
|
|
543
|
+
if (body && method !== "GET" && method !== "HEAD") {
|
|
544
|
+
fetchOptions.body = body;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Make the local HTTP request
|
|
548
|
+
const response = await fetch(url, fetchOptions);
|
|
549
|
+
|
|
550
|
+
// Collect response headers
|
|
551
|
+
const responseHeaders = [];
|
|
552
|
+
let contentType = "";
|
|
553
|
+
response.headers.forEach((value, key) => {
|
|
554
|
+
responseHeaders.push([key, value]);
|
|
555
|
+
if (key.toLowerCase() === "content-type") {
|
|
556
|
+
contentType = value;
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Determine if content is binary based on content-type
|
|
561
|
+
// Text types: text/*, application/json, application/javascript, application/xml, etc. with utf-8
|
|
562
|
+
const isTextContent =
|
|
563
|
+
contentType.startsWith("text/") ||
|
|
564
|
+
contentType.includes("application/json") ||
|
|
565
|
+
contentType.includes("application/javascript") ||
|
|
566
|
+
contentType.includes("application/xml") ||
|
|
567
|
+
contentType.includes("utf-8");
|
|
568
|
+
|
|
569
|
+
// Get response body - use arrayBuffer for binary, text for text content
|
|
570
|
+
let responseBody;
|
|
571
|
+
if (isTextContent) {
|
|
572
|
+
responseBody = await response.text();
|
|
573
|
+
} else {
|
|
574
|
+
// Binary content - read as arrayBuffer and base64 encode
|
|
575
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
576
|
+
responseBody = Buffer.from(arrayBuffer).toString("base64");
|
|
577
|
+
responseHeaders.push(["x-orch-encoding", "base64"]);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Rewrite URLs if proxy_base is provided and content type is HTML or JavaScript
|
|
581
|
+
let didRewrite = false;
|
|
582
|
+
if (proxy_base) {
|
|
583
|
+
if (contentType.includes("text/html")) {
|
|
584
|
+
console.log(
|
|
585
|
+
`[ORCHESTRATOR] Rewriting HTML URLs with proxy_base: ${proxy_base}`,
|
|
586
|
+
);
|
|
587
|
+
responseBody = this.rewriteHtmlUrls(
|
|
588
|
+
responseBody,
|
|
589
|
+
proxy_base,
|
|
590
|
+
orchestratorToken,
|
|
591
|
+
orchestratorUsername,
|
|
592
|
+
);
|
|
593
|
+
didRewrite = true;
|
|
594
|
+
} else if (contentType.includes("javascript")) {
|
|
595
|
+
console.log(
|
|
596
|
+
`[ORCHESTRATOR] Rewriting JS URLs with proxy_base: ${proxy_base} (content-type: ${contentType})`,
|
|
597
|
+
);
|
|
598
|
+
responseBody = this.rewriteJsUrls(responseBody, proxy_base);
|
|
599
|
+
didRewrite = true;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// If we rewrote content, adjust headers for proper caching behavior
|
|
604
|
+
// - Remove Content-Length since body size changed
|
|
605
|
+
// - Modify Cache-Control to use must-revalidate instead of immutable
|
|
606
|
+
// This allows Cloudflare to cache but revalidate periodically
|
|
607
|
+
let finalHeaders = responseHeaders;
|
|
608
|
+
if (didRewrite) {
|
|
609
|
+
finalHeaders = responseHeaders
|
|
610
|
+
.filter(([key]) => key.toLowerCase() !== "content-length")
|
|
611
|
+
.map(([key, value]) => {
|
|
612
|
+
// Modify Cache-Control for rewritten assets
|
|
613
|
+
// Replace immutable with must-revalidate and cap max-age to 1 hour
|
|
614
|
+
if (
|
|
615
|
+
key.toLowerCase() === "cache-control" &&
|
|
616
|
+
value.includes("immutable")
|
|
617
|
+
) {
|
|
618
|
+
// Change "public, max-age=31536000, immutable" to
|
|
619
|
+
// "public, max-age=3600, must-revalidate" (1 hour)
|
|
620
|
+
const newValue = value
|
|
621
|
+
.replace(/immutable/gi, "must-revalidate")
|
|
622
|
+
.replace(/max-age=\d+/i, "max-age=3600");
|
|
623
|
+
console.log(
|
|
624
|
+
`[ORCHESTRATOR] Modified Cache-Control: ${value} -> ${newValue}`,
|
|
625
|
+
);
|
|
626
|
+
return [key, newValue];
|
|
627
|
+
}
|
|
628
|
+
return [key, value];
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Send the response back to orchestrator
|
|
633
|
+
const proxyResponse = createHttpProxyResponseMessage(
|
|
634
|
+
request_id,
|
|
635
|
+
response.status,
|
|
636
|
+
finalHeaders,
|
|
637
|
+
responseBody,
|
|
638
|
+
);
|
|
639
|
+
this.sendMessage(proxyResponse);
|
|
640
|
+
|
|
641
|
+
console.log(
|
|
642
|
+
`[ORCHESTRATOR] HTTP proxy response: ${response.status} (${responseBody.length} bytes)`,
|
|
643
|
+
);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
// Log full error details internally but don't expose to client
|
|
646
|
+
console.error(
|
|
647
|
+
"[ORCHESTRATOR] HTTP proxy request failed:",
|
|
648
|
+
error.message,
|
|
649
|
+
error.stack,
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
// Send generic error response without internal details
|
|
653
|
+
const errorResponse = createHttpProxyResponseMessage(
|
|
654
|
+
request_id,
|
|
655
|
+
502,
|
|
656
|
+
[["Content-Type", "application/json"]],
|
|
657
|
+
JSON.stringify({ error: "Proxy request failed" }),
|
|
658
|
+
);
|
|
659
|
+
this.sendMessage(errorResponse);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Escapes a string for safe inclusion in inline JavaScript
|
|
665
|
+
* Prevents XSS by escaping quotes, backslashes, and script-breaking characters
|
|
666
|
+
* @param {string} str - String to escape
|
|
667
|
+
* @returns {string} Escaped string safe for JS interpolation
|
|
668
|
+
*/
|
|
669
|
+
escapeForJs(str) {
|
|
670
|
+
if (!str) return "";
|
|
671
|
+
return str
|
|
672
|
+
.replace(/\\/g, "\\\\")
|
|
673
|
+
.replace(/"/g, '\\"')
|
|
674
|
+
.replace(/'/g, "\\'")
|
|
675
|
+
.replace(/</g, "\\x3c")
|
|
676
|
+
.replace(/>/g, "\\x3e")
|
|
677
|
+
.replace(/\n/g, "\\n")
|
|
678
|
+
.replace(/\r/g, "\\r");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Rewrites absolute URLs in HTML content to go through the proxy
|
|
683
|
+
* @param {string} html - HTML content
|
|
684
|
+
* @param {string} proxyBase - Base proxy path (e.g., "/clients/{id}/proxy")
|
|
685
|
+
* @param {string|null} orchestratorToken - Optional JWT token for auto-authentication
|
|
686
|
+
* @param {string|null} orchestratorUsername - Optional username for token matching
|
|
687
|
+
* @returns {string} HTML with rewritten URLs
|
|
688
|
+
*/
|
|
689
|
+
rewriteHtmlUrls(
|
|
690
|
+
html,
|
|
691
|
+
proxyBase,
|
|
692
|
+
orchestratorToken = null,
|
|
693
|
+
orchestratorUsername = null,
|
|
694
|
+
) {
|
|
695
|
+
// Add a cache-busting version to force fresh fetches from Cloudflare edge
|
|
696
|
+
// Derived from package version to auto-increment on releases
|
|
697
|
+
const cacheVersion = process.env.npm_package_version || "1.0.0";
|
|
698
|
+
|
|
699
|
+
let result = html
|
|
700
|
+
.replace(/src="\/(?!\/)/g, `src="${proxyBase}/`)
|
|
701
|
+
.replace(/href="\/(?!\/)/g, `href="${proxyBase}/`)
|
|
702
|
+
.replace(/action="\/(?!\/)/g, `action="${proxyBase}/`)
|
|
703
|
+
.replace(/src='\/(?!\/)/g, `src='${proxyBase}/`)
|
|
704
|
+
.replace(/href='\/(?!\/)/g, `href='${proxyBase}/`)
|
|
705
|
+
.replace(/action='\/(?!\/)/g, `action='${proxyBase}/`)
|
|
706
|
+
// Handle service worker registration: pass proxyBase as query param
|
|
707
|
+
// e.g., register('/sw.js') -> register('/clients/.../proxy/sw.js?proxyBase=/clients/.../proxy')
|
|
708
|
+
.replace(
|
|
709
|
+
/\.register\('\/sw\.js'\)/g,
|
|
710
|
+
`.register('${proxyBase}/sw.js?proxyBase=${encodeURIComponent(proxyBase)}')`,
|
|
711
|
+
)
|
|
712
|
+
.replace(
|
|
713
|
+
/\.register\("\/sw\.js"\)/g,
|
|
714
|
+
`.register("${proxyBase}/sw.js?proxyBase=${encodeURIComponent(proxyBase)}")`,
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
// Add cache-busting parameter to JS and CSS asset URLs
|
|
718
|
+
// Match patterns like src="/clients/.../proxy/assets/file.js" or href="/clients/.../proxy/assets/file.css"
|
|
719
|
+
result = result.replace(
|
|
720
|
+
/(<script[^>]+src="[^"]+\.js)(")/g,
|
|
721
|
+
`$1?_=${cacheVersion}$2`,
|
|
722
|
+
);
|
|
723
|
+
result = result.replace(
|
|
724
|
+
/(<link[^>]+href="[^"]+\.css)(")/g,
|
|
725
|
+
`$1?_=${cacheVersion}$2`,
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
// Inject scripts to:
|
|
729
|
+
// 1. Auto-authenticate via orchestrator token
|
|
730
|
+
// 2. Patch fetch() to redirect API calls through the proxy
|
|
731
|
+
//
|
|
732
|
+
// Note: React app uses 'auth-token' as the localStorage key
|
|
733
|
+
// We need to check if the existing token belongs to the same orchestrator user.
|
|
734
|
+
// If not, we update the token. We also store the orchestrator username separately
|
|
735
|
+
// to detect user changes without decoding the JWT on every request.
|
|
736
|
+
const proxyPatchScript = `<script>
|
|
737
|
+
// Patch fetch and WebSocket to redirect through the proxy
|
|
738
|
+
(function() {
|
|
739
|
+
const proxyBase = "${proxyBase}";
|
|
740
|
+
|
|
741
|
+
// Make proxyBase available globally for React app components
|
|
742
|
+
window.__ORCHESTRATOR_PROXY_BASE__ = proxyBase;
|
|
743
|
+
|
|
744
|
+
const originalFetch = window.fetch;
|
|
745
|
+
|
|
746
|
+
window.fetch = function(url, options) {
|
|
747
|
+
// Convert URL to string if it's a Request object
|
|
748
|
+
let urlStr = url instanceof Request ? url.url : String(url);
|
|
749
|
+
|
|
750
|
+
// If it's an absolute path (starts with /) but not a full URL (no protocol)
|
|
751
|
+
// and not already going through the proxy, redirect it
|
|
752
|
+
// EXCEPT: Orchestrator API paths should go directly to the orchestrator, not through the proxy
|
|
753
|
+
const isOrchestratorApi = urlStr.startsWith('/api/clients/') || urlStr.startsWith('/api/tokens');
|
|
754
|
+
if (urlStr.startsWith('/') && !urlStr.startsWith('//') && !urlStr.startsWith(proxyBase) && !isOrchestratorApi) {
|
|
755
|
+
const newUrl = proxyBase + urlStr;
|
|
756
|
+
console.log('[ORCHESTRATOR] Redirecting fetch:', urlStr, '->', newUrl);
|
|
757
|
+
|
|
758
|
+
if (url instanceof Request) {
|
|
759
|
+
// Create a new Request with the modified URL
|
|
760
|
+
url = new Request(newUrl, url);
|
|
761
|
+
} else {
|
|
762
|
+
url = newUrl;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return originalFetch.call(this, url, options);
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
console.log('[ORCHESTRATOR] Fetch patched for proxy base:', proxyBase);
|
|
770
|
+
})();
|
|
771
|
+
</script>`;
|
|
772
|
+
|
|
773
|
+
// Escape user-controlled values to prevent XSS
|
|
774
|
+
const safeUsername = this.escapeForJs(orchestratorUsername || "");
|
|
775
|
+
const safeToken = this.escapeForJs(orchestratorToken || "");
|
|
776
|
+
|
|
777
|
+
const authScript = orchestratorToken
|
|
778
|
+
? `<script>
|
|
779
|
+
// Auto-authenticate via orchestrator token
|
|
780
|
+
(function() {
|
|
781
|
+
const existingToken = localStorage.getItem('auth-token');
|
|
782
|
+
const storedOrchestratorUser = localStorage.getItem('orchestrator-user');
|
|
783
|
+
const orchestratorUsername = "${safeUsername}";
|
|
784
|
+
|
|
785
|
+
// Check if we need to update the token:
|
|
786
|
+
// 1. No existing token
|
|
787
|
+
// 2. Orchestrator user changed (different GitHub user accessing via proxy)
|
|
788
|
+
// 3. Token exists but no stored orchestrator user (legacy direct login token)
|
|
789
|
+
const needsUpdate = !existingToken ||
|
|
790
|
+
(orchestratorUsername && storedOrchestratorUser !== orchestratorUsername) ||
|
|
791
|
+
(existingToken && !storedOrchestratorUser && orchestratorUsername);
|
|
792
|
+
|
|
793
|
+
if (needsUpdate) {
|
|
794
|
+
const token = "${safeToken}";
|
|
795
|
+
localStorage.setItem('auth-token', token);
|
|
796
|
+
if (orchestratorUsername) {
|
|
797
|
+
localStorage.setItem('orchestrator-user', orchestratorUsername);
|
|
798
|
+
}
|
|
799
|
+
console.log('[ORCHESTRATOR] Auto-authenticated via orchestrator proxy for user:', orchestratorUsername || 'unknown');
|
|
800
|
+
// Reload the page so the app initializes with the token
|
|
801
|
+
window.location.reload();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
console.log('[ORCHESTRATOR] Already have valid auth token for user:', storedOrchestratorUser);
|
|
805
|
+
})();
|
|
806
|
+
</script>`
|
|
807
|
+
: "";
|
|
808
|
+
|
|
809
|
+
// Inject both scripts right after the opening <head> tag
|
|
810
|
+
// Proxy patch must come first so fetch is patched before any other scripts run
|
|
811
|
+
result = result.replace(
|
|
812
|
+
/<head>/i,
|
|
813
|
+
`<head>${proxyPatchScript}${authScript}`,
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
return result;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Rewrites absolute URLs in JavaScript content to go through the proxy
|
|
821
|
+
* @param {string} js - JavaScript content
|
|
822
|
+
* @param {string} proxyBase - Base proxy path (e.g., "/clients/{id}/proxy")
|
|
823
|
+
* @returns {string} JavaScript with rewritten URLs
|
|
824
|
+
*/
|
|
825
|
+
rewriteJsUrls(js, proxyBase) {
|
|
826
|
+
// Only rewrite specific path prefixes that are likely URLs, not regex patterns
|
|
827
|
+
// This is more selective than matching all "/" to avoid breaking regex literals
|
|
828
|
+
const urlPrefixes = [
|
|
829
|
+
"api",
|
|
830
|
+
"assets",
|
|
831
|
+
"auth",
|
|
832
|
+
"ws",
|
|
833
|
+
"favicon",
|
|
834
|
+
"static",
|
|
835
|
+
"socket.io",
|
|
836
|
+
"sw.js",
|
|
837
|
+
"manifest.json",
|
|
838
|
+
"icons",
|
|
839
|
+
];
|
|
840
|
+
|
|
841
|
+
// Orchestrator API paths that should NOT be rewritten (they go directly to the orchestrator)
|
|
842
|
+
const orchestratorApiPaths = ["/api/clients/", "/api/tokens"];
|
|
843
|
+
|
|
844
|
+
let result = js;
|
|
845
|
+
for (const prefix of urlPrefixes) {
|
|
846
|
+
// Escape regex metacharacters in the prefix to match literal characters
|
|
847
|
+
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
848
|
+
// Match "/${prefix}, '/${prefix}, `/${prefix} patterns
|
|
849
|
+
result = result
|
|
850
|
+
.replace(
|
|
851
|
+
new RegExp(`"\\/${escapedPrefix}(?=[\\/"])`, "g"),
|
|
852
|
+
`"${proxyBase}/${prefix}`,
|
|
853
|
+
)
|
|
854
|
+
.replace(
|
|
855
|
+
new RegExp(`'\\/${escapedPrefix}(?=[\\/'])`, "g"),
|
|
856
|
+
`'${proxyBase}/${prefix}`,
|
|
857
|
+
)
|
|
858
|
+
.replace(
|
|
859
|
+
new RegExp(`\`\\/${escapedPrefix}(?=[\\/\`])`, "g"),
|
|
860
|
+
`\`${proxyBase}/${prefix}`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Undo rewriting for orchestrator API paths - these should go directly to orchestrator
|
|
865
|
+
for (const path of orchestratorApiPaths) {
|
|
866
|
+
const escapedPath = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
867
|
+
const rewrittenPath = `${proxyBase}${path}`;
|
|
868
|
+
const escapedRewrittenPath = rewrittenPath.replace(
|
|
869
|
+
/[.*+?^${}()|[\]\\]/g,
|
|
870
|
+
"\\$&",
|
|
871
|
+
);
|
|
872
|
+
// Restore the original path by replacing the rewritten version
|
|
873
|
+
result = result
|
|
874
|
+
.replace(new RegExp(`"${escapedRewrittenPath}`, "g"), `"${path}`)
|
|
875
|
+
.replace(new RegExp(`'${escapedRewrittenPath}`, "g"), `'${path}`)
|
|
876
|
+
.replace(new RegExp(`\`${escapedRewrittenPath}`, "g"), `\`${path}`);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return result;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Starts the heartbeat interval
|
|
884
|
+
*/
|
|
885
|
+
startHeartbeat() {
|
|
886
|
+
this.stopHeartbeat();
|
|
887
|
+
this.heartbeatTimer = setInterval(() => {
|
|
888
|
+
this.sendPing();
|
|
889
|
+
}, this.config.heartbeatInterval);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Stops the heartbeat interval
|
|
894
|
+
*/
|
|
895
|
+
stopHeartbeat() {
|
|
896
|
+
if (this.heartbeatTimer) {
|
|
897
|
+
clearInterval(this.heartbeatTimer);
|
|
898
|
+
this.heartbeatTimer = null;
|
|
899
|
+
}
|
|
900
|
+
if (this.heartbeatTimeoutTimer) {
|
|
901
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
902
|
+
this.heartbeatTimeoutTimer = null;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Schedules a reconnection attempt with exponential backoff
|
|
908
|
+
*/
|
|
909
|
+
scheduleReconnect() {
|
|
910
|
+
if (!this.shouldReconnect) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
915
|
+
console.error("[ORCHESTRATOR] Max reconnect attempts reached, giving up");
|
|
916
|
+
this.emit("error", new Error("Max reconnect attempts reached"));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
this.reconnectAttempts++;
|
|
921
|
+
console.log(
|
|
922
|
+
`[ORCHESTRATOR] Scheduling reconnect attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts} in ${this.currentReconnectInterval}ms`,
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
926
|
+
try {
|
|
927
|
+
await this.connect();
|
|
928
|
+
} catch (error) {
|
|
929
|
+
console.error("[ORCHESTRATOR] Reconnect failed:", error.message);
|
|
930
|
+
}
|
|
931
|
+
}, this.currentReconnectInterval);
|
|
932
|
+
|
|
933
|
+
// Exponential backoff
|
|
934
|
+
this.currentReconnectInterval = Math.min(
|
|
935
|
+
this.currentReconnectInterval * DEFAULTS.reconnectBackoffMultiplier,
|
|
936
|
+
DEFAULTS.maxReconnectInterval,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Clears the reconnect timer
|
|
942
|
+
*/
|
|
943
|
+
clearReconnectTimer() {
|
|
944
|
+
if (this.reconnectTimer) {
|
|
945
|
+
clearTimeout(this.reconnectTimer);
|
|
946
|
+
this.reconnectTimer = null;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Gets the current connection state
|
|
952
|
+
* @returns {Object} Connection state
|
|
953
|
+
*/
|
|
954
|
+
getState() {
|
|
955
|
+
return {
|
|
956
|
+
isConnected: this.isConnected,
|
|
957
|
+
isRegistered: this.isRegistered,
|
|
958
|
+
status: this.status,
|
|
959
|
+
clientId: this.config.clientId,
|
|
960
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Gets or creates a JWT token for an orchestrator-authenticated user
|
|
966
|
+
* This enables seamless authentication when accessing claudecodeui through the orchestrator proxy
|
|
967
|
+
* @param {string} githubId - GitHub user ID from orchestrator
|
|
968
|
+
* @param {string} githubUsername - GitHub username from orchestrator
|
|
969
|
+
* @returns {string} JWT token for the user
|
|
970
|
+
*/
|
|
971
|
+
async getOrCreateOrchestratorToken(githubId, githubUsername) {
|
|
972
|
+
// Get or create the user in the local database
|
|
973
|
+
const user = await userDb.getOrCreateOrchestratorUser(
|
|
974
|
+
githubId,
|
|
975
|
+
githubUsername,
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
// Generate a JWT token for this user
|
|
979
|
+
const token = generateToken(user);
|
|
980
|
+
|
|
981
|
+
console.log(
|
|
982
|
+
`[ORCHESTRATOR] Generated token for orchestrator user: ${githubUsername} (GitHub ID: ${githubId})`,
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
return token;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export default OrchestratorClient;
|