specrails-desktop 2.11.3 → 2.11.5

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.3",
3
+ "version": "2.11.5",
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;
@@ -83,17 +120,37 @@ function fastPathDirectories() {
83
120
  }
84
121
  return [];
85
122
  }
123
+ /**
124
+ * Well-known Windows directories that hold globally-installed CLI shims
125
+ * (`claude.cmd` / `codex.cmd` / `gemini.cmd`) which a GUI-launched (Explorer/
126
+ * Tauri) process may not have on its inherited PATH. The per-user npm prefix
127
+ * places `.cmd` shims DIRECTLY in the prefix root (`%APPDATA%\npm`), not a `bin`
128
+ * subdir. `npm prefix -g` is the authoritative location for a custom prefix.
129
+ */
130
+ function windowsGlobalBinDirs() {
131
+ if (process.platform !== 'win32')
132
+ return [];
133
+ const dirs = [];
134
+ // Default per-user npm prefix: shims (`claude.cmd` …) live in the prefix ROOT.
135
+ if (process.env.APPDATA)
136
+ dirs.push(path_1.default.join(process.env.APPDATA, 'npm'));
137
+ // Machine-wide Node install (also where a machine-scope npm prefix points).
138
+ if (process.env.ProgramFiles)
139
+ dirs.push(path_1.default.join(process.env.ProgramFiles, 'nodejs'));
140
+ return dirs;
141
+ }
86
142
  /**
87
143
  * Returns the absolute path to the bundled runtimes directory.
88
144
  * Only valid when SPECRAILS_IS_DESKTOP=1 and SPECRAILS_BUNDLED_RUNTIMES_PATH is set.
89
145
  * Throws if the env var is missing.
90
146
  */
