claude-spotter 1.4.23 → 1.4.25

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.
package/CHANGELOG.md CHANGED
@@ -1,11 +1,41 @@
1
1
  # Changelog
2
2
 
3
- ## 1.4.23 — release candidate (pending)
3
+ ## 1.4.25 — 2026-07-14
4
+
5
+ - **Windows Codex CLI実行経路を修正。** `spotter install`、`spotter doctor`、Codex hook /
6
+ factory diagnostics、Codex CLI auditorは、Windowsのnpm shim `codex.cmd`を`cmd.exe`経由で
7
+ 実行する。PowerShellから利用可能なCodexを未導入扱いせず、生成後のhook監査まで通す。
8
+ - **timeout時のプロセスツリー終了を追加。** Windowsのshim経由監査は`taskkill /T /F`で
9
+ 子Codexを残さず、終了確認に失敗すれば別codeでfail-loudにする。npm生成shimは
10
+ 検証済みNode entrypointへ解決してから直接起動し、project pathを`cmd.exe`に
11
+ 再解釈させない。Codex Sidecarの診断・監査・workflowも同じ解決契約へ揃える。
12
+ POSIXと実体`.exe`の直接spawn、stdin prompt、非0終了時のfail-loud診断は維持する。
13
+ - **検証。** 530 tests(528 pass / 2 platform skip)、68-file pack、FOX Windows nativeの
14
+ tarball仮導入、install、warning 0のdoctor、Codex hooks / catalog、実Codex CLI auditor、
15
+ codex-sidecar auditor diagnostics、dotagents `verify-install`を通過した。
16
+
17
+ ## 1.4.24 — 2026-07-14
18
+
19
+ - **installerがruntime error state rootをowner-privateへ正規化。** `spotter install`は
20
+ Hook・tool DB・daemon stateを作る前に、runtime storeと同じPOSIX uid / modeまたは
21
+ Windows current-SID-only DACL契約でglobal state rootを準備する。fresh installだけでなく、
22
+ umaskにより`0755` / `0775`で残っていた既存`~/.spotter`もPOSIXでは`0700`へ修復する。
23
+ - **unsafe pathを修復前に拒否。** POSIXのtype・symlink・owner検証を`chmod`より前へ移し、
24
+ `~/.spotter`がsymlinkの場合にリンク先のmodeを変えてから失敗する副作用を防いだ。
25
+ read-timeのmode検証、Windows DACL、collection明示opt-in、network非依存契約は変更しない。
26
+ - **検証・公開。** 519 tests(517 pass / 2 platform skip)、macOS/Linux/Windows ×
27
+ Node 22.13/22.xのCI run `29274984927`、67-file pack検査、隔離installを通過した。
28
+ 公開commit `52d6086`をnpm `latest`、`v1.4.24` tag、GitHub Releaseへ同期し、Mac、
29
+ main-server、FOX WSL2のregistry版global install、state root `0700`、runtime snapshot、
30
+ dotagents Mac BugHub canaryを確認した。
31
+
32
+ ## 1.4.23 — 2026-07-13
4
33
 
5
34
  - **Factory diagnostics と local runtime error store。** factory diagnostics と
6
35
  `spotter diagnostics runtime-errors` の公開候補。収集は canonical dotagents config の
7
36
  JSON boolean `collection.enabled: true` が明示された時だけ有効で既定OFF、保存は
8
- local-only、network送信は行わない。npm公開、tag、CI、registry由来installの確認は未実施。
37
+ local-only、network送信は行わない。公開commit `a117a99`、CI `29238199094`、npm
38
+ `latest`、tag / GitHub Release、registry由来隔離installと診断snapshotを確認済み。
9
39
  - **隔離observerのdeadlineを実測へ整合。** 並列full-suite負荷でcold workerとreceipt
10
40
  reconcilerが500msに収まらず、commit済みreceiptを`store_unavailable`へ誤分類するraceを
11
41
  再現した。絶対deadlineを1.5秒へ広げたうえでreceipt reconcilerを観測workerと
