codex-webapp 0.1.7 → 0.1.8
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/ACKNOWLEDGEMENTS.md +11 -37
- package/README.ja.md +56 -101
- package/README.md +64 -103
- package/docs/architecture.md +85 -0
- package/docs/clean-release-verification.md +95 -0
- package/docs/codex-app-install.md +5 -0
- package/docs/distribution-boundary.md +37 -0
- package/docs/i18n/README.ko.md +11 -65
- package/docs/i18n/README.zh-CN.md +11 -65
- package/package.json +9 -2
- package/scripts/check-public-package-boundary.mjs +248 -0
- package/scripts/verify-clean-release.mjs +492 -0
- package/src/appServerBridge.js +150 -0
- package/src/appServerMessageCodec.js +12 -0
- package/src/auditEvidenceHook.js +18 -0
- package/src/bridgeEventEnvelope.js +29 -0
- package/src/browserPreload.js +176 -0
- package/src/browserSmoke.js +2 -2
- package/src/codexAppRenderer.js +12 -0
- package/src/codexWeb.js +7 -14
- package/src/commands.js +40 -33
- package/src/electronBridge.js +324 -0
- package/src/localServer.js +184 -0
- package/src/projectionManifest.js +8 -0
- package/src/rendererAssetSource.js +278 -0
- package/docs/assets/codex-webapp-readme-ja.png +0 -0
- package/docs/assets/codex-webapp-readme.png +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { chmod, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import * as asar from "@electron/asar";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CODEX_APP_ASAR = "/Applications/Codex.app/Contents/Resources/app.asar";
|
|
10
|
+
export const DEFAULT_CODEX_APP_PATH = "/Applications/Codex.app";
|
|
11
|
+
export const BROWSER_PRELOAD_ROUTE = "/__codex-webapp/browser-preload.js";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_RENDERER_CACHE_ROOT = path.join(homedir(), ".cache", "codex-webapp");
|
|
14
|
+
const WEBVIEW_PREFIX = "webview/";
|
|
15
|
+
const PREPARED_MARKER = ".codex-webapp-renderer.json";
|
|
16
|
+
const PREPARED_VERSION = 2;
|
|
17
|
+
const MIME_TYPES = new Map([
|
|
18
|
+
[".avif", "image/avif"],
|
|
19
|
+
[".css", "text/css; charset=utf-8"],
|
|
20
|
+
[".gif", "image/gif"],
|
|
21
|
+
[".html", "text/html; charset=utf-8"],
|
|
22
|
+
[".ico", "image/x-icon"],
|
|
23
|
+
[".jpg", "image/jpeg"],
|
|
24
|
+
[".jpeg", "image/jpeg"],
|
|
25
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
26
|
+
[".json", "application/json; charset=utf-8"],
|
|
27
|
+
[".map", "application/json; charset=utf-8"],
|
|
28
|
+
[".mjs", "text/javascript; charset=utf-8"],
|
|
29
|
+
[".png", "image/png"],
|
|
30
|
+
[".svg", "image/svg+xml; charset=utf-8"],
|
|
31
|
+
[".ttf", "font/ttf"],
|
|
32
|
+
[".wasm", "application/wasm"],
|
|
33
|
+
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
|
34
|
+
[".webp", "image/webp"],
|
|
35
|
+
[".woff", "font/woff"],
|
|
36
|
+
[".woff2", "font/woff2"],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export async function prepareRendererAssetSource({
|
|
40
|
+
asarPath = resolveCodexAppAsarPath(),
|
|
41
|
+
cacheRoot = process.env.CODEX_WEBAPP_RENDERER_CACHE || DEFAULT_RENDERER_CACHE_ROOT,
|
|
42
|
+
} = {}) {
|
|
43
|
+
const archive = await inspectAsar(asarPath);
|
|
44
|
+
const fingerprint = rendererFingerprint(archive);
|
|
45
|
+
const runtimeRoot = path.join(cacheRoot, fingerprint);
|
|
46
|
+
const webviewRoot = path.join(runtimeRoot, "webview");
|
|
47
|
+
const markerPath = path.join(runtimeRoot, PREPARED_MARKER);
|
|
48
|
+
|
|
49
|
+
if (!(await isPrepared(markerPath, webviewRoot, fingerprint))) {
|
|
50
|
+
await mkdir(runtimeRoot, { recursive: true, mode: 0o700 });
|
|
51
|
+
await chmod(runtimeRoot, 0o700).catch(() => {});
|
|
52
|
+
const stagingRoot = path.join(runtimeRoot, `.staging-${process.pid}-${Date.now()}`);
|
|
53
|
+
await rm(stagingRoot, { recursive: true, force: true });
|
|
54
|
+
await mkdir(stagingRoot, { recursive: true, mode: 0o700 });
|
|
55
|
+
await extractWebviewFiles(archive.path, stagingRoot);
|
|
56
|
+
await transformRendererIndex(stagingRoot);
|
|
57
|
+
await rm(webviewRoot, { recursive: true, force: true });
|
|
58
|
+
await rename(stagingRoot, webviewRoot);
|
|
59
|
+
await writeFile(
|
|
60
|
+
markerPath,
|
|
61
|
+
`${JSON.stringify({ version: PREPARED_VERSION, fingerprint, source: archive.path, preparedAt: new Date().toISOString() })}\n`,
|
|
62
|
+
"utf8",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return createStaticRendererAssetSource({ webviewRoot, sourceAsar: archive.path, fingerprint });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function createStaticRendererAssetSource({ webviewRoot, sourceAsar = null, fingerprint = null }) {
|
|
70
|
+
const root = await realpath(webviewRoot);
|
|
71
|
+
const assets = await indexStaticAssets(root);
|
|
72
|
+
const indexAsset = assets.get("index.html");
|
|
73
|
+
if (!indexAsset) {
|
|
74
|
+
throw new Error(`Codex renderer webview is missing index.html: ${root}`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
root,
|
|
78
|
+
sourceAsar,
|
|
79
|
+
fingerprint,
|
|
80
|
+
assets,
|
|
81
|
+
indexAsset,
|
|
82
|
+
fileCount: assets.size,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function matchRendererAsset(source, pathname) {
|
|
87
|
+
const key = normalizeRequestPath(pathname);
|
|
88
|
+
if (!key) return null;
|
|
89
|
+
const asset = source.assets.get(key);
|
|
90
|
+
if (asset) return asset;
|
|
91
|
+
if (path.posix.extname(key)) return null;
|
|
92
|
+
return source.indexAsset;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isUnsafeRendererPath(pathname) {
|
|
96
|
+
return normalizeRequestPath(pathname) === null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function headersForAsset(asset) {
|
|
100
|
+
return asset.headers;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function inspectAsar(asarPath) {
|
|
104
|
+
let fileStat;
|
|
105
|
+
try {
|
|
106
|
+
fileStat = await stat(asarPath);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`Codex App renderer archive was not found at ${asarPath}. Install Codex.app or set CODEX_WEBAPP_CODEX_ASAR.`);
|
|
109
|
+
}
|
|
110
|
+
if (!fileStat.isFile()) {
|
|
111
|
+
throw new Error(`Codex App renderer archive is not a file: ${asarPath}`);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
asar.statFile(asarPath, "webview/index.html");
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`Codex App renderer archive does not contain webview/index.html: ${asarPath}`);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
path: asarPath,
|
|
120
|
+
sha256: await hashFile(asarPath),
|
|
121
|
+
size: fileStat.size,
|
|
122
|
+
mtimeMs: Math.trunc(fileStat.mtimeMs),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rendererFingerprint({ sha256 }) {
|
|
127
|
+
return sha256;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveCodexAppAsarPath() {
|
|
131
|
+
if (process.env.CODEX_WEBAPP_CODEX_ASAR) {
|
|
132
|
+
return process.env.CODEX_WEBAPP_CODEX_ASAR;
|
|
133
|
+
}
|
|
134
|
+
const appPath = process.env.CODEX_APP_PATH || DEFAULT_CODEX_APP_PATH;
|
|
135
|
+
return path.join(appPath, "Contents", "Resources", "app.asar");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function hashFile(filePath) {
|
|
139
|
+
const hash = createHash("sha256");
|
|
140
|
+
await new Promise((resolve, reject) => {
|
|
141
|
+
const stream = createReadStream(filePath);
|
|
142
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
143
|
+
stream.on("error", reject);
|
|
144
|
+
stream.on("end", resolve);
|
|
145
|
+
});
|
|
146
|
+
return hash.digest("hex");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function isPrepared(markerPath, webviewRoot, fingerprint) {
|
|
150
|
+
try {
|
|
151
|
+
const [marker, indexStat, indexHtml] = await Promise.all([
|
|
152
|
+
readFile(markerPath, "utf8"),
|
|
153
|
+
stat(path.join(webviewRoot, "index.html")),
|
|
154
|
+
readFile(path.join(webviewRoot, "index.html"), "utf8"),
|
|
155
|
+
]);
|
|
156
|
+
const parsed = JSON.parse(marker);
|
|
157
|
+
return indexStat.isFile() && parsed.version === PREPARED_VERSION && parsed.fingerprint === fingerprint && isRendererIndexTransformed(indexHtml);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function extractWebviewFiles(asarPath, webviewRoot) {
|
|
164
|
+
const entries = asar.listPackage(asarPath);
|
|
165
|
+
const files = entries
|
|
166
|
+
.map((entry) => entry.replace(/^\/+/, ""))
|
|
167
|
+
.filter((entry) => entry.startsWith(WEBVIEW_PREFIX))
|
|
168
|
+
.filter((entry) => !asar.statFile(asarPath, entry).files);
|
|
169
|
+
|
|
170
|
+
for (const entry of files) {
|
|
171
|
+
const relativePath = entry.slice(WEBVIEW_PREFIX.length);
|
|
172
|
+
const target = path.join(webviewRoot, relativePath);
|
|
173
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
174
|
+
await writeFile(target, asar.extractFile(asarPath, entry));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function transformRendererIndex(webviewRoot) {
|
|
179
|
+
const indexPath = path.join(webviewRoot, "index.html");
|
|
180
|
+
const source = await readFile(indexPath, "utf8");
|
|
181
|
+
const preloadTag = `<script src="${BROWSER_PRELOAD_ROUTE}"></script>`;
|
|
182
|
+
const withoutCsp = source
|
|
183
|
+
.replace(/\s*<meta\s+http-equiv=(["'])Content-Security-Policy\1[^>]*>\s*/i, "\n")
|
|
184
|
+
.replace(new RegExp(`\\s*<script\\s+src=(["'])${escapeRegExp(BROWSER_PRELOAD_ROUTE)}\\1\\s*>\\s*</script>\\s*`, "gi"), "\n");
|
|
185
|
+
const withPreload = withoutCsp.replace(
|
|
186
|
+
/(<script\s+type=(["'])module\2(?=[\s>]))/i,
|
|
187
|
+
`${preloadTag}\n $1`,
|
|
188
|
+
);
|
|
189
|
+
if (withPreload === withoutCsp) {
|
|
190
|
+
await writeFile(indexPath, withoutCsp.replace(/<\/head>/i, ` ${preloadTag}\n </head>`), "utf8");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await writeFile(indexPath, withPreload, "utf8");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isRendererIndexTransformed(indexHtml) {
|
|
197
|
+
if (/Content-Security-Policy/i.test(indexHtml)) return false;
|
|
198
|
+
const preloadIndex = indexHtml.indexOf(BROWSER_PRELOAD_ROUTE);
|
|
199
|
+
if (preloadIndex === -1) return false;
|
|
200
|
+
const moduleIndex = indexHtml.search(/<script\s+type=(["'])module\1(?=[\s>])/i);
|
|
201
|
+
return moduleIndex === -1 || preloadIndex < moduleIndex;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function escapeRegExp(value) {
|
|
205
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function indexStaticAssets(root) {
|
|
209
|
+
const assets = new Map();
|
|
210
|
+
await addAssets(root, root, assets);
|
|
211
|
+
return assets;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function addAssets(root, dir, assets) {
|
|
215
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const fullPath = path.join(dir, entry.name);
|
|
218
|
+
if (entry.isDirectory()) {
|
|
219
|
+
await addAssets(root, fullPath, assets);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (!entry.isFile()) continue;
|
|
223
|
+
const fileStat = await stat(fullPath);
|
|
224
|
+
const relativePath = path.relative(root, fullPath).split(path.sep).join("/");
|
|
225
|
+
assets.set(relativePath, {
|
|
226
|
+
path: fullPath,
|
|
227
|
+
relativePath,
|
|
228
|
+
headers: makeStaticHeaders(relativePath, fileStat),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function makeStaticHeaders(relativePath, fileStat) {
|
|
234
|
+
const etag = `"${fileStat.size.toString(16)}-${Math.trunc(fileStat.mtimeMs).toString(16)}"`;
|
|
235
|
+
return {
|
|
236
|
+
"cache-control": cacheControlFor(relativePath),
|
|
237
|
+
"content-length": String(fileStat.size),
|
|
238
|
+
"content-type": MIME_TYPES.get(path.extname(relativePath).toLowerCase()) || "application/octet-stream",
|
|
239
|
+
etag,
|
|
240
|
+
"last-modified": fileStat.mtime.toUTCString(),
|
|
241
|
+
"x-content-type-options": "nosniff",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function cacheControlFor(relativePath) {
|
|
246
|
+
if (relativePath === "index.html") return "no-cache";
|
|
247
|
+
if (relativePath.startsWith("assets/")) return "public, max-age=31536000, immutable";
|
|
248
|
+
return "public, max-age=3600";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function normalizeRequestPath(pathname) {
|
|
252
|
+
let decoded;
|
|
253
|
+
try {
|
|
254
|
+
decoded = decodeURIComponent(pathname || "/");
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (decoded.includes("\0")) return null;
|
|
259
|
+
if (decoded.split(/[\\/]+/).includes("..")) return null;
|
|
260
|
+
const normalized = path.posix.normalize(`/${decoded}`).replace(/^\/+/, "");
|
|
261
|
+
if (!normalized || normalized === ".") return "index.html";
|
|
262
|
+
if (normalized === ".." || normalized.startsWith("../")) return null;
|
|
263
|
+
return normalized;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function streamAsset(response, asset, requestHeaders = {}) {
|
|
267
|
+
if (requestHeaders["if-none-match"] === asset.headers.etag) {
|
|
268
|
+
response.writeHead(304, {
|
|
269
|
+
"cache-control": asset.headers["cache-control"],
|
|
270
|
+
etag: asset.headers.etag,
|
|
271
|
+
"x-content-type-options": "nosniff",
|
|
272
|
+
});
|
|
273
|
+
response.end();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
response.writeHead(200, asset.headers);
|
|
277
|
+
createReadStream(asset.path).pipe(response);
|
|
278
|
+
}
|
|
Binary file
|
|
Binary file
|