pi-cursor-sdk 0.1.56 → 0.1.57

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 (85) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +100 -28
  3. package/docs/cursor-live-smoke-checklist.md +7 -7
  4. package/docs/cursor-model-ux-spec.md +45 -38
  5. package/docs/cursor-native-tool-replay.md +2 -2
  6. package/docs/cursor-native-tool-visual-audit.md +1 -1
  7. package/docs/cursor-testing-lessons.md +20 -7
  8. package/docs/cursor-tool-surfaces.md +13 -2
  9. package/docs/platform-smoke-implementation.md +220 -0
  10. package/docs/platform-smoke.md +183 -247
  11. package/package.json +38 -6
  12. package/platform-smoke.config.mjs +5 -1
  13. package/scripts/cloud-runtime-smoke.d.mts +3 -0
  14. package/scripts/cloud-runtime-smoke.mjs +502 -0
  15. package/scripts/debug-provider-events.mjs +7 -2
  16. package/scripts/isolated-cursor-smoke.sh +4 -6
  17. package/scripts/lib/cursor-child-process.d.mts +1 -0
  18. package/scripts/lib/cursor-child-process.mjs +137 -7
  19. package/scripts/lib/local-resume-smoke-harness.mjs +543 -0
  20. package/scripts/local-resume-cleanup-smoke.mjs +108 -0
  21. package/scripts/local-resume-smoke.d.mts +1 -0
  22. package/scripts/local-resume-smoke.mjs +642 -0
  23. package/scripts/platform-smoke/artifact-anchored-extract.d.mts +10 -0
  24. package/scripts/platform-smoke/artifact-anchored-extract.mjs +111 -0
  25. package/scripts/platform-smoke/artifact-bundle-chunk.mjs +57 -0
  26. package/scripts/platform-smoke/artifact-bundle-contract.mjs +34 -0
  27. package/scripts/platform-smoke/artifact-fs-safety.mjs +311 -0
  28. package/scripts/platform-smoke/artifact-openat-extract.c +335 -0
  29. package/scripts/platform-smoke/artifact-secrets.mjs +155 -0
  30. package/scripts/platform-smoke/artifacts.mjs +293 -65
  31. package/scripts/platform-smoke/card-detect.mjs +16 -4
  32. package/scripts/platform-smoke/crabbox-runner.mjs +45 -3
  33. package/scripts/platform-smoke/doctor.mjs +20 -10
  34. package/scripts/platform-smoke/live-suite-runner.mjs +18 -57
  35. package/scripts/platform-smoke/local-resume-runner.mjs +252 -0
  36. package/scripts/platform-smoke/local-resume-suites.d.mts +15 -0
  37. package/scripts/platform-smoke/local-resume-suites.mjs +104 -0
  38. package/scripts/platform-smoke/scenarios.mjs +16 -2
  39. package/scripts/platform-smoke/target-runtime.mjs +206 -0
  40. package/scripts/platform-smoke/targets.mjs +33 -141
  41. package/scripts/platform-smoke/visual-evidence.mjs +6 -7
  42. package/scripts/platform-smoke/wrapped-line-match.mjs +9 -0
  43. package/scripts/platform-smoke.mjs +40 -27
  44. package/scripts/refresh-cursor-model-snapshots.mjs +18 -6
  45. package/scripts/steering-rpc-smoke.mjs +12 -2
  46. package/scripts/tmux-live-smoke.sh +3 -5
  47. package/shared/cursor-cloud-lifecycle-constants.d.mts +3 -0
  48. package/shared/cursor-cloud-lifecycle-constants.mjs +7 -0
  49. package/shared/cursor-sensitive-text.mjs +7 -1
  50. package/src/context.ts +5 -2
  51. package/src/cursor-agents-context-registration.ts +7 -0
  52. package/src/cursor-agents-context.ts +3 -1
  53. package/src/cursor-api-key.ts +15 -1
  54. package/src/cursor-bridge-contract.ts +3 -0
  55. package/src/cursor-cloud-lifecycle.ts +733 -0
  56. package/src/cursor-cloud-options.ts +206 -0
  57. package/src/cursor-cloud-reporting.ts +246 -0
  58. package/src/cursor-config.ts +659 -0
  59. package/src/cursor-display-only-trace.ts +14 -0
  60. package/src/cursor-display-text.ts +8 -2
  61. package/src/cursor-durable-fs.ts +49 -0
  62. package/src/cursor-fallback-models.generated.ts +2045 -485
  63. package/src/cursor-live-run-accounting.ts +7 -1
  64. package/src/cursor-live-run-coordinator.ts +1 -0
  65. package/src/cursor-pi-tool-bridge-run.ts +14 -4
  66. package/src/cursor-provider-live-run-drain.ts +5 -0
  67. package/src/cursor-provider-run-finalizer.ts +36 -28
  68. package/src/cursor-provider-turn-finalize.ts +72 -6
  69. package/src/cursor-provider-turn-prepare.ts +228 -12
  70. package/src/cursor-provider-turn-runner.ts +41 -13
  71. package/src/cursor-provider-turn-send.ts +59 -16
  72. package/src/cursor-provider-turn-types.ts +44 -10
  73. package/src/cursor-runtime-state.ts +478 -0
  74. package/src/cursor-sdk-event-debug.ts +46 -6
  75. package/src/cursor-sdk-process-error-guard.ts +101 -30
  76. package/src/cursor-session-agent-cleanup.ts +328 -0
  77. package/src/cursor-session-agent-resume.ts +439 -0
  78. package/src/cursor-session-agent.ts +109 -13
  79. package/src/cursor-session-scope.ts +35 -2
  80. package/src/cursor-session-send-policy.ts +1 -1
  81. package/src/cursor-skill-tool.ts +30 -11
  82. package/src/cursor-state.ts +112 -69
  83. package/src/cursor-usage-accounting.ts +14 -4
  84. package/src/index.ts +11 -2
  85. package/src/model-discovery.ts +10 -56
