@codedology/code-forge 0.1.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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pid registry + orphan reaper (plan §9.6 rule 3, V12, `[A30]`).
|
|
3
|
+
*
|
|
4
|
+
* `exec` writes one `<pids-dir>/<pid>.json` = `{pid, start_time, argv0}` per child it spawns and
|
|
5
|
+
* removes it when the child is done. A run that is killed never removes its entries, so the next
|
|
6
|
+
* `run start` / `run gc` / test preload calls `sweep(pidsDir)` on the dead run's directory.
|
|
7
|
+
*
|
|
8
|
+
* Safety rule — the reaper can only ever kill what this package started:
|
|
9
|
+
* - it only looks at pids listed in a registry directory it is handed;
|
|
10
|
+
* - it kills a pid only when the process's LIVE start time (`ps -o lstart= -p <pid>`) equals the
|
|
11
|
+
* start time recorded at spawn — a pid the OS has since reused for another program has a
|
|
12
|
+
* different start time and is left alone;
|
|
13
|
+
* - a start time it cannot read (recorded as `unknown`, or `ps` fails now) means NEVER kill
|
|
14
|
+
* (fail closed, plan §0.6 item 11);
|
|
15
|
+
* - a registered pid whose process is already gone is only un-registered; its process group is
|
|
16
|
+
* not signalled, because the group's identity can no longer be checked.
|
|
17
|
+
* The kill goes to the process GROUP (every `exec` child is a group leader, `detached: true`):
|
|
18
|
+
* SIGTERM first, then SIGKILL after a grace period, so a child that traps SIGTERM still dies.
|
|
19
|
+
* Every signal is sent right after a synchronous start-time match, with no await in between; the
|
|
20
|
+
* bare pid is signalled only when its group does not exist (ESRCH); EPERM means "not ours to
|
|
21
|
+
* touch" (reported `unknown`, never retried). Once the leader is dead its pid is never signalled
|
|
22
|
+
* again: the other group members captured before the SIGTERM are SIGKILLed one by one, each only
|
|
23
|
+
* after its own start time still matches.
|
|
24
|
+
*
|
|
25
|
+
* `ps` is run with `execFileSync` and an argv array (never a shell) rather than through
|
|
26
|
+
* `./exec.mjs`: `exec` registers every child here, so going through it would recurse.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
30
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
33
|
+
|
|
34
|
+
/** Value stored in `start_time` when `ps` could not read it at spawn time. */
|
|
35
|
+
export const UNKNOWN_START_TIME = 'unknown';
|
|
36
|
+
|
|
37
|
+
/** Default grace between SIGTERM and SIGKILL, in milliseconds (same as `exec`). */
|
|
38
|
+
const DEFAULT_GRACE_MS = 2000;
|
|
39
|
+
|
|
40
|
+
/** Poll interval while waiting for a signalled process to die. */
|
|
41
|
+
const POLL_MS = 25;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {object} PidEntry
|
|
45
|
+
* @property {number} pid
|
|
46
|
+
* @property {string} start_time - `ps -o lstart=` text, or `UNKNOWN_START_TIME`.
|
|
47
|
+
* @property {string} argv0
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {object} SweepResult
|
|
52
|
+
* @property {{pid: number, signal: 'SIGTERM'|'SIGKILL'}[]} killed - matched and killed; `signal`
|
|
53
|
+
* is the one that finished the process.
|
|
54
|
+
* @property {number[]} mismatched - alive, but the live start time differs: not ours, untouched.
|
|
55
|
+
* @property {number[]} unknown - alive, start time unreadable: never killed, entry kept.
|
|
56
|
+
* @property {number[]} stale - already dead: entry removed, nothing signalled.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** @param {unknown} pid @returns {pid is number} */
|
|
60
|
+
function isValidPid(pid) {
|
|
61
|
+
return Number.isInteger(pid) && /** @type {number} */ (pid) > 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a process with this pid exists. EPERM means it exists but belongs to someone else.
|
|
66
|
+
* @param {number} pid
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isAlive(pid) {
|
|
70
|
+
if (!isValidPid(pid)) return false;
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return true;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === 'EPERM';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The live start time of `pid` as `ps -o lstart=` prints it (C locale, trimmed), or `null` when
|
|
81
|
+
* it cannot be read (no such process, no `ps`, empty output).
|
|
82
|
+
* @param {number} pid
|
|
83
|
+
* @returns {string | null}
|
|
84
|
+
*/
|
|
85
|
+
export function readStartTime(pid) {
|
|
86
|
+
if (!isValidPid(pid)) return null;
|
|
87
|
+
try {
|
|
88
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
91
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
92
|
+
timeout: 5000,
|
|
93
|
+
});
|
|
94
|
+
const text = out.trim().replace(/\s+/g, ' ');
|
|
95
|
+
return text.length > 0 ? text : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Write `<dir>/<pid>.json` for a child just spawned. Returns the entry path.
|
|
103
|
+
* @param {string} dir
|
|
104
|
+
* @param {number} pid
|
|
105
|
+
* @param {string} argv0
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function registerPid(dir, pid, argv0) {
|
|
109
|
+
if (!isValidPid(pid)) throw new TypeError('reaper: pid must be an integer greater than 1');
|
|
110
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
111
|
+
/** @type {PidEntry} */
|
|
112
|
+
const entry = { pid, start_time: readStartTime(pid) ?? UNKNOWN_START_TIME, argv0: String(argv0) };
|
|
113
|
+
const file = path.join(dir, `${pid}.json`);
|
|
114
|
+
writeFileSync(file, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
115
|
+
return file;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remove `<dir>/<pid>.json`; a missing entry is fine.
|
|
120
|
+
* @param {string} dir
|
|
121
|
+
* @param {number} pid
|
|
122
|
+
*/
|
|
123
|
+
export function unregisterPid(dir, pid) {
|
|
124
|
+
rmSync(path.join(dir, `${pid}.json`), { force: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read every well-formed entry of a registry directory. Malformed files are skipped (never acted on).
|
|
129
|
+
* @param {string} dir
|
|
130
|
+
* @returns {PidEntry[]}
|
|
131
|
+
*/
|
|
132
|
+
export function listEntries(dir) {
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
let names;
|
|
135
|
+
try {
|
|
136
|
+
names = readdirSync(dir);
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
/** @type {PidEntry[]} */
|
|
141
|
+
const entries = [];
|
|
142
|
+
for (const name of names.sort()) {
|
|
143
|
+
const match = /^(\d+)\.json$/.exec(name);
|
|
144
|
+
if (!match) continue;
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(readFileSync(path.join(dir, name), 'utf8'));
|
|
147
|
+
if (parsed && isValidPid(parsed.pid) && parsed.pid === Number(match[1]) && typeof parsed.start_time === 'string') {
|
|
148
|
+
entries.push({ pid: parsed.pid, start_time: parsed.start_time, argv0: String(parsed.argv0 ?? '') });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// unreadable or not JSON: skipped, never killed
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return entries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every OTHER live member of process group `pgid` with its start time, read in one `ps` call:
|
|
159
|
+
* `Map<pid, lstart>`. Empty when `ps` fails.
|
|
160
|
+
* @param {number} pgid
|
|
161
|
+
* @returns {Map<number, string>}
|
|
162
|
+
*/
|
|
163
|
+
export function groupMembers(pgid) {
|
|
164
|
+
/** @type {Map<number, string>} */
|
|
165
|
+
const members = new Map();
|
|
166
|
+
let out = '';
|
|
167
|
+
try {
|
|
168
|
+
out = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
|
|
169
|
+
encoding: 'utf8',
|
|
170
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
171
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
172
|
+
timeout: 5000,
|
|
173
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
174
|
+
});
|
|
175
|
+
} catch {
|
|
176
|
+
return members;
|
|
177
|
+
}
|
|
178
|
+
for (const line of out.split('\n')) {
|
|
179
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
|
|
180
|
+
if (!match) continue;
|
|
181
|
+
const pid = Number(match[1]);
|
|
182
|
+
if (Number(match[2]) === pgid && pid !== pgid && pid !== process.pid) {
|
|
183
|
+
members.set(pid, match[3].replace(/\s+/g, ' '));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return members;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Send `signal` to `pid`'s group — only after a SYNCHRONOUS start-time match, with no await
|
|
191
|
+
* between the check and the kill. Falls back to the bare pid only when the group does not exist
|
|
192
|
+
* (ESRCH), still inside the same synchronous step.
|
|
193
|
+
* @param {number} pid
|
|
194
|
+
* @param {string} recorded - the start time recorded at spawn.
|
|
195
|
+
* @param {NodeJS.Signals} signal
|
|
196
|
+
* @returns {'sent'|'mismatch'|'eperm'|'gone'}
|
|
197
|
+
*/
|
|
198
|
+
function signalIfOurs(pid, recorded, signal) {
|
|
199
|
+
if (readStartTime(pid) !== recorded) return 'mismatch';
|
|
200
|
+
try {
|
|
201
|
+
process.kill(-pid, signal);
|
|
202
|
+
return 'sent';
|
|
203
|
+
} catch (err) {
|
|
204
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
205
|
+
if (code === 'EPERM') return 'eperm';
|
|
206
|
+
if (code !== 'ESRCH') return 'gone';
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, signal);
|
|
210
|
+
return 'sent';
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === 'EPERM' ? 'eperm' : 'gone';
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {number} pid @param {number} withinMs @returns {Promise<boolean>} true once dead */
|
|
217
|
+
async function waitForDeath(pid, withinMs) {
|
|
218
|
+
const deadline = Date.now() + withinMs;
|
|
219
|
+
while (Date.now() < deadline) {
|
|
220
|
+
if (!isAlive(pid)) return true;
|
|
221
|
+
await sleep(POLL_MS);
|
|
222
|
+
}
|
|
223
|
+
return !isAlive(pid);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reap one registry directory: kill registered children whose live start time still matches,
|
|
228
|
+
* leave everything else alone. See the module comment for the rules.
|
|
229
|
+
* @param {string} dir
|
|
230
|
+
* @param {{graceMs?: number}} [opts]
|
|
231
|
+
* @returns {Promise<SweepResult>}
|
|
232
|
+
*/
|
|
233
|
+
export async function sweep(dir, opts = {}) {
|
|
234
|
+
const { graceMs = DEFAULT_GRACE_MS } = opts;
|
|
235
|
+
/** @type {SweepResult} */
|
|
236
|
+
const result = { killed: [], mismatched: [], unknown: [], stale: [] };
|
|
237
|
+
|
|
238
|
+
for (const entry of listEntries(dir)) {
|
|
239
|
+
const { pid } = entry;
|
|
240
|
+
if (pid === process.pid) continue;
|
|
241
|
+
|
|
242
|
+
if (!isAlive(pid)) {
|
|
243
|
+
unregisterPid(dir, pid);
|
|
244
|
+
result.stale.push(pid);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (entry.start_time === UNKNOWN_START_TIME || readStartTime(pid) === null) {
|
|
248
|
+
result.unknown.push(pid);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Other members of the leader's group, captured while the leader is alive and verified ours:
|
|
253
|
+
// after the leader dies they are only killed one by one, each after its own start-time match.
|
|
254
|
+
const members = groupMembers(pid);
|
|
255
|
+
const term = signalIfOurs(pid, entry.start_time, 'SIGTERM');
|
|
256
|
+
if (term === 'mismatch') {
|
|
257
|
+
// The pid now names a different process: not ours. Drop the stale entry, touch nothing.
|
|
258
|
+
unregisterPid(dir, pid);
|
|
259
|
+
result.mismatched.push(pid);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (term === 'eperm') {
|
|
263
|
+
result.unknown.push(pid);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (await waitForDeath(pid, graceMs)) {
|
|
268
|
+
// The leader died of SIGTERM. Its pid is never signalled again (it may be reused); a
|
|
269
|
+
// member that trapped SIGTERM gets SIGKILL only if its start time still matches.
|
|
270
|
+
for (const [member, started] of members) {
|
|
271
|
+
signalMemberIfOurs(member, started);
|
|
272
|
+
}
|
|
273
|
+
result.killed.push({ pid, signal: 'SIGTERM' });
|
|
274
|
+
} else {
|
|
275
|
+
const kill = signalIfOurs(pid, entry.start_time, 'SIGKILL');
|
|
276
|
+
if (kill !== 'sent') {
|
|
277
|
+
result.unknown.push(pid);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
await waitForDeath(pid, graceMs);
|
|
281
|
+
result.killed.push({ pid, signal: 'SIGKILL' });
|
|
282
|
+
}
|
|
283
|
+
unregisterPid(dir, pid);
|
|
284
|
+
}
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* SIGKILL one former group member, only when its live start time equals the one captured before
|
|
290
|
+
* the leader was signalled (synchronous check, then the kill).
|
|
291
|
+
* @param {number} pid @param {string} started
|
|
292
|
+
*/
|
|
293
|
+
function signalMemberIfOurs(pid, started) {
|
|
294
|
+
if (readStartTime(pid) !== started) return;
|
|
295
|
+
try {
|
|
296
|
+
process.kill(pid, 'SIGKILL');
|
|
297
|
+
} catch {
|
|
298
|
+
// gone between the check and the kill
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction — the one gate every output path must pass through before a resolved key
|
|
3
|
+
* can reach a log line, a raw stdout write, or a JSON dump (plan §8.2: "as v1 §8.2 — unit test
|
|
4
|
+
* feeds a fake key through every path, mutation-verified").
|
|
5
|
+
*
|
|
6
|
+
* Secrets are opt-in: whatever resolves a key (`src/keys/**`, a later block) calls
|
|
7
|
+
* `registerSecret(value)` once it has the plaintext. Nothing here guesses at what "looks like" a
|
|
8
|
+
* secret — that would either over-redact ordinary text or under-redact a key shaped unlike the
|
|
9
|
+
* guess. A registered secret is scrubbed everywhere, forever, until `clearSecrets()` (tests only).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @type {Set<string>} */
|
|
13
|
+
const secrets = new Set();
|
|
14
|
+
|
|
15
|
+
const MASK = '[REDACTED]';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register a value that must never appear verbatim in any output. No-ops for empty/non-string
|
|
19
|
+
* input so an unresolved key can't accidentally register `undefined` as a literal string.
|
|
20
|
+
*
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
*/
|
|
23
|
+
export function registerSecret(value) {
|
|
24
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
25
|
+
secrets.add(value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Forget every registered secret. Exists for tests; production code never needs it. */
|
|
30
|
+
export function clearSecrets() {
|
|
31
|
+
secrets.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Redact every registered secret out of a string, or out of every string leaf (and every key) of
|
|
36
|
+
* an object/array. For a non-string input the result is a JSON-SAFE COPY, not a value of the
|
|
37
|
+
* input's type: `Map` becomes a plain object, `Set` an array, anything with `toJSON` (e.g. `Date`)
|
|
38
|
+
* its `toJSON()` result, and a cycle the string `'[Circular]'` — see `redactDeep`. A string in
|
|
39
|
+
* gives a string out.
|
|
40
|
+
*
|
|
41
|
+
* @overload
|
|
42
|
+
* @param {string} input
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* @overload
|
|
47
|
+
* @param {unknown} input
|
|
48
|
+
* @returns {unknown}
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* @param {unknown} input
|
|
52
|
+
* @returns {unknown}
|
|
53
|
+
*/
|
|
54
|
+
export function redact(input) {
|
|
55
|
+
if (typeof input === 'string') {
|
|
56
|
+
return redactString(input);
|
|
57
|
+
}
|
|
58
|
+
if (input !== null && typeof input === 'object') {
|
|
59
|
+
return redactDeep(input, new WeakSet());
|
|
60
|
+
}
|
|
61
|
+
return input;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Mask every character covered by any registered secret, in one pass over the ORIGINAL text:
|
|
66
|
+
* first record the [start, end) range of every occurrence of every secret, then merge
|
|
67
|
+
* overlapping/adjacent ranges and replace each merged range with one mask. Replacing secrets one
|
|
68
|
+
* after another instead would leak the tail of a secret that overlaps another without being
|
|
69
|
+
* nested in it ("abcd" + "cdef" in "abcdef" → "[REDACTED]ef"), and let a short secret match
|
|
70
|
+
* inside an earlier mask.
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function redactString(text) {
|
|
75
|
+
/** @type {[number, number][]} */
|
|
76
|
+
const ranges = [];
|
|
77
|
+
for (const secret of secrets) {
|
|
78
|
+
let at = text.indexOf(secret);
|
|
79
|
+
while (at !== -1) {
|
|
80
|
+
ranges.push([at, at + secret.length]);
|
|
81
|
+
at = text.indexOf(secret, at + 1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (ranges.length === 0) {
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
88
|
+
let out = '';
|
|
89
|
+
let cursor = 0;
|
|
90
|
+
let [start, end] = ranges[0];
|
|
91
|
+
for (const [s, e] of ranges.slice(1)) {
|
|
92
|
+
if (s <= end) {
|
|
93
|
+
end = Math.max(end, e);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
out += text.slice(cursor, start) + MASK;
|
|
97
|
+
cursor = end;
|
|
98
|
+
[start, end] = [s, e];
|
|
99
|
+
}
|
|
100
|
+
out += text.slice(cursor, start) + MASK + text.slice(end);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Write `value` under `key`, never overwriting: two different keys that redact to the same text
|
|
106
|
+
* (two secrets → `[REDACTED]`, or two non-string Map keys → `[object Object]`) get `#2`, `#3`, …
|
|
107
|
+
* suffixes so no entry is silently dropped.
|
|
108
|
+
* @param {Record<string, unknown>} out
|
|
109
|
+
* @param {string} key
|
|
110
|
+
* @param {unknown} value
|
|
111
|
+
*/
|
|
112
|
+
function putUnique(out, key, value) {
|
|
113
|
+
let candidate = key;
|
|
114
|
+
let n = 2;
|
|
115
|
+
while (Object.hasOwn(out, candidate)) {
|
|
116
|
+
candidate = `${key}#${n}`;
|
|
117
|
+
n += 1;
|
|
118
|
+
}
|
|
119
|
+
out[candidate] = value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The structural half of `redact`/`redactJSON`. Deliberately NOT a bare `JSON.stringify`
|
|
124
|
+
* replacer: a replacer callback can rewrite a *value* but not the *key* it sits under, and a
|
|
125
|
+
* secret used as an object key (e.g. a map keyed by token) needs redacting too.
|
|
126
|
+
*
|
|
127
|
+
* - Calls `.toJSON()` when present (before recursing into its result) so `Date`, and anything
|
|
128
|
+
* else that defines it, serializes as it would under a plain `JSON.stringify` — a raw
|
|
129
|
+
* `Object.entries` walk over a `Date` sees no own enumerable properties and would otherwise
|
|
130
|
+
* silently corrupt it to `{}`.
|
|
131
|
+
* - `Map`/`Set` get a JSON-safe shape (a plain object / array respectively) — `JSON.stringify`
|
|
132
|
+
* has no built-in behavior for either (both would also come out as `{}` natively), so this is
|
|
133
|
+
* strictly better, not just parity.
|
|
134
|
+
* - A `WeakSet` of objects currently being visited on the CURRENT path (not globally) catches a
|
|
135
|
+
* circular reference and substitutes `'[Circular]'`, so a cycle degrades to a marker instead of
|
|
136
|
+
* overflowing the stack — the same object appearing twice in unrelated, non-overlapping
|
|
137
|
+
* branches (not a real cycle) is not flagged, because it's removed from the set once that
|
|
138
|
+
* branch finishes.
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown} value
|
|
141
|
+
* @param {WeakSet<object>} seen
|
|
142
|
+
* @returns {unknown}
|
|
143
|
+
*/
|
|
144
|
+
function redactDeep(value, seen) {
|
|
145
|
+
if (typeof value === 'string') {
|
|
146
|
+
return redactString(value);
|
|
147
|
+
}
|
|
148
|
+
if (value === null || typeof value !== 'object') {
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
if (typeof (/** @type {{toJSON?: unknown}} */ (value).toJSON) === 'function') {
|
|
152
|
+
return redactDeep(/** @type {{toJSON: () => unknown}} */ (value).toJSON(), seen);
|
|
153
|
+
}
|
|
154
|
+
if (seen.has(value)) {
|
|
155
|
+
return '[Circular]';
|
|
156
|
+
}
|
|
157
|
+
seen.add(value);
|
|
158
|
+
try {
|
|
159
|
+
if (Array.isArray(value)) {
|
|
160
|
+
return value.map((item) => redactDeep(item, seen));
|
|
161
|
+
}
|
|
162
|
+
if (value instanceof Map) {
|
|
163
|
+
/** @type {Record<string, unknown>} */
|
|
164
|
+
const out = {};
|
|
165
|
+
for (const [key, v] of value.entries()) {
|
|
166
|
+
putUnique(out, redactString(String(key)), redactDeep(v, seen));
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
if (value instanceof Set) {
|
|
171
|
+
return [...value].map((item) => redactDeep(item, seen));
|
|
172
|
+
}
|
|
173
|
+
/** @type {Record<string, unknown>} */
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const [key, v] of Object.entries(value)) {
|
|
176
|
+
putUnique(out, redactString(key), redactDeep(v, seen));
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
} finally {
|
|
180
|
+
seen.delete(value);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Redact-then-serialize — the "JSON path": callers that dump a structured value (a ledger row,
|
|
186
|
+
* a `--json` CLI response) must go through this instead of a bare `JSON.stringify`.
|
|
187
|
+
*
|
|
188
|
+
* @param {unknown} value
|
|
189
|
+
* @param {number} [space]
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
export function redactJSON(value, space) {
|
|
193
|
+
return JSON.stringify(redact(value), null, space);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Redact-then-write — the "stdout path": a raw write to a stream (e.g. a `--json` flag piping
|
|
198
|
+
* straight to `process.stdout`) that does not go through `./log.mjs`.
|
|
199
|
+
*
|
|
200
|
+
* Redacts each call's `text` independently — a secret split across two separate `write` calls
|
|
201
|
+
* (a chunk boundary landing mid-secret) will NOT be caught. Callers must pass complete messages,
|
|
202
|
+
* not partial chunks, the same way `./log.mjs` does (one full line per call).
|
|
203
|
+
*
|
|
204
|
+
* The parameter type is the minimal shape this function actually uses (just `.write`), not the
|
|
205
|
+
* full `NodeJS.WritableStream` — so a plain `{ write(text) {...} }` test double type-checks
|
|
206
|
+
* without needing to fake the other dozen `WritableStream` members.
|
|
207
|
+
* @param {{write: (chunk: string) => unknown}} stream
|
|
208
|
+
* @param {string} text
|
|
209
|
+
*/
|
|
210
|
+
export function writeSafe(stream, text) {
|
|
211
|
+
stream.write(redactString(text));
|
|
212
|
+
}
|