rivet-design 0.9.7 → 0.9.8

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.
Files changed (56) hide show
  1. package/dist/mcp/agent-variants/SessionStore.d.ts +5 -0
  2. package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
  3. package/dist/mcp/agent-variants/SessionStore.js +9 -0
  4. package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
  5. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +42 -0
  6. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
  7. package/dist/mcp/agent-variants/WorktreeOrchestrator.js +411 -16
  8. package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
  9. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts.map +1 -1
  10. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js +20 -0
  11. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js.map +1 -1
  12. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts +1 -1
  13. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -1
  14. package/dist/mcp/agent-variants/tools.d.ts +3 -1
  15. package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
  16. package/dist/mcp/agent-variants/tools.js +14 -8
  17. package/dist/mcp/agent-variants/tools.js.map +1 -1
  18. package/dist/mcp/server.d.ts.map +1 -1
  19. package/dist/mcp/server.js +41 -1
  20. package/dist/mcp/server.js.map +1 -1
  21. package/dist/routes/agentVariants.d.ts.map +1 -1
  22. package/dist/routes/agentVariants.js +120 -1
  23. package/dist/routes/agentVariants.js.map +1 -1
  24. package/dist/services/ProjectDetectionService.d.ts.map +1 -1
  25. package/dist/services/ProjectDetectionService.js +13 -1
  26. package/dist/services/ProjectDetectionService.js.map +1 -1
  27. package/dist/services/SessionBridgeService.d.ts +13 -0
  28. package/dist/services/SessionBridgeService.d.ts.map +1 -1
  29. package/dist/services/SessionBridgeService.js +36 -0
  30. package/dist/services/SessionBridgeService.js.map +1 -1
  31. package/dist/services/StaticPreviewServer.d.ts +24 -0
  32. package/dist/services/StaticPreviewServer.d.ts.map +1 -0
  33. package/dist/services/StaticPreviewServer.js +232 -0
  34. package/dist/services/StaticPreviewServer.js.map +1 -0
  35. package/dist/services/WorktreeManager.d.ts +16 -0
  36. package/dist/services/WorktreeManager.d.ts.map +1 -1
  37. package/dist/services/WorktreeManager.js +46 -0
  38. package/dist/services/WorktreeManager.js.map +1 -1
  39. package/dist/services/staticStarter.d.ts +23 -0
  40. package/dist/services/staticStarter.d.ts.map +1 -0
  41. package/dist/services/staticStarter.js +91 -0
  42. package/dist/services/staticStarter.js.map +1 -0
  43. package/dist/utils/skills/claude-skill.d.ts +1 -1
  44. package/dist/utils/skills/claude-skill.d.ts.map +1 -1
  45. package/dist/utils/skills/claude-skill.js +4 -2
  46. package/dist/utils/skills/claude-skill.js.map +1 -1
  47. package/dist/utils/skills/cursor-rules.d.ts +1 -1
  48. package/dist/utils/skills/cursor-rules.d.ts.map +1 -1
  49. package/dist/utils/skills/cursor-rules.js +4 -2
  50. package/dist/utils/skills/cursor-rules.js.map +1 -1
  51. package/dist/utils/skills/shared-variants-protocol.d.ts +1 -1
  52. package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -1
  53. package/dist/utils/skills/shared-variants-protocol.js +2 -2
  54. package/package.json +1 -1
  55. package/src/ui/dist/assets/{main-CIqMI5Le.js → main-CFoJAn2T.js} +18 -18
  56. package/src/ui/dist/index.html +1 -1
@@ -17,6 +17,7 @@ const errors_1 = require("./errors");
17
17
  const createProjectArtifacts_1 = require("./createProjectArtifacts");
18
18
  const contracts_1 = require("./contracts");
19
19
  const viteReactTs_1 = require("../../services/templates/viteReactTs");
20
+ const StaticPreviewServer_1 = require("../../services/StaticPreviewServer");
20
21
  const designCatalog_1 = require("../../services/templates/designCatalog");
21
22
  const previewQa_1 = require("./previewQa");
22
23
  const VariantHistoryService_1 = require("../../services/VariantHistoryService");