@@ -2,13 +2,48 @@
2
2
  * Artifact management — directory layout, manifest, redaction scanning, packaging.
3
3
  */
4
4
 
5
- import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, renameSync } from "node:fs";
6
- import { resolve, relative, basename, dirname } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import { resolve, relative, dirname, isAbsolute } from "node:path";
8
+ import { gunzipSync, gzipSync } from "node:zlib";
9
+ import {
10
+ MAX_BUNDLE_AGGREGATE_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_BUNDLE_FILE_COUNT,
11
+ MAX_BUNDLE_PATH_BYTES, MAX_BUNDLE_PATH_COMPONENTS,
12
+ MAX_COMPRESSED_BUNDLE_BYTES, MAX_INFLATED_BUNDLE_JSON_BYTES,
13
+ PLATFORM_ARTIFACT_BUNDLE_END, PLATFORM_ARTIFACT_BUNDLE_FILE_MARKER,
14
+ PLATFORM_ARTIFACT_BUNDLE_PATH, PLATFORM_ARTIFACT_BUNDLE_START,
15
+ decodeCanonicalBase64, isCanonicalPlatformBundlePath,
16
+ } from "./artifact-bundle-contract.mjs";
17
+ import {
18
+ boundedFileSnapshot, openRegularFileNoFollow, walkArtifactTree,
19
+ writeBundleSpillFile, writeExtractedFiles,
20
+ } from "./artifact-fs-safety.mjs";
21
+ import { isBinaryArtifactContent, redactSecrets, scanForSecrets } from "./artifact-secrets.mjs";
22
+
23
+ export {
24
+ MAX_BUNDLE_AGGREGATE_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_BUNDLE_FILE_COUNT,
25
+ MAX_BUNDLE_PATH_BYTES, MAX_BUNDLE_PATH_COMPONENTS,
26
+ MAX_COMPRESSED_BUNDLE_BYTES, MAX_INFLATED_BUNDLE_JSON_BYTES,
27
+ PLATFORM_ARTIFACT_BUNDLE_END, PLATFORM_ARTIFACT_BUNDLE_FILE_MARKER,
28
+ PLATFORM_ARTIFACT_BUNDLE_PATH, PLATFORM_ARTIFACT_BUNDLE_START,
29
+ isCanonicalPlatformBundlePath,
30
+ };
31
+ export { openRegularFileNoFollow };
32
+ export { isBinaryArtifactContent, redactSecrets, scanForSecrets };
7
33
 
8
34
  const PLATFORM_SMOKE_RUN_DIR_PATTERN = /^run-(\d+)-[a-z0-9]+$/i;
9
35
  const HOURS_TO_MS = 60 * 60 * 1000;
10
36
  const DAYS_TO_MS = 24 * HOURS_TO_MS;
11
37
  const LATEST_INDEX_NAME = "latest.json";
38
+ const INLINE_BUNDLE_MAX_BYTES = 48 * 1024;
39
+ const PLATFORM_ARTIFACT_TEXT_EXTENSIONS = new Set([
40
+ "ansi", "html", "js", "json", "jsonl", "log", "md", "mjs", "ts", "txt", "yaml", "yml",
41
+ ]);
42
+
43
+ function isTransportableTextPath(path) {
44
+ const extension = path.toLowerCase().match(/\.([^.\/]+)$/)?.[1];
45
+ return extension !== undefined && PLATFORM_ARTIFACT_TEXT_EXTENSIONS.has(extension);
46
+ }
12
47
 