91
147
  function resolveBundledRuntimePath() {
92
- const p = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
93
- if (!p) {
148
+ const raw = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
149
+ if (!raw) {
94
150
  throw new Error('[path-resolver] resolveBundledRuntimePath() called but SPECRAILS_BUNDLED_RUNTIMES_PATH is not set');
95
151
  }
96
- return p;
152
+ // Strip the `\\?\` verbatim prefix (Tauri resource_dir) — see ensureWindowsBaseEnv.
153
+ return (0, win_spawn_1.stripWindowsVerbatimPrefix)(raw);
97
154
  }
98
155
  /**
99
156
  * Absolute path to the bundled REAL Node executable (`runtimes/node/bin/node` on
@@ -107,7 +164,9 @@ function resolveBundledRuntimePath() {
107
164
  * Existence-gated so a stale/partial bundle degrades to the PATH `node` instead.
108
165
  */
109
166
  function resolveBundledNodeExe() {
110
- const runtimesPath = process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH;
167
+ // Strip the `\\?\` verbatim prefix so the resulting node.exe path doesn't
168
+ // crash Node's module loader when it runs cli.js (EISDIR lstat 'C:').
169
+ const runtimesPath = (0, win_spawn_1.stripWindowsVerbatimPrefix)(process.env.SPECRAILS_BUNDLED_RUNTIMES_PATH ?? '');
111
170
  if (!runtimesPath || runtimesPath.length === 0)
112
171
  return null;
113
172
  const exe = process.platform === 'win32'
@@ -160,9 +219,26 @@ function resolveStartupPath() {
160
219
  const inherited = splitPath(process.env.PATH);
161
220
  const inheritedSet = new Set(inherited);
162
221
  if (process.platform === 'win32') {
222
+ // Prepend well-known global-CLI dirs (npm prefix, Program Files\nodejs) that
223
+ // a GUI-launched process may lack, so provider shims (claude/codex/gemini
224
+ // .cmd) — and any RECURSIVE bare-name invocation by a spawned CLI — resolve.
225
+ // Existence-gated + deduped; no-op when already present (the common case).
226
+ const winPrepend = [];
227
+ for (const dir of windowsGlobalBinDirs()) {
228
+ if (dir && fileExists(dir) && !inheritedSet.has(dir)) {
229
+ winPrepend.push(dir);
230
+ inheritedSet.add(dir);
231
+ }
232
+ }
233
+ const winMerged = [...winPrepend, ...inherited];
234
+ if (winPrepend.length > 0)
235
+ process.env.PATH = joinPath(winMerged);
163
236
  diagnostic = {
164
- pathSegments: inherited,
165
- pathSources: inherited.map(() => 'inherited'),
237
+ pathSegments: winMerged,
238
+ pathSources: [
239
+ ...winPrepend.map(() => 'fast-path'),
240
+ ...inherited.map(() => 'inherited'),
241
+ ],
166
242
  loginShellStatus: 'skipped',
167
243
  };
168
244
  return;
@@ -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 {
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports._GEMINI_MIN_VERSION = exports.geminiAdapter = void 0;
30
30
  exports._compareSemver = compareSemver;
31
31
  const child_process_1 = require("child_process");
32
+ const win_spawn_1 = require("../util/win-spawn");
32
33
  const gemini_agent_ack_1 = require("./gemini-agent-ack");
33
34
  const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
34
35
  // Floor where `--output-format stream-json` + headless `--resume` are available.
@@ -180,16 +181,20 @@ function compareSemver(a, b) {
180
181
  }
181
182
  async function detectGeminiInstalled() {
182
183
  try {
183
- (0, child_process_1.execSync)(`${WHICH_CMD} gemini`, { stdio: 'ignore' });
184
+ (0, child_process_1.execSync)(`${WHICH_CMD} gemini`, { stdio: 'ignore', env: (0, win_spawn_1.windowsSpawnEnv)() });
184
185
  }
185
186
  catch {
186
187
  return { installed: false, executable: false };
187
188
  }
188
189
  try {
190
+ // gemini is a Node CLI; its cold `--version` (cmd.exe → node → load bundle,
191
+ // Defender scanning) can exceed a few seconds on Windows. Give it a generous
192
+ // budget + SystemRoot env so it isn't wrongly reported not-executable.
189
193
  const raw = (0, child_process_1.execSync)('gemini --version', {
190
194
  encoding: 'utf-8',
191
195
  stdio: ['pipe', 'pipe', 'ignore'],
192
- timeout: 3000,
196
+ timeout: process.platform === 'win32' ? 20_000 : 8_000,
197
+ env: (0, win_spawn_1.windowsSpawnEnv)(),
193
198
  }).trim();
194
199
  const match = raw.match(/\d+\.\d+\.\d+/);
195
200
  const version = match ? match[0] : raw;
@@ -28,13 +28,19 @@ const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
28
28
  * inherited a stripped env) — without it every bundled probe failed with a bogus
29
29
  * "bundle corrupted — reinstall the app".
30
30
  */
31
- function runVersionSpawn(cmd, args) {
32
- const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout: 5_000 };
31
+ function runVersionSpawn(cmd, args, timeoutMs = 5_000) {
32
+ const opts = { env: (0, win_spawn_1.windowsSpawnEnv)(), encoding: 'utf-8', timeout: timeoutMs };
33
33
  if (process.platform === 'win32') {
34
34
  return cross_spawn_1.default.sync(cmd, args, opts);
35
35
  }
36
36
  return (0, child_process_1.spawnSync)(cmd, args, opts);
37
37
  }
38
+ // A bundled/system CLI's `--version` cold start can be slow on Windows: a npm
39
+ // `.cmd` shim goes cmd.exe → node.exe → load the CLI bundle, while Defender
40
+ // scans it. The Node-based gemini CLI in particular exceeded the old 5s cap and
41
+ // was wrongly reported not-executable. Give version probes a generous Windows
42
+ // budget; the `where`/`which` LOCATE probe stays fast (5s) since it's a builtin.
43
+ const VERSION_PROBE_TIMEOUT_MS = process.platform === 'win32' ? 20_000 : 8_000;
38
44
  exports.MIN_VERSIONS = {
39
45
  node: '18.0.0',
40
46
  npm: '9.0.0',
@@ -53,7 +59,7 @@ function locateCommand(command) {
53
59
  }
54
60
  /** Run `<bin> <args>` and interpret the result as a `--version` probe. */
55
61
  function runProbe(bin, args) {
56
- const result = runVersionSpawn(bin, args);
62
+ const result = runVersionSpawn(bin, args, VERSION_PROBE_TIMEOUT_MS);
57
63
  if (result.error) {
58
64
  const err = result.error;
59
65
  return { executed: false, error: `${err.code ?? 'ERR'}: ${err.message}` };
@@ -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.