pi-cursor-sdk 0.1.20 → 0.1.21

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 (88) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +49 -9
  3. package/docs/cursor-dogfood-checklist.md +57 -0
  4. package/docs/cursor-live-smoke-checklist.md +115 -9
  5. package/docs/cursor-model-ux-spec.md +57 -17
  6. package/docs/cursor-native-tool-replay.md +15 -7
  7. package/docs/cursor-native-tool-visual-audit.md +104 -59
  8. package/docs/cursor-testing-lessons.md +8 -3
  9. package/docs/cursor-tool-surfaces.md +69 -0
  10. package/package.json +34 -10
  11. package/scripts/debug-provider-events.d.mts +59 -0
  12. package/scripts/debug-provider-events.mjs +70 -175
  13. package/scripts/debug-sdk-events.d.mts +90 -0
  14. package/scripts/debug-sdk-events.mjs +36 -98
  15. package/scripts/fixtures/plan-strip-shim/index.ts +12 -0
  16. package/scripts/isolated-cursor-smoke.sh +264 -102
  17. package/scripts/lib/cursor-child-process.d.mts +10 -0
  18. package/scripts/lib/cursor-child-process.mjs +50 -0
  19. package/scripts/lib/cursor-cli-args.d.mts +63 -0
  20. package/scripts/lib/cursor-cli-args.mjs +129 -0
  21. package/scripts/lib/cursor-script-fail.d.mts +1 -0
  22. package/scripts/lib/cursor-script-fail.mjs +13 -0
  23. package/scripts/lib/cursor-sdk-output-filter.d.mts +5 -0
  24. package/scripts/lib/cursor-smoke-env.d.mts +38 -0
  25. package/scripts/lib/cursor-smoke-env.mjs +81 -0
  26. package/scripts/lib/cursor-smoke-shell.sh +174 -0
  27. package/scripts/lib/cursor-visual-render.d.mts +15 -0
  28. package/scripts/lib/cursor-visual-render.mjs +131 -0
  29. package/scripts/probe-mcp-coldstart.mjs +20 -38
  30. package/scripts/refresh-cursor-model-snapshots.mjs +29 -65
  31. package/scripts/steering-rpc-smoke.mjs +170 -65
  32. package/scripts/tmux-live-smoke.sh +152 -98
  33. package/scripts/visual-tui-smoke.mjs +659 -0
  34. package/shared/cursor-sdk-event-debug-env.d.mts +12 -0
  35. package/shared/cursor-sdk-event-debug-env.mjs +13 -0
  36. package/shared/cursor-sensitive-text.d.mts +1 -0
  37. package/{scripts/lib/cursor-probe-utils.mjs → shared/cursor-sensitive-text.mjs} +1 -13
  38. package/shared/cursor-setting-sources.d.mts +5 -0
  39. package/shared/cursor-setting-sources.mjs +22 -0
  40. package/src/context.ts +21 -12
  41. package/src/cursor-bridge-contract.ts +1 -3
  42. package/src/cursor-incomplete-tool-visibility.ts +22 -5
  43. package/src/cursor-native-tool-display-registration.ts +63 -27
  44. package/src/cursor-native-tool-display-replay.ts +246 -144
  45. package/src/cursor-native-tool-display-state.ts +2 -0
  46. package/src/cursor-native-tool-display-tools.ts +149 -41
  47. package/src/cursor-provider-live-run-drain.ts +1 -52
  48. package/src/cursor-provider-run-finalizer.ts +235 -0
  49. package/src/cursor-provider-run-outcome.ts +149 -0
  50. package/src/cursor-provider-turn-api-key.ts +8 -0
  51. package/src/cursor-provider-turn-coordinator.ts +98 -446
  52. package/src/cursor-provider-turn-display-router.ts +216 -0
  53. package/src/cursor-provider-turn-emit.ts +59 -0
  54. package/src/cursor-provider-turn-finalize.ts +119 -0
  55. package/src/cursor-provider-turn-lifecycle-emitter.ts +97 -0
  56. package/src/cursor-provider-turn-message-offset.ts +15 -0
  57. package/src/cursor-provider-turn-prepare.ts +216 -0
  58. package/src/cursor-provider-turn-runner.ts +138 -0
  59. package/src/cursor-provider-turn-sdk-normalizer.ts +88 -0
  60. package/src/cursor-provider-turn-send.ts +103 -0
  61. package/src/cursor-provider-turn-shell-output.ts +107 -0
  62. package/src/cursor-provider-turn-tool-ledger.ts +126 -0
  63. package/src/cursor-provider-turn-types.ts +87 -0
  64. package/src/cursor-provider.ts +16 -504
  65. package/src/cursor-replay-activity-builders.ts +276 -0
  66. package/src/cursor-replay-source-names.ts +33 -0
  67. package/src/cursor-replay-summary-args.ts +191 -0
  68. package/src/cursor-replay-tool-details.ts +464 -0
  69. package/src/cursor-run-final-text.ts +56 -0
  70. package/src/cursor-sdk-abort-error-guard.ts +4 -0
  71. package/src/cursor-sdk-event-debug-constants.ts +14 -5
  72. package/src/cursor-sdk-event-debug.ts +2 -1
  73. package/src/cursor-sensitive-text.ts +3 -36
  74. package/src/cursor-session-agent.ts +3 -1
  75. package/src/cursor-setting-sources.ts +7 -10
  76. package/src/cursor-state.ts +232 -28
  77. package/src/cursor-tool-lifecycle.ts +9 -8
  78. package/src/cursor-tool-manifest.ts +41 -0
  79. package/src/cursor-tool-names.ts +18 -106
  80. package/src/cursor-tool-presentation-registry.ts +556 -0
  81. package/src/cursor-tool-transcript.ts +1 -1
  82. package/src/cursor-tool-visibility.ts +3 -27
  83. package/src/cursor-transcript-tool-formatters.ts +0 -59
  84. package/src/cursor-transcript-tool-specs.ts +158 -233
  85. package/src/cursor-transcript-utils.ts +0 -44
  86. package/src/cursor-web-tool-activity.ts +10 -60
  87. package/src/cursor-web-tool-args.ts +39 -0
  88. package/src/index.ts +4 -10
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ function escapeHtml(text) {
6
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7
+ }
8
+
9
+ function htmlJson(value) {
10
+ return JSON.stringify(value).replace(/</g, "\\u003c");
11
+ }
12
+
13
+ function loadXtermAssets() {
14
+ const require = createRequire(import.meta.url);
15
+ try {
16
+ return {
17
+ css: readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"),
18
+ js: readFileSync(require.resolve("@xterm/xterm/lib/xterm.js"), "utf8"),
19
+ };
20
+ } catch (error) {
21
+ throw new Error(`failed to load @xterm/xterm assets; run npm install: ${error instanceof Error ? error.message : String(error)}`);
22
+ }
23
+ }
24
+
25
+ export function buildTerminalHtml({ ansi, plain, options }) {
26
+ const assets = loadXtermAssets();
27
+ return `<!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <title>pi-cursor-sdk visual smoke: ${escapeHtml(options.label)}</title>
32
+ <style>
33
+ ${assets.css}
34
+ :root { color-scheme: dark; }
35
+ body {
36
+ margin: 0;
37
+ padding: 16px;
38
+ background: #0b0f14;
39
+ color: #d8dee9;
40
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
41
+ }
42
+ header {
43
+ margin: 0 0 12px;
44
+ font-size: 13px;
45
+ line-height: 1.4;
46
+ color: #9aa4b2;
47
+ }
48
+ header code { color: #d8dee9; }
49
+ #terminal {
50
+ display: inline-block;
51
+ padding: 12px;
52
+ border: 1px solid #303846;
53
+ border-radius: 8px;
54
+ background: #0b0f14;
55
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
56
+ }
57
+ .fallback {
58
+ white-space: pre-wrap;
59
+ font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
60
+ font-size: 12px;
61
+ }
62
+ </style>
63
+ <script>${assets.js}</script>
64
+ </head>
65
+ <body>
66
+ <header>
67
+ <div><strong>pi-cursor-sdk visual smoke</strong> <code>${escapeHtml(options.label)}</code></div>
68
+ <div>model <code>${escapeHtml(options.model)}</code> · mode <code>${escapeHtml(options.mode)}</code> · cwd <code>${escapeHtml(options.cwd)}</code></div>
69
+ <div>session <code>${escapeHtml(options.sessionId)}</code> · captured ${new Date().toISOString()}</div>
70
+ </header>
71
+ <div id="terminal"></div>
72
+ <noscript><pre class="fallback">${escapeHtml(plain)}</pre></noscript>
73
+ <script>
74
+ const ansi = ${htmlJson(ansi)};
75
+ const fallbackText = ${htmlJson(plain)};
76
+ const terminalElement = document.getElementById("terminal");
77
+ try {
78
+ const term = new Terminal({
79
+ cols: ${options.width},
80
+ rows: ${options.height},
81
+ convertEol: true,
82
+ fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", monospace',
83
+ fontSize: 13,
84
+ lineHeight: 1.18,
85
+ scrollback: ${options.historyLines},
86
+ theme: {
87
+ background: '#0b0f14',
88
+ foreground: '#d8dee9',
89
+ cursor: '#d8dee9'
90
+ }
91
+ });
92
+ term.open(terminalElement);
93
+ term.resize(${options.width}, ${options.height});
94
+ term.write(ansi, () => {
95
+ document.body.setAttribute("data-render-ready", "true");
96
+ });
97
+ } catch (error) {
98
+ const pre = document.createElement("pre");
99
+ pre.className = "fallback";
100
+ pre.textContent = fallbackText + "\\n\\n[xterm render failed: " + String(error) + "]";
101
+ terminalElement.replaceChildren(pre);
102
+ document.body.setAttribute("data-render-ready", "true");
103
+ }
104
+ </script>
105
+ </body>
106
+ </html>
107
+ `;
108
+ }
109
+
110
+ export async function writeTerminalScreenshot(htmlPath, pngPath, width, height) {
111
+ let browser;
112
+ try {
113
+ const { chromium } = await import("playwright");
114
+ browser = await chromium.launch();
115
+ const page = await browser.newPage({
116
+ viewport: {
117
+ width: Math.max(1_200, width * 10),
118
+ height: Math.max(800, height * 22),
119
+ },
120
+ deviceScaleFactor: 1,
121
+ });
122
+ await page.goto(pathToFileURL(htmlPath).href);
123
+ await page.waitForSelector('body[data-render-ready="true"]', { timeout: 30_000 });
124
+ await page.locator("#terminal").screenshot({ path: pngPath });
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ throw new Error(`failed to capture PNG with Playwright: ${message}\nInstall Chromium with: npx playwright install chromium\nOr rerun with --no-screenshot and capture ${htmlPath} with agent_browser.`);
128
+ } finally {
129
+ if (browser) await browser.close();
130
+ }
131
+ }
@@ -10,7 +10,9 @@ import {
10
10
  installCursorMcpToolTimeoutOverride,
11
11
  restoreCursorMcpToolTimeoutOverride,
12
12
  } from "../src/cursor-mcp-timeout-override.ts";
