agentic-workflow-manager 3.13.0 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/src/commands/hooks/shared.js +14 -1
  2. package/dist/src/commands/registry/add.js +10 -1
  3. package/dist/src/core/atomic-file.js +24 -2
  4. package/dist/src/core/executor.js +40 -1
  5. package/dist/src/core/install-transaction.js +13 -1
  6. package/dist/src/core/journal/process.js +241 -9
  7. package/dist/tests/commands/hooks/install-symlink-fallback.test.js +25 -0
  8. package/dist/tests/commands/hooks/status.test.js +23 -3
  9. package/dist/tests/commands/job/exec-wrapper.test.js +15 -1
  10. package/dist/tests/commands/job/gate-reconcile.test.js +31 -5
  11. package/dist/tests/commands/preflight/preflight.test.js +4 -1
  12. package/dist/tests/commands/registry/add.test.js +27 -0
  13. package/dist/tests/commands/sensors/changed.test.js +13 -0
  14. package/dist/tests/commands/sensors/exec-windows.test.js +13 -0
  15. package/dist/tests/commands/sensors/exec.test.js +28 -6
  16. package/dist/tests/commands/sensors/formatters/ruff.test.js +22 -6
  17. package/dist/tests/commands/sensors/run-changed.test.js +33 -0
  18. package/dist/tests/commands/watch/e2e-crash.test.js +54 -18
  19. package/dist/tests/commands/watch/runner.test.js +32 -2
  20. package/dist/tests/commands/watch/supervisor-loop.test.js +24 -3
  21. package/dist/tests/core/artifact-state.test.js +11 -1
  22. package/dist/tests/core/atomic-file-durable.test.js +48 -1
  23. package/dist/tests/core/atomic-file.test.js +14 -3
  24. package/dist/tests/core/executor.test.js +58 -0
  25. package/dist/tests/core/install-transaction.test.js +59 -2
  26. package/dist/tests/core/journal/adapter.test.js +54 -0
  27. package/dist/tests/core/journal/fingerprint.test.js +10 -2
  28. package/dist/tests/core/journal/process.test.js +208 -9
  29. package/dist/tests/core/journal/store.test.js +8 -2
  30. package/dist/tests/core/no-color.test.js +23 -0
  31. package/dist/tests/core/registries-sync.test.js +32 -3
  32. package/package.json +1 -1
@@ -15,7 +15,10 @@ describe('writeFileAtomicDurable', () => {
15
15
  const f = path_1.default.join(dir, 'state.json');
16
16
  (0, atomic_file_1.writeFileAtomicDurable)(f, '{"a":1}', 0o600);
17
17
  expect(fs_1.default.readFileSync(f, 'utf8')).toBe('{"a":1}');
18
- expect(fs_1.default.statSync(f).mode & 0o777).toBe(0o600);
18
+ // Windows fs.chmod can only toggle the read-only attribute, not set
19
+ // granular POSIX bits — see tests/core/atomic-file.test.ts for the
20
+ // confirmed 0o600 -> 0o666 shape on win32.
21
+ expect(fs_1.default.statSync(f).mode & 0o777).toBe(process.platform === 'win32' ? 0o666 : 0o600);
19
22
  });
