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.
- package/dist/src/commands/job/exec-wrapper.js +136 -0
- package/dist/src/commands/job/export.js +94 -0
- package/dist/src/commands/job/gate.js +118 -0
- package/dist/src/commands/job/heartbeat.js +15 -0
- package/dist/src/commands/job/index.js +246 -0
- package/dist/src/commands/job/query.js +37 -0
- package/dist/src/commands/job/reap.js +24 -0
- package/dist/src/commands/job/reconcile.js +112 -0
- package/dist/src/commands/job/request.js +27 -0
- package/dist/src/commands/sensors/exec.js +121 -0
- package/dist/src/commands/sensors/index.js +4 -4
- package/dist/src/commands/sensors/run.js +124 -71
- package/dist/src/commands/watch/apply.js +352 -0
- package/dist/src/commands/watch/generations.js +249 -0
- package/dist/src/commands/watch/index.js +49 -0
- package/dist/src/commands/watch/init.js +72 -0
- package/dist/src/commands/watch/lock.js +89 -0
- package/dist/src/commands/watch/runner.js +191 -0
- package/dist/src/commands/watch/supervisor.js +266 -0
- package/dist/src/core/atomic-file.js +31 -0
- package/dist/src/core/export/pack.js +7 -1
- package/dist/src/core/journal/adapter.js +27 -0
- package/dist/src/core/journal/fingerprint.js +80 -0
- package/dist/src/core/journal/paths.js +56 -0
- package/dist/src/core/journal/process.js +284 -0
- package/dist/src/core/journal/redact.js +142 -0
- package/dist/src/core/journal/requests.js +132 -0
- package/dist/src/core/journal/store.js +107 -0
- package/dist/src/core/journal/types.js +165 -0
- package/dist/src/index.js +4 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
- package/dist/tests/commands/job/export.test.js +76 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
- package/dist/tests/commands/job/reap-cli.test.js +101 -0
- package/dist/tests/commands/job/verbs.test.js +56 -0
- package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
- package/dist/tests/commands/sensors/exec-fixtures.js +24 -0
- package/dist/tests/commands/sensors/exec.test.js +91 -0
- package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
- package/dist/tests/commands/sensors/run-partial.test.js +225 -0
- package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
- package/dist/tests/commands/sensors/run.test.js +64 -81
- package/dist/tests/commands/watch/apply.test.js +397 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
- package/dist/tests/commands/watch/generations.test.js +115 -0
- package/dist/tests/commands/watch/integration.test.js +124 -0
- package/dist/tests/commands/watch/lock.test.js +60 -0
- package/dist/tests/commands/watch/runner.test.js +239 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
- package/dist/tests/commands/watch/watch-init.test.js +43 -0
- package/dist/tests/core/atomic-file-durable.test.js +42 -0
- package/dist/tests/core/journal/adapter.test.js +27 -0
- package/dist/tests/core/journal/fingerprint.test.js +164 -0
- package/dist/tests/core/journal/paths.test.js +35 -0
- package/dist/tests/core/journal/process.test.js +213 -0
- package/dist/tests/core/journal/redact.test.js +59 -0
- package/dist/tests/core/journal/requests.test.js +134 -0
- package/dist/tests/core/journal/store.test.js +88 -0
- package/dist/tests/core/journal/types.test.js +78 -0
- package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
- 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
|
+
}
|