pi-cursor-sdk 0.1.39 → 0.1.41

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 (47) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +13 -12
  3. package/docs/cursor-dogfood-checklist.md +7 -1
  4. package/docs/cursor-live-smoke-checklist.md +13 -13
  5. package/docs/cursor-model-ux-spec.md +8 -8
  6. package/docs/cursor-native-tool-replay.md +4 -4
  7. package/docs/cursor-native-tool-visual-audit.md +5 -5
  8. package/docs/cursor-testing-lessons.md +5 -5
  9. package/docs/cursor-tool-surfaces.md +4 -0
  10. package/docs/platform-smoke.md +22 -7
  11. package/package.json +8 -5
  12. package/platform-smoke.config.mjs +5 -0
  13. package/scripts/debug-provider-events.mjs +1 -0
  14. package/scripts/isolated-cursor-smoke.sh +7 -7
  15. package/scripts/lib/cursor-visual-manifest.d.mts +3 -0
  16. package/scripts/lib/cursor-visual-manifest.mjs +82 -0
  17. package/scripts/platform-smoke/artifacts.mjs +225 -2
  18. package/scripts/platform-smoke/card-detect.mjs +1 -1
  19. package/scripts/platform-smoke/doctor.mjs +53 -8
  20. package/scripts/platform-smoke/live-suite-runner.mjs +7 -6
  21. package/scripts/platform-smoke/platform-build-windows.ps1 +2 -2
  22. package/scripts/platform-smoke/scenarios.mjs +1 -1
  23. package/scripts/platform-smoke/targets.mjs +2 -2
  24. package/scripts/platform-smoke.mjs +75 -6
  25. package/scripts/steering-rpc-smoke.mjs +1 -1
  26. package/scripts/tmux-live-smoke.sh +1 -1
  27. package/scripts/visual-tui-smoke-self-test.mjs +229 -0
  28. package/scripts/visual-tui-smoke.mjs +46 -179
  29. package/shared/cursor-setting-sources.d.mts +1 -0
  30. package/shared/cursor-setting-sources.mjs +2 -1
  31. package/src/context.ts +25 -10
  32. package/src/cursor-active-tools.ts +7 -0
  33. package/src/cursor-native-tool-display-registration.ts +31 -21
  34. package/src/cursor-native-tool-display-state.ts +13 -4
  35. package/src/cursor-pi-tool-bridge-run.ts +6 -3
  36. package/src/cursor-pi-tool-bridge-types.ts +2 -2
  37. package/src/cursor-provider-errors.ts +2 -1
  38. package/src/cursor-provider-live-run-drain.ts +1 -1
  39. package/src/cursor-provider-turn-prepare.ts +1 -1
  40. package/src/cursor-provider-turn-send.ts +2 -0
  41. package/src/cursor-question-tool.ts +2 -1
  42. package/src/cursor-sdk-event-debug.ts +3 -1
  43. package/src/cursor-setting-sources.ts +2 -0
  44. package/src/cursor-skill-tool.ts +2 -1
  45. package/src/cursor-state.ts +2 -1
  46. package/src/cursor-tool-manifest.ts +2 -1
  47. package/src/cursor-usage-accounting.ts +5 -4
@@ -603,10 +603,10 @@ async function main() {
603
603
  const npmInstallPacked = runLogged(logDir, "workspace-npm-install-packed", commandName("npm"), ["install", "--no-save", tarballPath], { cwd: workspaceDir, timeout: 180_000 });
604
604
  requireOk(npmInstallPacked, "workspace npm install packed tarball");
605
605
  }
606
- const install = runLogged(logDir, "pi-install", piCli, ["install", "-l", installPath], { cwd: workspaceDir, env: piEnv, timeout: 120_000 });
607
- requireOk(install, "pi install packed package directory");
608
- const list = runLogged(logDir, "pi-list", piCli, ["list"], { cwd: workspaceDir, env: piEnv, timeout: 60_000 });
609
- requireOk(list, "pi list");
606
+ const install = runLogged(logDir, "pi-install", piCli, ["install", "--approve", "-l", installPath], { cwd: workspaceDir, env: piEnv, timeout: 120_000 });
607
+ requireOk(install, "pi install --approve packed package directory");
608
+ const list = runLogged(logDir, "pi-list", piCli, ["list", "--approve"], { cwd: workspaceDir, env: piEnv, timeout: 60_000 });
609
+ requireOk(list, "pi list --approve");
610
610
 
