agentic-workflow-manager 3.4.0 → 3.6.0

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 (61) hide show
  1. package/dist/src/commands/job/exec-wrapper.js +136 -0
  2. package/dist/src/commands/job/export.js +94 -0
  3. package/dist/src/commands/job/gate.js +118 -0
  4. package/dist/src/commands/job/heartbeat.js +15 -0
  5. package/dist/src/commands/job/index.js +246 -0
  6. package/dist/src/commands/job/query.js +37 -0
  7. package/dist/src/commands/job/reap.js +24 -0
  8. package/dist/src/commands/job/reconcile.js +112 -0
  9. package/dist/src/commands/job/request.js +27 -0
  10. package/dist/src/commands/sensors/exec.js +121 -0
  11. package/dist/src/commands/sensors/index.js +4 -4
  12. package/dist/src/commands/sensors/run.js +124 -71
  13. package/dist/src/commands/watch/apply.js +352 -0
  14. package/dist/src/commands/watch/generations.js +249 -0
  15. package/dist/src/commands/watch/index.js +49 -0
  16. package/dist/src/commands/watch/init.js +72 -0
  17. package/dist/src/commands/watch/lock.js +89 -0
  18. package/dist/src/commands/watch/runner.js +191 -0
  19. package/dist/src/commands/watch/supervisor.js +266 -0
  20. package/dist/src/core/atomic-file.js +31 -0
  21. package/dist/src/core/export/pack.js +7 -1
  22. package/dist/src/core/journal/adapter.js +27 -0
  23. package/dist/src/core/journal/fingerprint.js +80 -0
  24. package/dist/src/core/journal/paths.js +56 -0
  25. package/dist/src/core/journal/process.js +284 -0
  26. package/dist/src/core/journal/redact.js +142 -0
  27. package/dist/src/core/journal/requests.js +132 -0
  28. package/dist/src/core/journal/store.js +107 -0
  29. package/dist/src/core/journal/types.js +165 -0
  30. package/dist/src/index.js +4 -0
  31. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  32. package/dist/tests/commands/job/export.test.js +76 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  34. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  35. package/dist/tests/commands/job/verbs.test.js +56 -0
  36. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  37. package/dist/tests/commands/sensors/exec-fixtures.js +24 -0
  38. package/dist/tests/commands/sensors/exec.test.js +91 -0
  39. package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
  40. package/dist/tests/commands/sensors/run-partial.test.js +225 -0
  41. package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
  42. package/dist/tests/commands/sensors/run.test.js +64 -81
  43. package/dist/tests/commands/watch/apply.test.js +397 -0
  44. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  45. package/dist/tests/commands/watch/generations.test.js +115 -0
  46. package/dist/tests/commands/watch/integration.test.js +124 -0
  47. package/dist/tests/commands/watch/lock.test.js +60 -0
  48. package/dist/tests/commands/watch/runner.test.js +239 -0
  49. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  50. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  51. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  52. package/dist/tests/core/journal/adapter.test.js +27 -0
  53. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  54. package/dist/tests/core/journal/paths.test.js +35 -0
  55. package/dist/tests/core/journal/process.test.js +213 -0
  56. package/dist/tests/core/journal/redact.test.js +59 -0
  57. package/dist/tests/core/journal/requests.test.js +134 -0
  58. package/dist/tests/core/journal/store.test.js +88 -0
  59. package/dist/tests/core/journal/types.test.js +78 -0
  60. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  61. package/package.json +1 -1
@@ -13,6 +13,12 @@ exports.packSkill = packSkill;
13
13
  const fs_1 = __importDefault(require("fs"));
14
14
  const path_1 = __importDefault(require("path"));
15
15
  const child_process_1 = require("child_process");
16
+ // stdio explicito: sin esto, spawnSync relayea el stderr del `zip` hijo hacia
17
+ // el stderr del proceso llamante (default `inheritStderr` de Node cuando no
18
+ // se pasa `stdio`) — si ese fd fuera un pipe roto/destruido, el relay mismo
19
+ // dispara un EPIPE no catcheable que crashea al llamante (mismo bug de raiz
20
+ // que motivo EXEC_STDIO en core/journal/process.ts).
21
+ const EXEC_STDIO = ['ignore', 'pipe', 'pipe'];
16
22
  /** Refuses symlinks anywhere in the tree — copying/zipping them could dereference
17
23
  * into content outside the registry (info-leak) or embed a broken/unexpected
18
24
  * link for the recipient. Exported artifacts are plain files only. */
@@ -28,7 +34,7 @@ function assertNoSymlinks(dir) {
28
34
  }
29
35
  /** Capa 1: binario `zip` del sistema. ENOENT → missing (capa 2: carpeta). */