13
- import { scrubSensitiveText } from "./lib/cursor-probe-utils.mjs";
13
+ import { apiKeySecretsFromProcess, defaultApiKeyFromEnv, parseArgv } from "./lib/cursor-cli-args.mjs";
14
+ import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs";
15
+ import { createScriptFail } from "./lib/cursor-script-fail.mjs";
14
16
  import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./lib/cursor-sdk-output-filter.mjs";
15
17
 
16
18
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
@@ -47,9 +49,11 @@ Safety:
47
49
  - Error messages are scrubbed for API keys, bearer tokens, cookies, and bridge endpoints.`);
48
50
  }
49
51
 
50
- function fail(message, apiKey) {
51
- console.error(`probe-mcp-coldstart: ${scrubSensitiveText(message, apiKey)}`);
52
- process.exit(1);
52
+ const exitWithFailure = createScriptFail("probe-mcp-coldstart");
53
+
54
+ function fail(message, secrets) {
55
+ const secretList = secrets === undefined ? [] : Array.isArray(secrets) ? secrets : [secrets];
56
+ exitWithFailure(message, secretList.filter(Boolean));
53
57
  }
54
58
 
55
59
  function findScenario(label) {
@@ -57,39 +61,17 @@ function findScenario(label) {
57
61
  }
58
62
 
59
63
  function parseArgs(argv, env = process.env) {
60
- const args = {
61
- apiKey: env.CURSOR_API_KEY?.trim() || undefined,
62
- help: false,
63
- scenario: undefined,
64
- };
65
- for (let index = 0; index < argv.length; index++) {
66
- const arg = argv[index];
67
- if (arg === "-h" || arg === "--help") {
68
- args.help = true;
69
- continue;
70
- }
71
- if (arg === "--api-key") {
72
- const value = argv[++index];
73
- if (!value || value.startsWith("--")) fail("--api-key requires a value", args.apiKey);
74
- args.apiKey = value.trim();
75
- continue;
76
- }
77
- if (arg.startsWith("--api-key=")) {
78
- args.apiKey = arg.slice("--api-key=".length).trim();
79
- continue;
80
- }
81
- if (arg === "--scenario") {
82
- const value = argv[++index];
83
- if (!value || value.startsWith("--")) fail("--scenario requires a value", args.apiKey);
84
- args.scenario = value.trim();
85
- continue;
86
- }
87
- if (arg.startsWith("--scenario=")) {
88
- args.scenario = arg.slice("--scenario=".length).trim();
89
- continue;
90
- }
91
- fail(`unknown argument: ${arg}`, args.apiKey);
92
- }
64
+ const args = parseArgv(argv, {
65
+ defaults: {
66
+ apiKey: defaultApiKeyFromEnv(env),
67
+ scenario: undefined,
68
+ },
69
+ flags: {
70
+ apiKey: { names: ["--api-key"], assign: (value) => value.trim() },
71
+ scenario: { names: ["--scenario"], assign: (value) => value.trim() },
72
+ },
73
+ fail: (message) => fail(message, defaultApiKeyFromEnv(env)),
74
+ });
93
75
  if (args.scenario && !findScenario(args.scenario)) {
94
76
  fail(`unknown scenario: ${args.scenario}`, args.apiKey);
95
77
  }
@@ -239,6 +221,6 @@ async function main(argv = process.argv.slice(2), env = process.env) {
239
221
  if (import.meta.url === new URL(process.argv[1], "file:").href) {
240
222
  main().catch((error) => {
241
223
  const message = error instanceof Error ? error.message : String(error);
242
- fail(message, process.env.CURSOR_API_KEY);
224
+ fail(message, apiKeySecretsFromProcess());
243
225
  });
244
226
  }
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
- import { basename, resolve } from "node:path";
4
3
  import { Cursor } from "@cursor/sdk";
4
+ import { defaultApiKeyFromEnv, parseArgv } from "./lib/cursor-cli-args.mjs";
5
+ import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs";
6
+ import { createScriptFail } from "./lib/cursor-script-fail.mjs";
5
7
 
6
8
  const FALLBACK_MODELS_PATH = "src/cursor-fallback-models.generated.ts";
7
9
  const CONTEXT_WINDOWS_PATH = "src/bundled-context-windows.ts";
@@ -38,70 +40,32 @@ Notes:
38
40
  requires successful local SDK runs; this script does not start agents.`);