611
611
  const suiteEnv = {
612
612
  ...process.env,
@@ -620,16 +620,17 @@ async function main() {
620
620
  if (args.suite === "cursor-abort-cleanup") writeProcessSnapshot(logDir, "process-before", platform);
621
621
  const prompt = renderPrompt(scenario, platform);
622
622
  writeFileSync(join(artifactDir, "prompt.txt"), prompt);
623
+ const piArgs = ["--approve", "--cursor-no-fast", "--cursor-mode", "agent", "--model", args.model, "--session-dir", sessionDir, "--session-id", `platform-${args.suite}-${Date.now()}`];
623
624
  writeFileSync(join(artifactDir, "pi-command.json"), JSON.stringify({
624
625
  piCli,
625
- args: ["--cursor-no-fast", "--cursor-mode", "agent", "--model", args.model, "--session-dir", sessionDir, "--session-id", `platform-${args.suite}-${Date.now()}`],
626
+ args: piArgs,
626
627
  cwd: workspaceDir,
627
628
  env: Object.fromEntries(Object.entries(suiteEnv).filter(([key]) => key.startsWith("PI_CURSOR_") || key === "PI_CODING_AGENT_DIR" || key === "TERM")),
628
629
  }, null, 2));
629
630
  const ptyResult = await runPtyPi({
630
631
  artifactDir,
631
632
  piCli,
632
- piArgs: ["--cursor-no-fast", "--cursor-mode", "agent", "--model", args.model, "--session-dir", sessionDir, "--session-id", `platform-${args.suite}-${Date.now()}`],
633
+ piArgs,
633
634
  env: suiteEnv,
634
635
  cwd: workspaceDir,
635
636
  sessionDir,
@@ -137,7 +137,7 @@ if ($PackTarball -and $PiCli -and (Test-Path -LiteralPath $TarballPath)) {
137
137
  if ($PACKED_NODE_INSTALL_EXIT -eq 0) {
138
138
  $PreviousPiOffline = $env:PI_OFFLINE
139
139
  $env:PI_OFFLINE = "1"
140
- & $PiCli install -l (Join-Path ".\node_modules" $PackageName) 1> $PiInstallOut 2> $PiInstallErr
140
+ & $PiCli install --approve -l (Join-Path ".\node_modules" $PackageName) 1> $PiInstallOut 2> $PiInstallErr
141
141
  $PI_INSTALL_EXIT = Exit-CodeFromLastCommand
142
142
  if ($null -eq $PreviousPiOffline) { Remove-Item Env:\PI_OFFLINE -ErrorAction SilentlyContinue } else { $env:PI_OFFLINE = $PreviousPiOffline }
143
143
  } else {
@@ -163,7 +163,7 @@ if ($PiCli) {
163
163
  Push-Location $PiProject
164
164
  $PreviousPiOffline = $env:PI_OFFLINE
165
165
  $env:PI_OFFLINE = "1"
166
- & $PiCli list 1> $PiListOut 2> $PiListErr
166
+ & $PiCli list --approve 1> $PiListOut 2> $PiListErr
167
167
  $PI_LIST_EXIT = Exit-CodeFromLastCommand
168
168
  if ($null -eq $PreviousPiOffline) { Remove-Item Env:\PI_OFFLINE -ErrorAction SilentlyContinue } else { $env:PI_OFFLINE = $PreviousPiOffline }
169
169
  Pop-Location
@@ -131,7 +131,7 @@ BRIDGE_MATRIX_OK bash_ok=<yes/no> read_ok=<yes/no> read_missing_error=<yes/no>`,
131
131
  { id: "bridge-shell-success", toolName: "bash", isError: false, contains: "bridge visual smoke" },
132
132
  ],
133
133
  visualEvidence: [
134
- { id: "bridge-read-success", pattern: "^\\s*read \\./package\\.json", jsonlResultId: "bridge-read-success" },
134
+ { id: "bridge-read-success", pattern: "^\\s*read (?:\\./package\\.json|.*[\\\\/]package\\.json)", jsonlResultId: "bridge-read-success" },
135
135
  { id: "bridge-read-failure", pattern: "^\\s*read \\./definitely-missing-platform-smoke-file\\.txt|ENOENT: no such file", jsonlResultId: "bridge-read-failure" },
136
136
  { id: "bridge-shell-success", pattern: "^\\s*bridge visual smoke\\s*$", jsonlResultId: "bridge-shell-success" },
137
137
  ],
@@ -469,13 +469,13 @@ export function buildPlatformBuildCommand(targetName, packageName = "pi-cursor-s
469
469
  lines.push('echo "PLATFORM_PACKED_NODE_INSTALL_EXIT=$PACKED_NODE_INSTALL_EXIT"');
470
470
  lines.push(...posixSection("PACKED_NODE_INSTALL_STDOUT", 'cat "$PACK_DIR/packed-node-install.stdout.txt" 2>/dev/null || true'));
471
471
  lines.push(...posixSection("PACKED_NODE_INSTALL_STDERR", 'cat "$PACK_DIR/packed-node-install.stderr.txt" 2>/dev/null || true'));
472
- lines.push(`if [ "$PACKED_NODE_INSTALL_EXIT" -eq 0 ] && [ -n "$PI_CLI" ]; then (cd "$PI_PROJECT" && PI_OFFLINE=1 "$PI_CLI" install -l ./node_modules/${packageName} >"$PACK_DIR/pi-install.stdout.txt" 2>"$PACK_DIR/pi-install.stderr.txt"); PI_INSTALL_EXIT=$?; else echo "packed npm install failed or missing pi cli" >"$PACK_DIR/pi-install.stderr.txt"; PI_INSTALL_EXIT=1; fi`);
472
+ lines.push(`if [ "$PACKED_NODE_INSTALL_EXIT" -eq 0 ] && [ -n "$PI_CLI" ]; then (cd "$PI_PROJECT" && PI_OFFLINE=1 "$PI_CLI" install --approve -l ./node_modules/${packageName} >"$PACK_DIR/pi-install.stdout.txt" 2>"$PACK_DIR/pi-install.stderr.txt"); PI_INSTALL_EXIT=$?; else echo "packed npm install failed or missing pi cli" >"$PACK_DIR/pi-install.stderr.txt"; PI_INSTALL_EXIT=1; fi`);
473
473
  lines.push('echo "PLATFORM_PI_INSTALL_EXIT=$PI_INSTALL_EXIT"');
474
474
  lines.push(...posixSection("PI_INSTALL_STDOUT", 'cat "$PACK_DIR/pi-install.stdout.txt" 2>/dev/null || true'));
475
475
  lines.push(...posixSection("PI_INSTALL_STDERR", 'cat "$PACK_DIR/pi-install.stderr.txt" 2>/dev/null || true'));
476
476
  lines.push("");
477
477
  lines.push('echo "=== pi list ==="');
478
- lines.push('if [ -n "$PI_CLI" ]; then (cd "$PI_PROJECT" && PI_OFFLINE=1 "$PI_CLI" list >"$PACK_DIR/pi-list.stdout.txt" 2>"$PACK_DIR/pi-list.stderr.txt"); PI_LIST_EXIT=$?; else echo "missing pi cli" >"$PACK_DIR/pi-list.stderr.txt"; PI_LIST_EXIT=1; fi');
478
+ lines.push('if [ -n "$PI_CLI" ]; then (cd "$PI_PROJECT" && PI_OFFLINE=1 "$PI_CLI" list --approve >"$PACK_DIR/pi-list.stdout.txt" 2>"$PACK_DIR/pi-list.stderr.txt"); PI_LIST_EXIT=$?; else echo "missing pi cli" >"$PACK_DIR/pi-list.stderr.txt"; PI_LIST_EXIT=1; fi');
479
479
  lines.push('echo "PLATFORM_PI_LIST_EXIT=$PI_LIST_EXIT"');
480
480
  lines.push(...posixSection("PI_LIST_STDOUT", 'cat "$PACK_DIR/pi-list.stdout.txt" 2>/dev/null || true'));
481
481
  lines.push(...posixSection("PI_LIST_STDERR", 'cat "$PACK_DIR/pi-list.stderr.txt" 2>/dev/null || true'));
@@ -3,7 +3,9 @@
3
3
  import { createRequire } from "node:module";
4
4
  import { resolve, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { accessSync, constants } from "node:fs";
6
+ import { existsSync } from "node:fs";
7
+
8
+ import { platformSmokeSuiteEvidence, prunePlatformSmokeArtifacts, redactSecrets, writeLatestPlatformSmokeIndex } from "./platform-smoke/artifacts.mjs";
7
9
 
8
10
  // ── helpers ────────────────────────────────────────────────────────────────
9
11
  const __filename = fileURLToPath(import.meta.url);
@@ -102,6 +104,53 @@ function validateSelections(targets, suites) {
102
104
  }
103
105
  }
104
106
 
107
+ function failedSuiteResults(result) {
108
+ if (!result) return [];
109
+ if (Array.isArray(result.results)) return result.results.filter((suiteResult) => suiteResult?.ok !== true);
110
+ return result.ok === true ? [] : [result];
111
+ }
112
+
113
+ function formatExistingPath(label, path) {
114
+ return path && existsSync(path) ? ` ${label}: ${path}` : undefined;
115
+ }
116
+
117
+ function printFailureEvidence(results, artifactRoot) {
118
+ const failed = [];
119
+ for (const { targetName, result } of results) {
120
+ let targetEvidenceCount = 0;
121
+ for (const suiteResult of failedSuiteResults(result)) {
122
+ const evidence = platformSmokeSuiteEvidence(suiteResult, artifactRoot);
123
+ if (evidence) {
124
+ targetEvidenceCount++;
125
+ failed.push({ targetName, ...evidence });
126
+ }
127
+ }
128
+ if (targetEvidenceCount === 0 && result?.ok !== true && result?.error) {
129
+ failed.push({ targetName, suite: "target", error: result.error });
130
+ }
131
+ }
132
+ if (failed.length === 0) return;
133
+ console.log("\nFailed suite artifacts:");
134
+ for (const item of failed) {
135
+ const paths = item.paths ?? {};
136
+ console.log(`- Suite: ${item.targetName}/${item.suite}`);
137
+ if (item.error) console.log(` Target error: ${item.error}`);
138
+ const lines = [
139
+ formatExistingPath("Artifact dir", item.artifactDir),
140
+ formatExistingPath("Assertions", paths.assertions),
141
+ formatExistingPath("Failures", paths.failures),
142
+ formatExistingPath("Terminal HTML", paths.terminalHtml),
143
+ formatExistingPath("Terminal full PNG", paths.terminalFullPng),
144
+ formatExistingPath("Terminal final viewport PNG", paths.terminalFinalViewportPng),
145
+ formatExistingPath("Visual evidence", paths.visualEvidence),
146
+ formatExistingPath("Session JSONL", paths.sessionJsonl),
147
+ formatExistingPath("JSONL tool results", paths.jsonlToolResults),
148
+ formatExistingPath("Provider/Cursor debug artifacts", paths.providerDebugRoot),
149
+ ].filter(Boolean);
150
+ for (const line of lines) console.log(line);
151
+ }
152
+ }
153
+
105
154
  // ── commands ───────────────────────────────────────────────────────────────
106
155
  async function runDoctor() {
107
156
  try {
@@ -123,8 +172,9 @@ async function runSuite(targetName, suiteName) {
123
172
  const result = await runTargetSuite(config, targetName, suiteName);
124
173
  return result;
125
174
  } catch (err) {
126
- console.error(`suite ${suiteName} on ${targetName} exception:`, err.message);
127
- return { ok: false, error: err.message };
175
+ const message = redactSecrets(err.message);
176
+ console.error(`suite ${suiteName} on ${targetName} exception:`, message);
177
+ return { ok: false, error: message };
128
178
  }
129
179
  }
130
180
 
@@ -133,8 +183,9 @@ async function runTarget(targetName, suites) {
133
183
  const { runTargetSuites } = await import("./platform-smoke/targets.mjs");
134
184
  return await runTargetSuites(config, targetName, suites);
135
185
  } catch (err) {
136
- console.error(`target ${targetName} exception:`, err.message);
137
- return { ok: false, error: err.message };
186
+ const message = redactSecrets(err.message);
187
+ console.error(`target ${targetName} exception:`, message);
188
+ return { ok: false, error: message };
138
189
  }
139
190
  }
140
191
 
@@ -172,6 +223,12 @@ async function main() {
172
223
  process.exit(2);
173
224
  }
174
225
 
226
+ const pruneResult = prunePlatformSmokeArtifacts(config.artifactRoot, config.artifactRetention);
227
+ if (pruneResult.removed.length > 0) {
228
+ console.log(`Pruned ${pruneResult.removed.length} old platform smoke artifact run(s) from ${pruneResult.root}`);
229
+ }
230
+
231
+ const startedAt = new Date().toISOString();
175
232
  const targetRuns = targets.map(async (targetName) => {
176
233
  console.log(`\n=== Target: ${targetName} ===`);
177
234
  const result = args.suite
@@ -180,9 +237,21 @@ async function main() {
180
237
  return { targetName, result };
181
238
  });
182
239
  const results = await Promise.all(targetRuns);
240
+ const finishedAt = new Date().toISOString();
241
+ const latest = writeLatestPlatformSmokeIndex(config, results, {
242
+ startedAt,
243
+ finishedAt,
244
+ command: {
245
+ cwd: process.cwd(),
246
+ targets,
247
+ suites,
248
+ },
249
+ });
250
+ console.log(`\nArtifact index: ${latest.path}`);
183
251
  const anyFailed = results.some(({ result }) => !result.ok);
184
252
  if (anyFailed) {
185
- console.log("\nOne or more suites failed. Check .artifacts/platform-smoke/ for details.");
253
+ printFailureEvidence(results, config.artifactRoot);
254
+ console.log("\nOne or more suites failed.");
186
255
  process.exit(1);
187
256
  }
188
257
  return;
@@ -165,7 +165,7 @@ function buildPiRpcEnv(baseEnv = process.env, nodePath = process.execPath) {
165
165
  }
166
166
 
167
167
  async function runPiRpcSmoke(sessionDir, piBin) {
168
- const args = ["-e", root, "--cursor-no-fast", "--model", "cursor/composer-2-5", "--mode", "rpc", "--session-dir", sessionDir];
168
+ const args = ["--approve", "-e", root, "--cursor-no-fast", "--model", "cursor/composer-2-5", "--mode", "rpc", "--session-dir", sessionDir];
169
169
  const env = buildPiRpcEnv();
170
170
 
171
171
  const child = spawn(piBin, args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
@@ -392,7 +392,7 @@ if [[ "$SHELL_BIN" != /* ]]; then
392
392
  SHELL_BIN="$(smoke_resolve_cmd "$SHELL_BIN")"
393
393
  fi
394
394
  PI_BASE=(
395
- "$PI_BIN" -e "$ROOT"
395
+ "$PI_BIN" --approve -e "$ROOT"
396
396
  --cursor-no-fast
397
397
  --model cursor/composer-2-5
398
398
  )
@@ -0,0 +1,229 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync, utimesSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { delimiter } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+
6
+ function assertSelfTest(condition, message) {
7
+ if (!condition) throw new Error(`self-test failed: ${message}`);
8
+ }
9
+
10
+ function envMap(assignments) {
11
+ return new Map(assignments.map(([name, value]) => [name, value]));
12
+ }
13
+
14
+ function parseEnvCapture(path) {
15
+ return new Map(
16
+ readFileSync(path, "utf8")
17
+ .split("\n")
18
+ .filter(Boolean)
19
+ .map((line) => {
20
+ const index = line.indexOf("=");
21
+ return index === -1 ? [line, ""] : [line.slice(0, index), line.slice(index + 1)];
22
+ }),
23
+ );
24
+ }
25
+
26
+ export function runVisualSmokeSelfTest(deps) {
27
+ const { ROOT, DEFAULT_MODE, DEFAULT_MODEL, DEFAULT_SETTING_SOURCES, DEBUG_ENV_NAMES, shellQuote, parseArgs, snapshotJsonlMtimes, findLatestJsonl, sealedNodePath, resolveCommand, requireNode, requireCommand, buildLaunchPlan, run, runVisualSmoke } = deps;
28
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-visual-self-test-"));
29
+ try {
30
+ const binDir = join(tempDir, "bin");
31
+ mkdirSync(binDir, { recursive: true });
32
+ const fakePi = join(binDir, "pi");
33
+ const fakeNode = join(binDir, "node");
34
+ const fakeNodeMarker = join(tempDir, "fake-node-used");
35
+ const envCapture = join(tempDir, "fake-pi.env");
36
+ writeFileSync(
37
+ fakePi,
38
+ `#!/usr/bin/env node\nconst { writeFileSync } = require("node:fs");\nwriteFileSync(${JSON.stringify(envCapture)}, Object.entries(process.env).map(([key, value]) => key + "=" + (value ?? "")).join("\\n") + "\\n", "utf8");\n`,
39
+ "utf8",
40
+ );
41
+ writeFileSync(fakeNode, `#!/bin/sh\necho fake-node-used > ${shellQuote(fakeNodeMarker)}\nexit 99\n`, "utf8");
42
+ chmodSync(fakePi, 0o755);
43
+ chmodSync(fakeNode, 0o755);
44
+
45
+ const promptFile = join(tempDir, "prompt.txt");
46
+ writeFileSync(promptFile, "file prompt", "utf8");
47
+ assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt-file", promptFile, "--prompt", "inline prompt"]).prompt === "inline prompt", "--prompt should override an earlier --prompt-file");
48
+ assertSelfTest(parseArgs(["--label", "prompt-dash", "--prompt", "--starts-with-dash"]).prompt === "--starts-with-dash", "--prompt should accept dash-prefixed free-form text");
49
+ assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt", "inline prompt", "--prompt-file", promptFile]).prompt === "file prompt", "--prompt-file should override an earlier --prompt");
50
+
51
+ const jsonlDir = join(tempDir, "jsonl-filter");
52
+ mkdirSync(jsonlDir, { recursive: true });
53
+ const staleJsonl = join(jsonlDir, "stale.jsonl");
54
+ const freshJsonl = join(jsonlDir, "fresh.jsonl");
55
+ writeFileSync(staleJsonl, "{}\n", "utf8");
56
+ utimesSync(staleJsonl, new Date(1_000), new Date(1_000));
57
+ const previousJsonlMtimes = snapshotJsonlMtimes(jsonlDir);
58
+ writeFileSync(freshJsonl, "{}\n", "utf8");
59
+ utimesSync(freshJsonl, new Date(3_000), new Date(3_000));
60
+ assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 2_000, previousMtimes: previousJsonlMtimes }) === freshJsonl, "JSONL discovery should ignore unchanged stale files before run start");
61
+ assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 4_000, previousMtimes: snapshotJsonlMtimes(jsonlDir) }) === undefined, "JSONL discovery should not return stale evidence when current run has no changed JSONL");
62
+
63
+ assertSelfTest(!sealedNodePath(process.execPath, "").includes(delimiter), "empty inherited PATH must not leave an empty PATH segment");
64
+ const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
65
+ const sealedHostilePath = sealedNodePath(process.execPath, hostilePath);
66
+ assertSelfTest(resolveCommand("pi", hostilePath) === fakePi, "direct PATH resolver did not prefer fake PATH head");
67
+ assertSelfTest(requireNode() === process.execPath, "node resolver must use process.execPath");
68
+ assertSelfTest(requireCommand("pi", { envPath: hostilePath, env: { ...process.env, PATH: sealedHostilePath } }) === fakePi, "pi prereq should use sealed PATH when executing the shim");
69
+ assertSelfTest(!existsSync(fakeNodeMarker), "pi prereq should not use hostile fake node");
70
+
71
+ const baseOptions = {
72
+ ext: ROOT,
73
+ cwd: ROOT,
74
+ mode: DEFAULT_MODE,
75
+ model: DEFAULT_MODEL,
76
+ outDir: tempDir,
77
+ safeLabel: "self-test",
78
+ sessionDir: join(tempDir, "session"),
79
+ sessionId: "self-test",
80
+ settingSources: DEFAULT_SETTING_SOURCES,
81
+ bridge: false,
82
+ exposeBuiltinTools: false,
83
+ eventDebug: false,
84
+ };
85
+ const plan = buildLaunchPlan(baseOptions, { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath }, "/bin/sh");
86
+ const defaults = envMap(plan.envAssignments);
87
+ assertSelfTest(defaults.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "native display must be forced on");
88
+ assertSelfTest(defaults.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "native tool registration must be forced on");
89
+ assertSelfTest(defaults.get("PI_CURSOR_SETTING_SOURCES") === "none", "setting sources must default to none");
90
+ assertSelfTest(defaults.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "bridge must default off");
91
+ assertSelfTest(defaults.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "built-in exposure must default off");
92
+ for (const name of DEBUG_ENV_NAMES) {
93
+ assertSelfTest(plan.clearEnvNames.includes(name), `${name} must be cleared by default`);
94
+ }
95
+ assertSelfTest(plan.script.includes(shellQuote(fakePi)), "launch script must use resolved pi path");
96
+ assertSelfTest(!plan.script.includes(" exec pi "), "launch script must not use bare pi");
97
+ const hostileEnv = {
98
+ ...process.env,
99
+ ...Object.fromEntries(DEBUG_ENV_NAMES.map((name) => [name, join(tempDir, name)])),
100
+ PATH: hostilePath,
101
+ PI_CURSOR_REGISTER_NATIVE_TOOLS: "0",
102
+ PI_CURSOR_SETTING_SOURCES: "all",
103
+ PI_CURSOR_PI_TOOL_BRIDGE: "1",
104
+ PI_CURSOR_EXPOSE_BUILTIN_TOOLS: "1",
105
+ };
106
+ const probe = run("/bin/sh", ["-c", plan.script], { env: hostileEnv });
107
+ assertSelfTest(probe.status === 0, `fake-pi env capture exited ${probe.status}: ${probe.stderr?.toString() ?? ""}`);
108
+ const capturedEnv = parseEnvCapture(envCapture);
109
+ assertSelfTest(!existsSync(fakeNodeMarker), "launch PATH should force the resolved node before hostile fake node");
110
+ assertSelfTest((capturedEnv.get("PATH") ?? "").split(delimiter)[0] === dirname(process.execPath), "captured PATH should start with resolved node directory");
111
+ assertSelfTest(capturedEnv.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "captured env should force native display on");
112
+ assertSelfTest(capturedEnv.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "captured env should force native registration on");
113
+ assertSelfTest(capturedEnv.get("PI_CURSOR_SETTING_SOURCES") === "none", "captured env should force settings off");
114
+ assertSelfTest(capturedEnv.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "captured env should force bridge off");
115
+ assertSelfTest(capturedEnv.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "captured env should force built-in exposure off");
116
+ for (const name of DEBUG_ENV_NAMES) {
117
+ assertSelfTest(!capturedEnv.has(name), `${name} should be absent from captured env by default`);
118
+ }
119
+
120
+ const optInPlan = buildLaunchPlan(
121
+ { ...baseOptions, settingSources: "all", bridge: true, exposeBuiltinTools: true, eventDebug: true },
122
+ { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath },
123
+ "/bin/sh",
124
+ );
125
+ const optIns = envMap(optInPlan.envAssignments);
126
+ assertSelfTest(optIns.get("PI_CURSOR_SETTING_SOURCES") === "all", "setting source opt-in must be reflected");
127
+ assertSelfTest(optIns.get("PI_CURSOR_PI_TOOL_BRIDGE") === "1", "bridge opt-in must be reflected");
128
+ assertSelfTest(optIns.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "1", "built-in exposure opt-in must be reflected");
129
+ assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug opt-in must be reflected");
130
+ assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir must be deterministic under out-dir");
131
+ for (const name of DEBUG_ENV_NAMES) {
132
+ assertSelfTest(optInPlan.clearEnvNames.includes(name), `${name} must be cleared even when event debug is explicit`);
133
+ }
134
+ const eventDebugProbe = run("/bin/sh", ["-c", optInPlan.script], { env: hostileEnv });
135
+ assertSelfTest(eventDebugProbe.status === 0, `fake-pi event-debug env capture exited ${eventDebugProbe.status}: ${eventDebugProbe.stderr?.toString() ?? ""}`);
136
+ const capturedEventDebugEnv = parseEnvCapture(envCapture);
137
+ assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug should be explicitly enabled");
138
+ assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir should be deterministic under out-dir");
139
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR"), "stale event debug run dir should be cleared");
140
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR"), "stale event debug session dir should be cleared");
141
+ assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_STDERR"), "stale event debug stderr flag should be cleared");
142
+
143
+ const fakeTmux = join(binDir, "tmux");
144
+ const deleteBufferMarker = join(tempDir, "delete-buffer-called");
145
+ writeFileSync(
146
+ fakeTmux,
147
+ `#!/bin/sh\ncase "$1" in\n -V) echo 'tmux fake'; exit 0 ;;\n new-session) exit 0 ;;\n load-buffer) cat >/dev/null; exit 0 ;;\n paste-buffer) exit 77 ;;\n delete-buffer) echo deleted > ${shellQuote(deleteBufferMarker)}; exit 0 ;;\n kill-session) exit 0 ;;\n *) echo "unexpected tmux command: $*" >&2; exit 64 ;;\nesac\n`,
148
+ "utf8",
149
+ );
150
+ chmodSync(fakeTmux, 0o755);
151
+ const originalPath = process.env.PATH;
152
+ try {
153
+ process.env.PATH = hostilePath;
154
+ let pasteFailed = false;
155
+ try {
156
+ runVisualSmoke({
157
+ ...baseOptions,
158
+ prompt: "buffer cleanup prompt",
159
+ startupMs: 1,
160
+ waitMs: 1,
161
+ width: 80,
162
+ height: 24,
163
+ historyLines: 100,
164
+ });
165
+ } catch (error) {
166
+ pasteFailed = /paste-buffer failed/.test(error instanceof Error ? error.message : String(error));
167
+ }
168
+ assertSelfTest(pasteFailed, "fake tmux paste failure should exercise prompt-buffer cleanup path");
169
+ assertSelfTest(existsSync(deleteBufferMarker), "prompt tmux buffer should be deleted when paste/send fails");
170
+ } finally {
171
+ if (originalPath === undefined) delete process.env.PATH;
172
+ else process.env.PATH = originalPath;
173
+ }
174
+
175
+ writeFileSync(
176
+ fakeTmux,
177
+ `#!/bin/sh
178
+ case "$1" in
179
+ -V) echo 'tmux fake'; exit 0 ;;
180
+ new-session) exit 0 ;;
181
+ load-buffer) cat >/dev/null; exit 0 ;;
182
+ paste-buffer) exit 0 ;;
183
+ send-keys) exit 0 ;;
184
+ delete-buffer) exit 0 ;;
185
+ capture-pane) echo 'captured visual smoke output'; exit 0 ;;
186
+ kill-session) exit 0 ;;
187
+ *) echo "unexpected tmux command: $*" >&2; exit 64 ;;
188
+ esac
189
+ `,
190
+ "utf8",
191
+ );
192
+ chmodSync(fakeTmux, 0o755);
193
+ const noJsonlManifest = join(tempDir, "self-test-jsonl-missing.manifest.json");
194
+ try {
195
+ process.env.PATH = hostilePath;
196
+ let missingJsonlFailed = false;
197
+ let missingJsonlError = "";
198
+ try {
199
+ runVisualSmoke({
200
+ ...baseOptions,
201
+ label: "self-test-jsonl-missing",
202
+ safeLabel: "self-test-jsonl-missing",
203
+ prompt: "jsonl failure prompt",
204
+ startupMs: 1,
205
+ waitMs: 1,
206
+ width: 80,
207
+ height: 24,
208
+ historyLines: 100,
209
+ sessionDir: join(tempDir, "missing-jsonl-session"),
210
+ });
211
+ } catch (error) {
212
+ missingJsonlError = error instanceof Error ? error.message : String(error);
213
+ missingJsonlFailed = /no current-run persisted \.jsonl/.test(missingJsonlError);
214
+ }
215
+ assertSelfTest(missingJsonlFailed, `missing JSONL should fail after partial visual artifacts are written: ${missingJsonlError || "no error"}`);
216
+ assertSelfTest(existsSync(noJsonlManifest), "missing JSONL should still write a failure manifest");
217
+ const manifest = JSON.parse(readFileSync(noJsonlManifest, "utf8"));
218
+ assertSelfTest(manifest.failure?.message?.includes("no current-run persisted .jsonl"), "failure manifest should record the missing JSONL reason");
219
+ assertSelfTest(manifest.paths?.html?.endsWith("self-test-jsonl-missing.html"), "failure manifest should point at partial HTML evidence");
220
+ } finally {
221
+ if (originalPath === undefined) delete process.env.PATH;
222
+ else process.env.PATH = originalPath;
223
+ }
224
+ console.log("[visual-smoke] self-test PASS");
225
+ } finally {
226
+ rmSync(tempDir, { recursive: true, force: true });
227
+ }
228
+ }
229
+