package/README.md CHANGED
@@ -248,9 +248,9 @@ otherwise the Haiku-compatible path. Codex native hooks automatically select Cod
248
248
 
249
249
  ## Local runtime error aggregates
250
250
 
251
- **v1.4.23 は release candidate(公開待ち)です。** 以下の factory diagnostics と
252
- local runtime error aggregate は collection が既定OFFで、公開・tag・CI・registry由来
253
- installの確認はいずれも未完了です。
251
+ **v1.4.23 は2026-07-13に公開済みです。** 以下の factory diagnostics と local runtime
252
+ error aggregate は collection が既定OFFです。npm `latest`、tag / GitHub Release、
253
+ 公開CI、registry由来installを確認済みです。
254
254
 
255
255
  Spotter collects fixed-code runtime failures only when the canonical dotagents factory reporter
256
256
  configuration contains the JSON boolean `collection.enabled: true`. Missing, malformed, and disabled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.4.23",
3
+ "version": "1.4.25",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,7 @@ import { delimiter, dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { createAuditorBackend, selectAuditorBackend } from '../core/auditor-backend.mjs';
8
8
  import { resolveCodexAuditorModelSelection } from '../core/codex-auditor-model-policy.mjs';
9
+ import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
9
10
  import { codexLastAssistantMessage, readCodexToolUsage } from '../core/codex-transcript.mjs';
10
11
  import { readLocal } from '../tool-db/refresh.mjs';
11
12
  import { spawnRefreshDetached } from '../hooks/spawn-daemon.mjs';
@@ -549,13 +550,23 @@ export async function codexHookDiagnostics({
549
550
  projectRoot = null,
550
551
  env = process.env,
551
552
  spawnSyncFn = spawnSync,
553
+ platform = process.platform,
552
554
  resolveModelSelectionFn = resolveCodexAuditorModelSelection,
553
555
  } = {}) {
554
556
  const auditorBackend = resolveCodexHookAuditorBackend({ env });
555
557
  const auditorModelSelection = auditorBackend === 'codex-cli'
556
558
  ? resolveModelSelectionFn({ env })
557
559
  : null;
558
- const features = spawnSyncFn('codex', ['features', 'list'], { encoding: 'utf8', maxBuffer: 1024 * 1024 });
560
+ const invocation = buildWindowsCompatibleInvocation({
561
+ command: 'codex',
562
+ args: ['features', 'list'],
563
+ platform,
564
+ });
565
+ const features = spawnSyncFn(invocation.command, invocation.args, {
566
+ encoding: 'utf8',
567
+ maxBuffer: 1024 * 1024,
568
+ windowsHide: true,
569
+ });
559
570
  const featureOutput = [features.stdout, features.stderr].filter(Boolean).join('\n');
560
571
  const hooks = await loadJson(join(codexHome, 'hooks.json'));
561
572
  const evidence = featureOutput.split('\n').find((line) => isEnabledCodexHookFeatureLine(line)) ?? null;
@@ -8,6 +8,7 @@ import { promisify } from 'node:util';
8
8
  import { loadDb, globalDbPath, localDbPath } from '../tool-db/loader.mjs';
9
9
  import { findSpotterMarker } from '../hooks/lib.mjs';
10
10
  import { codexHookDiagnostics } from './codex-hook-cmd.mjs';
11
+ import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
11
12
 
12
13
  const execFileP = promisify(execFile);
13
14
 
@@ -51,8 +52,8 @@ export async function runDoctor() {
51
52
  // Codex native readiness. These are warnings because Claude-backed installs may
52
53
  // still be valid, but Codex-first tuning needs the signal in one place.
53
54
  try {
54
- const { stdout } = await execFileP('codex', ['--version'], { timeout: 5_000, windowsHide: true });
55
- mark(true, `codex CLI: ${stdout.trim()}`);
55
+ const version = await inspectCodexCliVersion();
56
+ mark(true, `codex CLI: ${version}`);
56
57
  } catch (err) {
57
58
  mark(false, 'codex CLI', `not found or failed: ${err.message}`);
58
59
  warnings += 1;
@@ -115,6 +116,23 @@ export async function runDoctor() {
115
116
  console.log(`result: OK (${warnings} warnings)`);
116
117
  }
117
118
 
119
+ export async function inspectCodexCliVersion({
120
+ codexBin = 'codex',
121
+ platform = process.platform,
122
+ execFileFn = execFileP,
123
+ } = {}) {
124
+ const invocation = buildWindowsCompatibleInvocation({
125
+ command: codexBin,
126
+ args: ['--version'],
127
+ platform,
128
+ });
129
+ const { stdout } = await execFileFn(invocation.command, invocation.args, {
130
+ timeout: 5_000,
131
+ windowsHide: true,
132
+ });
133
+ return stdout.trim();
134
+ }
135
+
118
136
  export async function inspectCodexHookConfiguration({ projectRoot = null, diagnosticsFn = codexHookDiagnostics } = {}) {
119
137
  const diagnostics = await diagnosticsFn({ projectRoot });
120
138
  const issues = Object.entries(diagnostics.validation ?? {})
@@ -209,7 +227,13 @@ async function codexSidecarAuditorReadiness(projectRoot) {
209
227
  const cmd = cliPath ? process.execPath : 'codex-sidecar';
210
228
  const finalArgs = cliPath ? [cliPath, ...args] : args;
211
229
  try {
212
- const { stdout } = await execFileP(cmd, finalArgs, {
230
+ const invocation = buildWindowsCompatibleInvocation({
231
+ command: cmd,
232
+ args: finalArgs,
233
+ env: process.env,
234
+ allowCmdFallback: false,
235
+ });
236
+ const { stdout } = await execFileP(invocation.command, invocation.args, {
213
237
  timeout: 15_000,
214
238
  windowsHide: true,
215
239
  maxBuffer: 1024 * 1024,
@@ -15,6 +15,7 @@
15
15
  import { mkdir, writeFile, readFile, access, realpath } from 'node:fs/promises';
16
16
  import { constants as fsConstants } from 'node:fs';
17
17
  import { spawnSync } from 'node:child_process';
18
+ import { buildWindowsCompatibleInvocation } from '../core/windows-cli-shim.mjs';
18
19
  import { homedir } from 'node:os';
19
20
  import { join, resolve, dirname, delimiter, extname } from 'node:path';
20
21
  import { fileURLToPath } from 'node:url';
@@ -23,6 +24,7 @@ import { version as SPOTTER_VERSION } from '../version.mjs';
23
24
  import { refresh } from '../tool-db/refresh.mjs';
24
25
  import { localDbPath, globalDbPath } from '../tool-db/loader.mjs';
25
26
  import { installCodexHooks } from './codex-hook-cmd.mjs';
27
+ import { prepareRuntimeErrorStoreDirectory } from '../core/runtime-error-store.mjs';
26
28
 
27
29
  const HERE = dirname(fileURLToPath(import.meta.url));
28
30
  const PACKAGE_ROOT = resolve(HERE, '..', '..');
@@ -58,6 +60,7 @@ export async function runInstall({
58
60
  refreshFn = refresh,
59
61
  codexCliPresentFn = isCodexCliPresent,
60
62
  installCodexHooksFn = installCodexHooks,
63
+ prepareRuntimeErrorStoreDirectoryFn = prepareRuntimeErrorStoreDirectory,
61
64
  auditorContext,
62
65
  resolveDefaultAuditorContextFn = resolveDefaultAuditorContext,
63
66
  } = {}) {
@@ -70,6 +73,7 @@ export async function runInstall({
70
73
  console.log(` settings: ${settingsPath}`);
71
74
 
72
75
  // 1. create directories
76
+ await prepareRuntimeErrorStoreDirectoryFn();
73
77
  await mkdir(SPOTTER_HOME, { recursive: true });
74
78
  await mkdir(join(SPOTTER_HOME, 'runtime'), { recursive: true });
75
79
  await mkdir(join(SPOTTER_HOME, 'workdir'), { recursive: true });
@@ -275,8 +279,17 @@ async function exists(path) {
275
279
  }
276
280
  }
277
281
 
278
- function isCodexCliPresent() {
279
- const result = spawnSync('codex', ['--version'], {
282
+ export function isCodexCliPresent({
283
+ platform = process.platform,
284
+ spawnSyncFn = spawnSync,
285
+ codexBin = 'codex',
286
+ } = {}) {
287
+ const invocation = buildWindowsCompatibleInvocation({
288
+ command: codexBin,
289
+ args: ['--version'],
290
+ platform,
291
+ });
292
+ const result = spawnSyncFn(invocation.command, invocation.args, {
280
293
  encoding: 'utf8',
281
294
  stdio: ['ignore', 'pipe', 'pipe'],
282
295
  timeout: 5_000,
@@ -10,6 +10,7 @@ import {
10
10
  resolveCodexAuditorModelSelection,
11
11
  } from './codex-auditor-model-policy.mjs';
12
12
  import { serializeAuditorPromptData, validateRecentContext } from './auditor-prompt-data.mjs';
13
+ import { buildWindowsCompatibleInvocation, terminateProcessTree } from './windows-cli-shim.mjs';
13
14
 
14
15
  const DEFAULT_CODEX_CLI_TIMEOUT_MS = 45_000;
15
16
  const DEFAULT_CODEX_CLI_MODEL = CODEX_AUDITOR_MODEL_POLICY.production.model;
@@ -129,6 +130,8 @@ export function createCodexCliAuditorBackend({
129
130
  codexBin = 'codex',
130
131
  modelProfile = null,
131
132
  resolveModelSelectionFn = resolveCodexAuditorModelSelection,
133
+ platform = process.platform,
134
+ terminateChildFn = terminateProcessTree,
132
135
  } = {}) {
133
136
  if (!Array.isArray(catalog)) {
134
137
  throw new TypeError('createCodexCliAuditorBackend: catalog must be an array');
@@ -168,6 +171,8 @@ export function createCodexCliAuditorBackend({
168
171
  spawnFn,
169
172
  timeoutMs,
170
173
  modelSelection,
174
+ platform,
175
+ terminateChildFn,
171
176
  });
172
177
  let rawFinal;
173
178
  try {
@@ -321,6 +326,8 @@ async function runCodexExec({
321
326
  spawnFn,
322
327
  timeoutMs,
323
328
  modelSelection,
329
+ platform,
330
+ terminateChildFn,
324
331
  }) {
325
332
  const args = buildCodexExecArgs({
326
333
  schemaPath,
@@ -330,10 +337,36 @@ async function runCodexExec({
330
337
  reasoningEffort: modelSelection.effectiveReasoningEffort,
331
338
  });
332
339
  const options = buildCodexCliSpawnOptions({ projectRoot, env });
340
+ let invocation;
341
+ try {
342
+ invocation = buildWindowsCompatibleInvocation({
343
+ command: codexBin,
344
+ args,
345
+ platform,
346
+ env,
347
+ allowCmdFallback: false,
348
+ });
349
+ } catch (err) {
350
+ throw new AuditorBackendError('E_CODEX_CLI_SPAWN', `failed to resolve ${codexBin}: ${err.message}`, {
351
+ backend: 'codex-cli',
352
+ stage,
353
+ diagnostics: {
354
+ durationMs: 0,
355
+ processCount: 0,
356
+ processCountMethod: 'resolve_failed',
357
+ stdout: '',
358
+ stderr: '',
359
+ stdoutTruncated: false,
360
+ stderrTruncated: false,
361
+ modelSelection,
362
+ },
363
+ cause: err,
364
+ });
365
+ }
333
366
  const startedAt = Date.now();
334
367
  let child;
335
368
  try {
336
- child = spawnFn(codexBin, args, options);
369
+ child = spawnFn(invocation.command, invocation.args, options);
337
370
  } catch (err) {
338
371
  throw new AuditorBackendError('E_CODEX_CLI_SPAWN', `failed to spawn ${codexBin}: ${err.message}`, {
339
372
  backend: 'codex-cli',
@@ -368,8 +401,21 @@ async function runCodexExec({
368
401
  const timer = setTimeout(() => {
369
402
  settle(async () => {
370
403
  const completed = await readSchemaValidLastMessage({ lastMessagePath, stage });
404
+ try {
405
+ await terminateChildFn(child, { platform });
406
+ } catch (cause) {
407
+ reject(new AuditorBackendError('E_CODEX_CLI_TERMINATION', 'codex-cli process tree termination could not be verified', {
408
+ backend: 'codex-cli',
409
+ stage,
410
+ diagnostics: {
411
+ ...diagnostics(),
412
+ lastMessageCheck: completed.reason,
413
+ },
414
+ cause,
415
+ }));
416
+ return;
417
+ }
371
418
  if (completed.ok) {
372
- if (typeof child.kill === 'function') child.kill();
373
419
  resolve({
374
420
  diagnostics: {
375
421
  ...diagnostics(),
@@ -379,7 +425,6 @@ async function runCodexExec({
379
425
  });
380
426
  return;
381
427
  }
382
- if (typeof child.kill === 'function') child.kill();
383
428
  reject(new AuditorBackendError('E_CODEX_CLI_TIMEOUT', `codex-cli did not respond within ${timeoutMs}ms`, {
384
429
  backend: 'codex-cli',
385
430
  stage,
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { toSpotterJudgment } from './judgment.mjs';
7
7
  import { filterCatalogMisses } from './auditor-response.mjs';
8
8
  import { AuditorBackendError } from './auditor-error.mjs';
9
+ import { buildWindowsCompatibleInvocation } from './windows-cli-shim.mjs';
9
10
 
10
11
  const execFileP = promisify(execFile);
11
12
  const DEFAULT_CODEX_SIDECAR_AUDITOR_TIMEOUT_MS = 45_000;
@@ -18,6 +19,7 @@ export function createCodexSidecarAuditorBackend({
18
19
  projectRoot,
19
20
  env = process.env,
20
21
  execFileFn = execFileP,
22
+ buildInvocationFn = buildWindowsCompatibleInvocation,
21
23
  timeoutMs = DEFAULT_CODEX_SIDECAR_AUDITOR_TIMEOUT_MS,
22
24
  } = {}) {
23
25
  if (!Array.isArray(catalog)) {
@@ -47,6 +49,7 @@ export function createCodexSidecarAuditorBackend({
47
49
  contextFilePath,
48
50
  env,
49
51
  execFileFn,
52
+ buildInvocationFn,
50
53
  timeoutMs,
51
54
  });
52
55
  const parsed = parseCodexSidecarAuditorResult(run.value, { stage });
@@ -166,14 +169,27 @@ function buildExecOptions({ projectRoot, env, timeoutMs }) {
166
169
  };
167
170
  }
168
171
 
169
- async function runCodexSidecarAuditor({ projectRoot, contextFilePath, env, execFileFn, timeoutMs }) {
172
+ async function runCodexSidecarAuditor({
173
+ projectRoot,
174
+ contextFilePath,
175
+ env,
176
+ execFileFn,
177
+ buildInvocationFn,
178
+ timeoutMs,
179
+ }) {
170
180
  const { cmd, args } = buildCodexSidecarAuditorCommand({ projectRoot, contextFilePath, env, timeoutMs });
171
181
  const options = buildExecOptions({ projectRoot, env, timeoutMs });
182
+ const invocation = buildInvocationFn({
183
+ command: cmd,
184
+ args,
185
+ env,
186
+ allowCmdFallback: false,
187
+ });
172
188
  const startedAt = Date.now();
173
189
  let stdout = '';
174
190
  let stderr = '';
175
191
  try {
176
- const result = await execFileFn(cmd, args, options);
192
+ const result = await execFileFn(invocation.command, invocation.args, options);
177
193
  stdout = result.stdout ?? '';
178
194
  stderr = result.stderr ?? '';
179
195
  } catch (err) {
@@ -16,6 +16,7 @@ import {
16
16
  createSidecarResultRecord,
17
17
  spotterFindingsToSidecarContextBlocks,
18
18
  } from './sidecar-context.mjs';
19
+ import { buildWindowsCompatibleInvocation } from './windows-cli-shim.mjs';
19
20
 
20
21
  const execFileP = promisify(execFile);
21
22
  const WORKFLOW_MAP = {
@@ -36,6 +37,7 @@ export async function runCodexReadOnlyWorkflow({
36
37
  save = true,
37
38
  outPath = null,
38
39
  execFileFn = execFileP,
40
+ buildInvocationFn = buildWindowsCompatibleInvocation,
39
41
  gitStatusFn = execFileP,
40
42
  env = process.env,
41
43
  now = () => new Date(),
@@ -54,6 +56,7 @@ export async function runCodexReadOnlyWorkflow({
54
56
  const effectiveHostAgent = detectHostAgent({ explicitHostAgent: hostAgent, env });
55
57
  const contextBlocks = spotterFindingsToSidecarContextBlocks(findings);
56
58
  const spawnOptions = buildSidecarSpawnOptions({ projectRoot, env });
59
+ spawnOptions.buildInvocationFn = buildInvocationFn;
57
60
  const diagnostics = await runDiagnostics({ projectRoot, preset: workflowSpec.preset, execFileFn, spawnOptions });
58
61
  const availability = classifySidecarAvailability({ diagnostics });
59
62
  const decision = decideCodexSidecarUse({
@@ -129,6 +132,7 @@ export async function runCodexWork({
129
132
  save = true,
130
133
  outPath = null,
131
134
  execFileFn = execFileP,
135
+ buildInvocationFn = buildWindowsCompatibleInvocation,
132
136
  gitStatusFn = execFileP,
133
137
  env = process.env,
134
138
  now = () => new Date(),
@@ -163,6 +167,7 @@ export async function runCodexWork({
163
167
  gitStatusFn,
164
168
  });
165
169
  const spawnOptions = buildSidecarSpawnOptions({ projectRoot, env, marker: 'codex-work' });
170
+ spawnOptions.buildInvocationFn = buildInvocationFn;
166
171
  const diagnostics = dirtyScope.ok
167
172
  ? await runDiagnostics({ projectRoot, preset: workflowSpec.preset, execFileFn, spawnOptions })
168
173
  : null;
@@ -481,10 +486,13 @@ function execPortable(execFileFn, cmd, args, spawnOptions) {
481
486
  windowsHide: true,
482
487
  maxBuffer: 10 * 1024 * 1024,
483
488
  };
484
- if (process.platform === 'win32' && cmd !== process.execPath) {
485
- return execFileFn('cmd.exe', ['/c', cmd, ...args], options);
486
- }
487
- return execFileFn(cmd, args, options);
489
+ const invocation = spawnOptions.buildInvocationFn({
490
+ command: cmd,
491
+ args,
492
+ env: spawnOptions.env,
493
+ allowCmdFallback: false,
494
+ });
495
+ return execFileFn(invocation.command, invocation.args, options);
488
496
  }
489
497
 
490
498
  function parseJsonOutput(stdout) {
@@ -90,6 +90,11 @@ export function defaultRuntimeErrorStorePath({
90
90
  return join(homeDir, '.spotter', 'runtime-errors-v1.json');
91
91
  }
92
92
 
93
+ export async function prepareRuntimeErrorStoreDirectory(options = {}) {
94
+ const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
95
+ await ensurePrivateDirectory(dirname(storePath), options);
96
+ }
97
+
93
98
  export function runtimeErrorFingerprint(definition) {
94
99
  validateDefinition(definition);
95
100
  const canonical = [
@@ -578,22 +583,29 @@ async function enforcePrivatePath(path, options, { directory, repair }) {
578
583
  if (repair) await runWindowsAcl(path, { directory }, options);
579
584
  return;
580
585
  }
586
+ const initialInfo = await lstat(path);
587
+ validatePrivateIdentity(initialInfo, options, { directory, platform });
581
588
  if (repair) await chmod(path, directory ? 0o700 : 0o600);
582
589
  const info = await lstat(path);
583
590
  validatePrivateStat(info, options, { directory, platform });
584
591
  }
585
592
 
586
- function validatePrivateStat(info, options, { directory, platform }) {
593
+ function validatePrivateIdentity(info, options, { directory, platform }) {
587
594
  if ((directory ? !info.isDirectory() : !info.isFile()) || info.isSymbolicLink()) {
588
595
  throw storeError('runtime error path type is unsafe');
589
596
  }
590
597
  if (platform === 'win32') return;
591
- const expectedMode = directory ? 0o700 : 0o600;
592
- if ((info.mode & 0o777) !== expectedMode) throw storeError('runtime error path mode is unsafe');
593
598
  const getuid = options.getuidFn ?? process.getuid?.bind(process);
594
599
  if (typeof getuid === 'function' && info.uid !== getuid()) throw storeError('runtime error path owner is unsafe');
595
600
  }
596
601
 
602
+ function validatePrivateStat(info, options, { directory, platform }) {
603
+ validatePrivateIdentity(info, options, { directory, platform });
604
+ if (platform === 'win32') return;
605
+ const expectedMode = directory ? 0o700 : 0o600;
606
+ if ((info.mode & 0o777) !== expectedMode) throw storeError('runtime error path mode is unsafe');
607
+ }
608
+
597
609
  export function buildWindowsAclPowerShell({ directory }) {
598
610
  const securityType = directory ? 'DirectorySecurity' : 'FileSecurity';
599
611
  const ioType = directory ? 'Directory' : 'File';
@@ -0,0 +1,162 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { win32 } from 'node:path';
4
+
5
+ // Windows の npm global CLI は多くが `<name>.cmd` shim であり、Node の shell:false
6
+ // 直接 spawn では PATHEXT 解決されない。cmd.exe を明示して Node 24 の shell:true
7
+ // deprecation を避けつつ、POSIX と実体 .exe は従来どおり直接起動する。
8
+ export function buildWindowsCompatibleInvocation({
9
+ command,
10
+ args = [],
11
+ platform = process.platform,
12
+ env = process.env,
13
+ processExecPath = process.execPath,
14
+ fileExistsFn = defaultFileExists,
15
+ readFileFn = defaultReadFile,
16
+ allowCmdFallback = true,
17
+ } = {}) {
18
+ if (typeof command !== 'string' || command.length === 0) {
19
+ throw new TypeError('buildWindowsCompatibleInvocation: command must be a non-empty string');
20
+ }
21
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
22
+ throw new TypeError('buildWindowsCompatibleInvocation: args must be an array of strings');
23
+ }
24
+ if (platform !== 'win32' || /\.exe$/i.test(command)) {
25
+ return { command, args };
26
+ }
27
+ const direct = resolveWindowsNpmShim({
28
+ command,
29
+ args,
30
+ env,
31
+ processExecPath,
32
+ fileExistsFn,
33
+ readFileFn,
34
+ });
35
+ if (direct) return direct;
36
+ if (!allowCmdFallback) {
37
+ const error = new Error(`Windows CLI shim could not be resolved safely: ${command}`);
38
+ error.code = 'E_WINDOWS_CLI_SHIM_UNRESOLVED';
39
+ throw error;
40
+ }
41
+ return {
42
+ command: 'cmd.exe',
43
+ args: ['/d', '/s', '/c', command, ...args],
44
+ };
45
+ }
46
+
47
+ export function resolveWindowsNpmShim({
48
+ command,
49
+ args = [],
50
+ env = process.env,
51
+ processExecPath = process.execPath,
52
+ fileExistsFn = defaultFileExists,
53
+ readFileFn = defaultReadFile,
54
+ } = {}) {
55
+ const pathValue = env?.Path ?? env?.PATH ?? env?.path ?? '';
56
+ const hasPathSeparator = /[\\/]/.test(command);
57
+ const candidates = [];
58
+ if (hasPathSeparator) {
59
+ candidates.push(command);
60
+ if (!/\.[^\\/]+$/.test(command)) candidates.push(`${command}.exe`, `${command}.cmd`);
61
+ } else {
62
+ for (const rawDir of String(pathValue).split(';')) {
63
+ const dir = rawDir.trim();
64
+ if (!dir) continue;
65
+ candidates.push(win32.join(dir, `${command}.exe`), win32.join(dir, `${command}.cmd`));
66
+ }
67
+ }
68
+
69
+ for (const candidate of candidates) {
70
+ if (!fileExistsFn(candidate)) continue;
71
+ if (/\.exe$/i.test(candidate)) return { command: candidate, args };
72
+ if (!/\.cmd$/i.test(candidate)) continue;
73
+ const script = npmShimEntryPath(readFileFn(candidate), win32.dirname(candidate));
74
+ if (!script || !fileExistsFn(script)) continue;
75
+ const bundledNode = win32.join(win32.dirname(candidate), 'node.exe');
76
+ return {
77
+ command: fileExistsFn(bundledNode) ? bundledNode : processExecPath,
78
+ args: [script, ...args],
79
+ };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export function npmShimEntryPath(source, shimDir) {
85
+ if (typeof source !== 'string') return null;
86
+ const match = source.match(/"%dp0%\\(node_modules\\[^"\r\n]+\.(?:cjs|mjs|js))"\s+%\*\s*$/im);
87
+ if (!match) return null;
88
+ const segments = match[1].split(/[\\/]/);
89
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) return null;
90
+ return win32.join(shimDir, ...segments);
91
+ }
92
+
93
+ // cmd.exe shim のみ kill すると孫の CLI が残り得るため、Windows は process tree を
94
+ // taskkill で終了する。taskkill 自体の起動・終了に失敗した時だけ direct kill へ戻す。
95
+ export function terminateProcessTree(child, {
96
+ platform = process.platform,
97
+ spawnFn = spawn,
98
+ timeoutMs = 5_000,
99
+ } = {}) {
100
+ if (!child || typeof child.kill !== 'function') return Promise.resolve();
101
+ if (platform !== 'win32' || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
102
+ child.kill();
103
+ return Promise.resolve();
104
+ }
105
+
106
+ const fallback = () => {
107
+ try { child.kill(); } catch { /* process may already have exited */ }
108
+ };
109
+ return new Promise((resolve, reject) => {
110
+ let settled = false;
111
+ const finish = (error = null) => {
112
+ if (settled) return;
113
+ settled = true;
114
+ clearTimeout(timer);
115
+ if (error) {
116
+ fallback();
117
+ reject(error);
118
+ } else {
119
+ resolve();
120
+ }
121
+ };
122
+ const timer = setTimeout(() => {
123
+ const error = new Error(`taskkill did not finish within ${timeoutMs}ms`);
124
+ error.code = 'E_PROCESS_TREE_TERMINATION_TIMEOUT';
125
+ finish(error);
126
+ }, timeoutMs);
127
+ try {
128
+ const killer = spawnFn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
129
+ stdio: 'ignore',
130
+ windowsHide: true,
131
+ });
132
+ killer.once('error', (cause) => {
133
+ const error = new Error(`taskkill failed to start: ${cause.message}`);
134
+ error.code = 'E_PROCESS_TREE_TERMINATION';
135
+ error.cause = cause;
136
+ finish(error);
137
+ });
138
+ killer.once('close', (code) => {
139
+ if (code === 0) {
140
+ finish();
141
+ return;
142
+ }
143
+ const error = new Error(`taskkill exited with code ${code}`);
144
+ error.code = 'E_PROCESS_TREE_TERMINATION';
145
+ finish(error);
146
+ });
147
+ } catch (cause) {
148
+ const error = new Error(`taskkill spawn failed: ${cause.message}`);
149
+ error.code = 'E_PROCESS_TREE_TERMINATION';
150
+ error.cause = cause;
151
+ finish(error);
152
+ }
153
+ });
154
+ }
155
+
156
+ function defaultFileExists(path) {
157
+ try { return statSync(path).isFile(); } catch { return false; }
158
+ }
159
+
160
+ function defaultReadFile(path) {
161
+ try { return readFileSync(path, 'utf8'); } catch { return ''; }
162
+ }