@@ -234,7 +235,9 @@ class AgentVariantsOrchestrator {
234
235
  materializeProject;
235
236
  previewQaRunner;
236
237
  switchPreviewPort;
238
+ setCommittedDevServerHealth;
237
239
  variantHistory;
240
+ startStaticPreviewServerImpl;
238
241
  resources = new Map();
239
242
  /**
240
243
  * Committed dev servers from prior sessions that survived teardown. The
@@ -274,7 +277,10 @@ class AgentVariantsOrchestrator {
274
277
  deps.materializeProject ?? defaultMaterializeProject;
275
278
  this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
276
279
  this.switchPreviewPort = deps.switchPreviewPort;
280
+ this.setCommittedDevServerHealth = deps.setCommittedDevServerHealth;
277
281
  this.variantHistory = deps.variantHistory ?? new VariantHistoryService_1.VariantHistoryService();
282
+ this.startStaticPreviewServerImpl =
283
+ deps.startStaticPreviewServer ?? StaticPreviewServer_1.startStaticPreviewServer;
278
284
  }
279
285
  // --- Pure delegations (no side effects) ---------------------------------
280
286
  propose(args) {
@@ -539,6 +545,79 @@ class AgentVariantsOrchestrator {
539
545
  const artifact = findDesignContextArtifact(this.store.getProjectContext(sessionId), artifactId);
540
546
  return artifact ? buildDesignContextViewerDocument(artifact) : undefined;
541
547
  }
548
+ /**
549
+ * `true` when the variant has a non-empty `assetBase` — i.e. its inlined
550
+ * HTML references sibling files served by the asset route. The route
551
+ * uses this to decide whether to inject a `<base href>` (only needed
552
+ * when relative URLs in the HTML must resolve against the asset
553
+ * sub-path). Self-contained HTML keeps its natural URL resolution.
554
+ */
555
+ hasStaticPreviewAssets(sessionId, workItemId) {
556
+ const record = this.resources
557
+ .get(sessionId)
558
+ ?.staticPreviews.get(workItemId);
559
+ return Boolean(record?.assetBase);
560
+ }
561
+ /**
562
+ * Resolve an absolute, sandboxed path for a sibling asset that the
563
+ * variant's inlined HTML references (e.g. `./avatar.glb`, `./hero.png`).
564
+ * Returns `undefined` when the variant has no `assetBase`, the requested
565
+ * path escapes the base (path traversal), or the file does not exist.
566
+ *
567
+ * Used by the `/api/variants/:sessionId/static/:variantId/<asset>` route
568
+ * to serve assets from the agent's on-disk variant workspace pre-commit.
569
+ */
570
+ resolveStaticPreviewAssetPath(sessionId, workItemId, requestedPath) {
571
+ const record = this.resources
572
+ .get(sessionId)
573
+ ?.staticPreviews.get(workItemId);
574
+ if (!record?.assetBase)
575
+ return undefined;
576
+ if (!requestedPath || requestedPath.length === 0)
577
+ return undefined;
578
+ // Express has already URL-decoded `req.params.assetPath` for us — calling
579
+ // decodeURIComponent again would mangle filenames that legitimately
580
+ // contain `%` (e.g. `100%25.png` on disk → `100%.png` after the second
581
+ // pass, 404). Use the path as Express delivered it.
582
+ //
583
+ // `record.assetBase` is already a realpath'd, within-workspace directory
584
+ // (see `resolveStaticPreviewAssetBase`). Resolve the requested target
585
+ // through `realpathSync` too — without this, an attacker who manages to
586
+ // plant a symlink under the base (e.g. via a follow-up Write tool call)
587
+ // could pivot outside the sandbox.
588
+ const target = path_1.default.resolve(record.assetBase, '.' + path_1.default.sep + requestedPath);
589
+ if (target !== record.assetBase && !target.startsWith(record.assetBase + path_1.default.sep)) {
590
+ return undefined;
591
+ }
592
+ let realTarget;
593
+ try {
594
+ realTarget = fs_1.default.realpathSync(target);
595
+ }
596
+ catch {
597
+ return undefined;
598
+ }
599
+ if (realTarget !== record.assetBase &&
600
+ !realTarget.startsWith(record.assetBase + path_1.default.sep)) {
601
+ return undefined;
602
+ }
603
+ try {
604
+ const stat = fs_1.default.statSync(realTarget);
605
+ if (!stat.isFile())
606
+ return undefined;
607
+ // Hardlinks aren't dereferenced by `realpath` — they ARE the file from
608
+ // a path-resolution perspective, so the realpath check above can't
609
+ // detect them. An agent could `ln /path/to/.env assetBase/x.glb`
610
+ // (hardlink, not symlink) and the resolver would happily serve the
611
+ // secret. Reject anything with multiple links — legitimate
612
+ // agent-generated assets always have nlink === 1.
613
+ if (stat.nlink !== 1)
614
+ return undefined;
615
+ return realTarget;
616
+ }
617
+ catch {
618
+ return undefined;
619
+ }
620
+ }
542
621
  getStaticPreviewByBriefId(sessionId, briefId) {
543
622
  const resources = this.resources.get(sessionId);
544
623
  if (!resources)
@@ -690,6 +769,22 @@ class AgentVariantsOrchestrator {
690
769
  };
691
770
  }
692
771
  async reportComplete(args) {
772
+ // Contract validation: a `succeeded` static_preview report MUST carry the
773
+ // deliverable inline as `output.html` (with optional `css` / `js`). Agents
774
+ // sometimes default to writing files to disk and reporting back a file
775
+ // list — that leaves the orchestrator with no HTML to serve, the variant
776
+ // shows "Preview unavailable" in the panel, and `/api/variants/.../static`
777
+ // returns 404. Reject loudly so the agent self-corrects on retry instead
778
+ // of silently producing an empty-preview run.
779
+ if (args.status === 'succeeded' &&
780
+ this.store.hasSession(args.sessionId) &&
781
+ this.store.getWorkItemKind(args.sessionId, args.workItemId) ===
782
+ 'static_preview' &&
783
+ !parseStaticPreviewOutput(normalizeOutput(args.output))) {
784
+ throw new errors_1.AgentVariantsError('SCHEMA_VALIDATION_FAILED', 'static_preview report_variant_complete requires output.html (string, non-empty). ' +
785
+ 'Pass the full HTML inline as output: { html: "<!doctype html>...", css?: "...", js?: "..." }. ' +
786
+ 'Do NOT write the HTML to a file and report a file list — Rivet renders the HTML inline; on-disk files are not read.');
787
+ }
693
788
  // QA gate: for `succeeded` static_preview reports, run preview QA
694
789
  // synchronously before recording success in the store. A failed QA
695
790
  // verdict converts the report to `failed` with code `VARIANT_QA_FAILED`
@@ -895,6 +990,12 @@ class AgentVariantsOrchestrator {
895
990
  log.warn(`persistVariantHistoryAtCommit (duplicate retry) failed for session ${args.sessionId}`, err);
896
991
  });
897
992
  }