30
36
  const defaultZip = (cwd, zipName, folderName) => {
31
- const r = (0, child_process_1.spawnSync)('zip', ['-r', '-q', zipName, folderName], { cwd });
37
+ const r = (0, child_process_1.spawnSync)('zip', ['-r', '-q', zipName, folderName], { cwd, stdio: EXEC_STDIO });
32
38
  if (r.error && r.error.code === 'ENOENT') {
33
39
  return { ok: false, missing: true };
34
40
  }
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.adapterFor = adapterFor;
4
+ const process_1 = require("./process");
5
+ const RESUME_PROMPT_PREFIX = 'Sos el orquestador SDD de este repo. Corre `awm job reconcile` y ejecuta ';
6
+ function baseSafeToReplace(ref) {
7
+ return (0, process_1.refIsAlive)(ref) ? 'indeterminate' : 'safe';
8
+ }
9
+ const codexAdapter = {
10
+ provider: 'codex',
11
+ launchArgv: (resumePrompt) => ['codex', 'exec', `${RESUME_PROMPT_PREFIX}${resumePrompt}`],
12
+ activity: process_1.activitySnapshot,
13
+ safeToReplace: baseSafeToReplace,
14
+ };
15
+ const claudeAdapter = {
16
+ provider: 'claude-code',
17
+ launchArgv: (resumePrompt) => ['claude', '-p', `${RESUME_PROMPT_PREFIX}${resumePrompt}`],
18
+ activity: process_1.activitySnapshot,
19
+ safeToReplace: baseSafeToReplace,
20
+ };
21
+ function adapterFor(provider) {
22
+ if (provider === 'codex')
23
+ return codexAdapter;
24
+ if (provider === 'claude-code')
25
+ return claudeAdapter;
26
+ throw new Error(`provider desconocido: ${provider} (validos: codex, claude-code)`);
27
+ }
@@ -0,0 +1,80 @@
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
+ exports.resolveWorkingDirectory = resolveWorkingDirectory;
7
+ exports.computeFingerprint = computeFingerprint;
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const child_process_1 = require("child_process");
12
+ const process_1 = require("./process");
13
+ function sha(parts) {
14
+ return crypto_1.default.createHash('sha256').update(parts.join('\0')).digest('hex');
15
+ }
16
+ // Sin ceiling de maxBuffer: repos grandes (`ls-files` / `ls-files --stage` en
17
+ // miles de archivos) pueden superar el default de Node (1MB) y abortar con ENOBUFS.
18
+ // stdio explicito (ver EXEC_STDIO en process.ts): sin esto, execFileSync relayea
19
+ // el stderr de git hacia el stderr DEL SUPERVISOR — si ese fd es un pipe roto, el
20
+ // relay dispara un EPIPE no catcheable que crashea el proceso ENTERO (este helper
21
+ // backea computeFingerprint, invocado en CADA tick via FingerprintNow/computeGate).
22
+ function git(cwd, args) {
23
+ return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8', maxBuffer: Infinity, stdio: process_1.EXEC_STDIO });
24
+ }
25
+ function resolveWorkingDirectory(repoRoot, cwdRel) {
26
+ if (typeof cwdRel !== 'string' || cwdRel.length === 0)
27
+ throw new Error('cwd relativo requerido');
28
+ const relative = path_1.default.normalize(cwdRel);
29
+ if (path_1.default.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path_1.default.sep}`)) {
30
+ throw new Error(`cwd fuera del repo: ${JSON.stringify(cwdRel)}`);
31
+ }
32
+ const root = fs_1.default.realpathSync(repoRoot);
33
+ let cursor = root;
34
+ for (const segment of relative.split(path_1.default.sep).filter((part) => part !== '.')) {
35
+ cursor = path_1.default.join(cursor, segment);
36
+ const stat = fs_1.default.lstatSync(cursor);
37
+ if (stat.isSymbolicLink())
38
+ throw new Error(`cwd contiene symlink no permitido: ${JSON.stringify(cwdRel)}`);
39
+ }
40
+ const absolute = fs_1.default.realpathSync(path_1.default.join(root, relative));
41
+ if (absolute !== root && !absolute.startsWith(`${root}${path_1.default.sep}`))
42
+ throw new Error(`cwd fuera del repo: ${JSON.stringify(cwdRel)}`);
43
+ if (!fs_1.default.statSync(absolute).isDirectory())
44
+ throw new Error(`cwd no es directorio: ${JSON.stringify(cwdRel)}`);
45
+ return { relative: relative.split(path_1.default.sep).join('/'), absolute };
46
+ }
47
+ /** El journal jamás invalida evidencia: .awm/ queda fuera de toda expansión. */
48
+ const EXCLUDE_JOURNAL = ':(exclude).awm';
49
+ /** Componentes SEPARADOS (design R3.4, bloqueador 7 de la review):
50
+ * argv exacto + cwd relativo REAL + HEAD + índice real (`ls-files --stage`
51
+ * hasheado) + digest de contenido por archivo tracked/untracked/deleted. */
52
+ function computeFingerprint(repoRoot, argv, pathGlobs, cwdRel) {
53
+ if (!Array.isArray(argv) || argv.length === 0)
54
+ throw new Error('argv vacio');
55
+ const cwdNorm = resolveWorkingDirectory(repoRoot, cwdRel).relative;
56
+ const commandDigest = sha(argv);
57
+ const head = git(repoRoot, ['rev-parse', 'HEAD']).trim();
58
+ const pathspecs = pathGlobs.length > 0 ? pathGlobs : ['.'];
59
+ // Índice REAL: modos + blobs + stages + paths — un cambio staged-only con
60
+ // worktree idéntico produce salida distinta aquí. Sin -z a propósito: esta
61
+ // salida se hashea completa como texto opaco, nunca se separa en paths
62
+ // individuales, así que el quoting de core.quotePath es inofensivo aquí
63
+ // (a diferencia de expandedPaths abajo, cuyos paths SÍ se re-extraen para
64
+ // pasarlos a `hash-object` — por eso ese caso sí necesita -z).
65
+ const indexRaw = git(repoRoot, ['ls-files', '--stage', '--', ...pathspecs, EXCLUDE_JOURNAL]);
66
+ const indexDigest = sha([indexRaw]);
67
+ const expandedPaths = git(repoRoot, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...pathspecs, EXCLUDE_JOURNAL])
68
+ .split('\0').filter(Boolean).sort();
69
+ const perFile = expandedPaths.map((p) => {
70
+ try {
71
+ return `${p}:${git(repoRoot, ['hash-object', '--', p]).trim()}`;
72
+ }
73
+ catch {
74
+ return `${p}:deleted`; // listado pero ilegible/borrado del worktree: cuenta como cambio
75
+ }
76
+ });
77
+ const declaredPaths = pathGlobs.length > 0 ? pathGlobs : ['.'];
78
+ const fingerprint = sha([commandDigest, `cwd:${cwdNorm}`, `paths:${JSON.stringify(declaredPaths)}`, `head:${head}`, `index:${indexDigest}`, ...perFile]);
79
+ return { fingerprint, commandDigest, expandedPaths };
80
+ }
@@ -0,0 +1,56 @@
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
+ exports.branchSlug = branchSlug;
7
+ exports.journalDir = journalDir;
8
+ exports.supervisorLockPath = supervisorLockPath;
9
+ exports.statePath = statePath;
10
+ exports.requestsDir = requestsDir;
11
+ exports.acksDir = acksDir;
12
+ exports.logsDir = logsDir;
13
+ exports.eventsPath = eventsPath;
14
+ exports.exportDir = exportDir;
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const path_1 = __importDefault(require("path"));
17
+ function branchSlug(branch) {
18
+ if (!branch || branch === '.' || branch.includes('..')) {
19
+ throw new Error(`branch inválida para slug: ${JSON.stringify(branch)}`);
20
+ }
21
+ // Escapa PRIMERO el propio caracter de escape (_), despues / y \ — así
22
+ // ningún guion bajo literal sobrevive sin escapar, lo que hace la
23
+ // codificación biyectiva: dos ramas distintas nunca pueden colisionar
24
+ // (bloqueador encontrado en code-quality review de Task 3).
25
+ return branch
26
+ .replace(/_/g, '_5F')
27
+ .replace(/\//g, '_2F')
28
+ .replace(/\\/g, '_5C');
29
+ }
30
+ function journalDir(repoRoot, branch) {
31
+ return path_1.default.join(repoRoot, '.awm', 'journal', branchSlug(branch));
32
+ }
33
+ /** Lock único por worktree FÍSICO: clavado por realpath, fuera del dir de rama
34
+ * (design R1.1, bloqueante v5-5: dos ramas jamás toman locks distintos sobre
35
+ * el mismo árbol). */
36
+ function supervisorLockPath(repoRoot) {
37
+ return path_1.default.join(fs_1.default.realpathSync(repoRoot), '.awm', 'journal', 'supervisor.lock');
38
+ }
39
+ function statePath(repoRoot, branch) {
40
+ return path_1.default.join(journalDir(repoRoot, branch), 'state.json');
41
+ }
42
+ function requestsDir(repoRoot, branch) {
43
+ return path_1.default.join(journalDir(repoRoot, branch), 'requests');
44
+ }
45
+ function acksDir(repoRoot, branch) {
46
+ return path_1.default.join(journalDir(repoRoot, branch), 'acks');
47
+ }
48
+ function logsDir(repoRoot, branch) {
49
+ return path_1.default.join(journalDir(repoRoot, branch), 'logs');
50
+ }
51
+ function eventsPath(repoRoot, branch) {
52
+ return path_1.default.join(journalDir(repoRoot, branch), 'events.jsonl');
53
+ }
54
+ function exportDir(repoRoot, branch) {
55
+ return path_1.default.join(journalDir(repoRoot, branch), 'export');
56
+ }
@@ -0,0 +1,284 @@
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
+ exports.EXEC_STDIO = exports.NONCE_ENV = void 0;
7
+ exports.argvDigest = argvDigest;
8
+ exports.psArgsDigestOf = psArgsDigestOf;
9
+ exports.captureRefFor = captureRefFor;
10
+ exports.captureSelfRef = captureSelfRef;
11
+ exports.spawnStructured = spawnStructured;
12
+ exports.refIsAlive = refIsAlive;
13
+ exports.processStatesAreGone = processStatesAreGone;
14
+ exports.groupIsGone = groupIsGone;
15
+ exports.activitySnapshot = activitySnapshot;
16
+ exports.terminateGroupConfirmed = terminateGroupConfirmed;
17
+ exports.terminatePreviouslyOwnedGroup = terminatePreviouslyOwnedGroup;
18
+ const crypto_1 = __importDefault(require("crypto"));
19
+ const child_process_1 = require("child_process");
20
+ exports.NONCE_ENV = 'AWM_SPAWN_NONCE';
21
+ /** Contrato dual, a proposito (tomo 3 rondas de fixes reales llegar aca —
22
+ * ver historial de Task 10): devuelve `null` SOLO cuando `ps` corrio y
23
+ * confirmo positivamente que el pid no existe (exit status 1). Cualquier
24
+ * otro fallo (ENOENT del binario, permisos, error transitorio) se
25
+ * RELANZA — nunca se traduce a `null`, porque un `null` aca significaria
26
+ * "muerte confirmada" para cualquier caller que no distinga los casos.
27
+ * Hay DOS formas correctas de consumir esto, segun el contexto:
28
+ * - Declaracion de muerte (`refIsAlive`, `activitySnapshot`): el caller
29
+ * DEBE envolver en su propio try/catch y fallar A FAVOR de "vivo" —
30
+ * nunca asumir muerto por un throw. El silencio jamas es prueba.
31
+ * - Captura de identidad en spawn (`captureRefFor`, `stablePsArgs`): usar
32
+ * `psFieldSafe` en vez de esta funcion — ahi "no se pudo determinar" ya
33
+ * tiene un fallback seguro documentado ('unknown'), sin riesgo de
34
+ * declarar muerte por error. */
35
+ /** stdio explicito en TODOS los execFileSync de este archivo (ver EXEC_STDIO):
36
+ * sin esto, `execFileSync` por defecto hace `inheritStderr` — relayea el
37
+ * stderr del subproceso hacia el stderr DEL PROCESO LLAMANTE. Si ese stderr
38
+ * llegara a ser un pipe roto/destruido (ej. wrapper detached, ver
39
+ * spawnStructured), el relay mismo dispara el EPIPE que crashea al
40
+ * llamante — el mismo bug de raiz, reintroducido via esta funcion en vez
41
+ * de via el spawn del hijo. Con stdio explicito ('pipe' para stdout/stderr)
42
+ * ese relay jamas ocurre: `execFileSync` captura el stderr del subproceso
43
+ * internamente y listo, sin tocar el fd real del proceso actual. */
44
+ exports.EXEC_STDIO = ['ignore', 'pipe', 'pipe'];
45
+ function psField(pid, field) {
46
+ try {
47
+ const out = (0, child_process_1.execFileSync)('ps', ['-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO }).trim();
48
+ return out.length > 0 ? out : null;
49
+ }
50
+ catch (error) {
51
+ const status = error.status;
52
+ if (status === 1)
53
+ return null; // ps corrio y confirmo: el pid no existe
54
+ throw error; // ps no pudo ejecutarse (ENOENT/permisos/etc): NO es prueba de nada
55
+ }
56
+ }
57
+ function sleepSync(seconds) {
58
+ try {
59
+ (0, child_process_1.execFileSync)('sleep', [seconds], { stdio: exports.EXEC_STDIO });
60
+ }
61
+ catch { /* sin sleep: seguimos */ }
62
+ }
63
+ /** Variante de psField para contextos de CAPTURA de identidad (spawn time):
64
+ * aqui "no se pudo determinar" ya tiene un fallback seguro documentado
65
+ * ('unknown') — no es un contexto de declaracion de muerte, asi que
66
+ * cualquier fallo de ps se traga, igual que siempre. */
67
+ function psFieldSafe(pid, field) {
68
+ try {
69
+ return psField(pid, field);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /** ps args estable: dos lecturas consecutivas iguales (evita capturar el
76
+ * estado pre-exec del fork). null si el proceso ya no existe O si ps
77
+ * fallo en ejecutarse (via psFieldSafe) — ambos casos son "no se pudo
78
+ * determinar" aca, sin riesgo: este es un contexto de captura, no de
79
+ * declaracion de muerte. */
80
+ function stablePsArgs(pid) {
81
+ for (let i = 0; i < 5; i++) {
82
+ const a = psFieldSafe(pid, 'args');
83
+ if (a === null)
84
+ return null;
85
+ sleepSync('0.05');
86
+ const b = psFieldSafe(pid, 'args');
87
+ if (b === a)
88
+ return a;
89
+ }
90
+ return psFieldSafe(pid, 'args');
91
+ }
92
+ function argvDigest(argv) {
93
+ return crypto_1.default.createHash('sha256').update(argv.join('\0')).digest('hex').slice(0, 16);
94
+ }
95
+ /** EXPORTADA pero hereda el contrato crudo de psField (throws si ps falla
96
+ * en ejecutarse, mas alla de "pid no existe"). Hoy el unico caller es
97
+ * refIsAlive, que ya envuelve en su propio try/catch fail-safe — cualquier
98
+ * caller NUEVO que la use standalone debe hacer lo mismo (ver comentario
99
+ * de psField) o usar psFieldSafe si esta en un contexto de captura. */
100
+ function identityDigest(psArgs, spawnNonce, requestedArgvDigest) {
101
+ return crypto_1.default.createHash('sha256').update(`${psArgs}\0${spawnNonce}\0${requestedArgvDigest}`).digest('hex').slice(0, 16);
102
+ }
103
+ function psArgsDigestOf(pid, spawnNonce = '', requestedArgvDigest = '') {
104
+ const args = psField(pid, 'args');
105
+ if (args === null)
106
+ return null;
107
+ return identityDigest(args, spawnNonce, requestedArgvDigest);
108
+ }
109
+ /** Captura la identidad COMPLETA de un pid recien spawneado (R2.1):
110
+ * startTime + pgid reales de ps + digest de `ps -o args=` estable. */
111
+ function captureRefFor(pid, nonce, argv) {
112
+ let start = null;
113
+ for (let i = 0; i < 5 && start === null; i++) {
114
+ start = psFieldSafe(pid, 'lstart');
115
+ if (start === null)
116
+ sleepSync('0.05');
117
+ }
118
+ const pgid = psFieldSafe(pid, 'pgid');
119
+ const args = stablePsArgs(pid);
120
+ const requestedArgvDigest = argvDigest(argv);
121
+ return {
122
+ pid,
123
+ startTime: start ?? 'unknown',
124
+ spawnNonce: nonce,
125
+ argvDigest: requestedArgvDigest,
126
+ processGroup: pgid !== null ? Number(pgid) : pid,
127
+ // Liga nonce + argv solicitado con la observacion real de ps. Alterar
128
+ // cualquier miembro de la tupla invalida la identidad completa.
129
+ psArgsDigest: args !== null ? identityDigest(args, nonce, requestedArgvDigest) : 'unknown',
130
+ };
131
+ }
132
+ /** Identidad del proceso ACTUAL (la usa el wrapper externo y el lock). */
133
+ function captureSelfRef(nonce) {
134
+ return captureRefFor(process.pid, nonce, process.argv);
135
+ }
136
+ /** Ejecucion segura (design R4.7): executable+argv como array, shell:false,
137
+ * nonce por entorno (referencia, no valor persistido), grupo propio (detached). */
138
+ function spawnStructured(argv, cwd, nonce, extraEnv = {}) {
139
+ const [exe, ...args] = argv;
140
+ const child = (0, child_process_1.spawn)(exe, args, {
141
+ cwd, shell: false, detached: true,
142
+ env: { ...process.env, [exports.NONCE_ENV]: nonce, ...extraEnv },
143
+ // stdio:'ignore' completo (nada de pipes): un pipe destruido/abandonado
144
+ // por el padre puede EPIPE-crashear al hijo si este escribe a su propio
145
+ // stdout/stderr despues (ver defaultWrapperSpawner) — el hijo maneja su
146
+ // stdio propio, el padre no necesita capturarlo.
147
+ stdio: 'ignore',
148
+ });
149
+ if (child.pid === undefined)
150
+ throw new Error(`spawn fallo para ${exe}`);
151
+ return { child, ref: captureRefFor(child.pid, nonce, argv) };
152
+ }
153
+ /** Vivo Y con la MISMA identidad — tupla completa, nunca PID solo (R2.1,
154
+ * bloqueador 6): startTime + pgid + digest de ps args. */
155
+ function refIsAlive(ref) {
156
+ try {
157
+ const stat = psField(ref.pid, 'stat');
158
+ if (stat === null || stat.startsWith('Z'))
159
+ return false; // zombie = proceso terminado, solo espera reap
160
+ const start = psField(ref.pid, 'lstart');
161
+ if (start === null || start !== ref.startTime)
162
+ return false;
163
+ const pgid = psField(ref.pid, 'pgid');
164
+ if (pgid === null || Number(pgid) !== ref.processGroup)
165
+ return false;
166
+ const argsDig = psArgsDigestOf(ref.pid, ref.spawnNonce, ref.argvDigest);
167
+ if (argsDig === null || argsDig !== ref.psArgsDigest)
168
+ return false;
169
+ return true;
170
+ }
171
+ catch {
172
+ // ps no pudo ejecutarse: sin evidencia, jamas declarar muerte — se
173
+ // trata como vivo (R2.1, bloqueador de Task 10: silencio no es prueba).
174
+ return true;
175
+ }
176
+ }
177
+ /** true <=> pgrep no encuentra miembros ejecutables en el grupo. Los zombies
178
+ * ya terminaron y solo esperan reap; no pueden responder seniales ni retener
179
+ * trabajo. Un fallo de observacion devuelve false (R2.1). */
180
+ function processStatesAreGone(states) {
181
+ return states.every((stat) => stat === null || stat.startsWith('Z'));
182
+ }
183
+ function groupIsGone(pgid) {
184
+ let pids;
185
+ try {
186
+ const out = (0, child_process_1.execFileSync)('pgrep', ['-g', String(pgid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO });
187
+ pids = out.split('\n').filter(Boolean).map(Number).filter(Number.isInteger);
188
+ }
189
+ catch (error) {
190
+ const status = error.status;
191
+ return status === 1; // pgrep exit 1 = cero matches; cualquier otra cosa NO confirma
192
+ }
193
+ if (pids.length === 0)
194
+ return true;
195
+ try {
196
+ // `pgrep` tambien devuelve zombies. No pueden ejecutar, mantener FDs ni
197
+ // responder seniales; contarlos como vivos fuerza esperas completas y
198
+ // custodia falsa hasta que el parent haga reap. Solo un miembro no-zombie
199
+ // conserva ownership ejecutable del grupo.
200
+ return processStatesAreGone(pids.map((pid) => psField(pid, 'stat')));
201
+ }
202
+ catch {
203
+ return false; // sin observacion completa, falla cerrado
204
+ }
205
+ }
206
+ function activitySnapshot(ref) {
207
+ if (!refIsAlive(ref))
208
+ return null;
209
+ let cpu = '0';
210
+ try {
211
+ cpu = psField(ref.pid, 'time') ?? '0';
212
+ }
213
+ catch {
214
+ cpu = '0';
215
+ }
216
+ let groupSize = 1;
217
+ try {
218
+ groupSize = (0, child_process_1.execFileSync)('pgrep', ['-g', String(ref.processGroup)], { encoding: 'utf8', stdio: exports.EXEC_STDIO })
219
+ .split('\n').filter(Boolean).length;
220
+ }
221
+ catch {
222
+ groupSize = 1;
223
+ }
224
+ return { cpuTime: cpu, groupSize };
225
+ }
226
+ /** Escalera de gracia (design R4.2b): SIGTERM -> confirmar -> SIGKILL -> confirmar.
227
+ * true <=> lider muerto por identidad Y grupo entero desaparecido (pgrep -g
228
+ * vacio) — jamas confirmar solo el lider (bloqueador 6). */
229
+ async function terminateGroupConfirmed(ref, opts) {
230
+ const waitUntilGone = async (maxMs) => {
231
+ const deadline = Date.now() + maxMs;
232
+ while (Date.now() < deadline) {
233
+ if (groupIsGone(ref.processGroup))
234
+ return true;
235
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
236
+ }
237
+ return groupIsGone(ref.processGroup);
238
+ };
239
+ if (groupIsGone(ref.processGroup))
240
+ return true;
241
+ // Un PGID ocupado con lider de identidad distinta NO es nuestro. Nunca
242
+ // usar la falta de match como autorizacion para senializar ese grupo.
243
+ if (!refIsAlive(ref))
244
+ return false;
245
+ try {
246
+ process.kill(-ref.processGroup, 'SIGTERM');
247
+ }
248
+ catch { /* grupo ya ausente */ }
249
+ if (await waitUntilGone(opts.termGraceMs))
250
+ return true;
251
+ try {
252
+ process.kill(-ref.processGroup, 'SIGKILL');
253
+ }
254
+ catch { /* idem */ }
255
+ return waitUntilGone(opts.killGraceMs);
256
+ }
257
+ /** Drena un grupo cuya propiedad fue capturada por el caller mientras el
258
+ * lider aun estaba vivo. Se usa inmediatamente tras el exit del lider para
259
+ * eliminar descendientes remanentes; el PGID no puede reutilizarse mientras
260
+ * esos miembros sigan presentes. */
261
+ async function terminatePreviouslyOwnedGroup(ref, opts) {
262
+ const waitUntilGone = async (maxMs) => {
263
+ const deadline = Date.now() + maxMs;
264
+ while (Date.now() < deadline) {
265
+ if (groupIsGone(ref.processGroup))
266
+ return true;
267
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
268
+ }
269
+ return groupIsGone(ref.processGroup);
270
+ };
271
+ if (groupIsGone(ref.processGroup))
272
+ return true;
273
+ try {
274
+ process.kill(-ref.processGroup, 'SIGTERM');
275
+ }
276
+ catch { /* ya ausente */ }
277
+ if (await waitUntilGone(opts.termGraceMs))
278
+ return true;
279
+ try {
280
+ process.kill(-ref.processGroup, 'SIGKILL');
281
+ }
282
+ catch { /* ya ausente */ }
283
+ return waitUntilGone(opts.killGraceMs);
284
+ }
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ // Redacción EN EL EMISOR, antes de cualquier escritura (design R2.3).
3
+ // Patrones alineados con el sensor-pack de secretos del registry baseline.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.redactText = redactText;
6
+ exports.findLiteralSecretFlag = findLiteralSecretFlag;
7
+ exports.redactArgv = redactArgv;
8
+ const SECRET_WORD = /(password|passwd|secret|api[-_]?key|apikey|token|credential)/i;
9
+ // ASSIGNMENT (regex de único backtracking) tuvo 3 rondas de fallas reales:
10
+ // sin cota => ReDoS cuadrático (corridas largas sin match, o muchas
11
+ // ocurrencias sueltas del keyword sin separador); cotado en ambos lados =>
12
+ // fuga de secretos con identificadores largos entre el keyword y el "=".
13
+ // Sustituido por escaneo manual caracter-a-caracter: O(n) sin backtracking,
14
+ // sin cota de longitud posible en ningún lado, inmune a ambos hallazgos.
15
+ const KEYWORD_RE = /password|passwd|secret|api[-_]?key|apikey|token|credential/gi;
16
+ const IDENT_CHAR = /[a-z0-9_-]/i;
17
+ const WHITESPACE = /\s/;
18
+ // Distinto de SECRET_WORD (substring, para nombres de flag reales): aquí el
19
+ // keyword debe ser un segmento completo delimitado por -, _ o los bordes del
20
+ // string. Se usa SOLO dentro de redactArgv para decidir hasta dónde extender
21
+ // la redacción en cadena — nunca para decidir SI redactar: ver comentario en
22
+ // redactArgv sobre por qué la ambigüedad siempre se resuelve redactando de
23
+ // más, nunca de menos.
24
+ const SECRET_FLAG_SEGMENT = /(^|[-_])(password|passwd|secret|api[-_]?key|apikey|token|credential)($|[-_])/i;
25
+ function looksLikeSensitiveFlag(token) {
26
+ if (!token.startsWith('-'))
27
+ return false;
28
+ const eq = token.indexOf('=');
29
+ const flag = eq === -1 ? token : token.slice(0, eq);
30
+ return SECRET_FLAG_SEGMENT.test(flag.replace(/^-+/, ''));
31
+ }
32
+ function redactText(text) {
33
+ let result = '';
34
+ let cursor = 0;
35
+ KEYWORD_RE.lastIndex = 0;
36
+ let m;
37
+ while ((m = KEYWORD_RE.exec(text)) !== null) {
38
+ if (m.index < cursor)
39
+ continue;
40
+ let keyEnd = m.index + m[0].length;
41
+ while (keyEnd < text.length && IDENT_CHAR.test(text[keyEnd]))
42
+ keyEnd++;
43
+ let sepStart = keyEnd;
44
+ while (sepStart < text.length && WHITESPACE.test(text[sepStart]))
45
+ sepStart++;
46
+ if (sepStart >= text.length || (text[sepStart] !== '=' && text[sepStart] !== ':')) {
47
+ KEYWORD_RE.lastIndex = keyEnd;
48
+ continue;
49
+ }
50
+ let valueStart = sepStart + 1;
51
+ while (valueStart < text.length && WHITESPACE.test(text[valueStart]))
52
+ valueStart++;
53
+ if (valueStart >= text.length) {
54
+ KEYWORD_RE.lastIndex = keyEnd;
55
+ continue;
56
+ }
57
+ let valueEnd = valueStart;
58
+ while (valueEnd < text.length && !WHITESPACE.test(text[valueEnd]))
59
+ valueEnd++;
60
+ result += text.slice(cursor, valueStart) + '[REDACTED]';
61
+ cursor = valueEnd;
62
+ KEYWORD_RE.lastIndex = cursor;
63
+ }
64
+ result += text.slice(cursor);
65
+ return result;
66
+ }
67
+ /** Flag sensible que porta un secreto LITERAL (no una referencia `-env`):
68
+ * la request se rechaza, no se persiste ni redactada (R2.3). Deliberadamente
69
+ * NO intenta distinguir "el siguiente token es un flag hermano" de "es el
70
+ * valor literal": el rechazo es todo-o-nada (emitRequest lanza antes de
71
+ * persistir nada), así que la ambigüedad nunca importa — cualquier token
72
+ * después de un flag sensible ya es motivo suficiente de rechazo.
73
+ *
74
+ * LIMITACIÓN ACEPTADA (no es un bug pendiente): esta función solo reconoce
75
+ * secretos cuyo NOMBRE de flag contiene una palabra clave (password/token/
76
+ * secret/api-key/credential), sea con cualquier cantidad de guiones (-token, --token).
77
+ * Mnemónicos de una sola letra sin texto ninguno (ej. `-p` de mysql, `-i` de
78
+ * ssh, `-u` de curl) son indistinguibles de cualquier otro flag corto por
79
+ * texto solo — cerrar ese caso exigiría una tabla fija de convenciones por
80
+ * herramienta externa, que es enumeración no acotada y no pertenece a un
81
+ * mecanismo genérico (ver CLAUDE.md, frontera genérico/específico). Si esto
82
+ * resulta ser un problema real y recurrente en este proyecto, se resuelve
83
+ * vía harness-retro con una regla específica, no aquí. */
84
+ function findLiteralSecretFlag(argv) {
85
+ for (let i = 0; i < argv.length; i++) {
86
+ const arg = argv[i];
87
+ if (!arg.startsWith('-'))
88
+ continue;
89
+ const eq = arg.indexOf('=');
90
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
91
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1);
92
+ if (!SECRET_WORD.test(flag))
93
+ continue;
94
+ if (/-env$/i.test(flag))
95
+ continue; // referencia, permitida (R4.7)
96
+ const value = inlineValue !== undefined ? inlineValue : argv[i + 1];
97
+ if (value !== undefined)
98
+ return flag;
99
+ }
100
+ return null;
101
+ }
102
+ /** A diferencia de findLiteralSecretFlag (todo-o-nada), esta función SÍ debe
103
+ * devolver un array persistible — no puede simplemente rechazar. Cuando el
104
+ * token que sigue a un flag sensible también PARECE un flag sensible, es
105
+ * imposible distinguir por texto solo "es un flag hermano real" de "es el
106
+ * valor literal (adversario) del flag anterior disfrazado de flag" — ambas
107
+ * lecturas son indistinguibles sin un esquema de flags real (hallazgo de
108
+ * spec-review, R2.3). Ante esa ambigüedad se redacta TODA la cadena de
109
+ * tokens con forma de flag sensible más el token final que la cierra,
110
+ * nunca menos: sobre-redactar un nombre de flag es un costo cosmético,
111
+ * dejar pasar un secreto no lo es. */
112
+ function redactArgv(argv) {
113
+ const out = [];
114
+ let i = 0;
115
+ while (i < argv.length) {
116
+ const arg = argv[i];
117
+ const eq = arg.indexOf('=');
118
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
119
+ const isSensitive = arg.startsWith('-') && SECRET_WORD.test(flag) && !/-env$/i.test(flag);
120
+ if (!isSensitive) {
121
+ out.push(redactText(arg));
122
+ i++;
123
+ continue;
124
+ }
125
+ if (eq !== -1) {
126
+ out.push(`${flag}=[REDACTED]`);
127
+ i++;
128
+ continue;
129
+ }
130
+ out.push(arg);
131
+ i++;
132
+ while (i < argv.length && looksLikeSensitiveFlag(argv[i])) {
133
+ out.push('[REDACTED]');
134
+ i++;
135
+ }
136
+ if (i < argv.length) {
137
+ out.push('[REDACTED]');
138
+ i++;
139
+ }
140
+ }
141
+ return out;
142
+ }