13
48
  function finiteNonNegativeNumber(value) {
14
49
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -113,62 +148,44 @@ export function writeManifest(dir, expectedFiles) {
113
148
  return manifest;
114
149
  }
115
150
 
116
- const SECRET_PATTERNS = [
117
- [/Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}=*/gi, "Authorization header", "Authorization: Bearer [REDACTED_BEARER_TOKEN]"],
118
- [/(bearer\s+)[A-Za-z0-9\-._~+/]{20,}=*/gi, "bearer token", "$1[REDACTED_BEARER_TOKEN]"],
119
- [/connect\.sid=[A-Za-z0-9%]+/gi, "session cookie", "connect.sid=[REDACTED_SESSION_COOKIE]"],
120
- [/https?:\/\/[^/\s]*\/cursor-pi-tool-bridge\/[A-Za-z0-9_.:-]+\/mcp/gi, "bridge endpoint URL", "[REDACTED_BRIDGE_ENDPOINT_URL]"],
121
- [/"(apiKey|accessToken|refreshToken|session|cookie)"\s*:\s*"[^"\s]{12,}"/gi, "auth/token JSON field", '"$1":"[REDACTED_SECRET]"'],
122
- ];
123
-
124
- /** Redact known secret material before writing logs/artifacts. */
125
- export function redactSecrets(text) {
126
- let redacted = String(text ?? "");
127
- const cursorKey = process.env.CURSOR_API_KEY;
128
- if (cursorKey && cursorKey.length > 10) {
129
- redacted = redacted.split(cursorKey).join("[REDACTED_CURSOR_API_KEY]");
130
- }
131
- for (const [pattern, , replacement] of SECRET_PATTERNS) {
132
- redacted = redacted.replace(pattern, replacement);
133
- }
134
- return redacted;
151
+ function artifactRelativePath(root, path) {
152
+ return relative(root, path).replace(/\\/g, "/");
135
153
  }
136
154
 
137
- /** Scan text content for secrets. Returns array of violation descriptions. */
138
- export function scanForSecrets(text) {
139
- const violations = [];
140
- const cursorKey = process.env.CURSOR_API_KEY;
141
- if (cursorKey && cursorKey.length > 10 && String(text ?? "").includes(cursorKey)) {
142
- violations.push("CURSOR_API_KEY literal found");
143
- }
144
- for (const [pattern, label] of SECRET_PATTERNS) {
145
- pattern.lastIndex = 0;
146
- if (pattern.test(String(text ?? ""))) violations.push(`potential ${label}`);
147
- }
148
- return violations;
155
+ function safeArtifactPath(path) {
156
+ return redactSecrets(path || ".");
157
+ }
158
+
159
+ function isSensitiveTransportPath(root, path) {
160
+ return artifactRelativePath(root, path).split("/").some((name) =>
161
+ /^\.env/i.test(name) || /^auth\.json$/i.test(name) || /^(?:id_rsa|id_ed25519|.*\.pem|.*\.key)$/i.test(name));
149
162
  }
150
163
 
151
- /** Scan all text files in a directory for secrets. */
164
+ /** Binary-safe scan of every bounded artifact file except non-artifact infrastructure. */
152
165
  export function scanArtifacts(dir) {
153
166
  const findings = [];
154
- function walk(d) {
155
- for (const entry of readdirSync(d, { withFileTypes: true })) {
156
- const fp = resolve(d, entry.name);
157
- if (entry.isDirectory()) { walk(fp); continue; }
158
- if (!entry.isFile()) continue;
159
- const ext = entry.name.split(".").pop()?.toLowerCase() ?? "";
160
- if (!["txt", "json", "jsonl", "md", "log", "ansi", "html", "yml", "yaml", "js", "mjs", "ts"].includes(ext)) continue;
161
- try {
162
- const content = readFileSync(fp, "utf-8");
163
- const violations = scanForSecrets(content);
164
- for (const v of violations) {
165
- findings.push({ file: relative(dir, fp), violation: v });
166
- }
167
- } catch { /* binary or unreadable */ }
168
- }
167
+ const failures = [];
168
+ function relativeDetails(root, path) {
169
+ const relativePath = artifactRelativePath(root, path) || ".";
170
+ const file = safeArtifactPath(relativePath);
171
+ for (const violation of scanForSecrets(relativePath)) findings.push({ file, violation });
172
+ return { relativePath, file };
169
173
  }
170
- walk(dir);
171
- return findings;
174
+ walkArtifactTree(dir, {
175
+ directoryRead: (path, _parents, root) => failures.push({ file: relativeDetails(root, path).file, violation: "artifact scan directory-read" }),
176
+ fileRead: (path, _parents, root) => failures.push({ file: relativeDetails(root, path).file, violation: "artifact scan file-read" }),
177
+ nonRegular: (path, _parents, root) => failures.push({ file: relativeDetails(root, path).file, violation: "artifact scan non-regular-entry" }),
178
+ file: (path, parents, root) => {
179
+ const { file } = relativeDetails(root, path);
180
+ const snapshot = boundedFileSnapshot(path, MAX_BUNDLE_FILE_BYTES, parents);
181
+ if (!snapshot.ok) {
182
+ failures.push({ file, violation: `artifact scan ${snapshot.reason}` });
183
+ return;
184
+ }
185
+ for (const violation of scanForSecrets(snapshot.content)) findings.push({ file, violation });
186
+ },
187
+ });
188
+ return failures.length > 0 ? failures : findings;
172
189
  }
173
190
 
174
191
  /** Write summary.json for a suite. */
@@ -180,13 +197,233 @@ export function writeSummary(dir, data) {
180
197
  }
181
198
 
182
199
  function readJsonFile(path) {
200
+ const snapshot = boundedFileSnapshot(path);
201
+ if (!snapshot.ok) return undefined;
183
202
  try {
184
- return JSON.parse(readFileSync(path, "utf8"));
203
+ return JSON.parse(snapshot.content.toString("utf8"));
185
204
  } catch {
186
205
  return undefined;
187
206
  }
188
207
  }
189
208
 
209
+ function shouldTransportBundleFile(root, path, pathPrefix) {
210
+ if (isSensitiveTransportPath(root, path) || !isTransportableTextPath(path)) return false;
211
+ if (pathPrefix) return true;
212
+ const rel = relative(root, path).replace(/\\/g, "/");
213
+ return /^(?:artifacts\/(?:terminal\.(?:ansi|txt)|session\.jsonl|live-status\.json|pi-command\.json|bridge-diagnostics\.jsonl|abort-started\.txt)|logs\/(?:process-|leftover-process-check)[^/]*|cursor-sdk-events\/.*\/(?:session|metadata)\.json)$/i.test(rel);
214
+ }
215
+
216
+ function singleFilePlatformArtifactBundle(pathPrefix, name, value) {
217
+ const content = `${JSON.stringify(value, null, 2)}\n`;
218
+ return {
219
+ files: [{
220
+ path: [pathPrefix || "artifacts", name].join("/"),
221
+ contentBase64: Buffer.from(content).toString("base64"),
222
+ size: Buffer.byteLength(content),
223
+ }],
224
+ };
225
+ }
226
+
227
+ function bundleLimitArtifact(pathPrefix, reasons) {
228
+ return singleFilePlatformArtifactBundle(pathPrefix, "bundle-limit-exceeded.json", {
229
+ status: "failed",
230
+ reasons: [...new Set(reasons)],
231
+ limits: {
232
+ maxFileBytes: MAX_BUNDLE_FILE_BYTES,
233
+ maxFileCount: MAX_BUNDLE_FILE_COUNT,
234
+ maxAggregateBytes: MAX_BUNDLE_AGGREGATE_BYTES,
235
+ maxPathBytes: MAX_BUNDLE_PATH_BYTES,
236
+ maxPathComponents: MAX_BUNDLE_PATH_COMPONENTS,
237
+ maxInflatedJsonBytes: MAX_INFLATED_BUNDLE_JSON_BYTES,
238
+ maxCompressedBytes: MAX_COMPRESSED_BUNDLE_BYTES,
239
+ },
240
+ });
241
+ }
242
+
243
+ export function buildPlatformArtifactBundle(root, pathPrefix = "") {
244
+ const prefixViolations = scanForSecrets(pathPrefix);
245
+ if (prefixViolations.length > 0) {
246
+ return singleFilePlatformArtifactBundle("artifacts", "bundle-redaction-violations.json",
247
+ prefixViolations.map((violation) => ({ file: "[artifact-path-prefix]", violation })));
248
+ }
249
+ const files = [];
250
+ const findings = [];
251
+ const limitReasons = [];
252
+ let aggregateBytes = 0;
253
+ walkArtifactTree(root, {
254
+ directoryRead: () => limitReasons.push("directory-read"),
255
+ fileRead: () => limitReasons.push("file-read"),
256
+ nonRegular: () => limitReasons.push("non-regular-entry"),
257
+ file: (path, parents, canonicalRoot) => {
258
+ const rel = artifactRelativePath(canonicalRoot, path);
259
+ const file = safeArtifactPath(rel);
260
+ const pathViolations = scanForSecrets(rel);
261
+ for (const violation of pathViolations) findings.push({ file, violation });
262
+ const snapshot = boundedFileSnapshot(path, MAX_BUNDLE_FILE_BYTES, parents);
263
+ if (!snapshot.ok) {
264
+ limitReasons.push(snapshot.reason);
265
+ return;
266
+ }
267
+ const { content } = snapshot;
268
+ for (const violation of scanForSecrets(content)) findings.push({ file, violation });
269
+ if (pathViolations.length > 0 || !shouldTransportBundleFile(canonicalRoot, path, pathPrefix)) return;
270
+ if (isBinaryArtifactContent(content)) {
271
+ limitReasons.push("binary-content");
272
+ } else if (files.length >= MAX_BUNDLE_FILE_COUNT) {
273
+ limitReasons.push("file-count");
274
+ } else if (aggregateBytes + content.length > MAX_BUNDLE_AGGREGATE_BYTES) {
275
+ limitReasons.push("aggregate-bytes");
276
+ } else {
277
+ files.push({ path: [pathPrefix, rel].filter(Boolean).join("/"), contentBase64: content.toString("base64"), size: content.length });
278
+ aggregateBytes += content.length;
279
+ }
280
+ },
281
+ });
282
+ if (limitReasons.length > 0) return bundleLimitArtifact(pathPrefix, limitReasons);
283
+ if (findings.length > 0) {
284
+ return singleFilePlatformArtifactBundle(pathPrefix, "bundle-redaction-violations.json", findings);
285
+ }
286
+ return { files };
287
+ }
288
+
289
+ function validatePlatformArtifactBundle(bundle) {
290
+ if (!bundle || typeof bundle !== "object" || Array.isArray(bundle) ||
291
+ Object.keys(bundle).length !== 1 || !Object.hasOwn(bundle, "files") ||
292
+ !Array.isArray(bundle.files) || bundle.files.length > MAX_BUNDLE_FILE_COUNT) return undefined;
293
+ const files = [];
294
+ const paths = new Set();
295
+ let aggregateBytes = 0;
296
+ let pathComponents = 0;
297
+ for (const file of bundle.files) {
298
+ if (!file || typeof file !== "object" || Array.isArray(file) ||
299
+ Object.keys(file).length !== 3 || !Object.hasOwn(file, "path") ||
300
+ !Object.hasOwn(file, "contentBase64") || !Object.hasOwn(file, "size") ||
301
+ !isCanonicalPlatformBundlePath(file.path) || scanForSecrets(file.path).length > 0 ||
302
+ !isTransportableTextPath(file.path) || !Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_BUNDLE_FILE_BYTES) return undefined;
303
+ const comparisonPath = process.platform === "win32" ? file.path.toLowerCase() : file.path;
304
+ if (paths.has(comparisonPath)) return undefined;
305
+ paths.add(comparisonPath);
306
+ pathComponents += file.path.split("/").length;
307
+ if (pathComponents > MAX_BUNDLE_PATH_COMPONENTS) return undefined;
308
+ aggregateBytes += file.size;
309
+ if (aggregateBytes > MAX_BUNDLE_AGGREGATE_BYTES) return undefined;
310
+ const content = decodeCanonicalBase64(file.contentBase64, file.size);
311
+ if (!content || content.length !== file.size || isBinaryArtifactContent(content) || scanForSecrets(content).length > 0) return undefined;
312
+ files.push({ path: file.path, content });
313
+ }
314
+ for (const path of paths) {
315
+ const segments = path.split("/");
316
+ for (let index = 1; index < segments.length; index++) {
317
+ if (paths.has(segments.slice(0, index).join("/"))) return undefined;
318
+ }
319
+ }
320
+ return files;
321
+ }
322
+
323
+ function compressPlatformArtifactBundle(bundle) {
324
+ if (!validatePlatformArtifactBundle(bundle)) throw new Error("platform artifact bundle exceeds decoded limits");
325
+ const serialized = Buffer.from(JSON.stringify(bundle));
326
+ if (serialized.length > MAX_INFLATED_BUNDLE_JSON_BYTES) throw new Error("platform artifact bundle exceeds inflated JSON limit");
327
+ const compressed = gzipSync(serialized, { level: 9 });
328
+ if (compressed.length > MAX_COMPRESSED_BUNDLE_BYTES) throw new Error("platform artifact bundle exceeds compressed limit");
329
+ return compressed;
330
+ }
331
+
332
+ function platformArtifactBundleEnvelope(compressed) {
333
+ return {
334
+ encoding: "gzip-base64",
335
+ size: compressed.length,
336
+ sha256: createHash("sha256").update(compressed).digest("hex"),
337
+ contentBase64: compressed.toString("base64"),
338
+ };
339
+ }
340
+
341
+ function formatCompressedPlatformArtifactBundle(compressed) {
342
+ return `${PLATFORM_ARTIFACT_BUNDLE_START}\n${JSON.stringify(platformArtifactBundleEnvelope(compressed))}\n${PLATFORM_ARTIFACT_BUNDLE_END}\n`;
343
+ }
344
+
345
+ export function formatPlatformArtifactBundle(bundle) {
346
+ return formatCompressedPlatformArtifactBundle(compressPlatformArtifactBundle(bundle));
347
+ }
348
+
349
+ export function writePlatformArtifactBundle(root, pathPrefix = "") {
350
+ rmSync(PLATFORM_ARTIFACT_BUNDLE_PATH, { force: true });
351
+ const validPrefix = pathPrefix === "" || (isCanonicalPlatformBundlePath(pathPrefix) && scanForSecrets(pathPrefix).length === 0);
352
+ let bundle = validPrefix ? buildPlatformArtifactBundle(root, pathPrefix) : undefined;
353
+ let compressed;
354
+ if (!bundle || !validatePlatformArtifactBundle(bundle)) {
355
+ bundle = bundleLimitArtifact("artifacts", [validPrefix ? "invalid-bundle" : "invalid-path-prefix"]);
356
+ compressed = compressPlatformArtifactBundle(bundle);
357
+ } else {
358
+ try {
359
+ compressed = compressPlatformArtifactBundle(bundle);
360
+ } catch (error) {
361
+ bundle = bundleLimitArtifact(pathPrefix, [error instanceof Error ? error.message : "bundle-encoding"]);
362
+ compressed = compressPlatformArtifactBundle(bundle);
363
+ }
364
+ }
365
+ const inline = formatCompressedPlatformArtifactBundle(compressed);
366
+ if (Buffer.byteLength(inline) <= INLINE_BUNDLE_MAX_BYTES) {
367
+ process.stdout.write(inline);
368
+ return bundle;
369
+ }
370
+ writeBundleSpillFile(PLATFORM_ARTIFACT_BUNDLE_PATH, compressed);
371
+ process.stdout.write(`${PLATFORM_ARTIFACT_BUNDLE_FILE_MARKER}${JSON.stringify({
372
+ path: PLATFORM_ARTIFACT_BUNDLE_PATH,
373
+ ...platformArtifactBundleEnvelope(compressed),
374
+ encoding: "gzip",
375
+ contentBase64: undefined,
376
+ })}\n`);
377
+ return bundle;
378
+ }
379
+
380
+ export function isSafePlatformBundlePath(outputDir, bundlePath) {
381
+ if (!isCanonicalPlatformBundlePath(bundlePath)) return false;
382
+ const outPath = resolve(outputDir, bundlePath);
383
+ const rel = relative(outputDir, outPath).replace(/\\/g, "/");
384
+ return rel === bundlePath && !isAbsolute(rel);
385
+ }
386
+
387
+ export function extractPlatformArtifactBundle(outputDir, stdout) {
388
+ const failed = { ok: false, violations: [] };
389
+ const start = stdout.indexOf(PLATFORM_ARTIFACT_BUNDLE_START);
390
+ const end = stdout.indexOf(PLATFORM_ARTIFACT_BUNDLE_END, start);
391
+ if (start === -1 || end === -1) return failed;
392
+ let bundle;
393
+ const payload = stdout.slice(start + PLATFORM_ARTIFACT_BUNDLE_START.length, end).trim();
394
+ if (Buffer.byteLength(payload) > MAX_INFLATED_BUNDLE_JSON_BYTES) return failed;
395
+ try {
396
+ const parsed = JSON.parse(payload);
397
+ if (parsed?.encoding !== "gzip-base64" || typeof parsed.contentBase64 !== "string" ||
398
+ !Number.isSafeInteger(parsed.size) || parsed.size < 1 || parsed.size > MAX_COMPRESSED_BUNDLE_BYTES ||
399
+ typeof parsed.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(parsed.sha256)) return failed;
400
+ const compressed = decodeCanonicalBase64(parsed.contentBase64, parsed.size);
401
+ if (!compressed || compressed.length !== parsed.size || createHash("sha256").update(compressed).digest("hex") !== parsed.sha256) return failed;
402
+ bundle = JSON.parse(gunzipSync(compressed, { maxOutputLength: MAX_INFLATED_BUNDLE_JSON_BYTES }).toString("utf8"));
403
+ } catch { return failed; }
404
+ const decodedFiles = validatePlatformArtifactBundle(bundle);
405
+ if (!decodedFiles || decodedFiles.some((file) => !isSafePlatformBundlePath(outputDir, file.path))) return failed;
406
+
407
+ const files = [];
408
+ const violations = [];
409
+ for (const file of decodedFiles) {
410
+ const text = file.content.toString("utf8");
411
+ violations.push(...scanForSecrets(file.content).map((violation) => ({ file: file.path, violation })));
412
+ if (file.path.endsWith("redaction-violations.json")) {
413
+ try {
414
+ const parsed = JSON.parse(text);
415
+ if (Array.isArray(parsed)) violations.push(...parsed.filter((item) => typeof item?.violation === "string").map((item) => ({
416
+ file: typeof item.file === "string" ? item.file : file.path, violation: item.violation,
417
+ })));
418
+ } catch {}
419
+ }
420
+ files.push({ path: file.path, content: Buffer.from(redactSecrets(text)) });
421
+ }
422
+
423
+ const succeeded = writeExtractedFiles(outputDir, files);
424
+ return succeeded ? { ok: true, violations } : failed;
425
+ }
426
+
190
427
  function collectFiles(root) {
191
428
  const files = [];
192
429
  function walk(dir) {
@@ -227,6 +464,7 @@ function providerDebugPathFields(debugRoot) {
227
464
  function pathFields(suiteDir) {
228
465
  const artifactsDir = resolve(suiteDir, "artifacts");
229
466
  const debugRoot = resolve(suiteDir, "cursor-sdk-events");
467
+ const localResumeEvidenceRoot = resolve(suiteDir, "local-resume-evidence");
230
468
  const paths = {
231
469
  artifactManifest: existingPath(resolve(suiteDir, "artifact-manifest.json")),
232
470
  summary: existingPath(resolve(suiteDir, "summary.json")),
@@ -238,6 +476,9 @@ function pathFields(suiteDir) {
238
476
  visualEvidence: existingPath(resolve(artifactsDir, "visual-evidence.json")),
239
477
  sessionJsonl: existingPath(resolve(artifactsDir, "session.jsonl")),
240
478
  jsonlToolResults: existingPath(resolve(artifactsDir, "jsonl-tool-results.json")),
479
+ localResumeEvidence: existingPath(resolve(suiteDir, "local-resume-evidence.json")),
480
+ localResumeEvidenceRoot: existingPath(localResumeEvidenceRoot),
481
+ localResumeRuntimeLaunches: existingPath(resolve(localResumeEvidenceRoot, "runtime-launches.jsonl")),
241
482
  ...providerDebugPathFields(debugRoot),
242
483
  };
243
484
  for (const [key, value] of Object.entries(paths)) {
@@ -332,16 +573,3 @@ export function writeCommand(dir, cmd) {
332
573
  export function writeExitCode(dir, code, signal) {
333
574
  writeFileSync(resolve(dir, "exit-code.txt"), `code=${code}\nsignal=${signal ?? "none"}\n`);
334
575
  }
335
-
336
- /** Package a directory as tar.gz (posix) or zip (powershell). */
337
- export async function packageArtifacts(dir, archivePath) {
338
- const { execSync } = await import("node:child_process");
339
- const dirName = basename(dir);
340
- const parentDir = resolve(dir, "..");
341
- if (archivePath.endsWith(".tar.gz")) {
342
- execSync(`tar -czf "${archivePath}" -C "${parentDir}" "${dirName}"`, { stdio: "pipe" });
343
- } else if (archivePath.endsWith(".zip")) {
344
- execSync(`cd "${parentDir}" && zip -r "${archivePath}" "${dirName}"`, { stdio: "pipe" });
345
- }
346
- return archivePath;
347
- }
@@ -8,9 +8,14 @@
8
8
 
9
9
  import { writeFileSync, mkdirSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
+ import { matchesWrappedLineAt } from "./wrapped-line-match.mjs";
11
12
 
12
13
  const CARD_PATTERNS = [
13
- { id: "read", pattern: /^\s*read (?:\.\/)?package\.json\s*$/i },
14
+ {
15
+ id: "read",
16
+ pattern: /^\s*read\s+(?:(?:\.\/)?package\.json|.*[\\/]package\.json)\s*$/i,
17
+ wrappedPattern: /^\s*read\s+.*[\\/]package\.(?:json|js\s+on|j\s*son)\s*$/i,
18
+ },
14
19
  { id: "grep", pattern: /^\s*grep \/pi-cursor-sdk\/ in\s+(?:(?:\S+[\\/])?README\.md)\s*$/i },
15
20
  { id: "find", pattern: /^\s*find README\.md in\s+\S+/i },
16
21
  { id: "list", pattern: /^\s*(?:find \* in src|find src\/\* in \.|Get-ChildItem -Name \.\/src)\s*/i },
@@ -18,7 +23,11 @@ const CARD_PATTERNS = [
18
23
  { id: "write", pattern: /^\s*\+.*beta\s*$/i },
19
24
  { id: "edit-diff", pattern: /^\s*\+.*gamma\s*$/i },
20
25
  { id: "shell-failure", pattern: /^\s*(?:native shell failure|Command exited with code 7)\s*$/i },
21
- { id: "bridge-read-success", pattern: /^\s*read (?:\.\/package\.json|.*[\\/]package\.j(?:son|s))\s*$/i },
26
+ {
27
+ id: "bridge-read-success",
28
+ pattern: /^\s*read\s+(?:\.\/package\.json|.*[\\/]package\.j(?:son|s))\s*$/i,
29
+ wrappedPattern: /^\s*read\s+.*[\\/]package\.(?:json|js\s+on|j\s*son)\s*$/i,
30
+ },
22
31
  { id: "bridge-read-failure", pattern: /^\s*(?:read \.\/definitely-missing-platform-smoke-file\.txt|ENOENT: no such file)\s*/i },
23
32
  { id: "bridge-shell-success", pattern: /^\s*bridge visual smoke\s*$/i },
24
33
  { id: "footer-status", pattern: /\bcomposer-2-5\b|\bcomposer-2\.5\b/i },
@@ -31,6 +40,10 @@ function cleanLine(line) {
31
40
  .replace(/\r/g, "");
32
41
  }
33
42
 
43
+ function matchesCardAt(lines, index, card) {
44
+ return matchesWrappedLineAt(lines, index, card.pattern, card.wrappedPattern);
45
+ }
46
+
34
47
  /**
35
48
  * Detect stable rendered evidence regions in terminal text.
36
49
  *
@@ -44,8 +57,7 @@ export function detectCards(txtContent) {
44
57
  for (let i = 0; i < lines.length; i++) {
45
58
  for (const card of CARD_PATTERNS) {
46
59
  if (seen.has(card.id)) continue;
47
- card.pattern.lastIndex = 0;
48
- if (!card.pattern.test(lines[i])) continue;
60
+ if (!matchesCardAt(lines, i, card)) continue;
49
61
  seen.add(card.id);
50
62
  cards.push({
51
63
  id: card.id,
@@ -5,12 +5,30 @@
5
5
  * Never prints CURSOR_API_KEY.
6
6
  */
7
7
 
8
- import { spawn } from "node:child_process";
8
+ import { execFileSync, spawn } from "node:child_process";
9
+ import { existsSync, readFileSync, rmSync } from "node:fs";
9
10
 
10
11
  const CRABBOX_BIN = process.env.PLATFORM_SMOKE_CRABBOX || "crabbox";
11
12
 
12
13
  function env(name) { return process.env[name] ?? ""; }
13
14
 
15
+ export function ensureUbuntuContainerImage(config = {}) {
16
+ const override = env("PLATFORM_SMOKE_UBUNTU_IMAGE");
17
+ if (override) return override;
18
+ const image = config.ubuntuContainerImage;
19
+ const baseImage = config.ubuntuContainerBaseImage;
20
+ if (!image || !baseImage) throw new Error("platform smoke config requires ubuntuContainerImage and ubuntuContainerBaseImage");
21
+ const childEnv = { ...process.env };
22
+ delete childEnv.CURSOR_API_KEY;
23
+ execFileSync("docker", ["build", "--quiet", "--tag", image, "-"], {
24
+ env: childEnv,
25
+ input: `FROM ${baseImage}\nUSER root\n`,
26
+ stdio: ["pipe", "pipe", "pipe"],
27
+ timeout: 300_000,
28
+ });
29
+ return image;
30
+ }
31
+
14
32
  function buildCrabboxEnv(opts = {}) {
15
33
  const env = { ...process.env, CRABBOX_SYNC_GIT_SEED: "false", ...opts.env };
16
34
  const allowed = new Set(opts.allowEnv ?? []);
@@ -90,7 +108,8 @@ export function buildTargetBaseArgs(targetName, config = {}) {
90
108
  ];
91
109
  }
92
110
  case "ubuntu": {
93
- const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "cimg/node:24.16";
111
+ const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage;
112
+ if (!image) throw new Error("platform smoke config requires ubuntuContainerImage");
94
113
  return [
95
114
  "--provider", "local-container",
96
115
  "--target", "linux",
@@ -144,6 +163,21 @@ function parseLeaseId(output) {
144
163
  * The lease will be kept until explicitly stopped.
145
164
  */
146
165
  export async function warmupLease(targetName, slug, config = {}) {
166
+ if (targetName === "ubuntu") {
167
+ try {
168
+ const image = ensureUbuntuContainerImage(config);
169
+ console.log(` prepared Ubuntu container image ${image}`);
170
+ } catch (error) {
171
+ return {
172
+ ok: false,
173
+ stdout: "",
174
+ stderr: `failed to prepare Ubuntu container image: ${error instanceof Error ? error.message : String(error)}`,
175
+ code: 1,
176
+ signal: null,
177
+ leaseId: leaseIdFor(targetName),
178
+ };
179
+ }
180
+ }
147
181
  const fullArgs = ["warmup", ...buildTargetBaseArgs(targetName, config), "--slug", slug, "--keep"];
148
182
  if (targetName === "macos") fullArgs.push("--reclaim");
149
183
  console.log(` [crabbox] ${fullArgs.join(" ")}`);
@@ -174,17 +208,24 @@ export async function runOnLease(targetName, leaseId, command, opts = {}) {
174
208
  } else if (opts.freshSync !== false) {
175
209
  args.push("--fresh-sync");
176
210
  }
211
+ if (opts.captureStdoutPath) args.push("--capture-stdout", opts.captureStdoutPath);
177
212
  if (opts.shell) {
178
213
  args.push("--shell", command);
179
214
  } else {
180
215
  args.push("--", ...(Array.isArray(command) ? command : command.split(" ")));
181
216
  }
182
217
  console.log(` [crabbox] run ${args.slice(1, 6).join(" ")} ...`);
183
- return execCrabbox(args, {
218
+ const result = await execCrabbox(args, {
184
219
  timeout: opts.timeout ?? 600_000,
185
220
  env: opts.env,
186
221
  allowEnv: opts.allowEnv,
187
222
  });
223
+ if (!opts.captureStdoutPath || !existsSync(opts.captureStdoutPath)) return result;
224
+ try {
225
+ return { ...result, stdout: readFileSync(opts.captureStdoutPath, "utf8") };
226
+ } finally {
227
+ rmSync(opts.captureStdoutPath, { force: true });
228
+ }
188
229
  }
189
230
 
190
231
  /**
@@ -201,6 +242,7 @@ export async function stopLease(targetName, leaseId, config = {}) {
201
242
  * Crabbox syncs the checkout, executes, and releases the lease.
202
243
  */
203
244
  export async function runOneShot(targetName, command, opts = {}) {
245
+ if (targetName === "ubuntu") ensureUbuntuContainerImage(opts.config);
204
246
  const args = ["run", ...buildTargetBaseArgs(targetName, opts.config)];
205
247
  if (opts.shell) {
206
248
  args.push("--shell", command);
@@ -9,6 +9,7 @@
9
9
  import { execSync, execFileSync } from "node:child_process";
10
10
  import { accessSync, constants, existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, statSync } from "node:fs";
11
11
  import { dirname, resolve } from "node:path";
12
+ import { ensureUbuntuContainerImage } from "./crabbox-runner.mjs";
12
13
  import { renderAll } from "./render-ansi.mjs";
13
14
 
14
15
  let failures = 0;
@@ -259,17 +260,25 @@ async function runChecks(config) {
259
260
  } else {
260
261
  fail("crabbox providers failed");
261
262
  }
262
- const ubuntuImage = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config?.ubuntuContainerImage || "cimg/node:24.16";
263
- const lcDoc = silent(cbox, ["doctor", "--provider", "local-container", "--target", "linux", "--local-container-image", ubuntuImage, "--json"]);
264
- if (lcDoc) {
265
- try {
266
- const d = JSON.parse(lcDoc);
267
- d.ok ? ok("local-container provider OK") : fail(`local-container: ${d.error ?? "not ok"}`);
268
- } catch {
269
- fail("could not parse crabbox doctor --json for local-container");
263
+ let ubuntuImage;
264
+ try {
265
+ ubuntuImage = ensureUbuntuContainerImage(config);
266
+ ok(`Ubuntu container image ready: ${ubuntuImage}`);
267
+ } catch (error) {
268
+ fail(`could not prepare Ubuntu container image: ${error instanceof Error ? error.message : String(error)}`);
269
+ }
270
+ if (ubuntuImage) {
271
+ const lcDoc = silent(cbox, ["doctor", "--provider", "local-container", "--target", "linux", "--local-container-image", ubuntuImage, "--json"]);
272
+ if (lcDoc) {
273
+ try {
274
+ const d = JSON.parse(lcDoc);
275
+ d.ok ? ok("local-container provider OK") : fail(`local-container: ${d.error ?? "not ok"}`);
276
+ } catch {
277
+ fail("could not parse crabbox doctor --json for local-container");
278
+ }
279
+ } else {
280
+ fail("crabbox doctor --provider local-container --json failed");
270
281
  }
271
- } else {
272
- fail("crabbox doctor --provider local-container --json failed");
273
282
  }
274
283
  const sshHost = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
275
284
  const sshUser = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
@@ -414,6 +423,7 @@ async function runChecks(config) {
414
423
  ["git", "git --version"],
415
424
  ["rsync", "rsync --version"],
416
425
  ["tar", "tar --version"],
426
+ ...(process.platform === "win32" ? [] : [["C compiler", "cc --version"]]),
417
427
  ]) {
418
428
  const out = shell(command);
419
429
  out ? ok(`${name}: ${out.split("\n")[0]}`) : fail(`${name} not found`);