993
+ // If the committed dev/static server is still running (either tracked
994
+ // on resources, or handed off to the lingering registry), surface its
995
+ // URL so a retry restores the iframe to the live project.
996
+ const lingering = this.lingeringCommittedDevServers.get(args.sessionId);
997
+ const live = this.resources.get(args.sessionId)?.committedDevServer;
998
+ const replayPort = lingering?.port ?? live?.port;
898
999
  return {
899
1000
  enqueued: false,
900
1001
  duplicate: true,
@@ -903,6 +1004,9 @@ class AgentVariantsOrchestrator {
903
1004
  ? 'project-created'
904
1005
  : existingPick.payload.kind,
905
1006
  destinationPath: existingPick.destinationPath,
1007
+ ...(replayPort
1008
+ ? { previewUrl: `http://${FRESH_DEV_SERVER_HOST}:${replayPort}` }
1009
+ : {}),
906
1010
  };
907
1011
  }
908
1012
  const resources = this.resources.get(args.sessionId);
@@ -920,12 +1024,65 @@ class AgentVariantsOrchestrator {
920
1024
  let envelopeDestination;
921
1025
  let changedFilesCount;
922
1026
  let runnablePaths;
1027
+ // Port of the committed dev/static server, when one was spawned during
1028
+ // this commit. Surfaced on the response as `previewUrl` so the UI can
1029
+ // restore the iframe to the committed project without falling through to
1030
+ // the auto-static fallback.
1031
+ let committedPreviewPort;
923
1032
  if (projectContext.kind === 'fresh') {
924
1033
  const destinationPath = projectContext.workspacePath;
925
- this.assertDestinationAvailable(destinationPath);
1034
+ // Realpath the destination ONCE for both the empty-check (below) and
1035
+ // the static_preview flatten step (further down). Computing it twice
1036
+ // could diverge if the path's realpath status changes between calls
1037
+ // (e.g. the directory was a symlink during the empty check but got
1038
+ // resolved between then and `mkdirSync`). `undefined` means realpath
1039
+ // failed — caller branches treat that as "no canonical form known".
1040
+ let realDestination;
1041
+ try {
1042
+ realDestination = fs_1.default.realpathSync(destinationPath);
1043
+ }
1044
+ catch {
1045
+ realDestination = undefined;
1046
+ }
926
1047
  const freshMode = projectContext.executionPlan?.mode === 'vite_app'
927
1048
  ? 'vite_app'
928
1049
  : 'static_preview';
1050
+ // vite_app: strict emptiness check — that lane renames a worktree into
1051
+ // destinationPath and would clobber user files.
1052
+ // static_preview: the agent intentionally wrote sibling assets under
1053
+ // workspacePath (via `output.assetBase`) before reporting succeeded —
1054
+ // those per-variant subdirs ARE the deliverable. Allow exactly the
1055
+ // assetBase entries known to this session; anything else still
1056
+ // triggers DESTINATION_NOT_EMPTY.
1057
+ if (freshMode === 'vite_app') {
1058
+ this.assertDestinationAvailable(destinationPath);
1059
+ }
1060
+ else {
1061
+ // Compare assetBase entries (which are realpath'd by
1062
+ // resolveStaticPreviewAssetBase) against the realpath'd destination
1063
+ // — on macOS `/tmp` is a symlink to `/private/tmp`, and a raw
1064
+ // string comparison would falsely reject every entry.
1065
+ const compareBase = realDestination ?? destinationPath;
1066
+ // When ANY variant's assetBase is the destination itself (the agent
1067
+ // wrote files directly at the workspace root rather than under a
1068
+ // per-variant subdir), the empty-check would treat the agent's own
1069
+ // deliverable as foreign content. Skip the check in that case — the
1070
+ // workspace IS the deliverable; commit will overwrite index.html
1071
+ // below.
1072
+ const anyAssetBaseIsDestination = [
1073
+ ...resources.staticPreviews.values(),
1074
+ ].some((sp) => sp.assetBase === compareBase);
1075
+ if (!anyAssetBaseIsDestination) {
1076
+ const allowed = new Set();
1077
+ for (const sp of resources.staticPreviews.values()) {
1078
+ if (sp.assetBase &&
1079
+ isStrictlyInside(sp.assetBase, compareBase)) {
1080
+ allowed.add(path_1.default.basename(sp.assetBase));
1081
+ }
1082
+ }
1083
+ this.assertDestinationAvailable(destinationPath, allowed);
1084
+ }
1085
+ }
929
1086
  if (freshMode === 'vite_app') {
930
1087
  // Vite_app: the deliverable is the entire variant worktree, not a
931
1088
  // single HTML file. When the worktree lives on the same volume as
@@ -983,6 +1140,16 @@ class AgentVariantsOrchestrator {
983
1140
  // install — the agent's commit_variant call should return
984
1141
  // immediately — but chain the dev-server start to it so the iframe
985
1142
  // recovers without manual intervention once deps are ready.
1143
+ //
1144
+ // NOTE: `committedPreviewPort` stays undefined on this branch by
1145
+ // design — the install + start sequence takes minutes, far longer
1146
+ // than the response can wait. The iframe's `switchPreviewPort`
1147
+ // callback (invoked from `startCommittedDevServer` when the
1148
+ // server finally comes up) retargets the proxy then, so the
1149
+ // iframe reaches the new server without the URL being on the
1150
+ // commit response. The response surface stays `previewUrl?:` (the
1151
+ // field is optional) and consumers fall back to the proxy
1152
+ // upstream when it's absent.
986
1153
  void this.installDependencies(destinationPath)
987
1154
  .then(() => this.startCommittedDevServer({
988
1155
  resources,
@@ -1015,9 +1182,10 @@ class AgentVariantsOrchestrator {
1015
1182
  // destination the user would see "preview disconnected" the moment
1016
1183
  // they commit. Spawn one and retarget the proxy. Best-effort: if
1017
1184
  // it fails, the user can `npm run dev` themselves at destination.
1018
- await this.startCommittedDevServer({
1185
+ committedPreviewPort = await this.startCommittedDevServer({
1019
1186
  resources,
1020
1187
  destinationPath,
1188
+ mode: 'vite_app',
1021
1189
  });
1022
1190
  }
1023
1191
  payload = {
@@ -1047,13 +1215,94 @@ class AgentVariantsOrchestrator {
1047
1215
  }
1048
1216
  try {
1049
1217
  fs_1.default.mkdirSync(destinationPath, { recursive: true });
1218
+ // If the chosen variant has an `assetBase` subdir under
1219
+ // destinationPath (e.g. `<workspace>/var-1` containing
1220
+ // `avatar.glb`), move its contents up to the destination root so
1221
+ // the committed HTML can reference them as `./avatar.glb`. Then
1222
+ // delete every OTHER static_preview record's assetBase — the
1223
+ // unchosen variants' sibling assets are no longer relevant once
1224
+ // the user picked. Both steps run before the canonical index.html
1225
+ // is written so the chosen variant's on-disk index.html (if any)
1226
+ // gets overwritten by the inlined version below.
1227
+ // assetBase entries are realpath'd; compare against the realpath
1228
+ // of destinationPath so symlink-stripped paths line up (macOS:
1229
+ // `/tmp` → `/private/tmp`). `realDestination` was computed once
1230
+ // at the top of the fresh branch — re-realpath in case `mkdirSync`
1231
+ // above just created the directory (the initial computation may
1232
+ // have returned undefined when the dir didn't yet exist).
1233
+ let flattenBase = realDestination;
1234
+ if (!flattenBase) {
1235
+ try {
1236
+ flattenBase = fs_1.default.realpathSync(destinationPath);
1237
+ }
1238
+ catch {
1239
+ flattenBase = destinationPath;
1240
+ }
1241
+ }
1242
+ const chosenAssetBase = staticPreview?.assetBase;
1243
+ // Skip flattening when assetBase IS the destination — the agent
1244
+ // wrote files directly at the workspace root, so there's no subdir
1245
+ // to flatten. Only move when assetBase is a strict descendant.
1246
+ if (chosenAssetBase &&
1247
+ isStrictlyInside(chosenAssetBase, flattenBase)) {
1248
+ for (const entry of fs_1.default.readdirSync(chosenAssetBase)) {
1249
+ const src = path_1.default.join(chosenAssetBase, entry);
1250
+ const dst = path_1.default.join(flattenBase, entry);
1251
+ // Self-collision guard: when an agent names a child the same
1252
+ // as the parent dir (e.g. `var-1/var-1/`), `dst` resolves to
1253
+ // `chosenAssetBase` itself. The rmSync below would then delete
1254
+ // the parent — including every remaining entry we're about to
1255
+ // move — and the next iteration's `renameSync` would fail
1256
+ // with ENOENT. The agent's last attempted variant would also
1257
+ // be silently dropped. Skip this entry; we still rm the
1258
+ // parent at the end of the loop.
1259
+ if (path_1.default.resolve(dst) === path_1.default.resolve(chosenAssetBase)) {
1260
+ continue;
1261
+ }
1262
+ if (fs_1.default.existsSync(dst)) {
1263
+ fs_1.default.rmSync(dst, { recursive: true, force: true });
1264
+ }
1265
+ // Cross-volume safety: rename fails with EXDEV when src and
1266
+ // dst are on different mounts (possible when the user's
1267
+ // workspacePath crosses a symlink to another volume). Fall
1268
+ // back to recursive copy + remove so the commit doesn't die.
1269
+ try {
1270
+ fs_1.default.renameSync(src, dst);
1271
+ }
1272
+ catch (err) {
1273
+ const code = err.code;
1274
+ if (code !== 'EXDEV')
1275
+ throw err;
1276
+ fs_1.default.cpSync(src, dst, { recursive: true });
1277
+ fs_1.default.rmSync(src, { recursive: true, force: true });
1278
+ }
1279
+ }
1280
+ fs_1.default.rmSync(chosenAssetBase, { recursive: true, force: true });
1281
+ }
1282
+ for (const other of resources.staticPreviews.values()) {
1283
+ if (other.workItemId === args.variantId)
1284
+ continue;
1285
+ if (other.assetBase &&
1286
+ isStrictlyInside(other.assetBase, flattenBase)) {
1287
+ fs_1.default.rmSync(other.assetBase, { recursive: true, force: true });
1288
+ }
1289
+ }
1050
1290
  fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview?.html ?? htmlFromSnapshot ?? '', 'utf8');
1051
1291
  }
1052
1292
  catch (err) {
1053
1293
  const message = err instanceof Error ? err.message : String(err);
1054
1294
  throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
1055
1295
  }
1056
- changedFilesCount = 1;
1296
+ changedFilesCount = countWorktreeFiles(destinationPath);
1297
+ // Spawn a static file server at destinationPath and retarget the
1298
+ // proxy so the iframe stays live across commit (parity with the
1299
+ // vite_app rename path). Best-effort: a failure leaves the iframe
1300
+ // showing "preview disconnected" but doesn't fail the commit.
1301
+ committedPreviewPort = await this.startCommittedDevServer({
1302
+ resources,
1303
+ destinationPath,
1304
+ mode: 'static_preview',
1305
+ });
1057
1306
  payload = {
1058
1307
  kind: 'project-created',
1059
1308
  destinationPath,
@@ -1176,6 +1425,9 @@ class AgentVariantsOrchestrator {
1176
1425
  changedFilesCount,
1177
1426
  payloadKind: payload.kind,
1178
1427
  destinationPath: envelopeDestination,
1428
+ ...(committedPreviewPort
1429
+ ? { previewUrl: `http://${FRESH_DEV_SERVER_HOST}:${committedPreviewPort}` }
1430
+ : {}),
1179
1431
  };
1180
1432
  }
1181
1433
  async cleanupCommittedSession(sessionId) {
@@ -1190,11 +1442,22 @@ class AgentVariantsOrchestrator {
1190
1442
  * at success time) are both tolerated — neither is user-authored content
1191
1443
  * that would be clobbered by the materialize step.
1192
1444
  */
1193
- assertDestinationAvailable(destinationPath) {
1445
+ assertDestinationAvailable(destinationPath,
1446
+ /**
1447
+ * Top-level entries the caller knows it owns and can safely overwrite.
1448
+ * Used by the fresh static_preview commit path so a directory holding
1449
+ * only this session's per-variant `assetBase` subdirs still counts as
1450
+ * "available" — those came from the agent during generation. Any entry
1451
+ * NOT in this set (and not the standard `.rivet`/`.gitignore` exemptions)
1452
+ * still triggers DESTINATION_NOT_EMPTY.
1453
+ */
1454
+ additionalAllowedEntries = new Set()) {
1194
1455
  if (!fs_1.default.existsSync(destinationPath))
1195
1456
  return;
1196
1457
  const entries = fs_1.default.readdirSync(destinationPath);
1197
- const userVisibleEntries = entries.filter((entry) => entry !== '.rivet' && entry !== '.gitignore');
1458
+ const userVisibleEntries = entries.filter((entry) => entry !== '.rivet' &&
1459
+ entry !== '.gitignore' &&
1460
+ !additionalAllowedEntries.has(entry));
1198
1461
  if (userVisibleEntries.length === 0)
1199
1462
  return;
1200
1463
  throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
@@ -1485,10 +1748,15 @@ class AgentVariantsOrchestrator {
1485
1748
  if (staticPreview) {
1486
1749
  const input = this.store.getWorkItemInput(sessionId, workItemId);
1487
1750
  if (input.briefId) {
1751
+ const resolvedAssetBase = resolveStaticPreviewAssetBase({
1752
+ assetBase: staticPreview.assetBase,
1753
+ projectContext: this.store.getProjectContext(sessionId),
1754
+ });
1488
1755
  const record = {
1489
1756
  workItemId,
1490
1757
  briefId: input.briefId,
1491
1758
  html: staticPreview.html,
1759
+ ...(resolvedAssetBase ? { assetBase: resolvedAssetBase } : {}),
1492
1760
  };
1493
1761
  resources.staticPreviews.set(workItemId, record);
1494
1762
  if (this.store.getProjectContext(sessionId).kind === 'fresh') {
@@ -2120,34 +2388,61 @@ class AgentVariantsOrchestrator {
2120
2388
  * lingering registry instead of killing it. Best-effort: a failure here is
2121
2389
  * non-fatal — the commit still succeeds; the user just has to run
2122
2390
  * `npm run dev` themselves to bring the preview back.
2391
+ *
2392
+ * `mode` selects the server flavor: `vite_app` spawns Vite via `npm run
2393
+ * dev`; `static_preview` spawns the in-tree static file server bound to
2394
+ * the destination directory (no install, no build, ~ms startup).
2123
2395
  */
2124
2396
  async startCommittedDevServer(args) {
2397
+ const mode = args.mode ?? 'vite_app';
2125
2398
  try {
2126
2399
  const port = await this.worktrees.getFreePort();
2127
- const proc = await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
2128
- 'run',
2129
- 'dev',
2130
- '--',
2131
- '--port',
2132
- String(port),
2133
- '--host',
2134
- FRESH_DEV_SERVER_HOST,
2135
- ], { PORT: String(port) });
2400
+ const proc = mode === 'static_preview'
2401
+ ? await this.startStaticPreviewServerImpl({
2402
+ rootPath: args.destinationPath,
2403
+ port,
2404
+ host: FRESH_DEV_SERVER_HOST,
2405
+ })
2406
+ : await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
2407
+ 'run',
2408
+ 'dev',
2409
+ '--',
2410
+ '--port',
2411
+ String(port),
2412
+ '--host',
2413
+ FRESH_DEV_SERVER_HOST,
2414
+ ], { PORT: String(port) });
2136
2415
  args.resources.committedDevServer = {
2137
2416
  proc,
2138
2417
  port,
2139
2418
  path: args.destinationPath,
2140
2419
  };
2420
+ // Advertise the Rivet-owned server on devServerHealth so the iframe's
2421
+ // upstream probe stops reporting `ownership: 'none'`. Distinct from
2422
+ // switchPreviewPort: this updates health metadata, switchPreviewPort
2423
+ // retargets the proxy. Both fire here because the commit-handoff is
2424
+ // the only place Rivet legitimately takes ownership of the dev
2425
+ // server — variant cycling and cancel only retarget, they don't
2426
+ // touch health (which the user's external dev server owns during a
2427
+ // regular session).
2428
+ try {
2429
+ this.setCommittedDevServerHealth?.(port);
2430
+ }
2431
+ catch (err) {
2432
+ log.warn(`setCommittedDevServerHealth(${port}) after committed dev server start failed`, err);
2433
+ }
2141
2434
  try {
2142
2435
  this.switchPreviewPort?.(port);
2143
2436
  }
2144
2437
  catch (err) {
2145
2438
  log.warn(`switchPreviewPort(${port}) after committed dev server start failed`, err);
2146
2439
  }
2147
- log.info(`Committed dev server up at ${args.destinationPath} on port ${port}`);
2440
+ log.info(`Committed ${mode} dev server up at ${args.destinationPath} on port ${port}`);
2441
+ return port;
2148
2442
  }
2149
2443
  catch (err) {
2150
- log.warn(`Failed to start committed dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs npm run dev`, err);
2444
+ log.warn(`Failed to start committed ${mode} dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs the dev server manually`, err);
2445
+ return undefined;
2151
2446
  }
2152
2447
  }
2153
2448
  /**
@@ -2160,6 +2455,17 @@ class AgentVariantsOrchestrator {
2160
2455
  async stopLingeringCommittedDevServers() {
2161
2456
  const entries = [...this.lingeringCommittedDevServers.entries()];
2162
2457
  this.lingeringCommittedDevServers.clear();
2458
+ // Clear the Rivet-owned-server advertisement on `devServerHealth` so the
2459
+ // probe doesn't keep reporting a port that's about to stop listening.
2460
+ // Safe to call even when no committed server existed for this process.
2461
+ if (entries.length > 0) {
2462
+ try {
2463
+ this.setCommittedDevServerHealth?.(null);
2464
+ }
2465
+ catch (err) {
2466
+ log.warn('setCommittedDevServerHealth(null) on lingering teardown failed', err);
2467
+ }
2468
+ }
2163
2469
  await Promise.all(entries.map(async ([sessionId, entry]) => {
2164
2470
  try {
2165
2471
  await this.worktrees.stopDevServer(entry.proc);
@@ -2268,6 +2574,91 @@ function normalizeOutput(output) {
2268
2574
  }
2269
2575
  return output;
2270
2576
  }
2577
+ /**
2578
+ * `true` when `child` is a strict descendant of `parent` (resolved). Used by
2579
+ * the static_preview commit branch before rm/rename to make sure we never
2580
+ * touch anything outside the variant's workspace.
2581
+ */
2582
+ function isStrictlyInside(child, parent) {
2583
+ const c = path_1.default.resolve(child);
2584
+ const p = path_1.default.resolve(parent);
2585
+ return c !== p && c.startsWith(p + path_1.default.sep);
2586
+ }
2587
+ /**
2588
+ * Sandbox the agent's `output.assetBase` to a path inside the session
2589
+ * workspace. Accepts either an absolute path (used as-is after the
2590
+ * within-workspace check) or a path relative to the fresh-session
2591
+ * `workspacePath`. Returns `undefined` for non-fresh sessions, missing
2592
+ * directories, or paths that escape the workspace via `..` or symlinks.
2593
+ *
2594
+ * `assetBase` is agent-controlled, so a prompt-injected variant could point
2595
+ * it at a symlink whose real target is outside `workspacePath` — and the
2596
+ * asset endpoint would then happily serve files like `~/.ssh/id_rsa` or
2597
+ * `.env`. Resolving with `fs.realpathSync` collapses symlinks before the
2598
+ * containment check, so the check applies to the real target, not the link.
2599
+ */
2600
+ function resolveStaticPreviewAssetBase(args) {
2601
+ if (!args.assetBase)
2602
+ return undefined;
2603
+ if (args.projectContext.kind !== 'fresh')
2604
+ return undefined;
2605
+ let workspace;
2606
+ let resolved;
2607
+ try {
2608
+ // Defense against an agent that subverts the sandbox by replacing the
2609
+ // Rivet-created `workspacePath` directory with a symlink. Plain
2610
+ // `realpath` would silently follow that symlink and the containment
2611
+ // check below would then trust the wrong root.
2612
+ //
2613
+ // Two layers:
2614
+ // 1. `lstat` the workspacePath itself — if it's a symlink (rather
2615
+ // than a real directory Rivet created), reject outright. This
2616
+ // catches the obvious swap `ln -sf $HOME <workspacePath>` and the
2617
+ // subtler swap to a sibling like `<workspaceRoot>` itself
2618
+ // (which would pass any subsequent containment check based on
2619
+ // ancestor matching).
2620
+ // 2. realpath both and require the resolved workspacePath to be a
2621
+ // STRICT descendant of the resolved workspaceRoot — equal is not
2622
+ // enough (a symlink to workspaceRoot would otherwise be accepted
2623
+ // and the asset route would then serve files at the project
2624
+ // root, e.g. `.env`).
2625
+ const wsStat = fs_1.default.lstatSync(args.projectContext.workspacePath);
2626
+ if (wsStat.isSymbolicLink()) {
2627
+ log.warn(`Static preview workspacePath ${args.projectContext.workspacePath} is a symlink — refusing assetBase`);
2628
+ return undefined;
2629
+ }
2630
+ const realWorkspaceRoot = fs_1.default.realpathSync(args.projectContext.workspaceRoot);
2631
+ workspace = fs_1.default.realpathSync(args.projectContext.workspacePath);
2632
+ if (!workspace.startsWith(realWorkspaceRoot + path_1.default.sep)) {
2633
+ log.warn(`Static preview workspacePath ${workspace} is not a strict descendant of workspaceRoot ${realWorkspaceRoot} — refusing assetBase`);
2634
+ return undefined;
2635
+ }
2636
+ const candidate = path_1.default.isAbsolute(args.assetBase)
2637
+ ? args.assetBase
2638
+ : path_1.default.join(workspace, args.assetBase);
2639
+ resolved = fs_1.default.realpathSync(candidate);
2640
+ }
2641
+ catch {
2642
+ log.warn(`Static preview assetBase ${args.assetBase} could not be resolved — ignoring`);
2643
+ return undefined;
2644
+ }
2645
+ if (resolved !== workspace && !resolved.startsWith(workspace + path_1.default.sep)) {
2646
+ log.warn(`Static preview assetBase ${args.assetBase} escapes workspace ${workspace} — ignoring`);
2647
+ return undefined;
2648
+ }
2649
+ try {
2650
+ const stat = fs_1.default.statSync(resolved);
2651
+ if (!stat.isDirectory()) {
2652
+ log.warn(`Static preview assetBase ${resolved} is not a directory — ignoring`);
2653
+ return undefined;
2654
+ }
2655
+ return resolved;
2656
+ }
2657
+ catch {
2658
+ log.warn(`Static preview assetBase ${resolved} does not exist — ignoring`);
2659
+ return undefined;
2660
+ }
2661
+ }
2271
2662
  function parseStaticPreviewOutput(output) {
2272
2663
  if (!output || typeof output !== 'object')
2273
2664
  return null;
@@ -2276,12 +2667,16 @@ function parseStaticPreviewOutput(output) {
2276
2667
  return null;
2277
2668
  const css = output.css;
2278
2669
  const js = output.js;
2670
+ const assetBase = output.assetBase;
2279
2671
  return {
2280
2672
  html: buildStaticPreviewDocument({
2281
2673
  html,
2282
2674
  css: typeof css === 'string' ? css : undefined,
2283
2675
  js: typeof js === 'string' ? js : undefined,
2284
2676
  }),
2677
+ ...(typeof assetBase === 'string' && assetBase.length > 0
2678
+ ? { assetBase }
2679
+ : {}),
2285
2680
  };
2286
2681
  }
2287
2682
  function buildStaticPreviewDocument(input) {