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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "images",
4
- "version": "0.1.3",
4
+ "version": "0.1.5",
5
5
  "label": {
6
6
  "en": "Images",
7
7
  "zh-CN": "图片"
@@ -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 (!["GET", "HEAD", "OPTIONS"].includes(request.method)) {
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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "model-slider",
4
- "version": "0.1.3",
4
+ "version": "0.1.5",
5
5
  "label": {
6
6
  "en": "Model Slider",
7
7
  "zh-CN": "模型滑条"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "id": "notes",
4
- "version": "0.1.3",
4
+ "version": "0.1.5",
5
5
  "label": {
6
6
  "en": "Notes",
7
7
  "zh-CN": "笔记"
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecodex",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Lightweight, local-first plugins for the Codex desktop app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -141,9 +141,10 @@ Host 会再次发送当前状态。功能页面可据此暂停隐藏状态下的
141
141
 
142
142
  - 只在 top frame 安装 Runtime,绝不向业务 iframe 注入 binding token。
143
143
  - Watch / refresh 串行;已连接 Renderer 不重复连接,关闭 target 会清理状态。
144
- - 本地 Surface 注入前先启用 CSP bypass 并注册 document-start 脚本;缺少初始化标记的
145
- 首次或旧版 document 只受控 reload 一次。标记存在时,新的 RuntimeHost 会话直接
146
- 替换旧 Runtime,不刷新当前 Codex 页面。
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 继续注册同一套 Surface origin、`contentWindow`、Theme 与 Host-action bridge。
159
+ 中的 iframe 继续注册同一套可信 Host origin、`contentWindow`、Theme 与 Host-action bridge。
159
160
  - Surface 以 `ready` handshake 标记可用。若第一次加载发生在服务离线期间,下一次
160
- 打开只重载这个未 ready 的 iframe;已经 ready 的 Surface 不重载、不打断草稿。
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: new URL(surfaceUrl).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.frame.src = surfaceUrl;
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(surfaceUrl, title) {
2134
+ function createFrame(featureId, title) {
2060
2135
  const frame = document.createElement("iframe");
2061
- frame.src = surfaceUrl;
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.surfaceUrl, feature.label);
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(definition.surfaceUrl, definition.label ?? feature.label);
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(editorUrl.href, `${payload.id ? "Edit" : "Add"} ${kind}`);
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
- src: detail.surfaceUrl,
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(detail.surfaceUrl, detail.label);
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"`,
@@ -63,6 +63,7 @@ featureProcesses = await startFeatureProcesses(features, {
63
63
 
64
64
  runtime = new CodexRuntime({
65
65
  features,
66
+ featureInstanceId,
66
67
  profileDir,
67
68
  cdpPort,
68
69
  appPath: process.env.MINECODEX_CODEX_EXECUTABLE || undefined,