codex-webstrapper 0.1.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.md +21 -0
- package/README.md +239 -0
- package/bin/codex-webstrap.sh +63 -0
- package/package.json +27 -0
- package/src/app-server.mjs +289 -0
- package/src/assets.mjs +190 -0
- package/src/auth.mjs +166 -0
- package/src/bridge-shim.js +669 -0
- package/src/ipc-uds.mjs +320 -0
- package/src/message-router.mjs +1857 -0
- package/src/server.mjs +363 -0
- package/src/util.mjs +95 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { WebSocketServer } from "ws";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ensurePersistentToken,
|
|
11
|
+
SessionStore,
|
|
12
|
+
createAuthController,
|
|
13
|
+
defaultTokenFilePath
|
|
14
|
+
} from "./auth.mjs";
|
|
15
|
+
import {
|
|
16
|
+
buildPatchedIndexHtml,
|
|
17
|
+
ensureCodexAppExists,
|
|
18
|
+
ensureExtractedAssets,
|
|
19
|
+
readBuildMetadata,
|
|
20
|
+
readStaticFile,
|
|
21
|
+
resolveCodexAppPaths
|
|
22
|
+
} from "./assets.mjs";
|
|
23
|
+
import { AppServerManager } from "./app-server.mjs";
|
|
24
|
+
import { UdsIpcClient } from "./ipc-uds.mjs";
|
|
25
|
+
import { MessageRouter } from "./message-router.mjs";
|
|
26
|
+
import { createLogger, safeJsonParse, sleep, toErrorMessage } from "./util.mjs";
|
|
27
|
+
|
|
28
|
+
const logger = createLogger("server");
|
|
29
|
+
|
|
30
|
+
function parseConfig(argv = process.argv.slice(2), env = process.env) {
|
|
31
|
+
const config = {
|
|
32
|
+
port: Number(env.CODEX_WEBSTRAP_PORT || 8080),
|
|
33
|
+
bind: env.CODEX_WEBSTRAP_BIND || "127.0.0.1",
|
|
34
|
+
tokenFile: env.CODEX_WEBSTRAP_TOKEN_FILE || defaultTokenFilePath(),
|
|
35
|
+
codexAppPath: env.CODEX_WEBSTRAP_CODEX_APP || "/Applications/Codex.app",
|
|
36
|
+
internalWsPort: Number(env.CODEX_WEBSTRAP_INTERNAL_WS_PORT || 38080),
|
|
37
|
+
autoOpen: env.CODEX_WEBSTRAP_OPEN === "1"
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
41
|
+
const arg = argv[i];
|
|
42
|
+
switch (arg) {
|
|
43
|
+
case "--port":
|
|
44
|
+
config.port = Number(argv[++i]);
|
|
45
|
+
break;
|
|
46
|
+
case "--bind":
|
|
47
|
+
config.bind = argv[++i];
|
|
48
|
+
break;
|
|
49
|
+
case "--token-file":
|
|
50
|
+
config.tokenFile = argv[++i];
|
|
51
|
+
break;
|
|
52
|
+
case "--codex-app":
|
|
53
|
+
config.codexAppPath = argv[++i];
|
|
54
|
+
break;
|
|
55
|
+
case "--open":
|
|
56
|
+
config.autoOpen = true;
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return config;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function isCodexRunning() {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const child = spawn("pgrep", ["-x", "Codex"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
69
|
+
child.on("exit", (code) => {
|
|
70
|
+
resolve(code === 0);
|
|
71
|
+
});
|
|
72
|
+
child.on("error", () => {
|
|
73
|
+
resolve(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function openCodexApp(appPath) {
|
|
79
|
+
const child = spawn("open", ["-a", appPath], {
|
|
80
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
81
|
+
detached: true
|
|
82
|
+
});
|
|
83
|
+
child.unref();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function waitForFile(filePath, timeoutMs) {
|
|
87
|
+
const startedAt = Date.now();
|
|
88
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
89
|
+
const exists = await fs
|
|
90
|
+
.access(filePath)
|
|
91
|
+
.then(() => true)
|
|
92
|
+
.catch(() => false);
|
|
93
|
+
if (exists) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
await sleep(200);
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sendJson(res, statusCode, body) {
|
|
102
|
+
res.statusCode = statusCode;
|
|
103
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
104
|
+
res.end(JSON.stringify(body));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sendNotFound(res) {
|
|
108
|
+
res.statusCode = 404;
|
|
109
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
110
|
+
res.end("Not found");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function main() {
|
|
114
|
+
const config = parseConfig();
|
|
115
|
+
|
|
116
|
+
const tokenResult = await ensurePersistentToken(config.tokenFile);
|
|
117
|
+
const sessionStore = new SessionStore({ ttlMs: 1000 * 60 * 60 * 12 });
|
|
118
|
+
const auth = createAuthController({ token: tokenResult.token, sessionStore });
|
|
119
|
+
|
|
120
|
+
const codexPaths = resolveCodexAppPaths(config.codexAppPath);
|
|
121
|
+
await ensureCodexAppExists(codexPaths);
|
|
122
|
+
const build = await readBuildMetadata(codexPaths);
|
|
123
|
+
|
|
124
|
+
const running = await isCodexRunning();
|
|
125
|
+
if (!running) {
|
|
126
|
+
logger.info("Launching Codex desktop app", { appPath: codexPaths.appPath });
|
|
127
|
+
openCodexApp(codexPaths.appPath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const udsClient = new UdsIpcClient({ logger: createLogger("uds") });
|
|
131
|
+
const udsSocketReady = await waitForFile(udsClient.socketPath, 6000);
|
|
132
|
+
if (!udsSocketReady) {
|
|
133
|
+
logger.warn("UDS socket not detected before startup timeout", { socketPath: udsClient.socketPath });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await udsClient.start();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
logger.warn("UDS client start failed; continuing with app-server fallback", {
|
|
140
|
+
error: toErrorMessage(error)
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const assetBundle = await ensureExtractedAssets({
|
|
145
|
+
asarPath: codexPaths.asarPath,
|
|
146
|
+
buildKey: build.buildKey,
|
|
147
|
+
logger
|
|
148
|
+
});
|
|
149
|
+
const patchedIndexHtml = await buildPatchedIndexHtml(assetBundle.indexPath);
|
|
150
|
+
|
|
151
|
+
const appServer = new AppServerManager({
|
|
152
|
+
internalPort: config.internalWsPort,
|
|
153
|
+
logger: createLogger("app-server")
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await appServer.start();
|
|
158
|
+
} catch (error) {
|
|
159
|
+
logger.warn("App-server startup failed; UI may be degraded", {
|
|
160
|
+
error: toErrorMessage(error)
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const router = new MessageRouter({
|
|
165
|
+
appServer,
|
|
166
|
+
udsClient,
|
|
167
|
+
hostConfig: {
|
|
168
|
+
id: "local",
|
|
169
|
+
display_name: "Codex",
|
|
170
|
+
kind: "local"
|
|
171
|
+
},
|
|
172
|
+
workerPath: assetBundle.workerPath,
|
|
173
|
+
logger: createLogger("router")
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const thisFilePath = fileURLToPath(import.meta.url);
|
|
177
|
+
const shimPath = path.resolve(path.join(path.dirname(thisFilePath), "bridge-shim.js"));
|
|
178
|
+
const shimBody = await fs.readFile(shimPath);
|
|
179
|
+
|
|
180
|
+
const server = http.createServer(async (req, res) => {
|
|
181
|
+
try {
|
|
182
|
+
const host = req.headers.host || `${config.bind}:${config.port}`;
|
|
183
|
+
const url = new URL(req.url || "/", `http://${host}`);
|
|
184
|
+
|
|
185
|
+
if (url.pathname === "/__webstrapper/healthz") {
|
|
186
|
+
sendJson(res, 200, {
|
|
187
|
+
ok: true,
|
|
188
|
+
appServer: appServer.getState(),
|
|
189
|
+
udsReady: udsClient.isReady(),
|
|
190
|
+
build: build.buildKey
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (url.pathname === "/favicon.ico") {
|
|
196
|
+
res.statusCode = 204;
|
|
197
|
+
res.end();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (url.pathname === "/__webstrapper/auth") {
|
|
202
|
+
auth.handleAuthRoute(req, res, url);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!auth.requireAuth(req, res)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (url.pathname === "/__webstrapper/shim.js") {
|
|
211
|
+
res.statusCode = 200;
|
|
212
|
+
res.setHeader("content-type", "application/javascript; charset=utf-8");
|
|
213
|
+
res.end(shimBody);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
218
|
+
res.statusCode = 200;
|
|
219
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
220
|
+
res.end(patchedIndexHtml);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const staticFile = await readStaticFile(assetBundle.webRoot, url.pathname);
|
|
225
|
+
if (!staticFile) {
|
|
226
|
+
sendNotFound(res);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
res.statusCode = 200;
|
|
231
|
+
res.setHeader("content-type", staticFile.contentType);
|
|
232
|
+
res.end(staticFile.body);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
logger.error("HTTP handler failed", { error: toErrorMessage(error) });
|
|
235
|
+
sendJson(res, 500, { error: "internal_server_error" });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
240
|
+
|
|
241
|
+
wss.on("connection", (ws) => {
|
|
242
|
+
router.registerClient(ws);
|
|
243
|
+
|
|
244
|
+
ws.on("message", async (raw) => {
|
|
245
|
+
const text = raw.toString("utf8");
|
|
246
|
+
const parsed = safeJsonParse(text);
|
|
247
|
+
if (!parsed) {
|
|
248
|
+
router.sendBridgeError(ws, "invalid_json", "Failed to parse bridge JSON payload.");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await router.handleEnvelope(ws, parsed);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
ws.on("close", () => {
|
|
256
|
+
router.unregisterClient(ws);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
ws.on("error", () => {
|
|
260
|
+
router.unregisterClient(ws);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
server.on("upgrade", (req, socket, head) => {
|
|
265
|
+
const host = req.headers.host || `${config.bind}:${config.port}`;
|
|
266
|
+
const url = new URL(req.url || "/", `http://${host}`);
|
|
267
|
+
|
|
268
|
+
if (url.pathname !== "/__webstrapper/bridge") {
|
|
269
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
270
|
+
socket.destroy();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!auth.isAuthorizedRequest(req)) {
|
|
275
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
276
|
+
socket.destroy();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
281
|
+
wss.emit("connection", ws, req);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await new Promise((resolve, reject) => {
|
|
286
|
+
server.once("error", reject);
|
|
287
|
+
server.listen(config.port, config.bind, resolve);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const authHint = `http://${config.bind}:${config.port}/__webstrapper/auth?token=<redacted>`;
|
|
291
|
+
const loginCommand = `open \"http://${config.bind}:${config.port}/__webstrapper/auth?token=$(cat ${tokenResult.tokenFilePath})\"`;
|
|
292
|
+
|
|
293
|
+
logger.info("codex-webstrapper started", {
|
|
294
|
+
bind: config.bind,
|
|
295
|
+
port: config.port,
|
|
296
|
+
buildKey: build.buildKey,
|
|
297
|
+
tokenFilePath: tokenResult.tokenFilePath,
|
|
298
|
+
authHint
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
process.stdout.write(`\nCodex Webstrapper listening on http://${config.bind}:${config.port}\n`);
|
|
302
|
+
process.stdout.write(`Token file: ${tokenResult.tokenFilePath}\n`);
|
|
303
|
+
process.stdout.write(`Auth URL pattern: ${authHint}\n`);
|
|
304
|
+
process.stdout.write(`Local login command: ${loginCommand}\n\n`);
|
|
305
|
+
|
|
306
|
+
if (config.autoOpen) {
|
|
307
|
+
const openUrl = `http://${config.bind}:${config.port}/__webstrapper/auth?token=${encodeURIComponent(tokenResult.token)}`;
|
|
308
|
+
const child = spawn("open", [openUrl], {
|
|
309
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
310
|
+
detached: true
|
|
311
|
+
});
|
|
312
|
+
child.unref();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const pruneInterval = setInterval(() => {
|
|
316
|
+
sessionStore.pruneExpired();
|
|
317
|
+
}, 60_000);
|
|
318
|
+
pruneInterval.unref();
|
|
319
|
+
|
|
320
|
+
let shuttingDown = false;
|
|
321
|
+
|
|
322
|
+
async function shutdown(signal) {
|
|
323
|
+
if (shuttingDown) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
shuttingDown = true;
|
|
327
|
+
|
|
328
|
+
logger.info("Shutting down", { signal });
|
|
329
|
+
|
|
330
|
+
clearInterval(pruneInterval);
|
|
331
|
+
|
|
332
|
+
wss.clients.forEach((client) => {
|
|
333
|
+
try {
|
|
334
|
+
client.close();
|
|
335
|
+
} catch {
|
|
336
|
+
// ignore
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
router.dispose();
|
|
341
|
+
appServer.stop();
|
|
342
|
+
udsClient.stop();
|
|
343
|
+
|
|
344
|
+
await new Promise((resolve) => {
|
|
345
|
+
server.close(() => resolve());
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
process.exit(0);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
process.on("SIGINT", () => {
|
|
352
|
+
shutdown("SIGINT");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
process.on("SIGTERM", () => {
|
|
356
|
+
shutdown("SIGTERM");
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
main().catch((error) => {
|
|
361
|
+
logger.error("Fatal startup error", { error: toErrorMessage(error) });
|
|
362
|
+
process.exit(1);
|
|
363
|
+
});
|
package/src/util.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const LEVELS = {
|
|
5
|
+
trace: 10,
|
|
6
|
+
debug: 20,
|
|
7
|
+
info: 30,
|
|
8
|
+
warn: 40,
|
|
9
|
+
error: 50
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function normalizeLevel(level) {
|
|
13
|
+
const input = String(level ?? "info").toLowerCase();
|
|
14
|
+
return LEVELS[input] ? input : "info";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createLogger(scope, level = process.env.CODEX_WEBSTRAP_LOG_LEVEL) {
|
|
18
|
+
const threshold = LEVELS[normalizeLevel(level)];
|
|
19
|
+
|
|
20
|
+
function write(logLevel, message, fields) {
|
|
21
|
+
if (LEVELS[logLevel] < threshold) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const line = {
|
|
26
|
+
ts: new Date().toISOString(),
|
|
27
|
+
level: logLevel,
|
|
28
|
+
scope,
|
|
29
|
+
msg: message
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (fields && Object.keys(fields).length > 0) {
|
|
33
|
+
line.fields = fields;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rendered = JSON.stringify(line);
|
|
37
|
+
if (logLevel === "error" || logLevel === "warn") {
|
|
38
|
+
process.stderr.write(`${rendered}\n`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
process.stdout.write(`${rendered}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
trace(message, fields) {
|
|
46
|
+
write("trace", message, fields);
|
|
47
|
+
},
|
|
48
|
+
debug(message, fields) {
|
|
49
|
+
write("debug", message, fields);
|
|
50
|
+
},
|
|
51
|
+
info(message, fields) {
|
|
52
|
+
write("info", message, fields);
|
|
53
|
+
},
|
|
54
|
+
warn(message, fields) {
|
|
55
|
+
write("warn", message, fields);
|
|
56
|
+
},
|
|
57
|
+
error(message, fields) {
|
|
58
|
+
write("error", message, fields);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function randomId(bytes = 16) {
|
|
64
|
+
return crypto.randomBytes(bytes).toString("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function safeJsonParse(raw) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function safePathJoin(root, requestPath) {
|
|
76
|
+
const normalized = requestPath.replace(/^\/+/, "");
|
|
77
|
+
const fullPath = path.resolve(root, normalized);
|
|
78
|
+
if (!fullPath.startsWith(path.resolve(root))) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return fullPath;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function toErrorMessage(error) {
|
|
85
|
+
if (error instanceof Error) {
|
|
86
|
+
return error.message;
|
|
87
|
+
}
|
|
88
|
+
return String(error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function sleep(ms) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
setTimeout(resolve, ms);
|
|
94
|
+
});
|
|
95
|
+
}
|