@saws/open-design-service 2.0.0-beta.17
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/assets/Dockerfile +25 -0
- package/assets/gateway/app.js +188 -0
- package/assets/gateway/index.html +85 -0
- package/assets/gateway/package.json +14 -0
- package/assets/gateway/server.mjs +634 -0
- package/assets/gateway/style.css +172 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/OpenDesignService.d.ts +78 -0
- package/dist/OpenDesignService.js +163 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +27 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdir, open, readFile } from "node:fs/promises";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { spawn as spawnPty } from "node-pty";
|
|
10
|
+
import { WebSocketServer } from "ws";
|
|
11
|
+
|
|
12
|
+
const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const publicPort = parsePort(process.env.GATEWAY_PORT || "7456");
|
|
14
|
+
const internalOrigin = new URL(process.env.GATEWAY_INTERNAL_ORIGIN || "http://127.0.0.1:17456");
|
|
15
|
+
const setupPassword = requiredEnvironment("GATEWAY_SETUP_PASSWORD");
|
|
16
|
+
const requirement = parseRequirement(process.env.GATEWAY_AUTH_REQUIREMENT || "any");
|
|
17
|
+
const workspace = process.env.GATEWAY_WORKSPACE || "/workspace";
|
|
18
|
+
const sessionDirectory = path.join(process.env.HOME || "/agent-home", ".saws-open-design");
|
|
19
|
+
const sessionKey = await loadOrCreateSessionKey();
|
|
20
|
+
const passwordSalt = randomBytes(32);
|
|
21
|
+
const passwordHash = scryptSync(setupPassword, passwordSalt, 64);
|
|
22
|
+
const sessionLifetimeMs = 12 * 60 * 60 * 1000;
|
|
23
|
+
const csrfLifetimeMs = 60 * 60 * 1000;
|
|
24
|
+
const loginWindowMs = 15 * 60 * 1000;
|
|
25
|
+
const loginLimit = 5;
|
|
26
|
+
const attempts = new Map();
|
|
27
|
+
const claudeLogins = new Map();
|
|
28
|
+
let codexLogin;
|
|
29
|
+
let authCache;
|
|
30
|
+
let shuttingDown = false;
|
|
31
|
+
let openDesignProcess;
|
|
32
|
+
let openDesignRunning = false;
|
|
33
|
+
let openDesignRestartTimer;
|
|
34
|
+
|
|
35
|
+
await preparePersistentDirectories();
|
|
36
|
+
startOpenDesign();
|
|
37
|
+
|
|
38
|
+
const terminalServer = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
|
|
39
|
+
terminalServer.on("connection", (socket, _request, login) => attachClaudeTerminal(socket, login));
|
|
40
|
+
|
|
41
|
+
const server = http.createServer(async (request, response) => {
|
|
42
|
+
try {
|
|
43
|
+
addSecurityHeaders(response);
|
|
44
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
|
45
|
+
|
|
46
|
+
if (url.pathname === "/__saws/health") {
|
|
47
|
+
const auth = await getAuthenticationStatus();
|
|
48
|
+
return json(response, 200, {
|
|
49
|
+
gateway: true,
|
|
50
|
+
openDesign: openDesignRunning,
|
|
51
|
+
codex: auth.codex,
|
|
52
|
+
claude: auth.claude,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (url.pathname === "/__saws/csrf" && request.method === "GET") {
|
|
56
|
+
const token = createCsrfToken();
|
|
57
|
+
setCookie(response, "saws_od_csrf", token, request, csrfLifetimeMs, false);
|
|
58
|
+
return json(response, 200, { token });
|
|
59
|
+
}
|
|
60
|
+
if (url.pathname.startsWith("/__saws/assets/") && request.method === "GET") {
|
|
61
|
+
return serveAsset(url.pathname, response);
|
|
62
|
+
}
|
|
63
|
+
if (url.pathname === "/__saws/session" && request.method === "POST") {
|
|
64
|
+
enforceCsrf(request);
|
|
65
|
+
return await login(request, response);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const session = readSession(request);
|
|
69
|
+
if (url.pathname === "/__saws/status" && request.method === "GET") {
|
|
70
|
+
if (!session) return json(response, 200, { session: false });
|
|
71
|
+
const auth = await getAuthenticationStatus(true);
|
|
72
|
+
return json(response, 200, {
|
|
73
|
+
session: true,
|
|
74
|
+
requirement,
|
|
75
|
+
...auth,
|
|
76
|
+
satisfied: isSatisfied(auth),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (!session) {
|
|
80
|
+
if (url.pathname.startsWith("/__saws/"))
|
|
81
|
+
return json(response, 401, { error: "Sign in required" });
|
|
82
|
+
return serveIndex(response);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (url.pathname === "/__saws/login/codex") {
|
|
86
|
+
if (request.method === "POST") {
|
|
87
|
+
enforceCsrf(request);
|
|
88
|
+
return json(response, 202, await startCodexLogin());
|
|
89
|
+
}
|
|
90
|
+
if (request.method === "GET") return json(response, 200, publicCodexLogin());
|
|
91
|
+
}
|
|
92
|
+
if (url.pathname === "/__saws/login/claude" && request.method === "POST") {
|
|
93
|
+
enforceCsrf(request);
|
|
94
|
+
return json(response, 202, startClaudeLogin());
|
|
95
|
+
}
|
|
96
|
+
if (url.pathname.startsWith("/__saws/")) return json(response, 404, { error: "Not found" });
|
|
97
|
+
|
|
98
|
+
const auth = await getAuthenticationStatus();
|
|
99
|
+
if (!isSatisfied(auth)) return serveIndex(response);
|
|
100
|
+
return proxyHttp(request, response);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const status = error.statusCode || 500;
|
|
103
|
+
if (status >= 500) console.error("Gateway request failed:", safeError(error));
|
|
104
|
+
json(response, status, { error: status >= 500 ? "Gateway request failed" : error.message });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
server.on("upgrade", async (request, socket, head) => {
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
|
111
|
+
if (!readSession(request)) throw httpError(401, "Sign in required");
|
|
112
|
+
|
|
113
|
+
if (url.pathname === "/__saws/terminal") {
|
|
114
|
+
const login = claudeLogins.get(url.searchParams.get("id"));
|
|
115
|
+
if (!login || login.expiresAt < Date.now()) throw httpError(404, "Login session not found");
|
|
116
|
+
return terminalServer.handleUpgrade(request, socket, head, (webSocket) => {
|
|
117
|
+
terminalServer.emit("connection", webSocket, request, login);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const auth = await getAuthenticationStatus();
|
|
122
|
+
if (!isSatisfied(auth)) throw httpError(403, "Agent authentication required");
|
|
123
|
+
proxyUpgrade(request, socket, head);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
socket.end(`HTTP/1.1 ${error.statusCode || 500} Unauthorized\r\nConnection: close\r\n\r\n`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
server.listen(publicPort, "0.0.0.0", () => {
|
|
130
|
+
console.log(`SAWS OpenDesign gateway listening on port ${publicPort}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function startOpenDesign() {
|
|
134
|
+
if (shuttingDown) return;
|
|
135
|
+
openDesignProcess = spawn("node", ["apps/daemon/dist/cli.js", "--no-open"], {
|
|
136
|
+
cwd: "/app",
|
|
137
|
+
env: process.env,
|
|
138
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
139
|
+
});
|
|
140
|
+
openDesignRunning = true;
|
|
141
|
+
openDesignProcess.once("exit", (code, signal) => {
|
|
142
|
+
openDesignRunning = false;
|
|
143
|
+
if (shuttingDown) return;
|
|
144
|
+
console.error(`OpenDesign exited (${signal || code}); restarting in 2 seconds`);
|
|
145
|
+
openDesignRestartTimer = setTimeout(startOpenDesign, 2000);
|
|
146
|
+
});
|
|
147
|
+
openDesignProcess.once("error", (error) => {
|
|
148
|
+
openDesignRunning = false;
|
|
149
|
+
console.error("Unable to start OpenDesign:", safeError(error));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function login(request, response) {
|
|
154
|
+
const key = request.socket.remoteAddress || "unknown";
|
|
155
|
+
const record = attempts.get(key) || { count: 0, resetAt: Date.now() + loginWindowMs };
|
|
156
|
+
if (record.resetAt <= Date.now())
|
|
157
|
+
Object.assign(record, { count: 0, resetAt: Date.now() + loginWindowMs });
|
|
158
|
+
if (record.count >= loginLimit) throw httpError(429, "Too many attempts; try again later");
|
|
159
|
+
const body = await readJson(request, 8192);
|
|
160
|
+
const candidate = typeof body.password === "string" ? body.password : "";
|
|
161
|
+
const candidateHash = scryptSync(candidate, passwordSalt, 64);
|
|
162
|
+
if (!timingSafeEqual(passwordHash, candidateHash)) {
|
|
163
|
+
record.count += 1;
|
|
164
|
+
attempts.set(key, record);
|
|
165
|
+
throw httpError(401, "Invalid application password");
|
|
166
|
+
}
|
|
167
|
+
attempts.delete(key);
|
|
168
|
+
const token = signValue(JSON.stringify({ exp: Date.now() + sessionLifetimeMs }));
|
|
169
|
+
setCookie(response, "saws_od_session", token, request, sessionLifetimeMs, true);
|
|
170
|
+
json(response, 200, { ok: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readSession(request) {
|
|
174
|
+
const token = parseCookies(request.headers.cookie).saws_od_session;
|
|
175
|
+
if (!token) return false;
|
|
176
|
+
const value = verifyValue(token);
|
|
177
|
+
if (!value) return false;
|
|
178
|
+
try {
|
|
179
|
+
const session = JSON.parse(value);
|
|
180
|
+
return Number.isFinite(session.exp) && session.exp > Date.now();
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function createCsrfToken() {
|
|
187
|
+
return signValue(
|
|
188
|
+
JSON.stringify({
|
|
189
|
+
nonce: randomBytes(18).toString("base64url"),
|
|
190
|
+
exp: Date.now() + csrfLifetimeMs,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function enforceCsrf(request) {
|
|
196
|
+
const header = request.headers["x-csrf-token"];
|
|
197
|
+
const cookie = parseCookies(request.headers.cookie).saws_od_csrf;
|
|
198
|
+
if (typeof header !== "string" || !cookie || header !== cookie)
|
|
199
|
+
throw httpError(403, "Invalid CSRF token");
|
|
200
|
+
const value = verifyValue(header);
|
|
201
|
+
if (!value) throw httpError(403, "Invalid CSRF token");
|
|
202
|
+
try {
|
|
203
|
+
if (JSON.parse(value).exp <= Date.now()) throw new Error("expired");
|
|
204
|
+
} catch {
|
|
205
|
+
throw httpError(403, "Expired CSRF token");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function getAuthenticationStatus(force = false) {
|
|
210
|
+
if (!force && authCache && authCache.expiresAt > Date.now()) return authCache.value;
|
|
211
|
+
const [codex, claude] = await Promise.all([
|
|
212
|
+
commandSucceeds("codex", ["login", "status"]),
|
|
213
|
+
commandSucceeds("claude", ["auth", "status"]),
|
|
214
|
+
]);
|
|
215
|
+
const value = { codex, claude };
|
|
216
|
+
authCache = { value, expiresAt: Date.now() + 10_000 };
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isSatisfied(auth) {
|
|
221
|
+
if (requirement === "codex") return auth.codex;
|
|
222
|
+
if (requirement === "claude") return auth.claude;
|
|
223
|
+
if (requirement === "all") return auth.codex && auth.claude;
|
|
224
|
+
return auth.codex || auth.claude;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function commandSucceeds(command, args) {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const child = spawn(command, args, { cwd: workspace, env: process.env, stdio: "ignore" });
|
|
230
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 7000);
|
|
231
|
+
child.once("error", () => {
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
resolve(false);
|
|
234
|
+
});
|
|
235
|
+
child.once("exit", (code) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
resolve(code === 0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function startCodexLogin() {
|
|
243
|
+
if (codexLogin && ["starting", "waiting"].includes(codexLogin.state)) return publicCodexLogin();
|
|
244
|
+
const child = spawn("codex", ["app-server", "--stdio"], {
|
|
245
|
+
cwd: workspace,
|
|
246
|
+
env: process.env,
|
|
247
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
248
|
+
});
|
|
249
|
+
codexLogin = { state: "starting", child, buffer: "", expiresAt: Date.now() + 15 * 60 * 1000 };
|
|
250
|
+
const timeout = setTimeout(
|
|
251
|
+
() => finishCodexLogin(false, "Codex device login timed out"),
|
|
252
|
+
15 * 60 * 1000,
|
|
253
|
+
);
|
|
254
|
+
codexLogin.timeout = timeout;
|
|
255
|
+
child.stdout.setEncoding("utf8");
|
|
256
|
+
child.stdout.on("data", (data) => parseCodexMessages(data));
|
|
257
|
+
child.once("error", () => finishCodexLogin(false, "Unable to start Codex login"));
|
|
258
|
+
child.once("exit", (code) => {
|
|
259
|
+
if (["starting", "waiting"].includes(codexLogin?.state))
|
|
260
|
+
finishCodexLogin(false, `Codex login exited (${code})`);
|
|
261
|
+
});
|
|
262
|
+
sendCodex({
|
|
263
|
+
id: 1,
|
|
264
|
+
method: "initialize",
|
|
265
|
+
params: {
|
|
266
|
+
clientInfo: { name: "saws-open-design-gateway", version: "1.0.0" },
|
|
267
|
+
capabilities: { experimentalApi: true },
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
return publicCodexLogin();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function parseCodexMessages(data) {
|
|
274
|
+
if (!codexLogin) return;
|
|
275
|
+
codexLogin.buffer += data;
|
|
276
|
+
let newline;
|
|
277
|
+
while ((newline = codexLogin.buffer.indexOf("\n")) >= 0) {
|
|
278
|
+
const line = codexLogin.buffer.slice(0, newline).trim();
|
|
279
|
+
codexLogin.buffer = codexLogin.buffer.slice(newline + 1);
|
|
280
|
+
if (!line) continue;
|
|
281
|
+
let message;
|
|
282
|
+
try {
|
|
283
|
+
message = JSON.parse(line);
|
|
284
|
+
} catch {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (message.id === 1 && message.result) {
|
|
288
|
+
sendCodex({ method: "initialized" });
|
|
289
|
+
sendCodex({ id: 2, method: "account/login/start", params: { type: "chatgptDeviceCode" } });
|
|
290
|
+
} else if (message.id === 2 && message.result?.type === "chatgptDeviceCode") {
|
|
291
|
+
Object.assign(codexLogin, {
|
|
292
|
+
state: "waiting",
|
|
293
|
+
loginId: message.result.loginId,
|
|
294
|
+
verificationUrl: message.result.verificationUrl,
|
|
295
|
+
userCode: message.result.userCode,
|
|
296
|
+
});
|
|
297
|
+
} else if (message.method === "account/login/completed") {
|
|
298
|
+
finishCodexLogin(
|
|
299
|
+
Boolean(message.params?.success),
|
|
300
|
+
message.params?.error || "Codex login failed",
|
|
301
|
+
);
|
|
302
|
+
} else if (message.id === 2 && message.error) {
|
|
303
|
+
finishCodexLogin(
|
|
304
|
+
false,
|
|
305
|
+
"Codex device login is unavailable. Enable it in ChatGPT security or workspace settings.",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sendCodex(message) {
|
|
312
|
+
codexLogin?.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function finishCodexLogin(success, error) {
|
|
316
|
+
if (!codexLogin || ["complete", "error"].includes(codexLogin.state)) return;
|
|
317
|
+
clearTimeout(codexLogin.timeout);
|
|
318
|
+
codexLogin.state = success ? "complete" : "error";
|
|
319
|
+
if (!success) codexLogin.error = String(error).slice(0, 240);
|
|
320
|
+
codexLogin.child.kill("SIGTERM");
|
|
321
|
+
authCache = undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function publicCodexLogin() {
|
|
325
|
+
if (!codexLogin) return { state: "idle" };
|
|
326
|
+
return {
|
|
327
|
+
state: codexLogin.state,
|
|
328
|
+
verificationUrl: codexLogin.verificationUrl,
|
|
329
|
+
userCode: codexLogin.userCode,
|
|
330
|
+
error: codexLogin.error,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function startClaudeLogin() {
|
|
335
|
+
for (const login of claudeLogins.values()) {
|
|
336
|
+
if (!login.exited && login.expiresAt > Date.now())
|
|
337
|
+
throw httpError(409, "A Claude login is already active");
|
|
338
|
+
}
|
|
339
|
+
const id = randomBytes(18).toString("base64url");
|
|
340
|
+
const terminal = spawnPty("claude", ["auth", "login"], {
|
|
341
|
+
name: "xterm-256color",
|
|
342
|
+
cols: 100,
|
|
343
|
+
rows: 30,
|
|
344
|
+
cwd: workspace,
|
|
345
|
+
env: { ...process.env, TERM: "xterm-256color" },
|
|
346
|
+
});
|
|
347
|
+
const login = {
|
|
348
|
+
id,
|
|
349
|
+
terminal,
|
|
350
|
+
sockets: new Set(),
|
|
351
|
+
history: "",
|
|
352
|
+
exited: false,
|
|
353
|
+
expiresAt: Date.now() + 15 * 60 * 1000,
|
|
354
|
+
};
|
|
355
|
+
claudeLogins.set(id, login);
|
|
356
|
+
login.timer = setTimeout(() => terminal.kill(), 15 * 60 * 1000);
|
|
357
|
+
terminal.onData((data) => {
|
|
358
|
+
const safe = redactTerminalOutput(data);
|
|
359
|
+
login.history = `${login.history}${safe}`.slice(-64 * 1024);
|
|
360
|
+
for (const socket of login.sockets) sendSocket(socket, { type: "data", data: safe });
|
|
361
|
+
});
|
|
362
|
+
terminal.onExit(async ({ exitCode }) => {
|
|
363
|
+
clearTimeout(login.timer);
|
|
364
|
+
login.exited = true;
|
|
365
|
+
authCache = undefined;
|
|
366
|
+
const authenticated = await commandSucceeds("claude", ["auth", "status"]);
|
|
367
|
+
for (const socket of login.sockets)
|
|
368
|
+
sendSocket(socket, { type: "exit", exitCode, authenticated });
|
|
369
|
+
setTimeout(() => claudeLogins.delete(id), 60_000);
|
|
370
|
+
});
|
|
371
|
+
return { id };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function attachClaudeTerminal(socket, login) {
|
|
375
|
+
login.sockets.add(socket);
|
|
376
|
+
if (login.history) sendSocket(socket, { type: "data", data: login.history });
|
|
377
|
+
if (login.exited) sendSocket(socket, { type: "exit" });
|
|
378
|
+
socket.on("message", (raw) => {
|
|
379
|
+
try {
|
|
380
|
+
const message = JSON.parse(raw.toString());
|
|
381
|
+
if (
|
|
382
|
+
message.type === "input" &&
|
|
383
|
+
typeof message.data === "string" &&
|
|
384
|
+
message.data.length <= 4096
|
|
385
|
+
)
|
|
386
|
+
login.terminal.write(message.data);
|
|
387
|
+
if (message.type === "resize")
|
|
388
|
+
login.terminal.resize(clamp(message.cols, 40, 240), clamp(message.rows, 10, 80));
|
|
389
|
+
if (message.type === "cancel") login.terminal.kill();
|
|
390
|
+
} catch {}
|
|
391
|
+
});
|
|
392
|
+
socket.on("close", () => login.sockets.delete(socket));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function proxyHttp(request, response) {
|
|
396
|
+
const headers = proxyHeaders(request.headers);
|
|
397
|
+
const upstream = http.request(
|
|
398
|
+
{
|
|
399
|
+
hostname: internalOrigin.hostname,
|
|
400
|
+
port: internalOrigin.port,
|
|
401
|
+
method: request.method,
|
|
402
|
+
path: request.url,
|
|
403
|
+
headers,
|
|
404
|
+
},
|
|
405
|
+
(upstreamResponse) => {
|
|
406
|
+
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
|
|
407
|
+
upstreamResponse.pipe(response);
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
upstream.on("error", () => json(response, 502, { error: "OpenDesign is unavailable" }));
|
|
411
|
+
request.pipe(upstream);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function proxyUpgrade(request, socket, head) {
|
|
415
|
+
const upstream = net.connect(Number(internalOrigin.port), internalOrigin.hostname, () => {
|
|
416
|
+
const headers = proxyHeaders(request.headers);
|
|
417
|
+
upstream.write(`${request.method} ${request.url} HTTP/${request.httpVersion}\r\n`);
|
|
418
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
419
|
+
if (value != null)
|
|
420
|
+
upstream.write(`${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`);
|
|
421
|
+
}
|
|
422
|
+
upstream.write("\r\n");
|
|
423
|
+
if (head.length) upstream.write(head);
|
|
424
|
+
socket.pipe(upstream).pipe(socket);
|
|
425
|
+
});
|
|
426
|
+
upstream.on("error", () => socket.destroy());
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function proxyHeaders(headers) {
|
|
430
|
+
const result = { ...headers };
|
|
431
|
+
delete result.cookie;
|
|
432
|
+
delete result["x-csrf-token"];
|
|
433
|
+
result.host = internalOrigin.host;
|
|
434
|
+
result["x-forwarded-host"] = headers.host || "";
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function serveAsset(requestPath, response) {
|
|
439
|
+
const assets = {
|
|
440
|
+
"/__saws/assets/app.js": [path.join(directory, "app.js"), "text/javascript; charset=utf-8"],
|
|
441
|
+
"/__saws/assets/style.css": [path.join(directory, "style.css"), "text/css; charset=utf-8"],
|
|
442
|
+
"/__saws/assets/xterm.js": [
|
|
443
|
+
path.join(directory, "node_modules/@xterm/xterm/lib/xterm.js"),
|
|
444
|
+
"text/javascript; charset=utf-8",
|
|
445
|
+
],
|
|
446
|
+
"/__saws/assets/xterm.css": [
|
|
447
|
+
path.join(directory, "node_modules/@xterm/xterm/css/xterm.css"),
|
|
448
|
+
"text/css; charset=utf-8",
|
|
449
|
+
],
|
|
450
|
+
"/__saws/assets/addon-fit.js": [
|
|
451
|
+
path.join(directory, "node_modules/@xterm/addon-fit/lib/addon-fit.js"),
|
|
452
|
+
"text/javascript; charset=utf-8",
|
|
453
|
+
],
|
|
454
|
+
};
|
|
455
|
+
const asset = assets[requestPath];
|
|
456
|
+
if (!asset) return json(response, 404, { error: "Not found" });
|
|
457
|
+
response.writeHead(200, { "content-type": asset[1], "cache-control": "public, max-age=86400" });
|
|
458
|
+
createReadStream(asset[0]).pipe(response);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function serveIndex(response) {
|
|
462
|
+
response.writeHead(200, {
|
|
463
|
+
"content-type": "text/html; charset=utf-8",
|
|
464
|
+
"cache-control": "no-store",
|
|
465
|
+
});
|
|
466
|
+
createReadStream(path.join(directory, "index.html")).pipe(response);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function addSecurityHeaders(response) {
|
|
470
|
+
response.setHeader(
|
|
471
|
+
"content-security-policy",
|
|
472
|
+
"default-src 'self'; connect-src 'self' ws: wss:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
473
|
+
);
|
|
474
|
+
response.setHeader("x-content-type-options", "nosniff");
|
|
475
|
+
response.setHeader("x-frame-options", "DENY");
|
|
476
|
+
response.setHeader("referrer-policy", "no-referrer");
|
|
477
|
+
response.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function setCookie(response, name, value, request, lifetime, httpOnly) {
|
|
481
|
+
const forwardedProto = String(request.headers["x-forwarded-proto"] || "")
|
|
482
|
+
.split(",", 1)[0]
|
|
483
|
+
.trim();
|
|
484
|
+
const secure = request.socket.encrypted || forwardedProto === "https";
|
|
485
|
+
response.setHeader(
|
|
486
|
+
"set-cookie",
|
|
487
|
+
`${name}=${value}; Path=/; Max-Age=${Math.floor(lifetime / 1000)}; SameSite=Strict${httpOnly ? "; HttpOnly" : ""}${secure ? "; Secure" : ""}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function signValue(value) {
|
|
492
|
+
const encoded = Buffer.from(value).toString("base64url");
|
|
493
|
+
const signature = createHmac("sha256", sessionKey).update(encoded).digest("base64url");
|
|
494
|
+
return `${encoded}.${signature}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function verifyValue(token) {
|
|
498
|
+
const separator = token.lastIndexOf(".");
|
|
499
|
+
if (separator < 1) return undefined;
|
|
500
|
+
const encoded = token.slice(0, separator);
|
|
501
|
+
const supplied = Buffer.from(token.slice(separator + 1), "base64url");
|
|
502
|
+
const expected = createHmac("sha256", sessionKey).update(encoded).digest();
|
|
503
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return undefined;
|
|
504
|
+
return Buffer.from(encoded, "base64url").toString("utf8");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function loadOrCreateSessionKey() {
|
|
508
|
+
await mkdir(sessionDirectory, { recursive: true, mode: 0o700 });
|
|
509
|
+
const keyPath = path.join(sessionDirectory, "session-key");
|
|
510
|
+
try {
|
|
511
|
+
return await readFile(keyPath);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error.code !== "ENOENT") throw error;
|
|
514
|
+
}
|
|
515
|
+
const key = randomBytes(32);
|
|
516
|
+
const handle = await open(keyPath, "wx", 0o600);
|
|
517
|
+
try {
|
|
518
|
+
await handle.writeFile(key);
|
|
519
|
+
} finally {
|
|
520
|
+
await handle.close();
|
|
521
|
+
}
|
|
522
|
+
return key;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function preparePersistentDirectories() {
|
|
526
|
+
await Promise.all([
|
|
527
|
+
mkdir(process.env.CODEX_HOME || path.join(process.env.HOME || "/agent-home", ".codex"), {
|
|
528
|
+
recursive: true,
|
|
529
|
+
mode: 0o700,
|
|
530
|
+
}),
|
|
531
|
+
mkdir(path.join(process.env.HOME || "/agent-home", ".claude"), {
|
|
532
|
+
recursive: true,
|
|
533
|
+
mode: 0o700,
|
|
534
|
+
}),
|
|
535
|
+
mkdir(workspace, { recursive: true, mode: 0o700 }),
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function parseCookies(header = "") {
|
|
540
|
+
return Object.fromEntries(
|
|
541
|
+
header
|
|
542
|
+
.split(";")
|
|
543
|
+
.map((part) => part.trim().split("=", 2))
|
|
544
|
+
.filter(([key, value]) => key && value),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function readJson(request, limit) {
|
|
549
|
+
return new Promise((resolve, reject) => {
|
|
550
|
+
let body = "";
|
|
551
|
+
request.setEncoding("utf8");
|
|
552
|
+
request.on("data", (chunk) => {
|
|
553
|
+
body += chunk;
|
|
554
|
+
if (body.length > limit) reject(httpError(413, "Request is too large"));
|
|
555
|
+
});
|
|
556
|
+
request.on("end", () => {
|
|
557
|
+
try {
|
|
558
|
+
resolve(body ? JSON.parse(body) : {});
|
|
559
|
+
} catch {
|
|
560
|
+
reject(httpError(400, "Invalid JSON"));
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
request.on("error", reject);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function json(response, status, value) {
|
|
568
|
+
if (response.writableEnded) return;
|
|
569
|
+
response.writeHead(status, {
|
|
570
|
+
"content-type": "application/json; charset=utf-8",
|
|
571
|
+
"cache-control": "no-store",
|
|
572
|
+
});
|
|
573
|
+
response.end(JSON.stringify(value));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function redactTerminalOutput(value) {
|
|
577
|
+
return value
|
|
578
|
+
.replace(/\b(sk-ant-|sk-proj-|sk-)[A-Za-z0-9_-]{16,}\b/g, "$1[redacted]")
|
|
579
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~-]{20,}\b/gi, "Bearer [redacted]");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function sendSocket(socket, value) {
|
|
583
|
+
if (socket.readyState === 1) socket.send(JSON.stringify(value));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function clamp(value, minimum, maximum) {
|
|
587
|
+
const number = Number(value);
|
|
588
|
+
return Number.isFinite(number)
|
|
589
|
+
? Math.max(minimum, Math.min(maximum, Math.floor(number)))
|
|
590
|
+
: minimum;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function parsePort(value) {
|
|
594
|
+
const port = Number(value);
|
|
595
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid gateway port");
|
|
596
|
+
return port;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseRequirement(value) {
|
|
600
|
+
if (!["any", "codex", "claude", "all"].includes(value))
|
|
601
|
+
throw new Error("Invalid authentication requirement");
|
|
602
|
+
return value;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function requiredEnvironment(name) {
|
|
606
|
+
const value = process.env[name];
|
|
607
|
+
if (!value) throw new Error(`${name} is required`);
|
|
608
|
+
return value;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function httpError(statusCode, message) {
|
|
612
|
+
return Object.assign(new Error(message), { statusCode });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function safeError(error) {
|
|
616
|
+
return error instanceof Error ? error.message : "unknown error";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function shutdown(signal) {
|
|
620
|
+
if (shuttingDown) return;
|
|
621
|
+
shuttingDown = true;
|
|
622
|
+
clearTimeout(openDesignRestartTimer);
|
|
623
|
+
server.close();
|
|
624
|
+
codexLogin?.child.kill("SIGTERM");
|
|
625
|
+
for (const login of claudeLogins.values()) login.terminal.kill();
|
|
626
|
+
if (openDesignProcess && !openDesignProcess.killed) openDesignProcess.kill(signal);
|
|
627
|
+
const timer = setTimeout(() => process.exit(0), 8000);
|
|
628
|
+
timer.unref();
|
|
629
|
+
if (!openDesignProcess) process.exit(0);
|
|
630
|
+
openDesignProcess.once("exit", () => process.exit(0));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
634
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|