minecodex 0.1.4 → 0.1.6
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/README.md +5 -3
- package/features/images/codex-feature.json +1 -1
- package/features/images/src/http-server.mjs +20 -1
- package/features/model-slider/codex-feature.json +1 -1
- package/features/notes/codex-feature.json +1 -1
- package/features/notes/src/http-server.mjs +14 -6
- package/package.json +1 -1
- package/packages/cli/src/runtime-manager.mjs +175 -3
- package/packages/runtime-host/README.md +6 -4
- package/packages/runtime-host/src/codex-runtime.mjs +213 -11
- package/packages/runtime-host/src/main.mjs +1 -0
package/README.md
CHANGED
|
@@ -19,9 +19,11 @@ mcx install
|
|
|
19
19
|
|
|
20
20
|
`mcx install` 不会关闭或启动 Codex,也不会改变当前窗口。安装完成后,终端会提示
|
|
21
21
|
MineCodex 将在下次重启 Codex 时启用,并询问是否现在重启;默认答案是 **No**。
|
|
22
|
-
|
|
23
|
-
Codex。安装后,macOS 的“登录项与扩展”中会显示名为 **MineCodex**
|
|
24
|
-
|
|
22
|
+
安装过程只有在用户明确确认,或之后主动运行 `mcx restart` 时,才会关闭并重新打开
|
|
23
|
+
Codex。安装后,macOS 的“登录项与扩展”中会显示名为 **MineCodex** 的后台项目。
|
|
24
|
+
这个后台项目负责插件与本地控制台:用户退出 Codex 后,它会保持停止;用户后来从
|
|
25
|
+
Dock 主动打开 Codex 时,这次新启动会被视为明确的启动意图,并切换为可注入的托管
|
|
26
|
+
实例。由于普通 Dock 启动本身没有 CDP,首次切换时 Codex 可能会短暂重开一次。
|
|
25
27
|
|
|
26
28
|
安装后三个插件默认开启。打开本地控制台:
|
|
27
29
|
|
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { saveImageAs } from "./save-as.mjs";
|
|
6
6
|
|
|
7
7
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
8
|
+
const CODEX_EMBED_ORIGIN = "app://-";
|
|
8
9
|
const DEFAULT_PAGE_LIMIT = 36;
|
|
9
10
|
const MAX_PAGE_LIMIT = 72;
|
|
10
11
|
const MAX_PAGE_OFFSET = Number.MAX_SAFE_INTEGER;
|
|
@@ -69,6 +70,16 @@ function mutationError(message, code) {
|
|
|
69
70
|
return Object.assign(new Error(message), { status: 403, code });
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function applyCodexEmbedCors(request, response) {
|
|
74
|
+
if (request.headers.origin !== CODEX_EMBED_ORIGIN) return false;
|
|
75
|
+
response.setHeader("Access-Control-Allow-Origin", CODEX_EMBED_ORIGIN);
|
|
76
|
+
response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
|
|
77
|
+
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
78
|
+
response.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
79
|
+
response.setHeader("Vary", "Origin");
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
function parseIntegerQuery(searchParams, name, { defaultValue, min, max }) {
|
|
73
84
|
const values = searchParams.getAll(name);
|
|
74
85
|
if (values.length === 0) return defaultValue;
|
|
@@ -115,6 +126,7 @@ function validateMutationOrigin(request, address, host) {
|
|
|
115
126
|
if (!origin) {
|
|
116
127
|
throw mutationError("Mutation requests require the exact bound loopback Origin", "ORIGIN_NOT_ALLOWED");
|
|
117
128
|
}
|
|
129
|
+
if (origin === CODEX_EMBED_ORIGIN) return;
|
|
118
130
|
let parsed;
|
|
119
131
|
try {
|
|
120
132
|
parsed = new URL(origin);
|
|
@@ -137,13 +149,20 @@ export async function createHttpServer({
|
|
|
137
149
|
if (!LOOPBACK_HOSTS.has(host)) throw new Error("Images only supports loopback hosts");
|
|
138
150
|
const server = createNodeServer(async (request, response) => {
|
|
139
151
|
try {
|
|
152
|
+
applyCodexEmbedCors(request, response);
|
|
140
153
|
const address = server.address();
|
|
141
154
|
const boundOrigin = address && typeof address !== "string"
|
|
142
155
|
? `http://${formatHost(host)}:${address.port}`
|
|
143
156
|
: `http://${formatHost(host)}:${port}`;
|
|
144
157
|
const url = new URL(request.url ?? "/", boundOrigin);
|
|
145
158
|
|
|
146
|
-
if (
|
|
159
|
+
if (request.method === "OPTIONS") {
|
|
160
|
+
response.writeHead(204, { "Cache-Control": "no-store" });
|
|
161
|
+
response.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!["GET", "HEAD"].includes(request.method)) {
|
|
147
166
|
validateMutationOrigin(request, address, host);
|
|
148
167
|
}
|
|
149
168
|
|
|
@@ -15,6 +15,7 @@ const LUCIDE_ICON_ROOT = path.resolve(DEFAULT_WEB_ROOT, "../assets/icons");
|
|
|
15
15
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
16
16
|
const MAX_PREVIEW_BYTES = 64 * 1024 * 1024;
|
|
17
17
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
18
|
+
const CODEX_EMBED_ORIGIN = "app://-";
|
|
18
19
|
const LUCIDE_ICON_NAMES = [
|
|
19
20
|
"circle",
|
|
20
21
|
"ellipsis",
|
|
@@ -233,6 +234,16 @@ function formatHost(host) {
|
|
|
233
234
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
function applyCodexEmbedCors(request, response) {
|
|
238
|
+
if (request.headers.origin !== CODEX_EMBED_ORIGIN) return false;
|
|
239
|
+
response.setHeader("Access-Control-Allow-Origin", CODEX_EMBED_ORIGIN);
|
|
240
|
+
response.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PATCH, DELETE, OPTIONS");
|
|
241
|
+
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
242
|
+
response.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
243
|
+
response.setHeader("Vary", "Origin");
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
236
247
|
function validateMutationOrigin(request, address, host) {
|
|
237
248
|
const expectedHost = formatHost(host);
|
|
238
249
|
const expectedOrigin = `http://${expectedHost}:${address.port}`;
|
|
@@ -246,6 +257,7 @@ function validateMutationOrigin(request, address, host) {
|
|
|
246
257
|
|
|
247
258
|
const origin = request.headers.origin;
|
|
248
259
|
if (!origin) return;
|
|
260
|
+
if (origin === CODEX_EMBED_ORIGIN) return;
|
|
249
261
|
let parsed;
|
|
250
262
|
try {
|
|
251
263
|
parsed = new URL(origin);
|
|
@@ -285,6 +297,7 @@ export function createCodexNotesServer({
|
|
|
285
297
|
|
|
286
298
|
const server = createServer(async (request, response) => {
|
|
287
299
|
try {
|
|
300
|
+
applyCodexEmbedCors(request, response);
|
|
288
301
|
const url = new URL(request.url ?? "/", `http://${formatHost(host)}:${port}`);
|
|
289
302
|
const { pathname } = url;
|
|
290
303
|
const method = request.method ?? "GET";
|
|
@@ -293,12 +306,7 @@ export function createCodexNotesServer({
|
|
|
293
306
|
}
|
|
294
307
|
|
|
295
308
|
if (method === "OPTIONS") {
|
|
296
|
-
response.writeHead(204, {
|
|
297
|
-
"Access-Control-Allow-Origin": request.headers.origin ?? "null",
|
|
298
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
299
|
-
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
300
|
-
"Access-Control-Max-Age": "600",
|
|
301
|
-
});
|
|
309
|
+
response.writeHead(204, { "Access-Control-Max-Age": "600" });
|
|
302
310
|
response.end();
|
|
303
311
|
return;
|
|
304
312
|
}
|
package/package.json
CHANGED
|
@@ -19,6 +19,19 @@ function hasExited(child) {
|
|
|
19
19
|
return Boolean(child && (child.exitCode != null || child.signalCode != null));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function hasExactCommandArgument(command, argument) {
|
|
23
|
+
const value = String(command);
|
|
24
|
+
let offset = value.indexOf(argument);
|
|
25
|
+
while (offset >= 0) {
|
|
26
|
+
const before = offset === 0 || /\s/.test(value[offset - 1]);
|
|
27
|
+
const end = offset + argument.length;
|
|
28
|
+
const after = end === value.length || /\s/.test(value[end]);
|
|
29
|
+
if (before && after) return true;
|
|
30
|
+
offset = value.indexOf(argument, offset + 1);
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
function normalizedFailure(failure) {
|
|
23
36
|
if (!failure || typeof failure !== "object") return failure ?? null;
|
|
24
37
|
return {
|
|
@@ -119,6 +132,8 @@ export class RuntimeManager {
|
|
|
119
132
|
waitForReady: readyWaiter = waitForReady,
|
|
120
133
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
121
134
|
stopTimeoutMs = 5_000,
|
|
135
|
+
manualLaunchPollMs = 1_000,
|
|
136
|
+
cdpPort = Number(process.env.CODEX_RUNTIME_CDP_PORT ?? 9231),
|
|
122
137
|
}) {
|
|
123
138
|
this.paths = paths;
|
|
124
139
|
this.platform = platform;
|
|
@@ -127,6 +142,8 @@ export class RuntimeManager {
|
|
|
127
142
|
this.waitForReady = readyWaiter;
|
|
128
143
|
this.sleep = sleep;
|
|
129
144
|
this.stopTimeoutMs = stopTimeoutMs;
|
|
145
|
+
this.manualLaunchPollMs = manualLaunchPollMs;
|
|
146
|
+
this.cdpPort = cdpPort;
|
|
130
147
|
this.child = null;
|
|
131
148
|
this.appliedFeatures = [];
|
|
132
149
|
this.plugins = [];
|
|
@@ -134,6 +151,12 @@ export class RuntimeManager {
|
|
|
134
151
|
this.stopping = false;
|
|
135
152
|
this.lifecycleQueue = Promise.resolve();
|
|
136
153
|
this.restartInFlight = null;
|
|
154
|
+
this.manualLaunchMonitorPromise = null;
|
|
155
|
+
this.manualLaunchMonitorStopped = true;
|
|
156
|
+
this.manualLaunchTimer = null;
|
|
157
|
+
this.manualLaunchWake = null;
|
|
158
|
+
this.manualLaunchBaselinePids = new Set();
|
|
159
|
+
this.manualLaunchArmed = false;
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
enqueue(operation) {
|
|
@@ -163,7 +186,7 @@ export class RuntimeManager {
|
|
|
163
186
|
...process.env,
|
|
164
187
|
CODEX_FEATURES_ROOT: this.paths.featuresRoot,
|
|
165
188
|
CODEX_RUNTIME_PROFILE_DIR: this.paths.profileDir,
|
|
166
|
-
CODEX_RUNTIME_CDP_PORT:
|
|
189
|
+
CODEX_RUNTIME_CDP_PORT: String(this.cdpPort),
|
|
167
190
|
CODEX_IMAGE_HOST_DATA_DIR: this.paths.imagesDataDir,
|
|
168
191
|
CODEX_NOTES_DATA_DIR: this.paths.notesDataDir,
|
|
169
192
|
MINECODEX_ENABLED_FEATURES: features.join(","),
|
|
@@ -228,7 +251,155 @@ export class RuntimeManager {
|
|
|
228
251
|
}
|
|
229
252
|
|
|
230
253
|
start() {
|
|
231
|
-
return this.enqueue(() =>
|
|
254
|
+
return this.enqueue(async () => {
|
|
255
|
+
const status = await this.startInternal();
|
|
256
|
+
await this.startManualLaunchMonitor();
|
|
257
|
+
return status;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async nativeCodexSnapshot() {
|
|
262
|
+
if (typeof this.platform?.snapshotNativeCodexState !== "function") return null;
|
|
263
|
+
const snapshot = await this.platform.snapshotNativeCodexState();
|
|
264
|
+
const cdpArgument = `--remote-debugging-port=${this.cdpPort}`;
|
|
265
|
+
const processes = (Array.isArray(snapshot?.processes) ? snapshot.processes : [])
|
|
266
|
+
.filter(({ command }) => !hasExactCommandArgument(command, cdpArgument));
|
|
267
|
+
return {
|
|
268
|
+
count: processes.length,
|
|
269
|
+
processes,
|
|
270
|
+
pids: new Set(processes.flatMap(({ pid }) => Number.isInteger(pid) ? [pid] : [])),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async startManualLaunchMonitor() {
|
|
275
|
+
if (this.manualLaunchMonitorPromise || typeof this.platform?.snapshotNativeCodexState !== "function") return;
|
|
276
|
+
const snapshot = await this.nativeCodexSnapshot();
|
|
277
|
+
this.manualLaunchBaselinePids = new Set(snapshot?.pids ?? []);
|
|
278
|
+
this.manualLaunchArmed = this.manualLaunchBaselinePids.size === 0;
|
|
279
|
+
this.manualLaunchMonitorStopped = false;
|
|
280
|
+
const monitor = this.monitorManualCodexLaunches().catch((error) => {
|
|
281
|
+
if (!this.manualLaunchMonitorStopped) {
|
|
282
|
+
this.logger.warn?.("MineCodex manual Codex launch monitor failed", error.message);
|
|
283
|
+
}
|
|
284
|
+
}).finally(() => {
|
|
285
|
+
if (this.manualLaunchMonitorPromise === monitor) this.manualLaunchMonitorPromise = null;
|
|
286
|
+
});
|
|
287
|
+
this.manualLaunchMonitorPromise = monitor;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
waitForManualLaunchPoll() {
|
|
291
|
+
if (this.manualLaunchMonitorStopped) return Promise.resolve();
|
|
292
|
+
return new Promise((resolve) => {
|
|
293
|
+
const finish = () => {
|
|
294
|
+
if (this.manualLaunchTimer) clearTimeout(this.manualLaunchTimer);
|
|
295
|
+
this.manualLaunchTimer = null;
|
|
296
|
+
this.manualLaunchWake = null;
|
|
297
|
+
resolve();
|
|
298
|
+
};
|
|
299
|
+
this.manualLaunchWake = finish;
|
|
300
|
+
this.manualLaunchTimer = setTimeout(finish, this.manualLaunchPollMs);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async monitorManualCodexLaunches() {
|
|
305
|
+
while (!this.manualLaunchMonitorStopped) {
|
|
306
|
+
await this.waitForManualLaunchPoll();
|
|
307
|
+
if (this.manualLaunchMonitorStopped) break;
|
|
308
|
+
try {
|
|
309
|
+
await this.reconcileManualCodexLaunch();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
this.logger.warn?.("MineCodex could not adopt the Codex launch", error.message);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async stopManualLaunchMonitor() {
|
|
317
|
+
this.manualLaunchMonitorStopped = true;
|
|
318
|
+
this.manualLaunchWake?.();
|
|
319
|
+
await this.manualLaunchMonitorPromise;
|
|
320
|
+
this.manualLaunchMonitorPromise = null;
|
|
321
|
+
this.manualLaunchBaselinePids.clear();
|
|
322
|
+
this.manualLaunchArmed = false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async reconcileManualCodexLaunch() {
|
|
326
|
+
if (this.manualLaunchMonitorStopped) return false;
|
|
327
|
+
const snapshot = await this.nativeCodexSnapshot();
|
|
328
|
+
if (!snapshot) return false;
|
|
329
|
+
|
|
330
|
+
if (this.manualLaunchBaselinePids.size > 0) {
|
|
331
|
+
const baselineStillRunning = [...this.manualLaunchBaselinePids]
|
|
332
|
+
.some((pid) => snapshot.pids.has(pid));
|
|
333
|
+
if (baselineStillRunning) return false;
|
|
334
|
+
this.manualLaunchBaselinePids.clear();
|
|
335
|
+
if (snapshot.count === 0) {
|
|
336
|
+
this.manualLaunchArmed = true;
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
this.manualLaunchArmed = true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (snapshot.count === 0) {
|
|
343
|
+
this.manualLaunchArmed = true;
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
if (!this.manualLaunchArmed) return false;
|
|
347
|
+
|
|
348
|
+
const observedPids = new Set(snapshot.pids);
|
|
349
|
+
this.manualLaunchArmed = false;
|
|
350
|
+
this.manualLaunchBaselinePids = observedPids;
|
|
351
|
+
return this.enqueue(async () => {
|
|
352
|
+
if (this.manualLaunchMonitorStopped) return false;
|
|
353
|
+
const latest = await this.nativeCodexSnapshot();
|
|
354
|
+
const observedProcessStillRunning = latest
|
|
355
|
+
&& [...observedPids].some((pid) => latest.pids.has(pid));
|
|
356
|
+
if (!observedProcessStillRunning) {
|
|
357
|
+
if (!latest?.count) {
|
|
358
|
+
this.manualLaunchBaselinePids.clear();
|
|
359
|
+
this.manualLaunchArmed = true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await this.stopInternal();
|
|
365
|
+
let terminated;
|
|
366
|
+
try {
|
|
367
|
+
terminated = await this.platform.terminateNativeCodex();
|
|
368
|
+
} catch (error) {
|
|
369
|
+
await this.restoreIdleRuntimeAfterAdoptionFailure(error);
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!(terminated > 0)) {
|
|
374
|
+
await this.startInternal();
|
|
375
|
+
this.manualLaunchBaselinePids.clear();
|
|
376
|
+
this.manualLaunchArmed = true;
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const status = await this.startInternal({ launchCodex: true });
|
|
382
|
+
this.manualLaunchBaselinePids.clear();
|
|
383
|
+
this.manualLaunchArmed = true;
|
|
384
|
+
return status;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
this.manualLaunchBaselinePids.clear();
|
|
387
|
+
this.manualLaunchArmed = true;
|
|
388
|
+
await this.restoreIdleRuntimeAfterAdoptionFailure(error);
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async restoreIdleRuntimeAfterAdoptionFailure(failure) {
|
|
395
|
+
try {
|
|
396
|
+
await this.startInternal();
|
|
397
|
+
} catch (recoveryError) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`${failure.message}; idle RuntimeHost recovery failed: ${recoveryError.message}`,
|
|
400
|
+
{ cause: failure },
|
|
401
|
+
);
|
|
402
|
+
}
|
|
232
403
|
}
|
|
233
404
|
|
|
234
405
|
async stopInternal() {
|
|
@@ -274,7 +445,8 @@ export class RuntimeManager {
|
|
|
274
445
|
}
|
|
275
446
|
}
|
|
276
447
|
|
|
277
|
-
stop() {
|
|
448
|
+
async stop() {
|
|
449
|
+
await this.stopManualLaunchMonitor();
|
|
278
450
|
return this.enqueue(() => this.stopInternal());
|
|
279
451
|
}
|
|
280
452
|
|
|
@@ -141,8 +141,10 @@ Host 会再次发送当前状态。功能页面可据此暂停隐藏状态下的
|
|
|
141
141
|
|
|
142
142
|
- 只在 top frame 安装 Runtime,绝不向业务 iframe 注入 binding token。
|
|
143
143
|
- Watch / refresh 串行;已连接 Renderer 不重复连接,关闭 target 会清理状态。
|
|
144
|
-
-
|
|
145
|
-
|
|
144
|
+
- 当前 Codex document 始终直接注入或替换 Runtime,绝不通过 CDP reload Codex 页面。
|
|
145
|
+
本地 Surface 不直接导航到 loopback URL:Host 先创建 `about:blank` iframe,再校验
|
|
146
|
+
manifest URL、health service/protocol/instance,抓取 HTML 并用 `Page.setDocumentContent`
|
|
147
|
+
写入目标 frame。Images/Notes 只向精确的 `app://-` Origin 开放 CORS。
|
|
146
148
|
- 入口由 Renderer 内的 MutationObserver 幂等挂载,React 重绘不会产生重复入口。
|
|
147
149
|
- Summary 根据对话主区域宽度连续派生 `overlay / shift / gutter`:小于 1096px
|
|
148
150
|
使用临时 Popover;1096–1535px 预留 316px 并把对话内容左移 158px;更宽时
|
|
@@ -154,9 +156,9 @@ Host 会再次发送当前状态。功能页面可据此暂停隐藏状态下的
|
|
|
154
156
|
border、dropdown background 和 elevation tokens 发送给 Surface。
|
|
155
157
|
- 新客户端会话默认关闭;Thread 切换关闭 Pinned Summary 并清除旧 Composer Range。
|
|
156
158
|
- Detail Tab 使用 Codex 当前 `local-thread` scope,因此标签状态跟随 Task;React panel
|
|
157
|
-
中的 iframe
|
|
159
|
+
中的 iframe 继续注册同一套可信 Host origin、`contentWindow`、Theme 与 Host-action bridge。
|
|
158
160
|
- Surface 以 `ready` handshake 标记可用。若第一次加载发生在服务离线期间,下一次
|
|
159
|
-
|
|
161
|
+
打开只重新请求 Host 加载这个未 ready 的 frame;已经 ready 的 Surface 不重载、不打断草稿。
|
|
160
162
|
|
|
161
163
|
## 当前接口分类
|
|
162
164
|
|
|
@@ -7,6 +7,10 @@ const RUNTIME_VERSION = 99;
|
|
|
7
7
|
const HOST_BINDING_NAME = "__codexPersonalHostAction";
|
|
8
8
|
const CSP_BOOTSTRAP_VERSION = 1;
|
|
9
9
|
const CSP_BOOTSTRAP_KEY = "__mineCodexCspBootstrapVersion";
|
|
10
|
+
const CODEX_APP_ORIGIN = "app://-";
|
|
11
|
+
const SURFACE_LOAD_ACTION = "load-surface";
|
|
12
|
+
const SURFACE_FRAME_PREFIX = "minecodex-surface-";
|
|
13
|
+
const LOOPBACK_SURFACE_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
10
14
|
|
|
11
15
|
export const RESPONSIVE_SUMMARY_LAYOUT = Object.freeze({
|
|
12
16
|
contentBaseWidth: 736,
|
|
@@ -38,6 +42,48 @@ export function responsiveSummaryContentShift({ displayMode, isPinned }, layout
|
|
|
38
42
|
: 0;
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
function declaredSurfaceUrls(feature) {
|
|
46
|
+
return new Set([
|
|
47
|
+
feature.surfaceUrl,
|
|
48
|
+
feature.pinnedSummary?.surfaceUrl,
|
|
49
|
+
...(feature.detailTabs ?? []).map((detail) => detail.surfaceUrl),
|
|
50
|
+
].filter(Boolean));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requireDeclaredLoopbackSurface(feature, value) {
|
|
54
|
+
if (typeof value !== "string" || !declaredSurfaceUrls(feature).has(value)) {
|
|
55
|
+
throw Object.assign(new Error("Surface URL is not declared by this feature"), {
|
|
56
|
+
code: "SURFACE_URL_NOT_ALLOWED",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const url = new URL(value);
|
|
60
|
+
if (url.protocol !== "http:" || !LOOPBACK_SURFACE_HOSTS.has(url.hostname)) {
|
|
61
|
+
throw Object.assign(new Error("Surface URL must use an exact loopback HTTP origin"), {
|
|
62
|
+
code: "SURFACE_URL_NOT_ALLOWED",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findFrameByName(frameTree, frameName) {
|
|
69
|
+
if (frameTree.frame?.name === frameName) return frameTree.frame;
|
|
70
|
+
for (const child of frameTree.childFrames ?? []) {
|
|
71
|
+
const match = findFrameByName(child, frameName);
|
|
72
|
+
if (match) return match;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function documentWithBase(html, surfaceUrl) {
|
|
78
|
+
const head = /<head(?:\s[^>]*)?>/i;
|
|
79
|
+
if (!head.test(html)) {
|
|
80
|
+
throw Object.assign(new Error("Surface document has no head element"), {
|
|
81
|
+
code: "SURFACE_DOCUMENT_INVALID",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return html.replace(head, (match) => `${match}<base href=${JSON.stringify(surfaceUrl)}>`);
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
export function createInjectionSource(features, {
|
|
42
88
|
bindingName = HOST_BINDING_NAME,
|
|
43
89
|
bindingToken = "test-binding-token",
|
|
@@ -1785,6 +1831,32 @@ export function createInjectionSource(features, {
|
|
|
1785
1831
|
return `${featureId}:${kind}:${detailId}`;
|
|
1786
1832
|
}
|
|
1787
1833
|
|
|
1834
|
+
function surfaceFrameName(featureId) {
|
|
1835
|
+
const nonce = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1836
|
+
return `minecodex-surface-${config.sessionId}-${featureId}-${nonce}`;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function requestSurfaceLoad(record) {
|
|
1840
|
+
if (!record || record.loading || typeof globalThis[config.bindingName] !== "function") return false;
|
|
1841
|
+
const requestId = crypto.randomUUID?.()
|
|
1842
|
+
?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1843
|
+
record.loading = true;
|
|
1844
|
+
record.ready = false;
|
|
1845
|
+
record.loadError = null;
|
|
1846
|
+
pendingHostActions.set(requestId, { recordKey: record.key, kind: "surface-load" });
|
|
1847
|
+
globalThis[config.bindingName](JSON.stringify({
|
|
1848
|
+
token: config.bindingToken,
|
|
1849
|
+
featureId: record.featureId,
|
|
1850
|
+
requestId,
|
|
1851
|
+
action: "load-surface",
|
|
1852
|
+
payload: {
|
|
1853
|
+
frameName: record.frame.name,
|
|
1854
|
+
surfaceUrl: record.surfaceUrl,
|
|
1855
|
+
},
|
|
1856
|
+
}));
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1788
1860
|
function postSurfaceActive(record, active) {
|
|
1789
1861
|
if (!record || record.kind !== "page") return;
|
|
1790
1862
|
record.frame.contentWindow?.postMessage({
|
|
@@ -1799,10 +1871,13 @@ export function createInjectionSource(features, {
|
|
|
1799
1871
|
featureId: feature.id,
|
|
1800
1872
|
kind,
|
|
1801
1873
|
detailId,
|
|
1802
|
-
origin:
|
|
1874
|
+
origin: window.location.origin,
|
|
1875
|
+
surfaceUrl,
|
|
1803
1876
|
element,
|
|
1804
1877
|
frame,
|
|
1805
1878
|
ready: false,
|
|
1879
|
+
loading: false,
|
|
1880
|
+
loadError: null,
|
|
1806
1881
|
preferredHeight: null,
|
|
1807
1882
|
};
|
|
1808
1883
|
surfaceRecords.set(key, record);
|
|
@@ -1810,14 +1885,14 @@ export function createInjectionSource(features, {
|
|
|
1810
1885
|
queueTheme();
|
|
1811
1886
|
postSurfaceActive(record, activePageFeatureId === record.featureId && !record.element.hidden);
|
|
1812
1887
|
});
|
|
1888
|
+
requestSurfaceLoad(record);
|
|
1813
1889
|
return record;
|
|
1814
1890
|
}
|
|
1815
1891
|
|
|
1816
1892
|
function reloadSurfaceIfUnready(key, surfaceUrl) {
|
|
1817
1893
|
const record = surfaceRecords.get(key);
|
|
1818
|
-
if (!record || record.ready) return false;
|
|
1819
|
-
record
|
|
1820
|
-
return true;
|
|
1894
|
+
if (!record || record.ready || record.surfaceUrl !== surfaceUrl) return false;
|
|
1895
|
+
return requestSurfaceLoad(record);
|
|
1821
1896
|
}
|
|
1822
1897
|
|
|
1823
1898
|
function removeSurfaceRecord(key) {
|
|
@@ -2056,9 +2131,10 @@ export function createInjectionSource(features, {
|
|
|
2056
2131
|
}
|
|
2057
2132
|
}
|
|
2058
2133
|
|
|
2059
|
-
function createFrame(
|
|
2134
|
+
function createFrame(featureId, title) {
|
|
2060
2135
|
const frame = document.createElement("iframe");
|
|
2061
|
-
frame.
|
|
2136
|
+
frame.name = surfaceFrameName(featureId);
|
|
2137
|
+
frame.src = "about:blank";
|
|
2062
2138
|
frame.title = title;
|
|
2063
2139
|
frame.allow = "clipboard-write";
|
|
2064
2140
|
frame.style.cssText = "width:100%;height:100%;border:0;display:block;background:transparent";
|
|
@@ -2076,7 +2152,7 @@ export function createInjectionSource(features, {
|
|
|
2076
2152
|
"z-index:40",
|
|
2077
2153
|
"background:transparent",
|
|
2078
2154
|
].join(";");
|
|
2079
|
-
const frame = createFrame(feature.
|
|
2155
|
+
const frame = createFrame(feature.id, feature.label);
|
|
2080
2156
|
surface.append(frame);
|
|
2081
2157
|
document.body.append(surface);
|
|
2082
2158
|
pageSurfaces.set(feature.id, surface);
|
|
@@ -2311,7 +2387,7 @@ export function createInjectionSource(features, {
|
|
|
2311
2387
|
"pointer-events:none",
|
|
2312
2388
|
"will-change:transform,opacity",
|
|
2313
2389
|
].join(";");
|
|
2314
|
-
const frame = createFrame(
|
|
2390
|
+
const frame = createFrame(feature.id, definition.label ?? feature.label);
|
|
2315
2391
|
surface.append(frame);
|
|
2316
2392
|
document.body.append(surface);
|
|
2317
2393
|
pinnedSurfaces.set(feature.id, surface);
|
|
@@ -2505,7 +2581,7 @@ export function createInjectionSource(features, {
|
|
|
2505
2581
|
"outline:none",
|
|
2506
2582
|
].join(";");
|
|
2507
2583
|
|
|
2508
|
-
const frame = createFrame(
|
|
2584
|
+
const frame = createFrame(feature.id, `${payload.id ? "Edit" : "Add"} ${kind}`);
|
|
2509
2585
|
dialog.append(frame);
|
|
2510
2586
|
document.body.append(overlay, dialog);
|
|
2511
2587
|
const key = surfaceKey(feature.id, "modal");
|
|
@@ -2751,6 +2827,8 @@ export function createInjectionSource(features, {
|
|
|
2751
2827
|
const elementRef = React.useRef(null);
|
|
2752
2828
|
const frameRef = React.useRef(null);
|
|
2753
2829
|
const recordKeyRef = React.useRef(null);
|
|
2830
|
+
const frameNameRef = React.useRef(null);
|
|
2831
|
+
frameNameRef.current ??= surfaceFrameName(feature.id);
|
|
2754
2832
|
React.useLayoutEffect(() => {
|
|
2755
2833
|
const element = elementRef.current;
|
|
2756
2834
|
const frame = frameRef.current;
|
|
@@ -2785,7 +2863,8 @@ export function createInjectionSource(features, {
|
|
|
2785
2863
|
},
|
|
2786
2864
|
children: jsx.jsx("iframe", {
|
|
2787
2865
|
ref: frameRef,
|
|
2788
|
-
|
|
2866
|
+
name: frameNameRef.current,
|
|
2867
|
+
src: "about:blank",
|
|
2789
2868
|
title: detail.label,
|
|
2790
2869
|
allow: "clipboard-write",
|
|
2791
2870
|
style: {
|
|
@@ -3027,7 +3106,7 @@ export function createInjectionSource(features, {
|
|
|
3027
3106
|
panel.setAttribute("data-app-shell-tab-panel-controller", "right");
|
|
3028
3107
|
panel.setAttribute("data-tab-id", stableId);
|
|
3029
3108
|
panel.style.cssText = "position:absolute;inset:0;min-height:0;background:var(--color-token-main-surface-primary)";
|
|
3030
|
-
const frame = createFrame(
|
|
3109
|
+
const frame = createFrame(feature.id, detail.label);
|
|
3031
3110
|
panel.append(frame);
|
|
3032
3111
|
strip.append(tab);
|
|
3033
3112
|
panels.append(panel);
|
|
@@ -3337,6 +3416,11 @@ export function createInjectionSource(features, {
|
|
|
3337
3416
|
pendingHostActions.delete(requestId);
|
|
3338
3417
|
const record = surfaceRecords.get(pending.recordKey);
|
|
3339
3418
|
if (!record) return false;
|
|
3419
|
+
if (pending.kind === "surface-load") {
|
|
3420
|
+
record.loading = false;
|
|
3421
|
+
record.loadError = response.ok ? null : response.error;
|
|
3422
|
+
return response.ok;
|
|
3423
|
+
}
|
|
3340
3424
|
respondToSurface(record, requestId, response);
|
|
3341
3425
|
return true;
|
|
3342
3426
|
}
|
|
@@ -3735,6 +3819,9 @@ export class CodexRuntime {
|
|
|
3735
3819
|
availabilityTimeoutMs = 20_000,
|
|
3736
3820
|
availabilityPollMs = 500,
|
|
3737
3821
|
monitorIntervalMs = 2_000,
|
|
3822
|
+
surfaceLoadTimeoutMs = 5_000,
|
|
3823
|
+
surfaceFramePollMs = 25,
|
|
3824
|
+
featureInstanceId = null,
|
|
3738
3825
|
runtimeSessionId = randomBytes(16).toString("hex"),
|
|
3739
3826
|
onStatusChange = null,
|
|
3740
3827
|
onManagedCodexPidChange = null,
|
|
@@ -3751,6 +3838,9 @@ export class CodexRuntime {
|
|
|
3751
3838
|
this.availabilityTimeoutMs = availabilityTimeoutMs;
|
|
3752
3839
|
this.availabilityPollMs = availabilityPollMs;
|
|
3753
3840
|
this.monitorIntervalMs = monitorIntervalMs;
|
|
3841
|
+
this.surfaceLoadTimeoutMs = surfaceLoadTimeoutMs;
|
|
3842
|
+
this.surfaceFramePollMs = surfaceFramePollMs;
|
|
3843
|
+
this.featureInstanceId = featureInstanceId;
|
|
3754
3844
|
this.runtimeSessionId = runtimeSessionId;
|
|
3755
3845
|
this.onStatusChange = onStatusChange;
|
|
3756
3846
|
this.onManagedCodexPidChange = onManagedCodexPidChange;
|
|
@@ -4099,6 +4189,106 @@ export class CodexRuntime {
|
|
|
4099
4189
|
}
|
|
4100
4190
|
}
|
|
4101
4191
|
|
|
4192
|
+
async fetchSurfaceResource(url, label) {
|
|
4193
|
+
const controller = new AbortController();
|
|
4194
|
+
const timeout = setTimeout(() => controller.abort(), this.surfaceLoadTimeoutMs);
|
|
4195
|
+
try {
|
|
4196
|
+
return await this.fetchImpl(url, {
|
|
4197
|
+
cache: "no-store",
|
|
4198
|
+
headers: { origin: CODEX_APP_ORIGIN },
|
|
4199
|
+
signal: controller.signal,
|
|
4200
|
+
});
|
|
4201
|
+
} catch (error) {
|
|
4202
|
+
if (controller.signal.aborted) {
|
|
4203
|
+
throw Object.assign(new Error(`${label} timed out`), { code: "SURFACE_LOAD_TIMEOUT" });
|
|
4204
|
+
}
|
|
4205
|
+
throw error;
|
|
4206
|
+
} finally {
|
|
4207
|
+
clearTimeout(timeout);
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
async verifiedSurfaceDocument(feature, surfaceUrl) {
|
|
4212
|
+
requireDeclaredLoopbackSurface(feature, surfaceUrl);
|
|
4213
|
+
if (!this.featureInstanceId || !feature.healthUrl) {
|
|
4214
|
+
throw Object.assign(new Error("Surface service identity is unavailable"), {
|
|
4215
|
+
code: "SURFACE_IDENTITY_UNAVAILABLE",
|
|
4216
|
+
});
|
|
4217
|
+
}
|
|
4218
|
+
const healthResponse = await this.fetchSurfaceResource(feature.healthUrl, "Surface health check");
|
|
4219
|
+
if (!healthResponse?.ok) {
|
|
4220
|
+
throw Object.assign(new Error(`Surface health returned HTTP ${healthResponse?.status ?? "error"}`), {
|
|
4221
|
+
code: "SURFACE_HEALTH_FAILED",
|
|
4222
|
+
});
|
|
4223
|
+
}
|
|
4224
|
+
let health;
|
|
4225
|
+
try {
|
|
4226
|
+
health = await healthResponse.json();
|
|
4227
|
+
} catch {
|
|
4228
|
+
throw Object.assign(new Error("Surface health returned invalid JSON"), {
|
|
4229
|
+
code: "SURFACE_HEALTH_FAILED",
|
|
4230
|
+
});
|
|
4231
|
+
}
|
|
4232
|
+
if (
|
|
4233
|
+
health?.ok !== true
|
|
4234
|
+
|| health.service !== feature.id
|
|
4235
|
+
|| health.protocolVersion !== 1
|
|
4236
|
+
|| health.instanceId !== this.featureInstanceId
|
|
4237
|
+
) {
|
|
4238
|
+
throw Object.assign(new Error("Surface service identity does not match this RuntimeHost"), {
|
|
4239
|
+
code: "SURFACE_IDENTITY_MISMATCH",
|
|
4240
|
+
});
|
|
4241
|
+
}
|
|
4242
|
+
|
|
4243
|
+
const response = await this.fetchSurfaceResource(surfaceUrl, "Surface document request");
|
|
4244
|
+
if (!response?.ok) {
|
|
4245
|
+
throw Object.assign(new Error(`Surface document returned HTTP ${response?.status ?? "error"}`), {
|
|
4246
|
+
code: "SURFACE_DOCUMENT_FAILED",
|
|
4247
|
+
});
|
|
4248
|
+
}
|
|
4249
|
+
const contentType = String(response.headers?.get?.("content-type") ?? "").toLowerCase();
|
|
4250
|
+
if (!contentType.startsWith("text/html")) {
|
|
4251
|
+
throw Object.assign(new Error("Surface document must be HTML"), {
|
|
4252
|
+
code: "SURFACE_DOCUMENT_INVALID",
|
|
4253
|
+
});
|
|
4254
|
+
}
|
|
4255
|
+
return documentWithBase(await response.text(), surfaceUrl);
|
|
4256
|
+
}
|
|
4257
|
+
|
|
4258
|
+
async loadSurfaceIntoFrame(client, feature, payload = {}) {
|
|
4259
|
+
const frameName = payload.frameName;
|
|
4260
|
+
if (
|
|
4261
|
+
typeof frameName !== "string"
|
|
4262
|
+
|| !frameName.startsWith(SURFACE_FRAME_PREFIX)
|
|
4263
|
+
|| frameName.length > 256
|
|
4264
|
+
) {
|
|
4265
|
+
throw Object.assign(new Error("Surface frame identity is invalid"), {
|
|
4266
|
+
code: "SURFACE_FRAME_INVALID",
|
|
4267
|
+
});
|
|
4268
|
+
}
|
|
4269
|
+
const surfaceUrl = payload.surfaceUrl;
|
|
4270
|
+
const html = await this.verifiedSurfaceDocument(feature, surfaceUrl);
|
|
4271
|
+
const deadline = Date.now() + this.surfaceLoadTimeoutMs;
|
|
4272
|
+
let frame = null;
|
|
4273
|
+
while (!frame && Date.now() < deadline) {
|
|
4274
|
+
const { frameTree } = await client.send("Page.getFrameTree");
|
|
4275
|
+
frame = findFrameByName(frameTree, frameName);
|
|
4276
|
+
if (!frame) await this.sleep(this.surfaceFramePollMs);
|
|
4277
|
+
}
|
|
4278
|
+
if (!frame) {
|
|
4279
|
+
throw Object.assign(new Error("Surface frame was not discovered"), {
|
|
4280
|
+
code: "SURFACE_FRAME_NOT_FOUND",
|
|
4281
|
+
});
|
|
4282
|
+
}
|
|
4283
|
+
if (frame.url !== "about:blank") {
|
|
4284
|
+
throw Object.assign(new Error("Surface frame must remain at about:blank before loading"), {
|
|
4285
|
+
code: "SURFACE_FRAME_INVALID",
|
|
4286
|
+
});
|
|
4287
|
+
}
|
|
4288
|
+
await client.send("Page.setDocumentContent", { frameId: frame.id, html });
|
|
4289
|
+
return { loaded: true };
|
|
4290
|
+
}
|
|
4291
|
+
|
|
4102
4292
|
async handleBindingCalled(targetId, client, params) {
|
|
4103
4293
|
if (params.name !== HOST_BINDING_NAME) return;
|
|
4104
4294
|
let request;
|
|
@@ -4109,6 +4299,18 @@ export class CodexRuntime {
|
|
|
4109
4299
|
}
|
|
4110
4300
|
if (request.token !== this.bindingTokens.get(targetId) || typeof request.requestId !== "string") return;
|
|
4111
4301
|
const feature = this.features.find((candidate) => candidate.id === request.featureId);
|
|
4302
|
+
if (feature && request.action === SURFACE_LOAD_ACTION) {
|
|
4303
|
+
try {
|
|
4304
|
+
const result = await this.loadSurfaceIntoFrame(client, feature, request.payload);
|
|
4305
|
+
await this.resolveHostAction(client, request.requestId, { ok: true, result });
|
|
4306
|
+
} catch (error) {
|
|
4307
|
+
await this.resolveHostAction(client, request.requestId, {
|
|
4308
|
+
ok: false,
|
|
4309
|
+
error: { code: error.code ?? "SURFACE_LOAD_FAILED", message: error.message },
|
|
4310
|
+
});
|
|
4311
|
+
}
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4112
4314
|
if (!feature?.hostActions?.includes(request.action)) {
|
|
4113
4315
|
await this.resolveHostAction(client, request.requestId, {
|
|
4114
4316
|
ok: false,
|