20
23
  test('sobrevive a reemplazos consecutivos sin residuo tmp (R1.2)', () => {
21
24
  const f = path_1.default.join(dir, 'state.json');
@@ -39,4 +42,48 @@ describe('writeFileAtomicDurable', () => {
39
42
  test('fsyncDirSync exitoso no lanza sobre un directorio real (R1.2)', () => {
40
43
  expect(() => (0, atomic_file_1.fsyncDirSync)(dir)).not.toThrow();
41
44
  });
45
+ describe('fsyncDirSync en Windows (R6 CI, 2026-08-08)', () => {
46
+ // Windows no soporta fsync-ear un fd de directorio en absoluto (confirmado en la
47
+ // primera corrida real de R6 en windows-latest: EPERM en TODAS las llamadas,
48
+ // nunca un fallo intermitente) — degrada a no-op solo para esa combinacion exacta
49
+ // (win32 + EPERM en el fsync mismo), nunca para otra plataforma ni otro error.
50
+ const realPlatform = process.platform;
51
+ afterEach(() => {
52
+ Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
53
+ });
54
+ test('EPERM en el fsync no lanza en win32 — degrada a no-op', () => {
55
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
56
+ const err = new Error('operation not permitted, fsync');
57
+ err.code = 'EPERM';
58
+ jest.spyOn(fs_1.default, 'fsyncSync').mockImplementation(() => { throw err; });
59
+ expect(() => (0, atomic_file_1.fsyncDirSync)(dir)).not.toThrow();
60
+ });
61
+ test('un error que NO es EPERM en el fsync sigue lanzando en win32', () => {
62
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
63
+ const err = new Error('disk full');
64
+ err.code = 'ENOSPC';
65
+ jest.spyOn(fs_1.default, 'fsyncSync').mockImplementation(() => { throw err; });
66
+ expect(() => (0, atomic_file_1.fsyncDirSync)(dir)).toThrow(/fsync de directorio/);
67
+ });
68
+ test('EPERM en el fsync sigue lanzando fuera de win32 — la excepcion es exclusiva de Windows', () => {
69
+ Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
70
+ const err = new Error('operation not permitted, fsync');
71
+ err.code = 'EPERM';
72
+ jest.spyOn(fs_1.default, 'fsyncSync').mockImplementation(() => { throw err; });
73
+ expect(() => (0, atomic_file_1.fsyncDirSync)(dir)).toThrow(/fsync de directorio/);
74
+ });
75
+ test('un fallo en el open (no en el fsync) sigue lanzando incluso en win32', () => {
76
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
77
+ const realOpen = fs_1.default.openSync;
78
+ jest.spyOn(fs_1.default, 'openSync').mockImplementation(((p, flags, mode) => {
79
+ if (flags === 'r') {
80
+ const e = new Error('EPERM simulado');
81
+ e.code = 'EPERM';
82
+ throw e;
83
+ }
84
+ return realOpen(p, flags, mode);
85
+ }));
86
+ expect(() => (0, atomic_file_1.fsyncDirSync)(dir)).toThrow(/fsync de directorio/);
87
+ });
88
+ });
42
89
  });
@@ -28,7 +28,15 @@ describe('writeFileAtomic', () => {
28
28
  expect(open.mock.calls[0][1]).toBe('wx');
29
29
  expect(rename).toHaveBeenCalledWith(temporary, file);
30
30
  expect(fs_1.default.readFileSync(file, 'utf8')).toBe('content');
31
- expect(fs_1.default.statSync(file).mode & 0o777).toBe(0o600);
31
+ // Windows/NTFS has no POSIX permission bits: fs.fchmodSync there can only
32
+ // toggle the read-only attribute, so a *writable* file always reports back
33
+ // mode 0o666 regardless of the finer-grained mode requested (0o600 here) —
34
+ // confirmed directly from windows-latest CI output (R6, 2026-08-08: expected
35
+ // 384/0o600, got 438/0o666). This is a genuine platform capability gap, not a
36
+ // production bug: see the matching note in tests/core/artifact-state.test.ts
37
+ // for why the data this guards doesn't need Windows-specific hardening.
38
+ const expectedMode = process.platform === 'win32' ? 0o666 : 0o600;
39
+ expect(fs_1.default.statSync(file).mode & 0o777).toBe(expectedMode);
32
40
  expect(fs_1.default.existsSync(temporary)).toBe(false);
33
41
  });
34
42
  it('cleans only its temporary file when rename fails', () => {
@@ -53,14 +61,17 @@ describe('writeFileAtomic', () => {
53
61
  });
54
62
  expect(() => (0, atomic_file_1.writeFileAtomic)(file, 'replacement', 0o644)).toThrow('rename failed');
55
63
  expect(fs_1.default.readFileSync(file, 'utf8')).toBe('original');
56
- expect(fs_1.default.statSync(file).mode & 0o777).toBe(0o600);
64
+ // Windows fs.chmod can only toggle the read-only attribute, not set
65
+ // granular POSIX bits (see the platform-aware assertion above in this
66
+ // same file for the confirmed 0o600 -> 0o666 shape).
67
+ expect(fs_1.default.statSync(file).mode & 0o777).toBe(process.platform === 'win32' ? 0o666 : 0o600);
57
68
  });
58
69
  it('preserves existing target permissions after replacement', () => {
59
70
  const file = path_1.default.join(dir, 'AGENTS.md');
60
71
  fs_1.default.writeFileSync(file, 'original', { mode: 0o600 });
61
72
  (0, atomic_file_1.writeFileAtomic)(file, 'replacement');
62
73
  expect(fs_1.default.readFileSync(file, 'utf8')).toBe('replacement');
63
- expect(fs_1.default.statSync(file).mode & 0o777).toBe(0o600);
74
+ expect(fs_1.default.statSync(file).mode & 0o777).toBe(process.platform === 'win32' ? 0o666 : 0o600);
64
75
  });
65
76
  it('rejects a target symlink without severing it or changing its victim', () => {
66
77
  const victim = path_1.default.join(dir, 'victim');
@@ -61,4 +61,62 @@ describe('Executor Engine', () => {
61
61
  expect(fs_1.default.existsSync(path_1.default.join(target, 'test.txt'))).toBe(true);
62
62
  expect(fs_1.default.existsSync(staged)).toBe(false);
63
63
  });
64
+ // Regression: a directory *symlink* ('dir') needs SeCreateSymbolicLinkPrivilege
65
+ // on native Windows, which unprivileged accounts (incl. GitHub Actions'
66
+ // windows-latest runner) don't have — every `awm init` install of a skill/
67
+ // agent directory threw EPERM there, failing the step and exiting the whole
68
+ // run with code 2 (see tests/integration/codex-provider-isolated.test.ts).
69
+ // A 'junction' is a different NTFS reparse-point kind that any account can
70
+ // create, and Node reports it the same way a symlink is reported
71
+ // (isSymbolicLink() true, readlinkSync() resolves it), so this only needs
72
+ // to change the `type` argument passed to fs.symlinkSync on win32 — nothing
73
+ // downstream (verify(), R19 hashing, doctor checks) needs to change.
74
+ describe('on native Windows', () => {
75
+ const realPlatform = process.platform;
76
+ let symlinkSpy;
77
+ beforeEach(() => {
78
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
79
+ symlinkSpy = jest.spyOn(fs_1.default, 'symlinkSync').mockImplementation(() => undefined);
80
+ });
81
+ afterEach(() => {
82
+ Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
83
+ symlinkSpy.mockRestore();
84
+ });
85
+ it('stages a directory symlink as a junction instead of a dir-symlink', () => {
86
+ const target = path_1.default.join(targetDir, 'win-skill');
87
+ (0, executor_1.stageArtifact)(sourceDir, target, 'symlink');
88
+ expect(symlinkSpy).toHaveBeenCalledTimes(1);
89
+ const [calledSource, , calledType] = symlinkSpy.mock.calls[0];
90
+ expect(calledSource).toBe(sourceDir);
91
+ expect(calledType).toBe('junction');
92
+ });
93
+ // Regression (Failure 4/5, R6 round 3): a junction is a NTFS
94
+ // *directory* reparse point — it has no equivalent for a single FILE
95
+ // artifact (an agent/workflow .md). Before this branch existed, a file
96
+ // source got the exact same 'junction' treatment as a directory, which
97
+ // does not correctly resolve back to the file — see
98
+ // tests/core/bundle-install.test.ts's "claude-code agents" case, which
99
+ // reproduced this on windows-latest as a silently-missing target file.
100
+ it('stages a FILE source with a file-typed symlink, not a junction', () => {
101
+ const fileSource = path_1.default.join(sourceDir, 'test.txt');
102
+ const target = path_1.default.join(targetDir, 'win-agent.md');
103
+ (0, executor_1.stageArtifact)(fileSource, target, 'symlink');
104
+ expect(symlinkSpy).toHaveBeenCalledTimes(1);
105
+ const [calledSource, , calledType] = symlinkSpy.mock.calls[0];
106
+ expect(calledSource).toBe(fileSource);
107
+ expect(calledType).toBe('file');
108
+ });
109
+ it('falls back to a plain copy for a FILE source when the file symlink throws (EPERM — no SeCreateSymbolicLinkPrivilege)', () => {
110
+ symlinkSpy.mockImplementation(() => {
111
+ const err = new Error('EPERM: operation not permitted, symlink');
112
+ err.code = 'EPERM';
113
+ throw err;
114
+ });
115
+ const fileSource = path_1.default.join(sourceDir, 'test.txt');
116
+ const target = path_1.default.join(targetDir, 'win-agent-fallback.md');
117
+ const staged = (0, executor_1.stageArtifact)(fileSource, target, 'symlink');
118
+ expect(fs_1.default.lstatSync(staged).isSymbolicLink()).toBe(false);
119
+ expect(fs_1.default.readFileSync(staged, 'utf8')).toBe('hello');
120
+ });
121
+ });
64
122
  });
@@ -307,6 +307,52 @@ describe('defaultTransactionDeps — cursor-mdc / copilot-instructions renderers
307
307
  expect(() => (0, install_transaction_1.applyInstallPlan)(plan, deps)).toThrow('does not look like rendered Copilot instructions');
308
308
  });
309
309
  });
