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/assets.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
import { safePathJoin, toErrorMessage } from "./util.mjs";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CODEX_APP = "/Applications/Codex.app";
|
|
10
|
+
|
|
11
|
+
const CONTENT_TYPES = {
|
|
12
|
+
".html": "text/html; charset=utf-8",
|
|
13
|
+
".js": "application/javascript; charset=utf-8",
|
|
14
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
15
|
+
".css": "text/css; charset=utf-8",
|
|
16
|
+
".json": "application/json; charset=utf-8",
|
|
17
|
+
".svg": "image/svg+xml",
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".jpg": "image/jpeg",
|
|
20
|
+
".jpeg": "image/jpeg",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".webp": "image/webp",
|
|
23
|
+
".woff": "font/woff",
|
|
24
|
+
".woff2": "font/woff2",
|
|
25
|
+
".ttf": "font/ttf",
|
|
26
|
+
".ico": "image/x-icon",
|
|
27
|
+
".mjs.map": "application/json; charset=utf-8",
|
|
28
|
+
".js.map": "application/json; charset=utf-8"
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function defaultCacheRoot() {
|
|
32
|
+
return path.join(os.homedir(), ".cache", "codex-webstrap", "assets");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveCodexAppPaths(explicitCodexAppPath) {
|
|
36
|
+
const appPath = path.resolve(explicitCodexAppPath || DEFAULT_CODEX_APP);
|
|
37
|
+
const resourcesPath = path.join(appPath, "Contents", "Resources");
|
|
38
|
+
const asarPath = path.join(resourcesPath, "app.asar");
|
|
39
|
+
const infoPlistPath = path.join(appPath, "Contents", "Info.plist");
|
|
40
|
+
return { appPath, resourcesPath, asarPath, infoPlistPath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function ensureCodexAppExists(paths) {
|
|
44
|
+
const checks = [paths.appPath, paths.resourcesPath, paths.asarPath, paths.infoPlistPath];
|
|
45
|
+
for (const filePath of checks) {
|
|
46
|
+
await fsp.access(filePath, fs.constants.R_OK);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readBuildMetadata(paths) {
|
|
51
|
+
const plist = await fsp.readFile(paths.infoPlistPath, "utf8");
|
|
52
|
+
const bundleVersion = plist.match(/<key>CFBundleVersion<\/key>\s*<string>([^<]+)<\/string>/)?.[1] || "unknown";
|
|
53
|
+
const shortVersion = plist.match(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/)?.[1] || "unknown";
|
|
54
|
+
const buildKey = `${shortVersion}-${bundleVersion}`.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
55
|
+
return { bundleVersion, shortVersion, buildKey };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runAsarCliExtract(asarPath, outputPath) {
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
const child = spawn("npx", ["-y", "@electron/asar", "extract", asarPath, outputPath], {
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let stderr = "";
|
|
65
|
+
child.stderr.on("data", (chunk) => {
|
|
66
|
+
stderr += chunk.toString("utf8");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
child.on("error", reject);
|
|
70
|
+
child.on("exit", (code) => {
|
|
71
|
+
if (code === 0) {
|
|
72
|
+
resolve();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
reject(new Error(`asar extract failed with code ${code}: ${stderr.trim()}`));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function extractAsarAll(asarPath, outputPath) {
|
|
81
|
+
try {
|
|
82
|
+
const module = await import("@electron/asar");
|
|
83
|
+
const extractAll = module.extractAll || module.default?.extractAll;
|
|
84
|
+
if (typeof extractAll === "function") {
|
|
85
|
+
extractAll(asarPath, outputPath);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
throw new Error("@electron/asar extractAll API unavailable");
|
|
89
|
+
} catch {
|
|
90
|
+
await runAsarCliExtract(asarPath, outputPath);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function ensureExtractedAssets({
|
|
95
|
+
asarPath,
|
|
96
|
+
cacheRoot = defaultCacheRoot(),
|
|
97
|
+
buildKey,
|
|
98
|
+
logger
|
|
99
|
+
}) {
|
|
100
|
+
const root = path.resolve(cacheRoot);
|
|
101
|
+
const outputDir = path.join(root, buildKey);
|
|
102
|
+
const doneFile = path.join(outputDir, ".extract-complete.json");
|
|
103
|
+
|
|
104
|
+
await fsp.mkdir(root, { recursive: true, mode: 0o700 });
|
|
105
|
+
|
|
106
|
+
const alreadyExtracted = await fsp
|
|
107
|
+
.access(doneFile, fs.constants.R_OK)
|
|
108
|
+
.then(() => true)
|
|
109
|
+
.catch(() => false);
|
|
110
|
+
|
|
111
|
+
if (!alreadyExtracted) {
|
|
112
|
+
const tempDir = `${outputDir}.tmp-${Date.now()}`;
|
|
113
|
+
await fsp.rm(tempDir, { recursive: true, force: true });
|
|
114
|
+
await fsp.mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
115
|
+
|
|
116
|
+
logger.info("Extracting Codex assets", { outputDir });
|
|
117
|
+
await extractAsarAll(asarPath, tempDir);
|
|
118
|
+
|
|
119
|
+
await fsp.mkdir(outputDir, { recursive: true, mode: 0o700 });
|
|
120
|
+
await fsp.rm(outputDir, { recursive: true, force: true });
|
|
121
|
+
await fsp.rename(tempDir, outputDir);
|
|
122
|
+
|
|
123
|
+
await fsp.writeFile(
|
|
124
|
+
doneFile,
|
|
125
|
+
JSON.stringify(
|
|
126
|
+
{
|
|
127
|
+
extractedAt: new Date().toISOString(),
|
|
128
|
+
asarPath,
|
|
129
|
+
buildKey
|
|
130
|
+
},
|
|
131
|
+
null,
|
|
132
|
+
2
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const webRoot = path.join(outputDir, "webview");
|
|
138
|
+
const workerPath = path.join(outputDir, ".vite", "build", "worker.js");
|
|
139
|
+
const indexPath = path.join(webRoot, "index.html");
|
|
140
|
+
|
|
141
|
+
await fsp.access(webRoot, fs.constants.R_OK);
|
|
142
|
+
await fsp.access(indexPath, fs.constants.R_OK);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
outputDir,
|
|
146
|
+
webRoot,
|
|
147
|
+
indexPath,
|
|
148
|
+
workerPath
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function buildPatchedIndexHtml(indexPath) {
|
|
153
|
+
const html = await fsp.readFile(indexPath, "utf8");
|
|
154
|
+
const shimTag = '<script src="/__webstrapper/shim.js"></script>';
|
|
155
|
+
|
|
156
|
+
if (html.includes(shimTag)) {
|
|
157
|
+
return html;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (html.includes("</head>")) {
|
|
161
|
+
return html.replace("</head>", ` ${shimTag}\n</head>`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return `${shimTag}\n${html}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function readStaticFile(webRoot, requestPath) {
|
|
168
|
+
const normalized = requestPath === "/" ? "/index.html" : requestPath;
|
|
169
|
+
const filePath = safePathJoin(webRoot, normalized);
|
|
170
|
+
if (!filePath) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const stat = await fsp
|
|
175
|
+
.stat(filePath)
|
|
176
|
+
.catch(() => null);
|
|
177
|
+
if (!stat || !stat.isFile()) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ext = path.extname(filePath);
|
|
182
|
+
const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const body = await fsp.readFile(filePath);
|
|
186
|
+
return { body, contentType };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw new Error(`Failed reading asset ${filePath}: ${toErrorMessage(error)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { randomId } from "./util.mjs";
|
|
7
|
+
|
|
8
|
+
export const SESSION_COOKIE_NAME = "cw_session";
|
|
9
|
+
|
|
10
|
+
export function defaultTokenFilePath() {
|
|
11
|
+
return path.join(os.homedir(), ".codex-webstrap", "token");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function ensurePersistentToken(tokenFilePath) {
|
|
15
|
+
const resolvedPath = path.resolve(tokenFilePath || defaultTokenFilePath());
|
|
16
|
+
await fs.mkdir(path.dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const existing = (await fs.readFile(resolvedPath, "utf8")).trim();
|
|
20
|
+
if (existing.length >= 32) {
|
|
21
|
+
return { token: existing, tokenFilePath: resolvedPath };
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// No token yet.
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
28
|
+
await fs.writeFile(resolvedPath, `${token}\n`, { mode: 0o600 });
|
|
29
|
+
return { token, tokenFilePath: resolvedPath };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseCookies(cookieHeader) {
|
|
33
|
+
const output = Object.create(null);
|
|
34
|
+
if (!cookieHeader) {
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const segment of cookieHeader.split(";")) {
|
|
39
|
+
const [rawKey, ...rest] = segment.trim().split("=");
|
|
40
|
+
if (!rawKey) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
output[rawKey] = decodeURIComponent(rest.join("="));
|
|
44
|
+
}
|
|
45
|
+
return output;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function serializeCookie(name, value, options = {}) {
|
|
49
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
50
|
+
|
|
51
|
+
if (options.maxAgeSeconds != null) {
|
|
52
|
+
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
56
|
+
|
|
57
|
+
if (options.httpOnly !== false) {
|
|
58
|
+
parts.push("HttpOnly");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
parts.push(`SameSite=${options.sameSite ?? "Lax"}`);
|
|
62
|
+
|
|
63
|
+
if (options.secure) {
|
|
64
|
+
parts.push("Secure");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return parts.join("; ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class SessionStore {
|
|
71
|
+
constructor({ ttlMs = 1000 * 60 * 60 * 12 } = {}) {
|
|
72
|
+
this.ttlMs = ttlMs;
|
|
73
|
+
this.sessions = new Map();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
createSession() {
|
|
77
|
+
const id = randomId(24);
|
|
78
|
+
const expiresAt = Date.now() + this.ttlMs;
|
|
79
|
+
this.sessions.set(id, expiresAt);
|
|
80
|
+
return { id, expiresAt };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
isValid(sessionId) {
|
|
84
|
+
if (!sessionId) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const expiresAt = this.sessions.get(sessionId);
|
|
88
|
+
if (!expiresAt) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (expiresAt < Date.now()) {
|
|
92
|
+
this.sessions.delete(sessionId);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
pruneExpired() {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
for (const [id, expiresAt] of this.sessions.entries()) {
|
|
101
|
+
if (expiresAt < now) {
|
|
102
|
+
this.sessions.delete(id);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createAuthController({ token, sessionStore, cookieName = SESSION_COOKIE_NAME }) {
|
|
109
|
+
function hasValidSession(req) {
|
|
110
|
+
const cookies = parseCookies(req.headers.cookie || "");
|
|
111
|
+
return sessionStore.isValid(cookies[cookieName]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isAuthorizedRequest(req) {
|
|
115
|
+
return hasValidSession(req);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function rejectUnauthorized(res) {
|
|
119
|
+
res.statusCode = 401;
|
|
120
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
121
|
+
res.end(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
error: "unauthorized",
|
|
124
|
+
hint: "Authenticate first via /__webstrapper/auth?token=<TOKEN>."
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function requireAuth(req, res) {
|
|
130
|
+
if (hasValidSession(req)) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
rejectUnauthorized(res);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function handleAuthRoute(req, res, parsedUrl) {
|
|
138
|
+
const provided = parsedUrl.searchParams.get("token") || "";
|
|
139
|
+
if (!provided || provided !== token) {
|
|
140
|
+
res.statusCode = 401;
|
|
141
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
142
|
+
res.end(JSON.stringify({ error: "invalid_token" }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const session = sessionStore.createSession();
|
|
147
|
+
const cookie = serializeCookie(cookieName, session.id, {
|
|
148
|
+
maxAgeSeconds: Math.floor(sessionStore.ttlMs / 1000),
|
|
149
|
+
httpOnly: true,
|
|
150
|
+
sameSite: "Lax",
|
|
151
|
+
path: "/"
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
res.statusCode = 302;
|
|
155
|
+
res.setHeader("set-cookie", cookie);
|
|
156
|
+
res.setHeader("location", "/");
|
|
157
|
+
res.end();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
cookieName,
|
|
162
|
+
isAuthorizedRequest,
|
|
163
|
+
requireAuth,
|
|
164
|
+
handleAuthRoute
|
|
165
|
+
};
|
|
166
|
+
}
|