39
41
  }
40
42
 
41
- function fail(message) {
42
- console.error(`refresh-cursor-snapshots: ${message}`);
43
- process.exit(1);
44
- }
43
+ const fail = createScriptFail("refresh-cursor-snapshots");
45
44
 
46
- function scrubSensitiveText(text, secrets = []) {
47
- let scrubbed = text;
48
- for (const secret of secrets) {
49
- if (secret) scrubbed = scrubbed.split(secret).join("[REDACTED]");
50
- }
51
- return scrubbed
52
- .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
53
- .replace(/(api[_-]?key|authorization|auth[_-]?token)([\"'\s:=]+)[^\"'\s,}]+/gi, "$1$2[REDACTED]");
54
- }
55
-
56
- function parseArgs(argv) {
57
- const args = {
58
- write: false,
59
- apiKey: process.env.CURSOR_API_KEY?.trim() || undefined,
60
- contextWindowsPath: undefined,
61
- fallbackContextWindow: DEFAULT_CONTEXT_WINDOW,
62
- };
63
- for (let index = 0; index < argv.length; index++) {
64
- const arg = argv[index];
65
- if (arg === "-h" || arg === "--help") {
66
- printHelp();
67
- process.exit(0);
68
- }
69
- if (arg === "--write") {
70
- args.write = true;
71
- continue;
72
- }
73
- if (arg === "--api-key") {
74
- const value = argv[++index];
75
- if (!value || value.startsWith("--")) fail("--api-key requires a value");
76
- args.apiKey = value.trim();
77
- continue;
78
- }
79
- if (arg.startsWith("--api-key=")) {
80
- args.apiKey = arg.slice("--api-key=".length).trim();
81
- continue;
82
- }
83
- if (arg === "--context-windows") {
84
- const value = argv[++index];
85
- if (!value || value.startsWith("--")) fail("--context-windows requires a file path");
86
- args.contextWindowsPath = value;
87
- continue;
88
- }
89
- if (arg.startsWith("--context-windows=")) {
90
- args.contextWindowsPath = arg.slice("--context-windows=".length);
91
- continue;
92
- }
93
- if (arg === "--fallback-context-window") {
94
- const value = argv[++index];
95
- if (!value || value.startsWith("--")) fail("--fallback-context-window requires a positive integer");
96
- args.fallbackContextWindow = parsePositiveInteger(value, "--fallback-context-window");
97
- continue;
98
- }
99
- if (arg.startsWith("--fallback-context-window=")) {
100
- args.fallbackContextWindow = parsePositiveInteger(arg.slice("--fallback-context-window=".length), "--fallback-context-window");
101
- continue;
102
- }
103
- fail(`unknown argument ${arg}`);
45
+ function parseRefreshArgs(argv) {
46
+ if (argv.includes("-h") || argv.includes("--help")) {
47
+ printHelp();
48
+ process.exit(0);
104
49
  }
50
+ const write = argv.includes("--write");
51
+ const filteredArgv = argv.filter((arg) => arg !== "--write");
52
+ const args = parseArgv(filteredArgv, {
53
+ defaults: {
54
+ write,
55
+ apiKey: defaultApiKeyFromEnv(),
56
+ contextWindowsPath: undefined,
57
+ fallbackContextWindow: DEFAULT_CONTEXT_WINDOW,
58
+ },
59
+ flags: {
60
+ apiKey: { names: ["--api-key"], assign: (value) => value.trim() },
61
+ contextWindowsPath: { names: ["--context-windows"] },
62
+ fallbackContextWindow: {
63
+ names: ["--fallback-context-window"],
64
+ assign: (value) => parsePositiveInteger(value, "--fallback-context-window"),
65
+ },
66
+ },
67
+ fail,
68
+ });
105
69
  if (!args.apiKey) fail("missing Cursor API key; set CURSOR_API_KEY or pass --api-key");
106
70
  return args;
107
71
  }
@@ -200,13 +164,13 @@ function formatContextWindows(models, checkpointWindows, fallbackContextWindow)
200
164
  return `// Generated from Cursor SDK checkpoint tokenDetails.maxTokens on ${date}.\n// Refresh with: npm run refresh:cursor-snapshots -- --write --context-windows ~/.pi/agent/cursor-sdk-context-windows.json\n// These are default/non-Max-mode SDK context windows for Cursor models that do not\n// expose a catalog \`context\` parameter. Do not replace them with Max Mode values\n// unless the Cursor SDK exposes an exact Max Mode model selection and the extension\n// uses that selection for matching pi model IDs.\nexport const BUNDLED_CONTEXT_WINDOWS = {\n${lines.join("\n")}\n} as const satisfies Record<string, number>;\n`;
201
165
  }
202
166
 
203
- const args = parseArgs(process.argv.slice(2));
167
+ const args = parseRefreshArgs(process.argv.slice(2));
204
168
  let rawModels;
205
169
  try {
206
170
  rawModels = await Cursor.models.list({ apiKey: args.apiKey });
207
171
  } catch (error) {
208
172
  const rawMessage = error instanceof Error ? error.message : String(error);
209
- fail(`Cursor.models.list() failed: ${scrubSensitiveText(rawMessage, [args.apiKey])}`);
173
+ fail(`Cursor.models.list() failed: ${scrubSensitiveText(rawMessage, args.apiKey)}`);
210
174
  }
211
175
  if (!Array.isArray(rawModels) || rawModels.length === 0) fail("Cursor.models.list() returned no models");
212
176
 
@@ -2,13 +2,20 @@
2
2
  /**
3
3
  * RPC steering smoke: queue steer after a native-replay tool-use turn completes execution.
4
4
  */
5
- import { spawn } from "node:child_process";
6
- import { mkdirSync } from "node:fs";
7
- import { join } from "node:path";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { EventEmitter } from "node:events";
7
+ import { accessSync, chmodSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { delimiter, dirname, join, resolve } from "node:path";
8
10
  import { fileURLToPath } from "node:url";
11
+ import { parseJsonLines, terminateChild, waitForChildClose } from "./lib/cursor-child-process.mjs";
12
+ import { apiKeySecretsFromProcess } from "./lib/cursor-cli-args.mjs";
13
+ import { buildCursorSmokeEnv, CURSOR_SDK_EVENT_DEBUG_ENV_NAMES } from "./lib/cursor-smoke-env.mjs";
14
+ import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs";
9
15
 
10
16
  const root = fileURLToPath(new URL("..", import.meta.url));
11
- const CHILD_SHUTDOWN_GRACE_MS = 2_000;
17
+ const DEBUG_ENV_NAMES = CURSOR_SDK_EVENT_DEBUG_ENV_NAMES;
18
+ const DEFAULT_CHILD_CLOSE_TIMEOUT_MS = 30_000;
12
19
 
13
20
  function printHelp() {
14
21
  console.log(`RPC steering smoke for pi-cursor-sdk live runs.
@@ -18,10 +25,12 @@ Usage:
18
25
 
19
26
  Environment:
20
27
  SMOKE_SESSION_DIR Session directory for the RPC pi run. Defaults to /tmp/pi-cursor-steer-smoke-<timestamp>.
21
- CURSOR_API_KEY Required Cursor API key for live pi runs.
28
+ PI_BIN Optional absolute pi executable path. smoke:live injects the parent-resolved path.
29
+ CURSOR_API_KEY Optional fallback auth. Stored pi auth in ~/.pi/agent/auth.json is also supported.
22
30
 
23
31
  Options:
24
32
  -h, --help Show this help.
33
+ --self-test Run the fake-PATH resolver probe without launching pi.
25
34
 
26
35
  Exit codes:
27
36
  0 steering scenario completed without AgentBusyError; STEER_OK and STEER_CHAIN present
@@ -37,17 +46,63 @@ function fail(message) {
37
46
  throw new Error(message);
38
47
  }
39
48
 
40
- function parseEvents(stdout) {
41
- const events = [];
42
- for (const line of stdout.split("\n")) {
43
- if (!line.trim()) continue;
44
- try {
45
- events.push(JSON.parse(line));
46
- } catch {
47
- // ignore partial lines
48
- }
49
+ function scrubForReport(text) {
50
+ let scrubbed = scrubSensitiveText(text);
51
+ for (const secret of apiKeySecretsFromProcess()) {
52
+ if (secret) scrubbed = scrubSensitiveText(scrubbed, secret);
53
+ }
54
+ return scrubbed;
55
+ }
56
+
57
+ function smokeOutputTail(stdout, stderr) {
58
+ return `stdoutTail=${scrubForReport(stdout.slice(-2000))}\nstderrTail=${scrubForReport(stderr.slice(-2000))}`;
59
+ }
60
+
61
+ async function waitForChildCloseWithTimeout(child, timeoutMs, outputSummary = () => "") {
62
+ let timeout;
63
+ try {
64
+ return await Promise.race([
65
+ waitForChildClose(child),
66
+ new Promise((_, reject) => {
67
+ timeout = setTimeout(() => {
68
+ const summary = outputSummary();
69
+ reject(new Error(`pi did not exit within ${timeoutMs}ms after agent_end${summary ? `\n${summary}` : ""}`));
70
+ }, timeoutMs);
71
+ }),
72
+ ]);
73
+ } finally {
74
+ clearTimeout(timeout);
75
+ }
76
+ }
77
+
78
+ function isExecutable(path) {
79
+ try {
80
+ accessSync(path, constants.X_OK);
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function resolveCommand(command, envPath = process.env.PATH ?? "") {
88
+ if (!command.trim()) fail("empty command name");
89
+ if (command.includes("/")) {
90
+ const path = resolve(command);
91
+ if (!isExecutable(path)) fail(`${command} is not executable`);
92
+ return path;
93
+ }
94
+ for (const entry of envPath.split(delimiter)) {
95
+ if (!entry) continue;
96
+ const candidate = resolve(entry, command);
97
+ if (isExecutable(candidate)) return candidate;
49
98
  }
50
- return events;
99
+ fail(`${command} is required on PATH`);
100
+ }
101
+
102
+ function resolvePiBin() {
103
+ const path = process.env.PI_BIN?.trim() || resolveCommand("pi");
104
+ if (!path.startsWith("/")) fail(`PI_BIN must be an absolute path when provided: ${path}`);
105
+ return path;
51
106
  }
52
107
 
53
108
  function assistantText(events) {
@@ -79,7 +134,7 @@ function waitFor(getStdout, predicate, timeoutMs = 300_000) {
79
134
  const start = Date.now();
80
135
  return new Promise((resolve, reject) => {
81
136
  const tick = () => {
82
- const events = parseEvents(getStdout());
137
+ const events = parseJsonLines(getStdout());
83
138
  if (predicate(events)) {
84
139
  resolve(events);
85
140
  return;
@@ -98,52 +153,22 @@ function waitFor(getStdout, predicate, timeoutMs = 300_000) {
98
153
  });
99
154
  }
100
155
 
101
- function waitForChildClose(child) {
102
- if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(child.exitCode ?? 1);
103
- return new Promise((resolve) => {
104
- child.once("close", (code) => resolve(code ?? 1));
156
+ function buildPiRpcEnv(baseEnv = process.env, nodePath = process.execPath) {
157
+ return buildCursorSmokeEnv({
158
+ baseEnv,
159
+ nodePath,
160
+ settingSources: "none",
161
+ nativeToolDisplay: true,
162
+ registerNativeTools: true,
163
+ bridge: false,
105
164
  });
106
165
  }
107
166
 
108
- function signalChild(child, signal) {
109
- if (!child.pid) return;
110
- try {
111
- if (process.platform === "win32") {
112
- child.kill(signal);
113
- } else {
114
- process.kill(-child.pid, signal);
115
- }
116
- } catch {
117
- try {
118
- child.kill(signal);
119
- } catch {
120
- // child already exited
121
- }
122
- }
123
- }
124
-
125
- async function terminateChild(child) {
126
- child.stdin.destroy();
127
- if (child.exitCode !== null || child.signalCode !== null) return;
128
- signalChild(child, "SIGTERM");
129
- const killTimer = setTimeout(() => signalChild(child, "SIGKILL"), CHILD_SHUTDOWN_GRACE_MS);
130
- try {
131
- await waitForChildClose(child);
132
- } finally {
133
- clearTimeout(killTimer);
134
- }
135
- }
136
-
137
- async function runPiRpcSmoke(sessionDir) {
167
+ async function runPiRpcSmoke(sessionDir, piBin) {
138
168
  const args = ["-e", root, "--cursor-no-fast", "--model", "cursor/composer-2.5", "--mode", "rpc", "--session-dir", sessionDir];
139
- const env = {
140
- ...process.env,
141
- PI_CURSOR_SETTING_SOURCES: "none",
142
- PI_CURSOR_NATIVE_TOOL_DISPLAY: "1",
143
- PI_CURSOR_PI_TOOL_BRIDGE: "0",
144
- };
169
+ const env = buildPiRpcEnv();
145
170
 
146
- const child = spawn("pi", args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
171
+ const child = spawn(piBin, args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
147
172
  let closed = false;
148
173
  let stdout = "";
149
174
  let stderr = "";
@@ -191,7 +216,7 @@ async function runPiRpcSmoke(sessionDir) {
191
216
  fail("AgentBusyError detected in smoke output");
192
217
  }
193
218
 
194
- const text = assistantText(parseEvents(stdout));
219
+ const text = assistantText(parseJsonLines(stdout));
195
220
  if (!text.includes("STEER_OK=yes")) {
196
221
  fail(`missing STEER_OK=yes in assistant output: ${text.slice(0, 500)}`);
197
222
  }
@@ -200,10 +225,10 @@ async function runPiRpcSmoke(sessionDir) {
200
225
  }
201
226
 
202
227
  child.stdin.end();
203
- const exitCode = await waitForChildClose(child);
228
+ const exitCode = await waitForChildCloseWithTimeout(child, DEFAULT_CHILD_CLOSE_TIMEOUT_MS, () => smokeOutputTail(stdout, stderr));
204
229
  closed = true;
205
230
  if (exitCode !== 0) {
206
- fail(`pi exited ${exitCode}\nstderr=${stderr.slice(-2000)}`);
231
+ fail(`pi exited ${exitCode}\nstderr=${scrubForReport(stderr.slice(-2000))}`);
207
232
  }
208
233
 
209
234
  return {
@@ -217,22 +242,102 @@ async function runPiRpcSmoke(sessionDir) {
217
242
  }
218
243
  }
219
244
 
245
+ async function runSelfTest() {
246
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-steering-self-test-"));
247
+ try {
248
+ const binDir = join(tempDir, "bin");
249
+ mkdirSync(binDir, { recursive: true });
250
+ const fakePi = join(binDir, "pi");
251
+ const fakeNode = join(binDir, "node");
252
+ const fakeNodeMarker = join(tempDir, "fake-node-used");
253
+ const envCapture = join(tempDir, "fake-pi.env");
254
+ writeFileSync(
255
+ fakePi,
256
+ `#!/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`,
257
+ "utf8",
258
+ );
259
+ writeFileSync(fakeNode, `#!/bin/sh\necho fake-node-used > ${JSON.stringify(fakeNodeMarker)}\nexit 99\n`, "utf8");
260
+ chmodSync(fakePi, 0o755);
261
+ chmodSync(fakeNode, 0o755);
262
+ const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
263
+ if (buildPiRpcEnv({ PATH: "" }).PATH?.includes(delimiter)) fail("self-test failed: empty inherited PATH left an empty PATH segment");
264
+ if (resolveCommand("pi", hostilePath) !== fakePi) fail("self-test failed: direct PATH resolver did not prefer fake PATH head");
265
+ const originalPiBin = process.env.PI_BIN;
266
+ const originalPath = process.env.PATH;
267
+ try {
268
+ delete process.env.PI_BIN;
269
+ process.env.PATH = hostilePath;
270
+ if (resolvePiBin() !== fakePi) fail("self-test failed: resolvePiBin should use PATH when PI_BIN is absent");
271
+ process.env.PI_BIN = fakePi;
272
+ if (resolvePiBin() !== fakePi) fail("self-test failed: resolvePiBin should honor absolute PI_BIN");
273
+ const hostileEnv = buildPiRpcEnv({
274
+ ...Object.fromEntries(DEBUG_ENV_NAMES.map((name) => [name, join(tempDir, name)])),
275
+ PATH: hostilePath,
276
+ PI_CURSOR_REGISTER_NATIVE_TOOLS: "0",
277
+ PI_CURSOR_SETTING_SOURCES: "all",
278
+ PI_CURSOR_PI_TOOL_BRIDGE: "1",
279
+ });
280
+ if ((hostileEnv.PATH ?? "").split(delimiter)[0] !== dirname(process.execPath)) fail("self-test failed: sealed PATH should start with resolved node directory");
281
+ if (hostileEnv.PI_CURSOR_REGISTER_NATIVE_TOOLS !== "1") fail("self-test failed: native registration should be forced on");
282
+ if (hostileEnv.PI_CURSOR_SETTING_SOURCES !== "none") fail("self-test failed: setting sources should be forced off");
283
+ if (hostileEnv.PI_CURSOR_PI_TOOL_BRIDGE !== "0") fail("self-test failed: bridge should be forced off");
284
+ for (const name of DEBUG_ENV_NAMES) {
285
+ if (name in hostileEnv) fail(`self-test failed: ${name} should be cleared`);
286
+ }
287
+ const probe = spawnSync(fakePi, ["--version"], { cwd: root, env: hostileEnv, encoding: "utf8" });
288
+ if (probe.status !== 0) fail(`self-test failed: fake pi shim exited ${probe.status}; stderr=${probe.stderr}`);
289
+ let fakeNodeUsed = false;
290
+ try {
291
+ accessSync(fakeNodeMarker, constants.F_OK);
292
+ fakeNodeUsed = true;
293
+ } catch {
294
+ // Expected: sealed PATH should keep /usr/bin/env node away from the hostile fake node.
295
+ }
296
+ if (fakeNodeUsed) fail("self-test failed: steering child env used hostile fake node");
297
+ const hangingChild = new EventEmitter();
298
+ hangingChild.exitCode = null;
299
+ hangingChild.signalCode = null;
300
+ let closeTimedOut = false;
301
+ try {
302
+ await waitForChildCloseWithTimeout(hangingChild, 10, () => smokeOutputTail("STEER_OK=yes", ""));
303
+ } catch (error) {
304
+ const message = error instanceof Error ? error.message : String(error);
305
+ closeTimedOut = message.includes("pi did not exit within 10ms after agent_end") && message.includes("stdoutTail=STEER_OK=yes");
306
+ }
307
+ if (!closeTimedOut) fail("self-test failed: post-agent_end child close wait should be bounded and report output tail");
308
+ } finally {
309
+ if (originalPiBin === undefined) delete process.env.PI_BIN;
310
+ else process.env.PI_BIN = originalPiBin;
311
+ if (originalPath === undefined) delete process.env.PATH;
312
+ else process.env.PATH = originalPath;
313
+ }
314
+ console.log("[steering-rpc-smoke] self-test PASS");
315
+ } finally {
316
+ rmSync(tempDir, { recursive: true, force: true });
317
+ }
318
+ }
319
+
220
320
  async function main() {
221
321
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
222
322
  printHelp();
223
323
  return;
224
324
  }
225
-
226
- if (!process.env.CURSOR_API_KEY) {
227
- fail("steering-rpc-smoke: CURSOR_API_KEY is required");
325
+ if (process.argv.includes("--self-test")) {
326
+ await runSelfTest();
327
+ return;
228
328
  }
229
329
 
230
330
  const sessionDir = process.env.SMOKE_SESSION_DIR ?? join("/tmp", `pi-cursor-steer-smoke-${Date.now()}`);
331
+ const piBin = resolvePiBin();
231
332
  mkdirSync(sessionDir, { recursive: true });
232
- console.log(JSON.stringify(await runPiRpcSmoke(sessionDir)));
333
+ console.log(JSON.stringify(await runPiRpcSmoke(sessionDir, piBin)));
233
334
  }
234
335
 
235
336
  main().catch((error) => {
236
- console.error(error instanceof Error ? error.message : String(error));
337
+ let scrubbed = scrubSensitiveText(error instanceof Error ? error.message : String(error));
338
+ for (const secret of apiKeySecretsFromProcess()) {
339
+ if (secret) scrubbed = scrubSensitiveText(scrubbed, secret);
340
+ }
341
+ console.error(scrubbed);
237
342
  process.exitCode = 1;
238
343
  });