310
+ // Regression (Failure 4/5, R6 round 3): a 'link'-renderer op whose sourcePath
311
+ // is a FILE (an agent/workflow .md, not a skill directory) used to be staged
312
+ // with the same directory-oriented junction/dir symlink type as a directory
313
+ // source — a junction has no equivalent for a single file, so it silently
314
+ // failed to resolve back to the target on native Windows. executor.ts's
315
+ // stageArtifact now dispatches on source kind and falls back to a plain copy
316
+ // for files when the real file-symlink throws (no privilege-free Windows
317
+ // equivalent to a directory junction exists for files); verify() here must
318
+ // accept that fallback rather than failing "not a symlink" for an install
319
+ // that landed correctly, just not as a symlink — see
320
+ // tests/core/executor.test.ts for the stageArtifact-level unit coverage and
321
+ // tests/integration/codex-provider-isolated.test.ts for the real end-to-end
322
+ // flow this was breaking (`InitStepsFailedError` -> exit code 2).
323
+ describe('defaultTransactionDeps — win32 FILE-symlink fallback (Failure 4/5 regression)', () => {
324
+ const realPlatform = process.platform;
325
+ let symlinkSpy;
326
+ beforeEach(() => {
327
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
328
+ symlinkSpy = jest.spyOn(fs_1.default, 'symlinkSync').mockImplementation(() => {
329
+ const err = new Error('EPERM: operation not permitted, symlink');
330
+ err.code = 'EPERM';
331
+ throw err;
332
+ });
333
+ });
334
+ afterEach(() => {
335
+ Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
336
+ symlinkSpy.mockRestore();
337
+ });
338
+ it('completes the full validate/stage/replace/verify pipeline for a FILE `link` op via the copy fallback, instead of failing verification', () => {
339
+ const sourcePath = path_1.default.join(tmpWork, 'agent-source.md');
340
+ fs_1.default.writeFileSync(sourcePath, '---\nname: sample-agent\n---\nDo the thing.\n');
341
+ const targetPath = path_1.default.join(tmpWork, 'sample-agent.md');
342
+ const plan = {
343
+ operations: [makeOp('sample-agent', {
344
+ type: 'agent', renderer: 'link', output: 'link', method: 'symlink',
345
+ sourcePath, targetPath,
346
+ })],
347
+ records: [],
348
+ reports: [{ owner: 'claude-code', targetPath, action: 'install' }],
349
+ };
350
+ const summary = (0, install_transaction_1.applyInstallPlan)(plan);
351
+ expect(summary.modifiedFiles).toEqual([targetPath]);
352
+ expect(fs_1.default.lstatSync(targetPath).isSymbolicLink()).toBe(false); // copy fallback, not a symlink
353
+ expect(fs_1.default.readFileSync(targetPath, 'utf8')).toContain('Do the thing.');
354
+ });
355
+ });
310
356
  describe('beginBackupSession / restoreBackup', () => {
311
357
  it('backs up existing targets before mutation and restores them on rollback', () => {
312
358
  const fileA = path_1.default.join(tmpWork, 'a.json');
@@ -349,8 +395,19 @@ describe('beginBackupSession / restoreBackup', () => {
349
395
  const dirMode = fs_1.default.statSync(backupDir).mode & 0o777;
350
396
  const manifestPath = path_1.default.join(backupDir, 'manifest.json');
351
397
  const manifestMode = fs_1.default.statSync(manifestPath).mode & 0o777;
352
- expect(dirMode).toBe(0o700);
353
- expect(manifestMode).toBe(0o600);
398
+ // Windows fs.chmod can only toggle the read-only attribute, not set granular
399
+ // POSIX bits (see tests/core/atomic-file.test.ts for the confirmed 0o600 ->
400
+ // 0o666 file shape on win32, verified against real windows-latest CI). Directory
401
+ // mode is reasoned by the same mechanism but not yet independently confirmed
402
+ // against real Windows CI for THIS exact 0o700 -> 0o777 case -- libuv derives
403
+ // directory mode on win32 by always setting the execute bit for every class
404
+ // (traversal isn't gated by chmod there), so 0o777 is the expected shape for a
405
+ // non-read-only directory; flag for correction if a real CI run disagrees.
406
+ // Confirmed against real windows-latest CI (2026-08-08): directories get the same
407
+ // 0o666 shape as files there, not 0o777 as first reasoned — libuv does not
408
+ // synthesize a distinct execute bit for directories on win32 either.
409
+ expect(dirMode).toBe(process.platform === 'win32' ? 0o666 : 0o700);
410
+ expect(manifestMode).toBe(process.platform === 'win32' ? 0o666 : 0o600);
354
411
  const manifestRaw = fs_1.default.readFileSync(manifestPath, 'utf8');
355
412
  expect(manifestRaw).not.toContain('secret-content');
356
413
  });
@@ -16,6 +16,60 @@ describe('ControllerAdapter', () => {
16
16
  expect(a.safeToReplace(ref)).toBe('indeterminate'); // vivo: codex no observa llamadas en vuelo => custodia
17
17
  child.kill('SIGKILL');
18
18
  });
19
+ /** Regresion (CI windows-latest): reproduce el bug real via mock de
20
+ * process.platform, ya que no hay windows real disponible en este
21
+ * entorno. Antes del fix, refIsAlive en win32 caia en la rama POSIX
22
+ * (ps/pgrep), y ps/pgrep alli — cuando resuelven en el PATH — son el
23
+ * ps/pgrep EMULADO de MSYS/Cygwin, ciego a pids nativos: para el hijo
24
+ * real y vivo que spawnStructured produce, devolvian exit 1 ("no such
25
+ * process" segun su vista emulada), que el codigo interpretaba como
26
+ * "el SO confirmo la muerte" -> refIsAlive(ref) daba `false` -> este
27
+ * mismo test daba `safeToReplace(ref) === 'safe'` para un proceso
28
+ * VIVO, violando "JAMAS safe sin evidencia" (R4.2b). Tras el fix,
29
+ * refIsAlive en win32 usa process.kill(pid,0) (respaldado por el
30
+ * kernel via libuv) y nunca ps/pgrep. */
31
+ test('safeToReplace en win32 (mockeado): muerto probado (ESRCH real) => safe; vivo => indeterminate, JAMAS safe por ceguera de ps/pgrep emulado (R4.2b)', async () => {
32
+ const originalPlatform = process.platform;
33
+ try {
34
+ const a = (0, adapter_1.adapterFor)('codex');
35
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'nWin32');
36
+ Object.defineProperty(process, 'platform', { value: 'win32' });
37
+ // El pid vive de verdad: nunca 'safe' sin evidencia real de muerte.
38
+ expect(a.safeToReplace(ref)).toBe('indeterminate');
39
+ // Un ps/pgrep emulado y ciego (exit 1 para un pid nativo real) ya
40
+ // NO puede empujar el veredicto hacia 'safe': refIsAlive en win32
41
+ // nunca invoca ps/pgrep — SI invoca powershell.exe (win32ProcessInfo,
42
+ // R6 ronda 2) para la verificacion de identidad completa cuando el
43
+ // ref no esta degradado, que es un mecanismo distinto y legitimo
44
+ // (WMI, no la capa emulada MSYS/Cygwin) — no se afirma "cero
45
+ // llamadas", se afirma "ps/pgrep especificamente, nunca".
46
+ const cp = require('child_process');
47
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation(() => {
48
+ const err = new Error('no matches found');
49
+ err.status = 1;
50
+ throw err;
51
+ });
52
+ expect(a.safeToReplace(ref)).toBe('indeterminate');
53
+ const calledBinaries = execSpy.mock.calls.map((call) => call[0]);
54
+ expect(calledBinaries).not.toContain('ps');
55
+ expect(calledBinaries).not.toContain('pgrep');
56
+ execSpy.mockRestore();
57
+ child.kill('SIGKILL');
58
+ // Muerte PROBADA por el SO (ESRCH real de process.kill(pid,0)):
59
+ // recien ahi es legitimo declarar 'safe'.
60
+ const deadRef = { ...ref };
61
+ const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
62
+ const err = new Error('no such process');
63
+ err.code = 'ESRCH';
64
+ throw err;
65
+ });
66
+ expect(a.safeToReplace(deadRef)).toBe('safe');
67
+ killSpy.mockRestore();
68
+ }
69
+ finally {
70
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
71
+ }
72
+ });
19
73
  test('launchArgv construye el comando de reanudacion journal-first (R4.8)', () => {
20
74
  const argv = (0, adapter_1.adapterFor)('codex').launchArgv('retoma desde next_action');
21
75
  expect(argv[0]).toBe('codex');
@@ -111,7 +111,15 @@ describe('computeFingerprint', () => {
111
111
  * de un git real) — sin el fix, esto tumba al hijo; con el fix, sobrevive. */
112
112
  describe('fingerprint.ts git(): stdio explicito evita inheritStderr hacia un pipe roto', () => {
113
113
  const DIST_ENTRY = path_1.default.resolve(__dirname, '..', '..', '..', 'dist', 'src', 'core', 'journal', 'fingerprint.js');
114
- const REAL_GIT = (0, child_process_1.execFileSync)('which', ['git'], { encoding: 'utf8' }).trim();
114
+ // `which` is POSIX-only and not reliably on PATH in a pwsh-shell windows-latest
115
+ // runner even though Git for Windows is installed (Windows uses `where`). `where`
116
+ // can print one match per line when git is reachable via more than one PATH
117
+ // entry, so only the FIRST line is a valid single path to spawn directly — the
118
+ // rest would make execFileSync try to exec a multi-line string as one path.
119
+ const REAL_GIT = (0, child_process_1.execFileSync)(process.platform === 'win32' ? 'where' : 'which', ['git'], { encoding: 'utf8' })
120
+ .split(/\r?\n/)
121
+ .map((line) => line.trim())
122
+ .find((line) => line.length > 0);
115
123
  beforeAll(() => {
116
124
  if (!fs_1.default.existsSync(DIST_ENTRY)) {
117
125
  throw new Error('dist ausente: corre `cd cli && npm run build` antes de este test (verifica el dist compilado real, no el source transpilado por ts-jest)');
@@ -143,7 +151,7 @@ describe('fingerprint.ts git(): stdio explicito evita inheritStderr hacia un pip
143
151
  `);
144
152
  const child = (0, child_process_1.spawn)(process.execPath, [childScript], {
145
153
  cwd: workDir,
146
- env: { ...process.env, PATH: `${workDir}:${process.env.PATH}` },
154
+ env: { ...process.env, PATH: `${workDir}${path_1.default.delimiter}${process.env.PATH}` },
147
155
  stdio: ['ignore', 'pipe', 'pipe'],
148
156
  detached: true,
149
157
  });
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const child_process_1 = require("child_process");
10
10
  const process_1 = require("../../../src/core/journal/process");
11
+ const paths_1 = require("../../../src/core/paths");
11
12
  describe('process identity', () => {
12
13
  test('spawnStructured produce ProcessRef con tupla completa (R2.1, R4.7)', async () => {
13
14
  const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc');
@@ -15,24 +16,52 @@ describe('process identity', () => {
15
16
  expect(ref.spawnNonce).toBe('nonce-abc');
16
17
  expect(typeof ref.startTime).toBe('string');
17
18
  expect(ref.processGroup).toBeGreaterThan(0);
18
- expect(ref.psArgsDigest).toMatch(/^[0-9a-f]{16}$/);
19
+ // hex real cuando `ps` pudo observar el proceso (caso normal en
20
+ // POSIX); sentinel 'unknown' documentado (ver captureRefFor) cuando
21
+ // no pudo — en win32 esto es la ruta NORMAL, no un error: `ps`/`pgrep`
22
+ // ahi (si resuelven en el PATH) son el ps/pgrep emulado de
23
+ // MSYS/Cygwin, ciego a procesos nativos (ver pidExistsNative en
24
+ // src/core/journal/process.ts). El formato exacto de este campo
25
+ // nunca es la fuente de verdad de vida/muerte — solo lo es refIsAlive
26
+ // (ver los tests de la seccion 'process identity (win32, mockeado)').
27
+ expect(ref.psArgsDigest).toMatch(/^([0-9a-f]{16}|unknown)$/);
19
28
  expect((0, process_1.refIsAlive)(ref)).toBe(true);
20
29
  const dead = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: 300, killGraceMs: 300 });
21
30
  expect(dead).toBe(true);
22
31
  expect((0, process_1.refIsAlive)(ref)).toBe(false);
23
32
  });
24
- test('refIsAlive rechaza cualquier campo distinto de la tupla completa (R2.1)', () => {
33
+ test('refIsAlive rechaza cualquier campo distinto de la tupla completa (R2.1) — en win32 nativo, solo identidad reducida (ronda 3, ver src/core/journal/process.ts)', () => {
25
34
  const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n2');
26
- expect((0, process_1.refIsAlive)({ ...ref, startTime: 'otro-momento' })).toBe(false);
27
- expect((0, process_1.refIsAlive)({ ...ref, spawnNonce: 'otro-nonce' })).toBe(false);
28
- expect((0, process_1.refIsAlive)({ ...ref, argvDigest: 'ffffffffffffffff' })).toBe(false);
29
- expect((0, process_1.refIsAlive)({ ...ref, psArgsDigest: 'ffffffffffffffff' })).toBe(false);
30
- expect((0, process_1.refIsAlive)({ ...ref, processGroup: ref.processGroup + 1 })).toBe(false);
35
+ if ((0, paths_1.isWindowsNative)()) {
36
+ // win32 real (ronda 3): refIsAlive solo valida pid existente +
37
+ // processGroup === pid; el resto de la tupla es informativo
38
+ // (via WMI, captureRefFor) pero NO gatea el veredicto — gap
39
+ // aceptado y documentado (ver refIsAlive en process.ts).
40
+ expect((0, process_1.refIsAlive)({ ...ref, startTime: 'otro-momento' })).toBe(true);
41
+ expect((0, process_1.refIsAlive)({ ...ref, spawnNonce: 'otro-nonce' })).toBe(true);
42
+ expect((0, process_1.refIsAlive)({ ...ref, argvDigest: 'ffffffffffffffff' })).toBe(true);
43
+ expect((0, process_1.refIsAlive)({ ...ref, psArgsDigest: 'ffffffffffffffff' })).toBe(true);
44
+ expect((0, process_1.refIsAlive)({ ...ref, processGroup: ref.processGroup + 1 })).toBe(false);
45
+ }
46
+ else {
47
+ expect((0, process_1.refIsAlive)({ ...ref, startTime: 'otro-momento' })).toBe(false);
48
+ expect((0, process_1.refIsAlive)({ ...ref, spawnNonce: 'otro-nonce' })).toBe(false);
49
+ expect((0, process_1.refIsAlive)({ ...ref, argvDigest: 'ffffffffffffffff' })).toBe(false);
50
+ expect((0, process_1.refIsAlive)({ ...ref, psArgsDigest: 'ffffffffffffffff' })).toBe(false);
51
+ expect((0, process_1.refIsAlive)({ ...ref, processGroup: ref.processGroup + 1 })).toBe(false);
52
+ }
31
53
  child.kill('SIGKILL');
32
54
  });
33
55
  test('terminateGroupConfirmed no senializa un PGID si la identidad del lider no coincide', async () => {
34
56
  const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'n-no-kill');
35
- const mismatched = { ...ref, startTime: 'identidad-de-otro-proceso' };
57
+ // win32 (ronda 3): un mismatch de SOLO startTime ya no lo detecta
58
+ // refIsAlive ahi (gap de reciclado de PID aceptado, ver process.ts) —
59
+ // se usa un mismatch de processGroup, que SI se valida en ambas
60
+ // plataformas, para que este test siga siendo significativo en
61
+ // cualquier host.
62
+ const mismatched = (0, paths_1.isWindowsNative)()
63
+ ? { ...ref, processGroup: ref.processGroup + 1 }
64
+ : { ...ref, startTime: 'identidad-de-otro-proceso' };
36
65
  const confirmed = await (0, process_1.terminateGroupConfirmed)(mismatched, { termGraceMs: 20, killGraceMs: 20 });
37
66
  expect(confirmed).toBe(false);
38
67
  expect((0, process_1.refIsAlive)(ref)).toBe(true);
@@ -148,6 +177,176 @@ describe('process identity', () => {
148
177
  }
149
178
  });
150
179
  });
180
+ /** Regresion (CI windows-latest, primera corrida real de la matriz): ps/pgrep,
181
+ * cuando resuelven en el PATH en Windows, son el ps/pgrep EMULADO de
182
+ * MSYS/Cygwin (Git for Windows) — una capa con su propia tabla de pids,
183
+ * ciega a procesos nativos spawneados via CreateProcess (exactamente lo que
184
+ * produce spawnStructured). El codigo viejo interpretaba el exit 1 de ese
185
+ * ps/pgrep "ciego" como "el SO confirmo que el pid no existe" — falso, y
186
+ * rompia el invariante "JAMAS safe sin evidencia": `safeToReplace` devolvia
187
+ * 'safe' para un proceso genuinamente vivo (ver adapter.test.ts). No hay
188
+ * windows-latest real disponible en este entorno; estos tests mockean
189
+ * `process.platform` (mismo patron que
190
+ * tests/commands/sensors/exec-windows.test.ts, que ya cubre exactamente
191
+ * este problema para sensors/exec.ts::killTree) para ejercitar la rama
192
+ * win32 REAL del codigo de produccion contra un pid real y vivo. */
193
+ describe('process identity (win32, mockeado — sin windows real disponible en este entorno)', () => {
194
+ const originalPlatform = process.platform;
195
+ afterEach(() => {
196
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
197
+ jest.restoreAllMocks();
198
+ });
199
+ test('refIsAlive en win32 usa process.kill(pid,0), NUNCA ps/pgrep — reproduce el bug: un ps/pgrep "ciego" que devuelve exit 1 para un pid real y vivo ya no lo declara muerto (R2.1, R4.2b)', () => {
200
+ // Ronda 3 (ver refIsAlive en process.ts): el veredicto win32 ya NO
201
+ // depende de si la identidad esta degradada o no ('unknown' vs
202
+ // datos reales de WMI) — refIsAlive ahi SOLO llama a
203
+ // pidExistsNative (process.kill) + convencion de processGroup,
204
+ // incondicionalmente. La precondicion original de este test
205
+ // ("identidad degradada porque no hay powershell.exe real en este
206
+ // entorno") ya no es ni necesaria ni confiable: en windows-latest
207
+ // CI real, powershell.exe SI esta disponible y captureRefFor
208
+ // devuelve un startTime real via WMI — lo cual esta bien, porque
209
+ // esta rama de refIsAlive nunca lo consulta de todos modos.
210
+ Object.defineProperty(process, 'platform', { value: 'win32' });
211
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n-win32-a');
212
+ const cp = require('child_process');
213
+ // Simula EXACTAMENTE el bug real de CI: ps/pgrep "corren" pero
214
+ // devuelven exit 1 (ceguera de MSYS a pids nativos) para un pid que
215
+ // esta genuinamente vivo — el codigo viejo confiaba en esto.
216
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation(() => {
217
+ const err = new Error('no matches found');
218
+ err.status = 1;
219
+ throw err;
220
+ });
221
+ try {
222
+ expect((0, process_1.refIsAlive)(ref)).toBe(true); // vivo de verdad: nunca declarado muerto
223
+ expect(execSpy).not.toHaveBeenCalled(); // refIsAlive en win32 ni siquiera intenta ps/pgrep/WMI
224
+ }
225
+ finally {
226
+ child.kill('SIGKILL');
227
+ }
228
+ });
229
+ test('refIsAlive en win32 declara muerte SOLO con ESRCH real de process.kill(pid,0) (R2.1)', () => {
230
+ Object.defineProperty(process, 'platform', { value: 'win32' });
231
+ const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
232
+ const err = new Error('no such process');
233
+ err.code = 'ESRCH';
234
+ throw err;
235
+ });
236
+ const fakeRef = { pid: 999999, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
237
+ expect((0, process_1.refIsAlive)(fakeRef)).toBe(false);
238
+ expect(killSpy).toHaveBeenCalledWith(999999, 0);
239
+ });
240
+ test('refIsAlive en win32 NUNCA declara muerte por un error que no sea ESRCH (ej. EPERM: el pid existe pero sin permiso de senializarlo) (R2.1)', () => {
241
+ Object.defineProperty(process, 'platform', { value: 'win32' });
242
+ jest.spyOn(process, 'kill').mockImplementation(() => {
243
+ const err = new Error('operation not permitted');
244
+ err.code = 'EPERM';
245
+ throw err;
246
+ });
247
+ // Ronda 3: refIsAlive en win32 ya no consulta WMI/powershell — solo
248
+ // pidExistsNative + convencion de processGroup. EPERM (el pid existe
249
+ // pero sin permiso de senializarlo) no es ESRCH => pidExistsNative
250
+ // dice "vivo"; execFileSync no deberia ni invocarse.
251
+ const cp = require('child_process');
252
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
253
+ throw new Error('llamada inesperada a execFileSync en este test: ' + args[0]);
254
+ });
255
+ try {
256
+ const fakeRef = { pid: 4242, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 4242, psArgsDigest: 'x' };
257
+ expect((0, process_1.refIsAlive)(fakeRef)).toBe(true);
258
+ expect(execSpy).not.toHaveBeenCalled();
259
+ }
260
+ finally {
261
+ execSpy.mockRestore();
262
+ }
263
+ });
264
+ test('terminateGroupConfirmed en win32 usa `taskkill /pid <pid> /T /F`, NUNCA process.kill(-pgid) (mismo patron probado en sensors/exec.ts::killTree, ver tests/commands/sensors/exec-windows.test.ts)', async () => {
265
+ // Spawnea en modo POSIX real (platform sin mockear todavia) para que
266
+ // ref.processGroup sea un pgid real de ps (detached:true en esta
267
+ // plataforma) — evita que el pgid observado sea el del test runner.
268
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'n-win32-taskkill');
269
+ Object.defineProperty(process, 'platform', { value: 'win32' });
270
+ const cp = require('child_process');
271
+ // Ronda 3: refIsAlive en win32 ya no consulta WMI/powershell — solo
272
+ // pidExistsNative (process.kill) + convencion de processGroup, asi
273
+ // que el unico execFileSync que este camino dispara es taskkill.
274
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
275
+ const [cmd] = args;
276
+ if (cmd === 'taskkill') {
277
+ child.kill('SIGKILL'); // simula taskkill matando de verdad al pid real
278
+ return '';
279
+ }
280
+ throw new Error('llamada inesperada a execFileSync en este test: ' + cmd);
281
+ });
282
+ const posixKillSpy = jest.spyOn(process, 'kill');
283
+ const dead = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: 2000, killGraceMs: 500 });
284
+ expect(execSpy).toHaveBeenCalledWith('taskkill', ['/pid', String(ref.pid), '/T', '/F'], expect.anything());
285
+ expect(dead).toBe(true);
286
+ // Nunca la convencion POSIX de pid negativo (grupo) en esta plataforma.
287
+ for (const call of posixKillSpy.mock.calls) {
288
+ expect(call[0]).toBeGreaterThanOrEqual(0);
289
+ }
290
+ }, 15000);
291
+ });
292
+ /** Ronda 2 del fix win32 (R2.1/R6) agrego captura de identidad completa
293
+ * (startTime/psArgsDigest reales) via WMI (`Get-CimInstance Win32_Process`,
294
+ * ver win32ProcessInfo) para `captureRefFor`. Ronda 3 (ver refIsAlive en
295
+ * process.ts) revirtio el USO de esa captura como gate de liveness —
296
+ * `refIsAlive` en win32 volvio a pid-existence + convencion de
297
+ * processGroup solamente, tras un falso negativo real en CI (WMI
298
+ * demostradamente no confiable en su primera corrida real) — pero la
299
+ * CAPTURA en si (`captureRefFor`) sigue poblando esos campos como
300
+ * informacion persistida en el ProcessRef, asi que estos dos tests de
301
+ * captura siguen vigentes. Los tests que ejercitaban `refIsAlive` via el
302
+ * camino WMI (ronda 2) fueron removidos: ese camino ya no existe en
303
+ * produccion — ver la suite generica de arriba
304
+ * ('refIsAlive rechaza cualquier campo...') para la cobertura actual de
305
+ * refIsAlive en win32. */
306
+ describe('process identity (win32, mockeado) — captura de identidad via WMI (R2.1/R6, ronda 2)', () => {
307
+ const originalPlatform = process.platform;
308
+ afterEach(() => {
309
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
310
+ jest.restoreAllMocks();
311
+ });
312
+ function mockPowershell(response) {
313
+ const cp = require('child_process');
314
+ jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
315
+ const [cmd] = args;
316
+ if (cmd === 'powershell.exe')
317
+ return typeof response === 'function' ? response() : response;
318
+ throw new Error('llamada inesperada a execFileSync en este test: ' + cmd);
319
+ });
320
+ }
321
+ test('captureRefFor en win32 usa WMI para obtener startTime/psArgsDigest REALES cuando powershell responde (R2.1)', () => {
322
+ Object.defineProperty(process, 'platform', { value: 'win32' });
323
+ const fakeCreationDate = '2026-08-08T00:00:00.0000000-00:00';
324
+ mockPowershell(JSON.stringify({ CreationDate: fakeCreationDate, CommandLine: 'C:\\node.exe fake-argv' }));
325
+ const ref = (0, process_1.captureRefFor)(process.pid, 'nonce-win32-wmi-ok', ['node', 'fake-argv']);
326
+ expect(ref.startTime).toBe(fakeCreationDate); // ya NO 'unknown': WMI respondio
327
+ expect(ref.psArgsDigest).toMatch(/^[0-9a-f]{16}$/); // digest real, no sentinel
328
+ expect(ref.processGroup).toBe(process.pid); // convencion fija win32: sin pgid real jamas
329
+ });
330
+ test('captureRefFor en win32 degrada a unknown si WMI/powershell no responde, nunca crashea (R2.1) — mismo contrato que la rama POSIX', () => {
331
+ Object.defineProperty(process, 'platform', { value: 'win32' });
332
+ const cp = require('child_process');
333
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation(() => {
334
+ const err = new Error('powershell no encontrado');
335
+ err.code = 'ENOENT';
336
+ throw err;
337
+ });
338
+ try {
339
+ expect(() => (0, process_1.captureRefFor)(process.pid, 'nonce-win32-wmi-fail', ['node'])).not.toThrow();
340
+ const ref = (0, process_1.captureRefFor)(process.pid, 'nonce-win32-wmi-fail', ['node']);
341
+ expect(ref.startTime).toBe('unknown');
342
+ expect(ref.psArgsDigest).toBe('unknown');
343
+ expect(ref.processGroup).toBe(process.pid);
344
+ }
345
+ finally {
346
+ execSpy.mockRestore();
347
+ }
348
+ });
349
+ });
151
350
  /** Defense-in-depth: los execFileSync internos de este archivo (psField,
152
351
  * sleepSync, groupIsGone, activitySnapshot) deben capturar el stderr del
153
352
  * subproceso INTERNAMENTE, nunca relayearlo al stderr del proceso llamante
@@ -192,7 +391,7 @@ describe('process.ts execFileSync: stdio explicito evita inheritStderr hacia un
192
391
  `);
193
392
  const child = (0, child_process_1.spawn)(process.execPath, [childScript], {
194
393
  cwd: workDir,
195
- env: { ...process.env, PATH: `${workDir}:${process.env.PATH}` },
394
+ env: { ...process.env, PATH: `${workDir}${path_1.default.delimiter}${process.env.PATH}` },
196
395
  stdio: ['ignore', 'pipe', 'pipe'],
197
396
  detached: true,
198
397
  });
@@ -15,8 +15,14 @@ describe('journal store', () => {
15
15
  test('initJournal crea 0700/0600 y estado inicial valido (R1.2)', () => {
16
16
  (0, store_1.initJournal)(repo, 'rama');
17
17
  const dir = (0, paths_1.journalDir)(repo, 'rama');
18
- expect(fs_1.default.statSync(dir).mode & 0o777).toBe(0o700);
19
- expect(fs_1.default.statSync((0, paths_1.statePath)(repo, 'rama')).mode & 0o777).toBe(0o600);
18
+ // Windows fs.chmod can only toggle the read-only attribute, not set granular
19
+ // POSIX bits -- see tests/core/atomic-file.test.ts (files, confirmed against
20
+ // real windows-latest CI) and tests/core/install-transaction.test.ts
21
+ // (directories, same reasoning) for the 0o666/0o777 shapes.
22
+ // Confirmed against real windows-latest CI (2026-08-08): directories get the same
23
+ // 0o666 shape as files there, not 0o777 as first reasoned.
24
+ expect(fs_1.default.statSync(dir).mode & 0o777).toBe(process.platform === 'win32' ? 0o666 : 0o700);
25
+ expect(fs_1.default.statSync((0, paths_1.statePath)(repo, 'rama')).mode & 0o777).toBe(process.platform === 'win32' ? 0o666 : 0o600);
20
26
  const r = (0, store_1.readJournal)(repo, 'rama');
21
27
  expect(r.corrupt).toBe(false);
22
28
  expect(r.state.revision).toBe(0);
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // tests/core/no-color.test.ts
7
+ //
8
+ // Regression for R6 (2026-08-08): picocolors treats being on win32 OR having a `CI`
9
+ // env var set as automatic color support, REGARDLESS of TTY status — GitHub Actions
10
+ // sets `CI=true` on every runner (any OS) and this suite's own tests assert exact/
11
+ // substring CLI text output that silently breaks once ANSI escapes are interleaved
12
+ // into it. jest.setup.js forces `NO_COLOR=1` before any test file loads specifically
13
+ // to make this deterministic everywhere this suite runs, not just where it happens
14
+ // to be colorless by accident (no real TTY, no CI env). This test pins that: if
15
+ // jest.setup.js's NO_COLOR line were ever removed or the wiring in jest.config.js
16
+ // broke, this is the one test that fails on ITS OWN merits, not as a side effect of
17
+ // some other test's substring assertion breaking.
18
+ const picocolors_1 = __importDefault(require("picocolors"));
19
+ test('picocolors never emits ANSI escapes during this test run', () => {
20
+ expect(picocolors_1.default.isColorSupported).toBe(false);
21
+ expect(picocolors_1.default.red('x')).toBe('x');
22
+ expect(picocolors_1.default.green('x')).toBe('x');
23
+ });