claude-spotter 1.4.24 → 1.4.26
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 +32 -0
- package/package.json +1 -1
- package/src/cli/codex-hook-cmd.mjs +20 -8
- package/src/cli/doctor.mjs +27 -3
- package/src/cli/install.mjs +12 -2
- package/src/core/codex-cli-backend.mjs +48 -3
- package/src/core/codex-sidecar-auditor-backend.mjs +18 -2
- package/src/core/codex-sidecar-runner.mjs +12 -4
- package/src/core/runtime-error-store.mjs +1 -1
- package/src/core/windows-cli-shim.mjs +162 -0
- package/src/hooks/session-end.mjs +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.26 — 2026-07-20
|
|
4
|
+
|
|
5
|
+
- **SessionEnd cleanupを冪等化。** daemonが未起動または既に停止済みの`E_UNREACHABLE`は
|
|
6
|
+
`already-stopped`として記録し、正常な終了状態をstderr warningへ誤分類しない。他のcleanup
|
|
7
|
+
failureは従来どおりwarningと構造eventを残す。
|
|
8
|
+
- **Windows native Codex hookをPowerShell実行可能に修正。** quoted Node commandの先頭へ
|
|
9
|
+
Windowsだけcall operator `&`を付ける。installerのownership判定は新旧両形式を認識し、
|
|
10
|
+
再installで旧形式をcanonicalへ更新、uninstallでも除去できる。
|
|
11
|
+
- **Windows runtime error storeのACL処理を安定化。** 混雑したWindows runnerでも
|
|
12
|
+
PowerShellによるprivate ACL設定が誤ってtimeoutしないよう、上限を15秒へ拡張した。
|
|
13
|
+
|
|
14
|
+
## 1.4.25 — 2026-07-14
|
|
15
|
+
|
|
16
|
+
- **Windows Codex CLI実行経路を修正。** `spotter install`、`spotter doctor`、Codex hook /
|
|
17
|
+
factory diagnostics、Codex CLI auditorは、Windowsのnpm shim `codex.cmd`を`cmd.exe`経由で
|
|
18
|
+
実行する。PowerShellから利用可能なCodexを未導入扱いせず、生成後のhook監査まで通す。
|
|
19
|
+
- **timeout時のプロセスツリー終了を追加。** Windowsのshim経由監査は`taskkill /T /F`で
|
|
20
|
+
子Codexを残さず、終了確認に失敗すれば別codeでfail-loudにする。npm生成shimは
|
|
21
|
+
検証済みNode entrypointへ解決してから直接起動し、project pathを`cmd.exe`に
|
|
22
|
+
再解釈させない。Codex Sidecarの診断・監査・workflowも同じ解決契約へ揃える。
|
|
23
|
+
POSIXと実体`.exe`の直接spawn、stdin prompt、非0終了時のfail-loud診断は維持する。
|
|
24
|
+
- **検証。** 530 tests(528 pass / 2 platform skip)、68-file pack、FOX Windows nativeの
|
|
25
|
+
tarball仮導入、install、warning 0のdoctor、Codex hooks / catalog、実Codex CLI auditor、
|
|
26
|
+
codex-sidecar auditor diagnostics、dotagents `verify-install`を通過した。公開commit
|
|
27
|
+
`65bccf8`をnpm `latest`、`v1.4.25` tag、GitHub Releaseへ同期し、Mac、main-server、
|
|
28
|
+
FOX WSL2、FOX Windows nativeのregistry版global installとwarning 0のdoctorを確認した。
|
|
29
|
+
|
|
3
30
|
## 1.4.24 — 2026-07-14
|
|
4
31
|
|
|
5
32
|
- **installerがruntime error state rootをowner-privateへ正規化。** `spotter install`は
|
|
@@ -9,6 +36,11 @@
|
|
|
9
36
|
- **unsafe pathを修復前に拒否。** POSIXのtype・symlink・owner検証を`chmod`より前へ移し、
|
|
10
37
|
`~/.spotter`がsymlinkの場合にリンク先のmodeを変えてから失敗する副作用を防いだ。
|
|
11
38
|
read-timeのmode検証、Windows DACL、collection明示opt-in、network非依存契約は変更しない。
|
|
39
|
+
- **検証・公開。** 519 tests(517 pass / 2 platform skip)、macOS/Linux/Windows ×
|
|
40
|
+
Node 22.13/22.xのCI run `29274984927`、67-file pack検査、隔離installを通過した。
|
|
41
|
+
公開commit `52d6086`をnpm `latest`、`v1.4.24` tag、GitHub Releaseへ同期し、Mac、
|
|
42
|
+
main-server、FOX WSL2のregistry版global install、state root `0700`、runtime snapshot、
|
|
43
|
+
dotagents Mac BugHub canaryを確認した。
|
|
12
44
|
|
|
13
45
|
## 1.4.23 — 2026-07-13
|
|
14
46
|
|
package/package.json
CHANGED
|
@@ -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';
|
|
@@ -463,11 +464,11 @@ export async function runCodexHookDiagnosticsCommand({
|
|
|
463
464
|
writeOutput(JSON.stringify(result, null, 2) + '\n');
|
|
464
465
|
}
|
|
465
466
|
|
|
466
|
-
export async function installCodexHooks({ codexHome = defaultCodexHome(), nodePath = resolveCodexHookNodePath(), spotterBin = SPOTTER_BIN } = {}) {
|
|
467
|
+
export async function installCodexHooks({ codexHome = defaultCodexHome(), nodePath = resolveCodexHookNodePath(), spotterBin = SPOTTER_BIN, platform = process.platform } = {}) {
|
|
467
468
|
const hooksPath = join(codexHome, 'hooks.json');
|
|
468
469
|
const configPath = join(codexHome, 'config.toml');
|
|
469
470
|
const current = await loadJson(hooksPath);
|
|
470
|
-
const next = mergeCodexHooks(current, { nodePath, spotterBin });
|
|
471
|
+
const next = mergeCodexHooks(current, { nodePath, spotterBin, platform });
|
|
471
472
|
const hooksChanged = JSON.stringify(current) !== JSON.stringify(next);
|
|
472
473
|
if (hooksChanged) {
|
|
473
474
|
await mkdir(dirname(hooksPath), { recursive: true });
|
|
@@ -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
|
|
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;
|
|
@@ -868,14 +879,15 @@ async function loadJson(path) {
|
|
|
868
879
|
}
|
|
869
880
|
}
|
|
870
881
|
|
|
871
|
-
function mergeCodexHooks(current, { nodePath, spotterBin }) {
|
|
882
|
+
function mergeCodexHooks(current, { nodePath, spotterBin, platform = process.platform }) {
|
|
872
883
|
const next = structuredClone(current ?? {});
|
|
873
884
|
next.hooks = next.hooks ?? {};
|
|
874
|
-
|
|
885
|
+
const prefix = platform === 'win32' ? '& ' : '';
|
|
886
|
+
addCodexHook(next, 'SessionStart', `${prefix}${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook session-start`, {
|
|
875
887
|
timeout: 5,
|
|
876
888
|
});
|
|
877
|
-
addCodexHook(next, 'UserPromptSubmit', `${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook user-prompt-submit`);
|
|
878
|
-
addCodexHook(next, 'Stop', `${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook stop`);
|
|
889
|
+
addCodexHook(next, 'UserPromptSubmit', `${prefix}${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook user-prompt-submit`);
|
|
890
|
+
addCodexHook(next, 'Stop', `${prefix}${quoteArg(nodePath)} ${quoteArg(spotterBin)} codex-hook stop`);
|
|
879
891
|
return next;
|
|
880
892
|
}
|
|
881
893
|
|
|
@@ -932,7 +944,7 @@ function isSpotterCodexHook(hook) {
|
|
|
932
944
|
}
|
|
933
945
|
|
|
934
946
|
function isSpotterCodexCommand(command, event = null) {
|
|
935
|
-
const match = command.match(/^(?:"((?:\\.|[^"\\])*)"|(\S+))\s+(?:"((?:\\.|[^"\\])*)"|(\S+))\s+codex-hook\s+(session-start|user-prompt-submit|stop)\s*$/);
|
|
947
|
+
const match = command.match(/^(?:&\s+)?(?:"((?:\\.|[^"\\])*)"|(\S+))\s+(?:"((?:\\.|[^"\\])*)"|(\S+))\s+codex-hook\s+(session-start|user-prompt-submit|stop)\s*$/);
|
|
936
948
|
if (!match) return false;
|
|
937
949
|
const nodePath = unescapeQuotedCommandToken(match[1] ?? match[2]);
|
|
938
950
|
const spotterPath = unescapeQuotedCommandToken(match[3] ?? match[4]);
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -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
|
|
55
|
-
mark(true, `codex CLI: ${
|
|
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
|
|
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,
|
package/src/cli/install.mjs
CHANGED
|
@@ -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';
|
|
@@ -278,8 +279,17 @@ async function exists(path) {
|
|
|
278
279
|
}
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
function isCodexCliPresent(
|
|
282
|
-
|
|
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, {
|
|
283
293
|
encoding: 'utf8',
|
|
284
294
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
285
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(
|
|
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({
|
|
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(
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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) {
|
|
@@ -635,7 +635,7 @@ async function runWindowsAcl(path, { directory }, options) {
|
|
|
635
635
|
const executable = options.powerShellPath ?? 'powershell.exe';
|
|
636
636
|
const script = buildWindowsAclPowerShell({ directory });
|
|
637
637
|
await spawnForExit(executable, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
638
|
-
timeoutMs: options.aclTimeoutMs ??
|
|
638
|
+
timeoutMs: options.aclTimeoutMs ?? 15_000,
|
|
639
639
|
env: { ...process.env, SPOTTER_ACL_PATH: path },
|
|
640
640
|
});
|
|
641
641
|
}
|
|
@@ -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
|
+
}
|
|
@@ -20,6 +20,7 @@ export async function runSessionEnd({
|
|
|
20
20
|
readInput = readStdinJson,
|
|
21
21
|
sendRequestFn = sendRequest,
|
|
22
22
|
recordHookEventFn = recordClaudeHookEvent,
|
|
23
|
+
writeError = (text) => process.stderr.write(text),
|
|
23
24
|
} = {}) {
|
|
24
25
|
if (isChildCall()) return;
|
|
25
26
|
const input = await readInput();
|
|
@@ -41,6 +42,13 @@ export async function runSessionEnd({
|
|
|
41
42
|
event: { hook: 'SessionEnd', status: 'shutdown', durationMs: Date.now() - startedAt },
|
|
42
43
|
});
|
|
43
44
|
} catch (err) {
|
|
45
|
+
if (err?.code === 'E_UNREACHABLE') {
|
|
46
|
+
await recordHookEventFn({
|
|
47
|
+
projectRoot,
|
|
48
|
+
event: { hook: 'SessionEnd', status: 'already-stopped', durationMs: Date.now() - startedAt },
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
44
52
|
await recordHookEventFn({
|
|
45
53
|
projectRoot,
|
|
46
54
|
event: {
|
|
@@ -51,8 +59,7 @@ export async function runSessionEnd({
|
|
|
51
59
|
},
|
|
52
60
|
});
|
|
53
61
|
// §14.1 exception — don't fail the Claude Code session just because cleanup failed.
|
|
54
|
-
|
|
55
|
-
process.exit(0);
|
|
62
|
+
writeError(`spotter-hook: session-end shutdown warning: ${err.code ?? '?'}: ${err.message}\n`);
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|