specrails-desktop 2.11.2 → 2.11.4

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.
@@ -27,12 +27,11 @@ exports.formatTokens = formatTokens;
27
27
  exports.printSummary = printSummary;
28
28
  const http_1 = __importDefault(require("http"));
29
29
  const net_1 = __importDefault(require("net"));
30
- const child_process_1 = require("child_process");
31
- const child_process_2 = require("child_process");
32
30
  const readline_1 = require("readline");
33
31
  const ws_1 = __importDefault(require("ws"));
34
32
  const path_1 = __importDefault(require("path"));
35
33
  const os_1 = __importDefault(require("os"));
34
+ const win_spawn_1 = require("./win-spawn");
36
35
  const fs_1 = __importDefault(require("fs"));
37
36
  // ---------------------------------------------------------------------------
38
37
  // Constants
@@ -324,7 +323,15 @@ async function resolveProjectFromCwd(baseUrl, projectOverride) {
324
323
  try {
325
324
  // --project flag: resolve by path (absolute/relative) or by name
326
325
  if (projectOverride) {
327
- const isPathLike = projectOverride.startsWith('/') || projectOverride.startsWith('.');
326
+ // Path vs name: POSIX absolute (`/…`), relative (`./…`), OR a Windows
327
+ // absolute (`C:\…` / `C:/…`) or any path containing a separator. Without
328
+ // the drive-letter/backslash cases `--project C:\repo` was mis-read as a
329
+ // project NAME on Windows and never matched.
330
+ const isPathLike = path_1.default.isAbsolute(projectOverride) ||
331
+ projectOverride.startsWith('.') ||
332
+ /^[A-Za-z]:[\\/]/.test(projectOverride) ||
333
+ projectOverride.includes('/') ||
334
+ projectOverride.includes('\\');
328
335
  if (isPathLike) {
329
336
  const res = await httpGet(`${baseUrl}/api/resolve?path=${encodeURIComponent(projectOverride)}`);
330
337
  if (res.status === 200) {
@@ -531,7 +538,10 @@ async function runDirect(command) {
531
538
  ];
532
539
  let child;
533
540
  try {
534
- child = (0, child_process_1.spawn)('claude', args, {
541
+ // Windows: `claude` is `claude.cmd`; spawnCli routes through cross-spawn so
542
+ // the shim resolves and multi-line args survive (raw `spawn(shell:false)`
543
+ // threw ENOENT/EINVAL on Windows even with claude installed).
544
+ child = (0, win_spawn_1.spawnCli)('claude', args, {
535
545
  env: process.env,
536
546
  shell: false,
537
547
  });
@@ -812,7 +822,10 @@ async function desktopStart(port) {
812
822
  logFd ?? 'ignore',
813
823
  logFd ?? 'ignore',
814
824
  ];
815
- const child = (0, child_process_2.spawn)(args[0], args.slice(1), {
825
+ // spawnCli so a dev `tsx`(.cmd) launcher resolves on Windows and the env is
826
+ // SystemRoot-backfilled; for the packaged `node` path cross-spawn spawns the
827
+ // .exe directly (no cmd.exe wrapper), keeping `detached` clean.
828
+ const child = (0, win_spawn_1.spawnCli)(args[0], args.slice(1), {
816
829
  detached: true,
817
830
  stdio,
818
831
  env: { ...process.env },
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ // Windows-safe spawn for the CLI bridge. Mirrors server/util/win-spawn.ts but is
3
+ // self-contained because the CLI compiles with its own tsconfig (rootDir: cli).
4
+ //
5
+ // Two Windows problems this solves:
6
+ // 1. `claude`/`tsx` etc. ship as `.cmd` shims. `spawn('claude', …, { shell:false })`
7
+ // fails with ENOENT (no extension expansion) and, since Node 20.12 /
8
+ // CVE-2024-27980, `spawn('claude.cmd', …, { shell:false })` fails with EINVAL.
9
+ // cross-spawn launches cmd.exe with quoted args so `.cmd` resolves and
10
+ // multi-line `--system-prompt`/`-p` values survive intact.
11
+ // 2. A GUI-launched packaged app can inherit a stripped env missing SystemRoot/
12
+ // ComSpec, which cmd.exe needs to start — windowsSpawnEnv backfills them.
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.windowsSpawnEnv = windowsSpawnEnv;
18
+ exports.spawnCli = spawnCli;
19
+ const child_process_1 = require("child_process");
20
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
21
+ /** Reconstruct the Windows shell-critical env when a stripped sidecar lacks it. */
22
+ function windowsSpawnEnv(base = process.env) {
23
+ if (process.platform !== 'win32')
24
+ return base;
25
+ const env = { ...base };
26
+ const systemRoot = (env.SystemRoot || env.windir || 'C:\\Windows').replace(/[\\/]$/, '');
27
+ env.SystemRoot = env.SystemRoot || systemRoot;
28
+ env.windir = env.windir || systemRoot;
29
+ env.ComSpec = env.ComSpec || `${systemRoot}\\System32\\cmd.exe`;
30
+ return env;
31
+ }
32
+ /** Spawn `binary` Windows-safely: cross-spawn on win32 (resolves `.cmd`, quotes
33
+ * args), plain spawn on POSIX. Backfills the Windows base env unless the caller
34
+ * already supplied one. */
35
+ function spawnCli(binary, args, options = {}) {
36
+ if (process.platform === 'win32') {
37
+ return (0, cross_spawn_1.default)(binary, args, { ...options, env: windowsSpawnEnv(options.env) });
38
+ }
39
+ return (0, child_process_1.spawn)(binary, args, options);
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.11.2",
3
+ "version": "2.11.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -189,7 +189,31 @@ function atomicWrite(filePath, data) {
189
189
  finally {
190
190
  (0, fs_1.closeSync)(fd);
191
191
  }
192
- (0, fs_1.renameSync)(tmp, filePath);
192
+ // Windows: renaming over an EXISTING destination held open by a reader (the
193
+ // bundled core reads registry.json WITHOUT the lock by contract), an AV
194
+ // scanner, or a concurrent reader fails transiently with EPERM/EACCES/EBUSY.
195
+ // POSIX rename-over is atomic and never hits this. Bounded retry on Windows
196
+ // only; rethrow the last error so the caller's try/catch still surfaces it.
197
+ let lastErr;
198
+ for (let attempt = 0; attempt < 5; attempt++) {
199
+ try {
200
+ (0, fs_1.renameSync)(tmp, filePath);
201
+ return;
202
+ }
203
+ catch (err) {
204
+ lastErr = err;
205
+ const code = err.code;
206
+ const retryable = process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY');
207
+ if (!retryable)
208
+ throw err;
209
+ syncSleep(20 * (attempt + 1));
210
+ }
211
+ }
212
+ try {
213
+ (0, fs_1.unlinkSync)(tmp);
214
+ }
215
+ catch { /* best-effort temp cleanup */ }
216
+ throw lastErr;
193
217
  }
194
218
  /** Synchronous sleep without busy-spinning the CPU. */
195
219
  function syncSleep(ms) {
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.binaryOnPath = binaryOnPath;
4
4
  exports.__resetBinaryProbeCacheForTest = __resetBinaryProbeCacheForTest;
5
5
  const child_process_1 = require("child_process");
6
+ const win_spawn_1 = require("./util/win-spawn");
6
7
  // Windows has no `which`; probe via `where` instead. Both exit non-zero
7
8
  // when the command is missing, which the try/catch relies on.
8
9
  const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
@@ -20,7 +21,10 @@ function binaryOnPath(binary) {
20
21
  return hit.onPath;
21
22
  let onPath;
22
23
  try {
23
- (0, child_process_1.execSync)(`${WHICH_CMD} ${binary}`, { stdio: 'ignore' });
24
+ // `where` runs via cmd.exe; pass a SystemRoot-backfilled env so the probe
25
+ // (a HARD gate before job/chat spawns) can't be falsely cached as missing
26
+ // when a packaged sidecar inherited a stripped env.
27
+ (0, child_process_1.execSync)(`${WHICH_CMD} ${binary}`, { stdio: 'ignore', env: (0, win_spawn_1.windowsSpawnEnv)() });
24
28
  onPath = true;
25
29
  }
26
30
  catch {
@@ -9,6 +9,7 @@ exports.getBundledCoreVersion = getBundledCoreVersion;
9
9
  exports.hasBundledCore = hasBundledCore;
10
10
  const fs_1 = __importDefault(require("fs"));
11
11
  const path_1 = __importDefault(require("path"));
12
+ const win_spawn_1 = require("./util/win-spawn");
12
13
  /**
13
14
  * Bundled specrails-core resolution.
14
15
  *
@@ -24,7 +25,9 @@ const path_1 = __importDefault(require("path"));
24
25
  */
25
26
  /** The bundled core package root (`<resource_dir>/core`) or null when absent. */
26
27
  function getBundledCoreRoot() {
27
- const p = process.env.SPECRAILS_BUNDLED_CORE_PATH;
28
+ // Strip any `\\?\` verbatim prefix (Tauri resource_dir) — Node's module loader
29
+ // realpathSync chokes on it when running cli.js (EISDIR lstat 'C:').
30
+ const p = (0, win_spawn_1.stripWindowsVerbatimPrefix)(process.env.SPECRAILS_BUNDLED_CORE_PATH ?? '');
28
31
  if (!p || p.length === 0)
29
32
  return null;
30
33
  // Existence-gate (defence-in-depth — lib.rs already gates, but a stale env
@@ -7,6 +7,7 @@ exports.getBundledOpenspecCli = getBundledOpenspecCli;
7
7
  exports.hasBundledOpenspec = hasBundledOpenspec;
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
+ const win_spawn_1 = require("./util/win-spawn");
10
11
  /**
11
12
  * Bundled `@fission-ai/openspec` resolution.
12
13
  *
@@ -29,7 +30,9 @@ const path_1 = __importDefault(require("path"));
29
30
  const OPENSPEC_CLI_REL = path_1.default.join('node_modules', '@fission-ai', 'openspec', 'bin', 'openspec.js');
30
31
  /** The bundled openspec root (`<resource_dir>/openspec`) or null when absent. */
31
32
  function getBundledOpenspecRoot() {
32
- const p = process.env.SPECRAILS_BUNDLED_OPENSPEC_PATH;
33
+ // Strip any `\\?\` verbatim prefix (Tauri resource_dir) so `node openspec.js`
34
+ // resolves its entry without the realpathSync EISDIR-on-'C:' crash.
35
+ const p = (0, win_spawn_1.stripWindowsVerbatimPrefix)(process.env.SPECRAILS_BUNDLED_OPENSPEC_PATH ?? '');
33
36
  if (!p || p.length === 0)
34
37
  return null;
35
38
  // Existence-gate (defence-in-depth — lib.rs already gates, but a stale env
@@ -9,6 +9,9 @@ exports.fetchIssues = fetchIssues;
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const child_process_1 = require("child_process");
12
+ // Windows has no `which`; it's `where`. The lone holdout that hardcoded `which`
13
+ // here made gh/jira tracker detection always report unavailable on Windows.
14
+ const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
12
15
  function runCommand(cmd, cwd) {
13
16
  try {
14
17
  return (0, child_process_1.execSync)(cmd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, cwd }).toString().trim();
@@ -37,7 +40,7 @@ function runCommandArgs(file, args, cwd) {
37
40
  }
38
41
  }
39
42
  function detectGithub() {
40
- const ghPath = runCommand('which gh');
43
+ const ghPath = runCommand(`${WHICH_CMD} gh`);
41
44
  if (!ghPath)
42
45
  return { available: false, authenticated: false };
43
46
  const authOutput = runCommand('gh auth status');
@@ -45,7 +48,7 @@ function detectGithub() {
45
48
  return { available: true, authenticated };
46
49
  }
47
50
  function detectJira() {
48
- const jiraPath = runCommand('which jira');
51
+ const jiraPath = runCommand(`${WHICH_CMD} jira`);
49
52
  if (!jiraPath)
50
53
  return { available: false, authenticated: false };
51
54
  // jira CLI availability means it is configured (auth is implicit via jira config)
@@ -65,13 +65,28 @@ const THEME_ID_ALLOWLIST = new Set(['dracula', 'aurora-light', 'obsidian-dark',
65
65
  // Language allow-list. Mirror of LANGUAGE_IDS in `client/src/lib/i18n.ts` —
66
66
  // kept duplicated to avoid pulling client code into the server bundle.
67
67
  const LANGUAGE_ID_ALLOWLIST = new Set(['en', 'es', 'fr', 'de', 'pt', 'it', 'zh', 'ja']);
68
- // LOW-04: Deny registration of system-critical directory paths.
68
+ // LOW-04: Deny registration of system-critical directory paths. The POSIX list
69
+ // is matched against forward-slash-normalized, lowercased paths; on Windows it
70
+ // was a complete no-op (no path matches `/etc`), so add a Windows deny-list and
71
+ // fold case (Windows FS is case-insensitive).
69
72
  const DENIED_PATH_PREFIXES = [
70
73
  '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64',
71
74
  '/sys', '/proc', '/dev', '/boot', '/run',
72
75
  ];
76
+ // Windows: deny the Windows dir, Program Files variants, and any bare drive root.
77
+ const DENIED_WINDOWS_PREFIXES = [
78
+ 'c:/windows', 'c:/program files', 'c:/program files (x86)', 'c:/programdata',
79
+ ];
73
80
  function isPathSafe(resolvedPath) {
74
- const normalized = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
81
+ const slashed = resolvedPath.replace(/\\/g, '/');
82
+ const normalized = slashed.endsWith('/') ? slashed : slashed + '/';
83
+ if (process.platform === 'win32') {
84
+ const lower = normalized.toLowerCase();
85
+ // Bare drive root (C:/) is never a valid project location.
86
+ if (/^[a-z]:\/$/.test(lower))
87
+ return false;
88
+ return !DENIED_WINDOWS_PREFIXES.some((prefix) => lower.startsWith(prefix + '/') || lower === prefix + '/');
89
+ }
75
90
  return !DENIED_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix + '/') || normalized === prefix + '/');
76
91
  }
77
92
  function deriveProjectName(projectPath) {
@@ -592,7 +592,11 @@ class FileSummaryManager {
592
592
  awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 },
593
593
  });
594
594
  watcher.on('change', (changed) => {
595
- const rel = path.relative(projectPath, changed);
595
+ // The summary store keys off POSIX forward-slash relpaths everywhere
596
+ // (the REST normalizeRel, pathHash, knownSummaries seeding). On Windows
597
+ // path.relative yields backslashes, so normalize before lookup/markStale —
598
+ // otherwise `known.has(rel)` always misses and edits never mark stale.
599
+ const rel = path.relative(projectPath, changed).split(path.sep).join('/');
596
600
  if (!rel || rel.startsWith('..'))
597
601
  return;
598
602
  // Skip the readSummary disk hit when this file provably has no summary.
@@ -180,7 +180,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
180
180
  try {
181
181
  // Clear a stale backup from a prior aborted run.
182
182
  if (fs_1.default.existsSync(bak))
183
- fs_1.default.rmSync(bak, { recursive: true, force: true });
183
+ fs_1.default.rmSync(bak, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
184
184
  fs_1.default.renameSync(live, bak);
185
185
  backedUp.push({ live, bak });
186
186
  }
@@ -216,7 +216,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
216
216
  for (const { live } of backedUp) {
217
217
  try {
218
218
  if (fs_1.default.existsSync(live) || isSymlink(live))
219
- fs_1.default.rmSync(live, { recursive: true, force: true });
219
+ fs_1.default.rmSync(live, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
220
220
  }
221
221
  catch {
222
222
  /* best-effort */
@@ -242,7 +242,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
242
242
  const preserved = live + '.custom.preserved';
243
243
  try {
244
244
  if (fs_1.default.existsSync(preserved))
245
- fs_1.default.rmSync(preserved, { recursive: true, force: true });
245
+ fs_1.default.rmSync(preserved, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
246
246
  fs_1.default.renameSync(bak, preserved);
247
247
  preservedCustom.push(...customs.map((c) => path_1.default.basename(c)));
248
248
  }
@@ -252,7 +252,7 @@ function runMigration(slug, projectPath, provider, fwCurrent, fm, opts) {
252
252
  }
253
253
  else {
254
254
  try {
255
- fs_1.default.rmSync(bak, { recursive: true, force: true });
255
+ fs_1.default.rmSync(bak, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
256
256
  }
257
257
  catch {
258
258
  /* leaving a stale .bak is harmless; never fail the migration on cleanup */
@@ -272,7 +272,7 @@ function restoreBackups(backedUp) {
272
272
  if (!fs_1.default.existsSync(bak))
273
273
  continue;
274
274
  if (fs_1.default.existsSync(live) || isSymlink(live))
275
- fs_1.default.rmSync(live, { recursive: true, force: true });
275
+ fs_1.default.rmSync(live, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
276
276
  fs_1.default.renameSync(bak, live);
277
277
  }
278
278
  catch {
@@ -34,6 +34,10 @@ const path_resolver_1 = require("./path-resolver");
34
34
  // openspec/changes/add-multi-provider-support/specs/multi-provider-architecture/spec.md.
35
35
  require("./providers");
36
36
  const inheritedPathBeforeResolve = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean).length;
37
+ // Backfill SystemRoot/ComSpec/etc into process.env BEFORE anything spawns, so a
38
+ // stripped GUI-launch sidecar env never breaks cmd.exe-mediated spawns (PTY,
39
+ // where-probes, .cmd shims). No-op on POSIX. Must precede resolveStartupPath().
40
+ (0, path_resolver_1.ensureWindowsBaseEnv)();
37
41
  (0, path_resolver_1.resolveStartupPath)();
38
42
  const TERMINAL_PANEL_ENABLED = process.env.SPECRAILS_TERMINAL_PANEL !== 'false';
39
43
  const BROWSER_CAPTURE_ENABLED = (0, feature_flags_1.isBrowserCaptureEnabled)();
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ensureWindowsBaseEnv = ensureWindowsBaseEnv;
6
7
  exports.resolveBundledRuntimePath = resolveBundledRuntimePath;
7
8
  exports.resolveBundledNodeExe = resolveBundledNodeExe;
8
9
  exports.resolveStartupPath = resolveStartupPath;
@@ -14,6 +15,42 @@ const child_process_1 = require("child_process");
14
15
  const fs_1 = __importDefault(require("fs"));
15
16
  const os_1 = __importDefault(require("os"));
16
17
  const path_1 = __importDefault(require("path"));
18
+ const win_spawn_1 = require("./util/win-spawn");
19
+ /** Bundled-path env vars set by the Tauri host — normalized at startup. */
20
+ const BUNDLED_PATH_ENV_VARS = [
21
+ 'SPECRAILS_BUNDLED_RUNTIMES_PATH',
22
+ 'SPECRAILS_BUNDLED_CORE_PATH',
23
+ 'SPECRAILS_BUNDLED_OPENSPEC_PATH',
24
+ ];
25
+ /**
26
+ * Backfill the Windows shell-critical environment into `process.env` ONCE at
27
+ * startup. The desktop server runs as a pkg sidecar launched by the Tauri host,
28
+ * which can deliver a STRIPPED env missing `SystemRoot`/`windir`/`ComSpec`
29
+ * (and the npm-config family). Without `SystemRoot`, every `cmd.exe`-mediated
30
+ * spawn — PTY/PowerShell, `execSync('where …')`, `.cmd` shims — fails to start.
31
+ * Doing this on `process.env` directly means EVERY downstream consumer that
32
+ * copies `process.env` (terminal-manager, binary-probe, plugin spawns, …) is
33
+ * protected at the source, in addition to the per-callsite `windowsSpawnEnv()`.
34
+ *
35
+ * ALSO strips the Windows verbatim prefix (`\\?\`) from the bundled-path env
36
+ * vars. Tauri's `resource_dir()` returns `\\?\C:\…` paths; Node's module loader
37
+ * `realpathSync` mishandles that prefix when resolving the main entry script,
38
+ * crashing the bundled-core child with `EISDIR: lstat 'C:'`. Normalizing here
39
+ * (before resolveStartupPath + before any spawn) means every reader
40
+ * (getBundledCoreCli, resolveBundledNodeExe, chromium/docs/setup-prerequisites,
41
+ * the PATH prepend) gets a plain `C:\…` path. No-op on POSIX / already-present /
42
+ * unprefixed. Idempotent.
43
+ */
44
+ function ensureWindowsBaseEnv() {
45
+ if (process.platform !== 'win32')
46
+ return;
47
+ Object.assign(process.env, (0, win_spawn_1.windowsSpawnEnv)(process.env));
48
+ for (const key of BUNDLED_PATH_ENV_VARS) {
49
+ const v = process.env[key];
50
+ if (v)
51
+ process.env[key] = (0, win_spawn_1.stripWindowsVerbatimPrefix)(v);
52
+ }
53
+ }
17
54
  const PATH_BEGIN = '__SRH_PATH_BEGIN__';
18
55
  const PATH_END = '__SRH_PATH_END__';
19
56
  const LOGIN_SHELL_TIMEOUT_MS = 1500;
@@ -89,11 +126,12 @@ function fastPathDirectories() {
89
126
  * Throws if the env var is missing.
90
127
  */
91
128
  function resolveBundledRuntimePath() {
92
- const p = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
93
- if (!p) {
129
+ const raw = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
130
+ if (!raw) {
94
131
  throw new Error('[path-resolver] resolveBundledRuntimePath() called but SPECRAILS_BUNDLED_RUNTIMES_PATH is not set');
95
132
  }
96
- return p;
133
+ // Strip the `\\?\` verbatim prefix (Tauri resource_dir) — see ensureWindowsBaseEnv.
134
+ return (0, win_spawn_1.stripWindowsVerbatimPrefix)(raw);
97
135
  }
98
136
  /**
99
137
  * Absolute path to the bundled REAL Node executable (`runtimes/node/bin/node` on
@@ -107,7 +145,9 @@ function resolveBundledRuntimePath() {
107
145
  * Existence-gated so a stale/partial bundle degrades to the PATH `node` instead.
108
146
  */
109
147
  function resolveBundledNodeExe() {
110
- const runtimesPath = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
148
+ // Strip the `\\?\` verbatim prefix so the resulting node.exe path doesn't
149
+ // crash Node's module loader when it runs cli.js (EISDIR lstat 'C:').
150
+ const runtimesPath = (0, win_spawn_1.stripWindowsVerbatimPrefix)(process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH ?? '');
111
151
  if (!runtimesPath || runtimesPath.length === 0)
112
152
  return null;
113
153
  const exe = process.platform === 'win32'
@@ -18,9 +18,25 @@ exports.codexMcpAdd = codexMcpAdd;
18
18
  exports.codexMcpRemove = codexMcpRemove;
19
19
  exports.codexMcpList = codexMcpList;
20
20
  const child_process_1 = require("child_process");
21
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
21
22
  const fs_1 = __importDefault(require("fs"));
22
23
  const path_1 = __importDefault(require("path"));
23
24
  const os_1 = __importDefault(require("os"));
25
+ const win_spawn_1 = require("../util/win-spawn");
26
+ /**
27
+ * Spawn `codex` synchronously, Windows-safe. On Windows the codex CLI is a
28
+ * `.cmd` shim; Node 20.12+ (CVE-2024-27980) refuses to spawn `.cmd` without a
29
+ * shell, and the packaged sidecar may lack `SystemRoot`/`ComSpec` — so route
30
+ * through cross-spawn (resolves the shim, runs cmd.exe with correct quoting)
31
+ * under `windowsSpawnEnv()`. POSIX is a plain `spawnSync`.
32
+ */
33
+ function codexSpawn(argv, extraEnv) {
34
+ const env = (0, win_spawn_1.windowsSpawnEnv)({ ...process.env, ...extraEnv });
35
+ const opts = { env, encoding: 'utf-8', timeout: 10_000 };
36
+ return process.platform === 'win32'
37
+ ? cross_spawn_1.default.sync('codex', argv, opts)
38
+ : (0, child_process_1.spawnSync)('codex', argv, opts);
39
+ }
24
40
  /** Per-project CODEX_HOME root, sibling of the existing
25
41
  * `~/.specrails/projects/<slug>/{telemetry,jobs,explore-cwd,...}` tree. */
26
42
  function codexHomeFor(slug) {
@@ -39,15 +55,7 @@ function ensureCodexHome(slug) {
39
55
  function codexMcpAdd(slug, name, entry) {
40
56
  const home = ensureCodexHome(slug);
41
57
  const argv = ['mcp', 'add', name, '--', entry.command, ...entry.args];
42
- const result = (0, child_process_1.spawnSync)('codex', argv, {
43
- env: {
44
- ...process.env,
45
- ...(entry.env ?? {}),
46
- CODEX_HOME: home,
47
- },
48
- encoding: 'utf-8',
49
- timeout: 10_000,
50
- });
58
+ const result = codexSpawn(argv, { ...(entry.env ?? {}), CODEX_HOME: home });
51
59
  if (result.error) {
52
60
  return { ok: false, stdout: '', stderr: `${result.error.message}` };
53
61
  }
@@ -60,11 +68,7 @@ function codexMcpAdd(slug, name, entry) {
60
68
  /** Run `codex mcp remove <name>` against the per-project CODEX_HOME. */
61
69
  function codexMcpRemove(slug, name) {
62
70
  const home = ensureCodexHome(slug);
63
- const result = (0, child_process_1.spawnSync)('codex', ['mcp', 'remove', name], {
64
- env: { ...process.env, CODEX_HOME: home },
65
- encoding: 'utf-8',
66
- timeout: 10_000,
67
- });
71
+ const result = codexSpawn(['mcp', 'remove', name], { CODEX_HOME: home });
68
72
  if (result.error) {
69
73
  return { ok: false, stdout: '', stderr: `${result.error.message}` };
70
74
  }
@@ -80,11 +84,7 @@ function codexMcpRemove(slug, name) {
80
84
  * for this subcommand. */
81
85
  function codexMcpList(slug) {
82
86
  const home = ensureCodexHome(slug);
83
- const result = (0, child_process_1.spawnSync)('codex', ['mcp', 'list'], {
84
- env: { ...process.env, CODEX_HOME: home },
85
- encoding: 'utf-8',
86
- timeout: 10_000,
87
- });
87
+ const result = codexSpawn(['mcp', 'list'], { CODEX_HOME: home });
88
88
  if (result.error || (result.status ?? 1) !== 0) {
89
89
  return { ok: false, servers: [], raw: `${result.stderr ?? result.stdout ?? ''}` };
90
90
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.installPrerequisite = installPrerequisite;
4
4
  const child_process_1 = require("child_process");
5
+ const win_spawn_1 = require("../util/win-spawn");
5
6
  /**
6
7
  * Returns the official installer command for `name` on the current platform.
7
8
  * Returns `null` for unsupported (name, platform) pairs — caller should treat
@@ -60,7 +61,8 @@ async function installPrerequisite(name, projectId, broadcast) {
60
61
  const child = (0, child_process_1.spawn)(cmd.shell, [], {
61
62
  shell: isWin ? 'powershell.exe' : true,
62
63
  stdio: ['ignore', 'pipe', 'pipe'],
63
- env: process.env,
64
+ // PowerShell needs SystemRoot/windir to start; backfill for a stripped env.
65
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
64
66
  });
65
67
  let settled = false;
66
68
  const finish = (result) => {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.verifySerena = verifySerena;
4
4
  const child_process_1 = require("child_process");
5
+ const win_spawn_1 = require("../../util/win-spawn");
5
6
  const TIMEOUT_MS = 1800;
6
7
  /**
7
8
  * Verify Serena availability. We only probe `uv --version` here — proxy for
@@ -29,6 +30,9 @@ async function verifySerena() {
29
30
  child = (0, child_process_1.spawn)('uv', ['--version'], {
30
31
  stdio: ['ignore', 'pipe', 'pipe'],
31
32
  shell: isWin,
33
+ // SystemRoot/ComSpec so cmd.exe (shell:true) can start under a stripped
34
+ // packaged-sidecar env; else uv is wrongly reported not-on-path.
35
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
32
36
  });
33
37
  }
34
38
  catch {
@@ -620,19 +620,42 @@ async function validateCoreContract() {
620
620
  }
621
621
  // ─── SetupManager ─────────────────────────────────────────────────────────────
622
622
  const INSTALL_LOG_BUFFER_MAX = 2000;
623
- function formatBufferedInstallError(baseMessage, logBuffer) {
623
+ /**
624
+ * Persist the FULL install log to `~/.specrails/logs/` and return the path (or
625
+ * null on failure). The in-error tail is only a window; the full log carries the
626
+ * complete child stack — needed because a Node uncaught error prints the ORIGIN
627
+ * frames ABOVE the entry frames, so an 8-line tail shows only the bottom of the
628
+ * stack + the error object, never where it was thrown.
629
+ */
630
+ function persistInstallLog(projectId, logBuffer) {
631
+ try {
632
+ const dir = (0, path_1.join)((0, artifact_registry_1.resolveHome)(), '.specrails', 'logs');
633
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
634
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
635
+ const file = (0, path_1.join)(dir, `setup-${projectId}-${stamp}.log`);
636
+ (0, fs_1.writeFileSync)(file, logBuffer.join('\n') + '\n', { mode: 0o600 });
637
+ return file;
638
+ }
639
+ catch {
640
+ return null;
641
+ }
642
+ }
643
+ function formatBufferedInstallError(baseMessage, logBuffer, logPath) {
644
+ // Show a generous tail: a Node uncaught-exception dump is ~15-30 lines (header,
645
+ // stack frames, the `{errno,code,syscall,path}` object, version footer). 8 lines
646
+ // truncated to just the entry frames + error object, hiding the throw origin.
624
647
  const recentLines = logBuffer
625
648
  .map((line) => line.trim())
626
649
  .filter(Boolean)
627
- .slice(-8);
628
- if (recentLines.length === 0)
629
- return baseMessage;
630
- return [
631
- baseMessage,
632
- '',
633
- 'Recent output:',
634
- ...recentLines.map((line) => `- ${line}`),
635
- ].join('\n');
650
+ .slice(-40);
651
+ const parts = [baseMessage];
652
+ if (recentLines.length > 0) {
653
+ parts.push('', 'Recent output:', ...recentLines.map((line) => `- ${line}`));
654
+ }
655
+ if (logPath) {
656
+ parts.push('', `Full log: ${logPath}`);
657
+ }
658
+ return parts.join('\n');
636
659
  }
637
660
  class SetupManager {
638
661
  _broadcast;
@@ -788,12 +811,20 @@ class SetupManager {
788
811
  const initArgs = hasConfig
789
812
  ? ['--yes', '--from-config', spawnConfigPath ?? configPath]
790
813
  : ['--yes', '--root-dir', projectPath];
814
+ // Seed the install log with a diagnostic header capturing the EXACT spawn
815
+ // (node interpreter, cli entry, cwd) + relevant env. This lands in the
816
+ // failure report so a Windows/packaged path issue (e.g. an EISDIR on the
817
+ // entry realpath) is diagnosable without server-console access.
818
+ const diagHeader = [];
819
+ if (useBundledCore) {
820
+ diagHeader.push(`[diag] node=${(0, path_resolver_1.resolveBundledNodeExe)() ?? process.execPath}`, `[diag] cli=${(0, bundled_core_1.getBundledCoreCli)() ?? '<none>'}`, `[diag] cwd=${projectPath}`, `[diag] args=${initArgs.join(' ')}`, `[diag] SPECRAILS_BUNDLED_RUNTIMES_PATH=${process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH ?? '<unset>'}`, `[diag] SPECRAILS_BUNDLED_CORE_PATH=${process.env.SPECRAILS_BUNDLED_CORE_PATH ?? '<unset>'}`);
821
+ }
791
822
  // Bundled core (offline, node <cli> init) when available, else legacy npx.
792
823
  const child = useBundledCore
793
824
  ? spawnBundledCoreInit(initArgs, projectPath)
794
825
  : spawnCoreInit(initArgs, projectPath);
795
826
  this._installProcesses.set(projectId, child);
796
- this._installLogBuffer.set(projectId, []);
827
+ this._installLogBuffer.set(projectId, diagHeader);
797
828
  // spawnCoreInit uses shell:false on POSIX, so a spawn failure emits 'error'
798
829
  // (and NOT 'close') — without this handler the temp config file leaks and
799
830
  // the unhandled 'error' event would crash the app.
@@ -867,10 +898,11 @@ class SetupManager {
867
898
  }
868
899
  else {
869
900
  const logBuffer = this._installLogBuffer.get(projectId) ?? [];
901
+ const logPath = persistInstallLog(projectId, logBuffer);
870
902
  this._broadcast({
871
903
  type: 'setup_error',
872
904
  projectId,
873
- error: formatBufferedInstallError(`${useBundledCore ? 'bundled specrails-core' : 'npx specrails-core'} exited with code ${code ?? 'unknown'}`, logBuffer),
905
+ error: formatBufferedInstallError(`${useBundledCore ? 'bundled specrails-core' : 'npx specrails-core'} exited with code ${code ?? 'unknown'}`, logBuffer, logPath),
874
906
  });
875
907
  }
876
908
  });
@@ -14,6 +14,7 @@ const fs_1 = __importDefault(require("fs"));
14
14
  const path_1 = __importDefault(require("path"));
15
15
  const node_pty_1 = require("node-pty");
16
16
  const ids_1 = require("./ids");
17
+ const win_spawn_1 = require("./util/win-spawn");
17
18
  const terminal_osc_parser_1 = require("./terminal-osc-parser");
18
19
  const terminal_marks_store_1 = require("./terminal-marks-store");
19
20
  const terminal_shell_integration_1 = require("./terminal-shell-integration");
@@ -69,8 +70,11 @@ function resolveShell() {
69
70
  * default and has no shell-integration shim.
70
71
  */
71
72
  function resolveShellFor(platform, env, exists) {
73
+ // Honor $SHELL only on POSIX. On Windows $SHELL is not native but is commonly
74
+ // exported by Git Bash/MSYS2 as a Unix path (e.g. /usr/bin/bash) that ConPTY
75
+ // cannot spawn — so on win32 ignore it and fall through to pwsh→powershell→cmd.
72
76
  const envShell = env.SHELL;
73
- if (envShell && envShell.trim().length > 0)
77
+ if (platform !== 'win32' && envShell && envShell.trim().length > 0)
74
78
  return envShell.trim();
75
79
  if (platform === 'win32') {
76
80
  for (const dir of (env.PATH ?? '').split(';')) {
@@ -215,8 +219,13 @@ class TerminalManager {
215
219
  const baseArgs = shellArgs(shell);
216
220
  const cols = clampDim(opts.cols ?? exports.TERMINAL_DEFAULT_COLS, 2, 1000);
217
221
  const rows = clampDim(opts.rows ?? exports.TERMINAL_DEFAULT_ROWS, 2, 1000);
222
+ // Build the PTY env from a SystemRoot-backfilled base so PowerShell/cmd can
223
+ // start even when the packaged sidecar inherited a stripped env (no
224
+ // SystemRoot/ComSpec → ConPTY spawn fails and the panel never opens). No-op
225
+ // on POSIX. (process.env is also backfilled globally at startup; this is the
226
+ // explicit guard at the PTY boundary.)
218
227
  const env = {};
219
- for (const [k, v] of Object.entries(process.env)) {
228
+ for (const [k, v] of Object.entries((0, win_spawn_1.windowsSpawnEnv)(process.env))) {
220
229
  if (typeof v === 'string')
221
230
  env[k] = v;
222
231
  }
@@ -26,6 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.spawnCli = spawnCli;
28
28
  exports.windowsSpawnEnv = windowsSpawnEnv;
29
+ exports.stripWindowsVerbatimPrefix = stripWindowsVerbatimPrefix;
29
30
  exports.resolveWindowsBinary = resolveWindowsBinary;
30
31
  const child_process_1 = require("child_process");
31
32
  const cross_spawn_1 = __importDefault(require("cross-spawn"));
@@ -79,6 +80,25 @@ function windowsSpawnEnv(base = process.env) {
79
80
  env.TMP = env.TMP || temp;
80
81
  return env;
81
82
  }
83
+ /**
84
+ * Strip the Windows extended-length / "verbatim" path prefix (`\\?\`) from a
85
+ * path. Tauri's `resource_dir()` returns canonicalized paths like
86
+ * `\\?\C:\Users\…\core`, and Node's MODULE LOADER `realpathSync` (used to
87
+ * resolve the main entry script) does NOT handle the `\\?\` prefix — it parses
88
+ * the root as a bare `C:` and throws `EISDIR: lstat 'C:'`. So a bundled `node`
89
+ * interpreter or `cli.js` entry carrying this prefix crashes the child at
90
+ * startup. Normalizing the bundled path env vars to plain `C:\…` form fixes it.
91
+ * `\\?\UNC\server\share` → `\\server\share`. No-op on POSIX / unprefixed paths.
92
+ */
93
+ function stripWindowsVerbatimPrefix(p) {
94
+ if (typeof p !== 'string' || p.length === 0)
95
+ return p;
96
+ if (p.startsWith('\\\\?\\UNC\\'))
97
+ return '\\\\' + p.slice(8);
98
+ if (p.startsWith('\\\\?\\'))
99
+ return p.slice(4);
100
+ return p;
101
+ }
82
102
  // Back-compat for callsites that only need the resolved binary
83
103
  // (e.g. logging). Kept as a no-op identity on POSIX; on Windows
84
104
  // `where`-based resolution lives inside cross-spawn now.