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.
- package/dist/mcp/agent-variants/SessionStore.d.ts +5 -0
- package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
- package/dist/mcp/agent-variants/SessionStore.js +9 -0
- package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +42 -0
- package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.js +411 -16
- package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts.map +1 -1
- package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js +20 -0
- package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js.map +1 -1
- package/dist/mcp/agent-variants/createZeroToOneTool.d.ts +1 -1
- package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -1
- package/dist/mcp/agent-variants/tools.d.ts +3 -1
- package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
- package/dist/mcp/agent-variants/tools.js +14 -8
- package/dist/mcp/agent-variants/tools.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +41 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/routes/agentVariants.d.ts.map +1 -1
- package/dist/routes/agentVariants.js +120 -1
- package/dist/routes/agentVariants.js.map +1 -1
- package/dist/services/ProjectDetectionService.d.ts.map +1 -1
- package/dist/services/ProjectDetectionService.js +13 -1
- package/dist/services/ProjectDetectionService.js.map +1 -1
- package/dist/services/SessionBridgeService.d.ts +13 -0
- package/dist/services/SessionBridgeService.d.ts.map +1 -1
- package/dist/services/SessionBridgeService.js +36 -0
- package/dist/services/SessionBridgeService.js.map +1 -1
- package/dist/services/StaticPreviewServer.d.ts +24 -0
- package/dist/services/StaticPreviewServer.d.ts.map +1 -0
- package/dist/services/StaticPreviewServer.js +232 -0
- package/dist/services/StaticPreviewServer.js.map +1 -0
- package/dist/services/WorktreeManager.d.ts +16 -0
- package/dist/services/WorktreeManager.d.ts.map +1 -1
- package/dist/services/WorktreeManager.js +46 -0
- package/dist/services/WorktreeManager.js.map +1 -1
- package/dist/services/staticStarter.d.ts +23 -0
- package/dist/services/staticStarter.d.ts.map +1 -0
- package/dist/services/staticStarter.js +91 -0
- package/dist/services/staticStarter.js.map +1 -0
- package/dist/utils/skills/claude-skill.d.ts +1 -1
- package/dist/utils/skills/claude-skill.d.ts.map +1 -1
- package/dist/utils/skills/claude-skill.js +4 -2
- package/dist/utils/skills/claude-skill.js.map +1 -1
- package/dist/utils/skills/cursor-rules.d.ts +1 -1
- package/dist/utils/skills/cursor-rules.d.ts.map +1 -1
- package/dist/utils/skills/cursor-rules.js +4 -2
- package/dist/utils/skills/cursor-rules.js.map +1 -1
- package/dist/utils/skills/shared-variants-protocol.d.ts +1 -1
- package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -1
- package/dist/utils/skills/shared-variants-protocol.js +2 -2
- package/package.json +1 -1
- package/src/ui/dist/assets/{main-CIqMI5Le.js → main-CFoJAn2T.js} +18 -18
- 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
|
-
|
|
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 =
|
|
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' &&
|
|
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 =
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
'
|
|
2134
|
-
|
|
2135
|
-
|
|
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
|
|
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) {
|