minecodex 0.1.3 → 0.1.5
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/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/runtime-host/README.md +6 -5
- package/packages/runtime-host/src/codex-runtime.mjs +213 -26
- package/packages/runtime-host/src/main.mjs +1 -0
|
@@ -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
|
@@ -141,9 +141,10 @@ Host 会再次发送当前状态。功能页面可据此暂停隐藏状态下的
|
|
|
141
141
|
|
|
142
142
|
- 只在 top frame 安装 Runtime,绝不向业务 iframe 注入 binding token。
|
|
143
143
|
- Watch / refresh 串行;已连接 Renderer 不重复连接,关闭 target 会清理状态。
|
|
144
|
-
-
|
|
145
|
-
|
|
146
|
-
|
|
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。
|
|
147
148
|
- 入口由 Renderer 内的 MutationObserver 幂等挂载,React 重绘不会产生重复入口。
|
|
148
149
|
- Summary 根据对话主区域宽度连续派生 `overlay / shift / gutter`:小于 1096px
|
|
149
150
|
使用临时 Popover;1096–1535px 预留 316px 并把对话内容左移 158px;更宽时
|
|
@@ -155,9 +156,9 @@ Host 会再次发送当前状态。功能页面可据此暂停隐藏状态下的
|
|
|
155
156
|
border、dropdown background 和 elevation tokens 发送给 Surface。
|
|
156
157
|
- 新客户端会话默认关闭;Thread 切换关闭 Pinned Summary 并清除旧 Composer Range。
|
|
157
158
|
- Detail Tab 使用 Codex 当前 `local-thread` scope,因此标签状态跟随 Task;React panel
|
|
158
|
-
中的 iframe
|
|
159
|
+
中的 iframe 继续注册同一套可信 Host origin、`contentWindow`、Theme 与 Host-action bridge。
|
|
159
160
|
- Surface 以 `ready` handshake 标记可用。若第一次加载发生在服务离线期间,下一次
|
|
160
|
-
|
|
161
|
+
打开只重新请求 Host 加载这个未 ready 的 frame;已经 ready 的 Surface 不重载、不打断草稿。
|
|
161
162
|
|
|
162
163
|
## 当前接口分类
|
|
163
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
|
}
|
|
@@ -3570,10 +3654,6 @@ function createDocumentBootstrapSource(source) {
|
|
|
3570
3654
|
return `window[${JSON.stringify(CSP_BOOTSTRAP_KEY)}] = ${CSP_BOOTSTRAP_VERSION};\n${source}`;
|
|
3571
3655
|
}
|
|
3572
3656
|
|
|
3573
|
-
function documentBootstrapExpression() {
|
|
3574
|
-
return `window[${JSON.stringify(CSP_BOOTSTRAP_KEY)}] === ${CSP_BOOTSTRAP_VERSION}`;
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
3657
|
async function connect(url) {
|
|
3578
3658
|
const socket = new WebSocket(url);
|
|
3579
3659
|
const pending = new Map();
|
|
@@ -3739,6 +3819,9 @@ export class CodexRuntime {
|
|
|
3739
3819
|
availabilityTimeoutMs = 20_000,
|
|
3740
3820
|
availabilityPollMs = 500,
|
|
3741
3821
|
monitorIntervalMs = 2_000,
|
|
3822
|
+
surfaceLoadTimeoutMs = 5_000,
|
|
3823
|
+
surfaceFramePollMs = 25,
|
|
3824
|
+
featureInstanceId = null,
|
|
3742
3825
|
runtimeSessionId = randomBytes(16).toString("hex"),
|
|
3743
3826
|
onStatusChange = null,
|
|
3744
3827
|
onManagedCodexPidChange = null,
|
|
@@ -3755,6 +3838,9 @@ export class CodexRuntime {
|
|
|
3755
3838
|
this.availabilityTimeoutMs = availabilityTimeoutMs;
|
|
3756
3839
|
this.availabilityPollMs = availabilityPollMs;
|
|
3757
3840
|
this.monitorIntervalMs = monitorIntervalMs;
|
|
3841
|
+
this.surfaceLoadTimeoutMs = surfaceLoadTimeoutMs;
|
|
3842
|
+
this.surfaceFramePollMs = surfaceFramePollMs;
|
|
3843
|
+
this.featureInstanceId = featureInstanceId;
|
|
3758
3844
|
this.runtimeSessionId = runtimeSessionId;
|
|
3759
3845
|
this.onStatusChange = onStatusChange;
|
|
3760
3846
|
this.onManagedCodexPidChange = onManagedCodexPidChange;
|
|
@@ -4103,6 +4189,106 @@ export class CodexRuntime {
|
|
|
4103
4189
|
}
|
|
4104
4190
|
}
|
|
4105
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
|
+
|
|
4106
4292
|
async handleBindingCalled(targetId, client, params) {
|
|
4107
4293
|
if (params.name !== HOST_BINDING_NAME) return;
|
|
4108
4294
|
let request;
|
|
@@ -4113,6 +4299,18 @@ export class CodexRuntime {
|
|
|
4113
4299
|
}
|
|
4114
4300
|
if (request.token !== this.bindingTokens.get(targetId) || typeof request.requestId !== "string") return;
|
|
4115
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
|
+
}
|
|
4116
4314
|
if (!feature?.hostActions?.includes(request.action)) {
|
|
4117
4315
|
await this.resolveHostAction(client, request.requestId, {
|
|
4118
4316
|
ok: false,
|
|
@@ -4198,20 +4396,9 @@ export class CodexRuntime {
|
|
|
4198
4396
|
bindingToken,
|
|
4199
4397
|
runtimeSessionId: this.runtimeSessionId,
|
|
4200
4398
|
});
|
|
4201
|
-
const bootstrapExpression = documentBootstrapExpression();
|
|
4202
4399
|
const script = await client.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
4203
4400
|
source: createDocumentBootstrapSource(source),
|
|
4204
4401
|
});
|
|
4205
|
-
const documentWasBootstrapped = Boolean(evaluationValue(await client.send("Runtime.evaluate", {
|
|
4206
|
-
expression: bootstrapExpression,
|
|
4207
|
-
returnByValue: true,
|
|
4208
|
-
})));
|
|
4209
|
-
if (!documentWasBootstrapped) {
|
|
4210
|
-
const pageLoaded = client.waitFor("Page.loadEventFired", 20_000);
|
|
4211
|
-
await client.send("Page.reload");
|
|
4212
|
-
await pageLoaded;
|
|
4213
|
-
await waitForExpression(client, bootstrapExpression);
|
|
4214
|
-
}
|
|
4215
4402
|
await waitForExpression(
|
|
4216
4403
|
client,
|
|
4217
4404
|
`document.readyState === "interactive" || document.readyState === "complete"`,
|