summer-engine 2.6.1 → 2.6.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.
@@ -14,6 +14,9 @@ function stringFrom(value) {
14
14
  function numberFrom(value) {
15
15
  return typeof value === "number" ? value : undefined;
16
16
  }
17
+ function boolFrom(value) {
18
+ return typeof value === "boolean" ? value : undefined;
19
+ }
17
20
  function findSnapshotPayload(value) {
18
21
  const record = asRecord(value);
19
22
  if (!record)
@@ -21,7 +24,8 @@ function findSnapshotPayload(value) {
21
24
  if (typeof record.base64 === "string" ||
22
25
  typeof record.image_base64 === "string" ||
23
26
  record.op === "ViewportSnapshot" ||
24
- record.op === "GameSnapshot") {
27
+ record.op === "GameSnapshot" ||
28
+ record.op === "ScenePreview") {
25
29
  return record;
26
30
  }
27
31
  if (Array.isArray(record.results)) {
@@ -63,9 +67,17 @@ function withoutImageData(payload) {
63
67
  export class EngineApiClient {
64
68
  port;
65
69
  token;
66
- constructor(port, token) {
70
+ // The project this session is BOUND to — the projectIdHash reported by the
71
+ // engine when this client first connected. Sent on every mutating op so the
72
+ // engine's identity guard (local_api_server.cpp ~271-302) atomically rejects a
73
+ // write aimed at a DIFFERENT project (e.g. after the user switched projects
74
+ // in-place). Health is the only identity signal the MCP has — /api/state/project
75
+ // carries no path and /api/health exposes only projectIdHash, not the raw id.
76
+ boundProjectIdHash;
77
+ constructor(port, token, boundProjectIdHash) {
67
78
  this.port = port;
68
79
  this.token = token;
80
+ this.boundProjectIdHash = boundProjectIdHash;
69
81
  }
70
82
  static async connect() {
71
83
  const port = await getApiPort();
@@ -77,7 +89,36 @@ export class EngineApiClient {
77
89
  if (!health) {
78
90
  throw new Error(`Summer Engine is not responding on port ${port}. Make sure it's open.`);
79
91
  }
80
- return new EngineApiClient(port, token);
92
+ // Bind to whatever project is open right now. On a genuine engine restart the
93
+ // token rotates and getClient() rebuilds this client, so a restart naturally
94
+ // rebinds to the current project. An in-place project switch keeps the same
95
+ // token, so the cached client retains its original binding and the engine
96
+ // rejects mismatched mutations until the agent explicitly rebinds.
97
+ return new EngineApiClient(port, token, health.projectIdHash);
98
+ }
99
+ /** The projectIdHash this session is bound to (undefined if none was reported
100
+ * at connect — e.g. no project open yet). */
101
+ getBoundProjectIdHash() {
102
+ return this.boundProjectIdHash;
103
+ }
104
+ /**
105
+ * Re-read health and rebind to the currently-open project. Called by
106
+ * summer_get_project_context so the agent can INTENTIONALLY follow a project
107
+ * switch (the deliberate escape hatch after an identity_mismatch). Returns the
108
+ * new bound hash. Reads carry no identity, so this always reaches the engine
109
+ * even when a mutation would be rejected.
110
+ */
111
+ async rebind() {
112
+ const health = await checkEngineHealth(this.port);
113
+ if (health) {
114
+ this.boundProjectIdHash = health.projectIdHash;
115
+ }
116
+ return this.boundProjectIdHash;
117
+ }
118
+ /** Identity to attach to a MUTATING command's options so the engine can reject
119
+ * a wrong-project write. Empty when unbound (engine then skips the check). */
120
+ identityOptions() {
121
+ return this.boundProjectIdHash ? { projectIdHash: this.boundProjectIdHash } : {};
81
122
  }
82
123
  async request(method, path, body) {
83
124
  const url = `http://127.0.0.1:${this.port}${path}`;
@@ -150,7 +191,11 @@ export class EngineApiClient {
150
191
  // Ops may include long-running work (ImportFromUrlBatch, GitCommit); the
151
192
  // engine keeps it "running" and we poll. 120s budget. Resolves legacy-200 or
152
193
  // async-202 (Block E).
153
- return this._requestQueued("POST", "/api/ops", { ops, options }, 120_000);
194
+ // Attach the bound project identity so the engine rejects a write aimed at a
195
+ // different project (identity_mismatch, atomic — before any op applies).
196
+ const merged = { ...this.identityOptions(), ...(options ?? {}) };
197
+ const body = Object.keys(merged).length ? { ops, options: merged } : { ops };
198
+ return this._requestQueued("POST", "/api/ops", body, 120_000);
154
199
  }
155
200
  async getSceneState() {
156
201
  return this.request("GET", "/api/state/scene");
@@ -174,11 +219,14 @@ export class EngineApiClient {
174
219
  // Cold-load on large projects can take 25-40s. 60s budget.
175
220
  // The engine reads play params from body.options (tool_net_thread.cpp:503),
176
221
  // and the play handler reads options["scene"] — a top-level { scene } is
177
- // dropped, so the scene MUST be nested inside options.
178
- return this._requestQueued("POST", "/api/play", scene ? { options: { scene } } : {}, 60_000);
222
+ // dropped, so the scene MUST be nested inside options. The bound identity
223
+ // rides in the same options dict so play is refused on a mismatched project.
224
+ const options = { ...this.identityOptions(), ...(scene ? { scene } : {}) };
225
+ return this._requestQueued("POST", "/api/play", Object.keys(options).length ? { options } : {}, 60_000);
179
226
  }
180
227
  async stop() {
181
- return this._requestQueued("POST", "/api/stop", undefined, 15_000);
228
+ const options = this.identityOptions();
229
+ return this._requestQueued("POST", "/api/stop", Object.keys(options).length ? { options } : undefined, 15_000);
182
230
  }
183
231
  async readFile(path, maxBytes) {
184
232
  const params = new URLSearchParams({ path });
@@ -211,6 +259,18 @@ export class EngineApiClient {
211
259
  // The terminal result is the apply dict; the image rides as base64 inside it
212
260
  // (no raw-binary channel). Legacy/dormant engines answer 200 synchronously
213
261
  // with the same payload shape — _requestQueued resolves both.
262
+ //
263
+ // Game capture over local HTTP structurally 409s today with a STRUCTURED
264
+ // reason (`failure_reason:"unsupported_transport"`, `bridge_required:true`,
265
+ // tool_net_thread.cpp:495-503). Detect that specific shape and return it
266
+ // verbatim so the tool can give an honest message — do NOT hardcode "game
267
+ // always fails": once the engine answers 200/202 (P4.4), the normal path
268
+ // below just works.
269
+ if (kind === "game") {
270
+ const bridge = await this._detectBridgeRequired(`/api/snapshot/${kind}`);
271
+ if (bridge)
272
+ return bridge;
273
+ }
214
274
  let response;
215
275
  try {
216
276
  response = await this._requestQueued("GET", `/api/snapshot/${kind}`, undefined, 60_000);
@@ -218,11 +278,16 @@ export class EngineApiClient {
218
278
  catch (err) {
219
279
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
220
280
  }
281
+ return this._parseSnapshotResponse(response, kind);
282
+ }
283
+ /** Shared: turn an ops/snapshot response envelope into an EngineSnapshot.
284
+ * Used by viewport/game snapshots and by scenePreview. */
285
+ async _parseSnapshotResponse(response, kind) {
221
286
  const payload = findSnapshotPayload(response);
222
287
  if (!payload) {
223
288
  return {
224
289
  ok: false,
225
- error: "Snapshot response did not include a viewport/game payload.",
290
+ error: "Snapshot response did not include an image payload.",
226
291
  };
227
292
  }
228
293
  const base64 = stringFrom(payload.base64) ?? stringFrom(payload.image_base64);
@@ -230,16 +295,40 @@ export class EngineApiClient {
230
295
  return {
231
296
  ok: false,
232
297
  error: stringFrom(payload.error) ?? "Snapshot response did not include image data.",
298
+ failureReason: stringFrom(payload.failure_reason),
299
+ sceneHasCamera: boolFrom(payload.scene_has_camera),
300
+ sceneHadLight: boolFrom(payload.scene_had_light),
301
+ usedSyntheticCamera: boolFrom(payload.used_synthetic_camera),
233
302
  metadata: withoutImageData(payload),
234
303
  };
235
304
  }
236
305
  const { format, mime, ext } = snapshotFormat(payload);
237
306
  const buffer = Buffer.from(base64, "base64");
238
307
  const localPath = await this.writeSnapshotFile(kind, buffer, ext);
308
+ // Client-side identity drift check (item 4). The engine's identity guard only
309
+ // acts on options.projectIdHash inside a queued command; a GET snapshot from
310
+ // `fetch` carries no reliable body, so we cannot count on the engine to reject
311
+ // a drifted read. Re-read health and compare to the bound hash — if the engine
312
+ // has switched projects since we bound, this frame may be from the WRONG
313
+ // project. Best-effort: a failed health read never blocks the (already
314
+ // captured) image.
315
+ let projectMismatch;
316
+ if (this.boundProjectIdHash) {
317
+ try {
318
+ const health = await checkEngineHealth(this.port);
319
+ if (health?.projectIdHash && health.projectIdHash !== this.boundProjectIdHash) {
320
+ projectMismatch = true;
321
+ }
322
+ }
323
+ catch {
324
+ // ignore — never fail a capture on a transient health read
325
+ }
326
+ }
239
327
  return {
240
328
  ok: payload.ok !== false,
241
329
  localPath,
242
330
  path: localPath,
331
+ base64,
243
332
  width: numberFrom(payload.width),
244
333
  height: numberFrom(payload.height),
245
334
  format,
@@ -248,16 +337,110 @@ export class EngineApiClient {
248
337
  op: stringFrom(payload.op),
249
338
  provenance: payload.provenance,
250
339
  bytes: buffer.byteLength,
340
+ sceneHasCamera: boolFrom(payload.scene_has_camera),
341
+ sceneHadLight: boolFrom(payload.scene_had_light),
342
+ usedSyntheticCamera: boolFrom(payload.used_synthetic_camera),
343
+ projectMismatch,
251
344
  metadata: withoutImageData(payload),
252
345
  };
253
346
  }
347
+ /**
348
+ * Probe /api/snapshot/game for the structural 409 `bridge_required` shape
349
+ * (tool_net_thread.cpp:495-503). Returns a structured failure only when the
350
+ * engine actually answers 409 with `bridge_required` / `unsupported_transport`;
351
+ * ANY other status (200/202/other) returns null so the caller proceeds to the
352
+ * normal queued path — so the moment the engine starts answering over HTTP
353
+ * (P4.4), game capture just works with no code change here.
354
+ */
355
+ async _detectBridgeRequired(path) {
356
+ let res;
357
+ try {
358
+ res = await this._fetchRaw("GET", path, undefined, 15_000);
359
+ }
360
+ catch {
361
+ // Transport error — let the normal path surface it uniformly.
362
+ return null;
363
+ }
364
+ if (res.status !== 409)
365
+ return null;
366
+ const body = (await res.json().catch(() => ({})));
367
+ const reason = stringFrom(body.failure_reason);
368
+ const bridge = body.bridge_required === true;
369
+ if (!bridge && reason !== "unsupported_transport") {
370
+ // A 409 we don't recognize — surface it as a plain error rather than
371
+ // silently claiming "bridge required".
372
+ return {
373
+ ok: false,
374
+ error: stringFrom(body.error) ?? "Engine returned 409 for game snapshot.",
375
+ };
376
+ }
377
+ return {
378
+ ok: false,
379
+ failureReason: reason ?? "bridge_required",
380
+ error: stringFrom(body.error) ?? "Game snapshots require the desktop bridge async transport.",
381
+ };
382
+ }
254
383
  async viewportSnapshot() {
255
384
  return this.snapshot("viewport");
256
385
  }
257
386
  async gameSnapshot() {
258
387
  return this.snapshot("game");
259
388
  }
389
+ /**
390
+ * Offscreen scene render via the `ScenePreview` op over /api/ops (no game
391
+ * boot; physics/animations are static). Mirrors the web previewScene op input
392
+ * (snapshot-tools.ts): { op:'ScenePreview', scene_path?, framing?, size?,
393
+ * node_path? } — scene_path optional, engine defaults to the open scene. The
394
+ * result carries image_base64 + mime (+ width/height) plus the P4.3 confession
395
+ * fields (scene_has_camera / scene_had_light / used_synthetic_camera).
396
+ */
397
+ async scenePreview(input) {
398
+ const opInput = { op: "ScenePreview" };
399
+ const trimmed = input?.scenePath?.trim();
400
+ if (trimmed && trimmed !== "." && trimmed !== "./")
401
+ opInput.scene_path = trimmed;
402
+ if (input?.framing)
403
+ opInput.framing = input.framing;
404
+ if (input?.size)
405
+ opInput.size = input.size;
406
+ if (input?.nodePath)
407
+ opInput.node_path = input.nodePath;
408
+ let response;
409
+ try {
410
+ // executeOps stamps the bound identity so a drifted project is rejected
411
+ // (identity_mismatch) before rendering — ScenePreview reads no game state
412
+ // but still targets a project's resources.
413
+ response = await this.executeOps([opInput]);
414
+ }
415
+ catch (err) {
416
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
417
+ }
418
+ return this._parseSnapshotResponse(response, "scene");
419
+ }
260
420
  getPort() {
261
421
  return this.port;
262
422
  }
423
+ /**
424
+ * Cheap drift probe for the MCP client cache. The engine mints a fresh
425
+ * api-token on every launch (local_api_server.cpp::_generate_api_token) and can
426
+ * bind a different port (tool_net_thread.cpp::start increments 6550..6565 when
427
+ * the old socket lingers), so a client built before an engine restart holds
428
+ * dead credentials. Re-read the on-disk creds and report whether they no longer
429
+ * match this client's snapshot.
430
+ *
431
+ * Empty / unreadable creds (engine mid-write, or just closed) are treated as
432
+ * "no drift" so a transient read never thrashes the cache — the live request
433
+ * will fail and trigger a reconnect+retry if the client really is stale.
434
+ */
435
+ async credentialsChanged() {
436
+ try {
437
+ const [port, token] = await Promise.all([getApiPort(), getApiToken()]);
438
+ if (!token)
439
+ return false;
440
+ return port !== this.port || token !== this.token;
441
+ }
442
+ catch {
443
+ return false;
444
+ }
445
+ }
263
446
  }
@@ -308,10 +308,14 @@ function buildMcpToolPlan(mode, target, assetPolicy, verification, goal = "") {
308
308
  assets.push("summer_search_assets", "summer_import_asset");
309
309
  }
310
310
  if (verification !== "none") {
311
- verify.push("summer_get_script_errors", "summer_get_diagnostics");
311
+ // Rung 1-2 of the ladder: does it compile / does it look right.
312
+ verify.push("summer_get_script_errors", "summer_get_diagnostics", "summer_screenshot");
312
313
  }
313
314
  if (verification === "full") {
314
- verify.push("summer_clear_console", "summer_play", "summer_get_debugger_errors", "summer_stop");
315
+ // Rung 3: run it and read runtime errors. Ordered as the composed loop —
316
+ // clear -> play -> read runtime errors -> look at the live frame -> stop.
317
+ // (There is no single "verify" tool; the agent composes these.)
318
+ verify.push("summer_clear_console", "summer_play", "summer_get_debugger_errors", "summer_screenshot", "summer_stop");
315
319
  }
316
320
  return { start, mutate, assets, verify };
317
321
  }