minecodex 0.1.4 → 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.4",
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.4",
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.4",
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.4",
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,8 +141,10 @@ Host 会再次发送当前状态。功能页面可据此暂停隐藏状态下的
141
141
 
142
142
  - 只在 top frame 安装 Runtime,绝不向业务 iframe 注入 binding token。
143
143
  - Watch / refresh 串行;已连接 Renderer 不重复连接,关闭 target 会清理状态。
144
- - 本地 Surface 注入前先启用 CSP bypass,并为后续 document 注册 document-start 脚本;
145
- 当前 Codex document 始终直接注入或替换 Runtime,绝不通过 CDP reload 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。
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 继续注册同一套 Surface origin、`contentWindow`、Theme 与 Host-action bridge。
159
+ 中的 iframe 继续注册同一套可信 Host origin、`contentWindow`、Theme 与 Host-action bridge。
158
160
  - Surface 以 `ready` handshake 标记可用。若第一次加载发生在服务离线期间,下一次
159
- 打开只重载这个未 ready 的 iframe;已经 ready 的 Surface 不重载、不打断草稿。
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: 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
  }
@@ -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,
@@ -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,