@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.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 +33 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2349 -182
- package/lib/client.js +344 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +746 -40
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/restart-context-DlaDZWKq.js +1250 -0
- package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1421 -166
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +348 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +192 -41
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +117 -6
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +30 -11
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { c as appendTestLifecycleEvent } from "./test-seam-dwvaKjRp.js";
|
|
1
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
//#region lib/types/state-files.js
|
|
@@ -44,8 +45,26 @@ const STATE_FILES = {
|
|
|
44
45
|
scheduleExitLog: "schedule-exit.log",
|
|
45
46
|
/** How the current instance was launched (recorded by the plugin at apply). */
|
|
46
47
|
instanceLaunch: "instance-launch.json",
|
|
48
|
+
/** The atomically selected full launch configuration (stable or in cutover). */
|
|
49
|
+
launchSpec: "launch-spec.json",
|
|
50
|
+
/** Redacted durable receipt for the latest launch-configuration cutover. */
|
|
51
|
+
launchCutover: "launch-cutover.json",
|
|
52
|
+
/** Legacy combined operator-control marker, retained for rolling upgrades. */
|
|
53
|
+
cutoverControl: "launch-cutover-control.json",
|
|
54
|
+
/** Operator → watchdog: abort according to the pre-approved recovery policy. */
|
|
55
|
+
cutoverAbort: "launch-cutover-abort.json",
|
|
56
|
+
/** Monotonic stronger operator action: explicitly restore the previous spec. */
|
|
57
|
+
cutoverRestorePrevious: "launch-cutover-restore-previous.json",
|
|
58
|
+
/** Original browser-tab registry: per-tab capability hashes only; retained through terminal recovery. */
|
|
59
|
+
browserHandoffRequest: "browser-handoff-request.json",
|
|
60
|
+
/** Browser → watchdog acknowledgement for one proven final process. */
|
|
61
|
+
browserHandoffAck: "browser-handoff-ack.json",
|
|
47
62
|
/** Whether the restart-protocol skill registered at apply (and why not). */
|
|
48
|
-
skillRegistration: "skill-registration.json"
|
|
63
|
+
skillRegistration: "skill-registration.json",
|
|
64
|
+
/** Directory: the healthy-boot snapshot of the profile composition inputs. */
|
|
65
|
+
lastGoodComposition: "last-good-composition",
|
|
66
|
+
/** Directory prefix: a failing composition backed up before rollback restores over it. */
|
|
67
|
+
compositionBackup: "composition-backup-"
|
|
49
68
|
};
|
|
50
69
|
/** Absolute path of a state file inside a state directory. */
|
|
51
70
|
function stateFile(stateDir, role) {
|
|
@@ -67,6 +86,42 @@ function lastGoodBootRevision(stateDir) {
|
|
|
67
86
|
return;
|
|
68
87
|
}
|
|
69
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether the pid named by this raw pid/lock-file content is alive. Empty
|
|
91
|
+
* content reads as NO holder: Number('') is 0 and kill(0, 0) probes our own
|
|
92
|
+
* process group (always succeeds), which once read as "alive" and refused
|
|
93
|
+
* every restart forever — the bug that had to be fixed in two copies of this
|
|
94
|
+
* logic before it was consolidated here.
|
|
95
|
+
*/
|
|
96
|
+
function pidAlive(raw) {
|
|
97
|
+
const pid = Number(raw);
|
|
98
|
+
if (raw === "" || !Number.isInteger(pid) || pid <= 0) return false;
|
|
99
|
+
try {
|
|
100
|
+
process.kill(pid, 0);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** The live pid named by a pid/lock file (as the raw string), or null when absent/stale. */
|
|
107
|
+
function livePidIn(file) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
110
|
+
return pidAlive(raw) ? raw : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** The live supervising watchdog's pid, or null when none is (pidfile + kill 0). */
|
|
116
|
+
function liveWatchdogPid(stateDir) {
|
|
117
|
+
const raw = livePidIn(stateFile(stateDir, "watchdogPid"));
|
|
118
|
+
const pid = raw === null ? null : Number(raw);
|
|
119
|
+
appendTestLifecycleEvent("watchdog-liveness-probe", {
|
|
120
|
+
pid: pid ?? 0,
|
|
121
|
+
live: pid !== null
|
|
122
|
+
}, "parent-observer");
|
|
123
|
+
return pid;
|
|
124
|
+
}
|
|
70
125
|
//#endregion
|
|
71
126
|
//#region lib/types/state.js
|
|
72
127
|
/**
|
|
@@ -74,10 +129,9 @@ function lastGoodBootRevision(stateDir) {
|
|
|
74
129
|
*
|
|
75
130
|
* The guard records one "green build" credential — bound to the git HEAD it
|
|
76
131
|
* was recorded on and to a freshness window — and answers `verify()` against
|
|
77
|
-
* the CURRENT head and wall clock. A
|
|
78
|
-
* credential
|
|
79
|
-
*
|
|
80
|
-
* authorize a restart of unverified code. Framework-free: the cordis plugin,
|
|
132
|
+
* the CURRENT head and wall clock. A later fully gated boot may promote that
|
|
133
|
+
* credential into a deployment proof whose exact runtime fingerprint can be
|
|
134
|
+
* reused by the same-launch restart path. Framework-free: the cordis plugin,
|
|
81
135
|
* the CLI, and the invariant companion all share this module.
|
|
82
136
|
*/
|
|
83
137
|
/** Absolute path of the state file inside a state directory. */
|
|
@@ -98,10 +152,20 @@ function isCheckpoint(value) {
|
|
|
98
152
|
const c = value;
|
|
99
153
|
return typeof c.revision === "string" && typeof c.recordedAt === "number" && typeof c.message === "string";
|
|
100
154
|
}
|
|
155
|
+
function isSha256(value) {
|
|
156
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
157
|
+
}
|
|
158
|
+
function isProvenDeployment(value) {
|
|
159
|
+
if (typeof value !== "object" || value === null) return false;
|
|
160
|
+
const proof = value;
|
|
161
|
+
const credential = proof.credential;
|
|
162
|
+
const fingerprint = proof.fingerprint;
|
|
163
|
+
return proof.version === 1 && typeof proof.provenAt === "number" && credential !== void 0 && typeof credential.revision === "string" && credential.revision !== "" && typeof credential.recordedAt === "number" && typeof credential.scope === "string" && isSha256(credential.commandSha256) && fingerprint?.version === 1 && typeof fingerprint.credentialRevision === "string" && fingerprint.credentialRevision !== "" && typeof fingerprint.harnessRevision === "string" && fingerprint.harnessRevision !== "" && isSha256(fingerprint.launchSpecSha256) && isSha256(fingerprint.profileSha256) && isSha256(fingerprint.hostRuntimeSha256) && isSha256(proof.fingerprintSha256);
|
|
164
|
+
}
|
|
101
165
|
function isAuditEntry(value) {
|
|
102
166
|
if (typeof value !== "object" || value === null) return false;
|
|
103
167
|
const e = value;
|
|
104
|
-
return (e.action === "record" || e.action === "clear" || e.action === "checkpoint") && typeof e.ts === "number" && typeof e.detail === "string";
|
|
168
|
+
return (e.action === "record" || e.action === "clear" || e.action === "checkpoint" || e.action === "prove-deployment") && typeof e.ts === "number" && typeof e.detail === "string";
|
|
105
169
|
}
|
|
106
170
|
/**
|
|
107
171
|
* Read the state file; an absent file is an empty state, a malformed one is a
|
|
@@ -116,6 +180,7 @@ function loadState(stateDir) {
|
|
|
116
180
|
if (parsed === null || typeof parsed !== "object") return emptyState();
|
|
117
181
|
const state = { audit: Array.isArray(parsed.audit) ? parsed.audit.filter(isAuditEntry) : [] };
|
|
118
182
|
if (isCredential(parsed.credential)) state.credential = parsed.credential;
|
|
183
|
+
if (isProvenDeployment(parsed.provenDeployment)) state.provenDeployment = parsed.provenDeployment;
|
|
119
184
|
if (isCheckpoint(parsed.checkpoint)) state.checkpoint = parsed.checkpoint;
|
|
120
185
|
return state;
|
|
121
186
|
} catch (error) {
|
|
@@ -149,12 +214,13 @@ function recordCredential(stateDir, input, now) {
|
|
|
149
214
|
...input,
|
|
150
215
|
recordedAt: now
|
|
151
216
|
};
|
|
217
|
+
const { provenDeployment: _oldProof, ...withoutOldProof } = withAudit(loadState(stateDir), {
|
|
218
|
+
action: "record",
|
|
219
|
+
ts: now,
|
|
220
|
+
detail: `${input.scope} @ ${input.revision}`
|
|
221
|
+
});
|
|
152
222
|
const updated = {
|
|
153
|
-
...
|
|
154
|
-
action: "record",
|
|
155
|
-
ts: now,
|
|
156
|
-
detail: `${input.scope} @ ${input.revision}`
|
|
157
|
-
}),
|
|
223
|
+
...withoutOldProof,
|
|
158
224
|
credential
|
|
159
225
|
};
|
|
160
226
|
saveState(stateDir, updated);
|
|
@@ -177,6 +243,19 @@ function clearCredential(stateDir, now) {
|
|
|
177
243
|
saveState(stateDir, updated);
|
|
178
244
|
return updated;
|
|
179
245
|
}
|
|
246
|
+
/** Persist a deployment proof after the watchdog has completed its canary. */
|
|
247
|
+
function setProvenDeployment(stateDir, proof, now) {
|
|
248
|
+
const updated = {
|
|
249
|
+
...withAudit(loadState(stateDir), {
|
|
250
|
+
action: "prove-deployment",
|
|
251
|
+
ts: now,
|
|
252
|
+
detail: `${proof.credential.revision} ${proof.fingerprintSha256.slice(0, 16)}`
|
|
253
|
+
}),
|
|
254
|
+
provenDeployment: proof
|
|
255
|
+
};
|
|
256
|
+
saveState(stateDir, updated);
|
|
257
|
+
return updated;
|
|
258
|
+
}
|
|
180
259
|
/**
|
|
181
260
|
* Persist a pre-batch checkpoint commit reference.
|
|
182
261
|
* @param stateDir - state directory.
|
|
@@ -207,9 +286,10 @@ function setCheckpoint(stateDir, input, now) {
|
|
|
207
286
|
* @param currentRevision - the git HEAD of the checkout, or null when unavailable.
|
|
208
287
|
* @param now - epoch milliseconds (injected for deterministic tests).
|
|
209
288
|
* @param maxAgeMinutes - freshness window.
|
|
289
|
+
* @param workingTreeClean - whether staged, unstaged, and untracked inputs are absent.
|
|
210
290
|
* @returns ok plus a human reason either way.
|
|
211
291
|
*/
|
|
212
|
-
function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
|
|
292
|
+
function verifyCredential(state, currentRevision, now, maxAgeMinutes, workingTreeClean = true) {
|
|
213
293
|
const credential = state.credential;
|
|
214
294
|
if (credential === void 0) return {
|
|
215
295
|
ok: false,
|
|
@@ -219,6 +299,10 @@ function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
|
|
|
219
299
|
ok: false,
|
|
220
300
|
reason: "current git HEAD unavailable (not inside a git repository?)"
|
|
221
301
|
};
|
|
302
|
+
if (!workingTreeClean) return {
|
|
303
|
+
ok: false,
|
|
304
|
+
reason: "working tree is dirty (staged, unstaged, or untracked changes exist) — commit or remove them, then rebuild and re-record"
|
|
305
|
+
};
|
|
222
306
|
if (credential.revision !== currentRevision) return {
|
|
223
307
|
ok: false,
|
|
224
308
|
reason: `green credential is bound to revision ${credential.revision}, but current HEAD is ${currentRevision} — the tree changed since it was recorded; rebuild and re-record`
|
|
@@ -236,4 +320,4 @@ function verifyCredential(state, currentRevision, now, maxAgeMinutes) {
|
|
|
236
320
|
};
|
|
237
321
|
}
|
|
238
322
|
//#endregion
|
|
239
|
-
export {
|
|
323
|
+
export { setProvenDeployment as a, lastGoodBootRevision as c, pidAlive as d, stateFile as f, setCheckpoint as i, livePidIn as l, loadState as n, stateFilePath as o, recordCredential as r, verifyCredential as s, clearCredential as t, liveWatchdogPid as u };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { d as registerTestProcess, l as appendTestLifecycleEventForProcess, u as registerCurrentTestProcess } from "./test-seam-dwvaKjRp.js";
|
|
3
|
+
//#region lib/types/test-seam-cli.js
|
|
4
|
+
/** Internal executable used only by dsh-watchdog.sh under an explicit test run. */
|
|
5
|
+
const command = process.argv[2] ?? "register";
|
|
6
|
+
if (command === "register-parent") {
|
|
7
|
+
const role = process.argv[3] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
|
|
8
|
+
registerTestProcess(process.ppid, role, { source: "child-self" });
|
|
9
|
+
} else if (command === "event-parent") {
|
|
10
|
+
const role = process.argv[3] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
|
|
11
|
+
const event = process.argv[4] ?? "unknown";
|
|
12
|
+
appendTestLifecycleEventForProcess(process.ppid, role, event);
|
|
13
|
+
} else if (command === "register-pid") {
|
|
14
|
+
const pid = Number(process.argv[3]);
|
|
15
|
+
const role = process.argv[4] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
|
|
16
|
+
registerTestProcess(pid, role, { source: "child-self" });
|
|
17
|
+
} else if (command === "event-pid") {
|
|
18
|
+
const pid = Number(process.argv[3]);
|
|
19
|
+
const role = process.argv[4] ?? process.env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown";
|
|
20
|
+
const event = process.argv[5] ?? "unknown";
|
|
21
|
+
appendTestLifecycleEventForProcess(pid, role, event);
|
|
22
|
+
} else registerCurrentTestProcess();
|
|
23
|
+
//#endregion
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
//#region lib/types/processes.js
|
|
6
|
+
/**
|
|
7
|
+
* Process primitives shared by the guard CLI and the exit agent: locating the
|
|
8
|
+
* listener on a port and killing a process tree. One owner — the watchdog's
|
|
9
|
+
* bash side keeps its own copy (different runtime), but every TypeScript
|
|
10
|
+
* caller goes through here.
|
|
11
|
+
*/
|
|
12
|
+
function executable(name, candidates) {
|
|
13
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? name;
|
|
14
|
+
}
|
|
15
|
+
const LSOF = executable("lsof", ["/usr/sbin/lsof", "/usr/bin/lsof"]);
|
|
16
|
+
const PS = executable("ps", ["/bin/ps", "/usr/bin/ps"]);
|
|
17
|
+
const PGREP = executable("pgrep", ["/usr/bin/pgrep", "/bin/pgrep"]);
|
|
18
|
+
const SYSCTL = executable("sysctl", ["/usr/sbin/sysctl", "/sbin/sysctl"]);
|
|
19
|
+
const PYTHON = executable("python3", ["/usr/bin/python3", "/opt/homebrew/bin/python3"]);
|
|
20
|
+
const DARWIN_START_TOKEN = [
|
|
21
|
+
"import ctypes,struct,sys",
|
|
22
|
+
"p=int(sys.argv[1])",
|
|
23
|
+
"b=ctypes.create_string_buffer(136)",
|
|
24
|
+
"n=ctypes.CDLL(\"/usr/lib/libproc.dylib\").proc_pidinfo(p,3,0,b,136)",
|
|
25
|
+
"n == 136 or sys.exit(1)",
|
|
26
|
+
"s,u=struct.unpack_from(\"QQ\",b.raw,120)",
|
|
27
|
+
"print(f\"darwin:{s}:{u}\",end=\"\")"
|
|
28
|
+
].join(";");
|
|
29
|
+
/** Every process listening on a TCP port. An empty list also covers unavailable lsof. */
|
|
30
|
+
function findPidsOnPort(port) {
|
|
31
|
+
try {
|
|
32
|
+
const out = execFileSync(LSOF, [
|
|
33
|
+
`-tiTCP:${port}`,
|
|
34
|
+
"-sTCP:LISTEN",
|
|
35
|
+
"-P"
|
|
36
|
+
], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
stdio: "pipe"
|
|
39
|
+
}).trim();
|
|
40
|
+
return [...new Set(out.split("\n").map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** TCP listen ports owned directly by one PID, using the same absolute lsof resolution. */
|
|
46
|
+
function listeningPortsForPid(pid) {
|
|
47
|
+
try {
|
|
48
|
+
const out = execFileSync(LSOF, [
|
|
49
|
+
"-nP",
|
|
50
|
+
"-a",
|
|
51
|
+
"-p",
|
|
52
|
+
String(pid),
|
|
53
|
+
"-iTCP",
|
|
54
|
+
"-sTCP:LISTEN"
|
|
55
|
+
], {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
stdio: "pipe"
|
|
58
|
+
});
|
|
59
|
+
return [...new Set([...out.matchAll(/:(\d+) \(LISTEN\)/g)].map((match) => Number(match[1])).filter(Number.isInteger))];
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The first process listening on a TCP port, or null when none is (via lsof). */
|
|
65
|
+
function findPidOnPort(port) {
|
|
66
|
+
return findPidsOnPort(port)[0]?.toString() ?? null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Current identity for a live process, or null when it cannot be proved.
|
|
70
|
+
* Linux exposes a boot-scoped kernel start tick. Other POSIX hosts do not,
|
|
71
|
+
* so bind the start instant to the boot, uid, session, and settled command.
|
|
72
|
+
* This materially strengthens macOS ps(1)'s second-granularity lstart; all
|
|
73
|
+
* guard captures occur after the watchdog/child has reached its steady argv.
|
|
74
|
+
*/
|
|
75
|
+
function processIdentity(pid) {
|
|
76
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
77
|
+
try {
|
|
78
|
+
const status = execFileSync(PS, [
|
|
79
|
+
"-o",
|
|
80
|
+
"stat=",
|
|
81
|
+
"-p",
|
|
82
|
+
String(pid)
|
|
83
|
+
], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
stdio: "pipe"
|
|
86
|
+
}).trim();
|
|
87
|
+
if (status === "" || status.startsWith("Z")) return null;
|
|
88
|
+
if (process.platform === "darwin") try {
|
|
89
|
+
const nativeStart = execFileSync(PYTHON, [
|
|
90
|
+
"-c",
|
|
91
|
+
DARWIN_START_TOKEN,
|
|
92
|
+
String(pid)
|
|
93
|
+
], {
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
stdio: "pipe"
|
|
96
|
+
}).trim();
|
|
97
|
+
if (/^darwin:\d+:\d+$/.test(nativeStart)) return {
|
|
98
|
+
pid,
|
|
99
|
+
startToken: nativeStart
|
|
100
|
+
};
|
|
101
|
+
} catch {}
|
|
102
|
+
const procStat = `/proc/${pid}/stat`;
|
|
103
|
+
if (existsSync(procStat)) {
|
|
104
|
+
const stat = readFileSync(procStat, "utf8");
|
|
105
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
106
|
+
const startTicks = (commandEnd < 0 ? [] : stat.slice(commandEnd + 1).trim().split(/\s+/))[19];
|
|
107
|
+
if (startTicks === void 0 || startTicks === "") return null;
|
|
108
|
+
let bootId = "unknown-boot";
|
|
109
|
+
try {
|
|
110
|
+
bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || bootId;
|
|
111
|
+
} catch {}
|
|
112
|
+
return {
|
|
113
|
+
pid,
|
|
114
|
+
startToken: `linux:${bootId}:${startTicks}`
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const processEvidence = execFileSync(PS, [
|
|
118
|
+
"-o",
|
|
119
|
+
"sess=",
|
|
120
|
+
"-o",
|
|
121
|
+
"uid=",
|
|
122
|
+
"-o",
|
|
123
|
+
"lstart=",
|
|
124
|
+
"-o",
|
|
125
|
+
"command=",
|
|
126
|
+
"-p",
|
|
127
|
+
String(pid)
|
|
128
|
+
], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
stdio: "pipe"
|
|
131
|
+
}).trim();
|
|
132
|
+
if (processEvidence === "") return null;
|
|
133
|
+
let bootEvidence = "unknown-boot";
|
|
134
|
+
try {
|
|
135
|
+
bootEvidence = execFileSync(SYSCTL, ["-n", "kern.boottime"], {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
stdio: "pipe"
|
|
138
|
+
}).trim() || bootEvidence;
|
|
139
|
+
} catch {}
|
|
140
|
+
return {
|
|
141
|
+
pid,
|
|
142
|
+
startToken: `posix:${createHash("sha256").update(bootEvidence).update("\0").update(processEvidence).digest("hex")}`
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Whether the same, non-recycled process is still alive. */
|
|
149
|
+
function processIdentityMatches(identity) {
|
|
150
|
+
return processIdentity(identity.pid)?.startToken === identity.startToken;
|
|
151
|
+
}
|
|
152
|
+
function parentPid(pid) {
|
|
153
|
+
try {
|
|
154
|
+
const value = Number(execFileSync(PS, [
|
|
155
|
+
"-o",
|
|
156
|
+
"ppid=",
|
|
157
|
+
"-p",
|
|
158
|
+
String(pid)
|
|
159
|
+
], {
|
|
160
|
+
encoding: "utf8",
|
|
161
|
+
stdio: "pipe"
|
|
162
|
+
}).trim());
|
|
163
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** The live POSIX process-group id for one PID, or null when unavailable. */
|
|
169
|
+
function processGroupId(pid) {
|
|
170
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
171
|
+
try {
|
|
172
|
+
const value = Number(execFileSync(PS, [
|
|
173
|
+
"-o",
|
|
174
|
+
"pgid=",
|
|
175
|
+
"-p",
|
|
176
|
+
String(pid)
|
|
177
|
+
], {
|
|
178
|
+
encoding: "utf8",
|
|
179
|
+
stdio: "pipe"
|
|
180
|
+
}).trim());
|
|
181
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Prove that exactly one port listener belongs to a supervisor and return the
|
|
188
|
+
* direct child root through which that supervisor owns it. This is captured
|
|
189
|
+
* before cutover; the successor must stop this identity, never an arbitrary
|
|
190
|
+
* process discovered later from the shared port.
|
|
191
|
+
*/
|
|
192
|
+
function findOwnedListener(port, supervisorPid) {
|
|
193
|
+
const listeners = findPidsOnPort(port);
|
|
194
|
+
if (listeners.length !== 1) return null;
|
|
195
|
+
const listenerPid = listeners[0];
|
|
196
|
+
if (listenerPid === void 0) return null;
|
|
197
|
+
const listener = processIdentity(listenerPid);
|
|
198
|
+
if (listener === null) return null;
|
|
199
|
+
let cursor = listenerPid;
|
|
200
|
+
let childRoot = null;
|
|
201
|
+
const seen = /* @__PURE__ */ new Set();
|
|
202
|
+
while (cursor > 0 && !seen.has(cursor)) {
|
|
203
|
+
seen.add(cursor);
|
|
204
|
+
const parent = parentPid(cursor);
|
|
205
|
+
if (parent === supervisorPid) {
|
|
206
|
+
childRoot = cursor;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
if (parent === null) return null;
|
|
210
|
+
cursor = parent;
|
|
211
|
+
}
|
|
212
|
+
if (childRoot === null) return null;
|
|
213
|
+
const child = processIdentity(childRoot);
|
|
214
|
+
return child === null ? null : {
|
|
215
|
+
child,
|
|
216
|
+
listener
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** POSIX single-quote one word for a shell command line. */
|
|
220
|
+
function shellQuote(word) {
|
|
221
|
+
return `'${word.replace(/'/g, "'\\''")}'`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Discover how the process on a port was launched — its exact argv from
|
|
225
|
+
* `ps -o command=`, its cwd from lsof, and its DSH_* environment from
|
|
226
|
+
* `ps eww` — rendered as a shell command. This exists because the agent's
|
|
227
|
+
* sandbox blocks ps entirely, so every fresh-machine agent fell into a
|
|
228
|
+
* process-tree archaeology loop before its first restart; the CLI (running
|
|
229
|
+
* unsandboxed) answers the same question mechanically and reliably. Returns
|
|
230
|
+
* null when the process is gone or ps/lsof are unavailable.
|
|
231
|
+
* @param pid - the listener's pid.
|
|
232
|
+
*/
|
|
233
|
+
function discoverLaunchCommand(pid) {
|
|
234
|
+
let argv;
|
|
235
|
+
let cwd;
|
|
236
|
+
try {
|
|
237
|
+
argv = execFileSync(PS, [
|
|
238
|
+
"-o",
|
|
239
|
+
"command=",
|
|
240
|
+
"-p",
|
|
241
|
+
pid
|
|
242
|
+
], {
|
|
243
|
+
encoding: "utf8",
|
|
244
|
+
stdio: "pipe"
|
|
245
|
+
}).trim();
|
|
246
|
+
if (argv === "") return null;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
throw new Error(`ps unavailable: ${String(error)}`);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const out = execFileSync(LSOF, [
|
|
252
|
+
"-a",
|
|
253
|
+
"-p",
|
|
254
|
+
pid,
|
|
255
|
+
"-d",
|
|
256
|
+
"cwd",
|
|
257
|
+
"-Fn"
|
|
258
|
+
], {
|
|
259
|
+
encoding: "utf8",
|
|
260
|
+
stdio: "pipe"
|
|
261
|
+
});
|
|
262
|
+
const match = /^n(.+)$/m.exec(out);
|
|
263
|
+
if (match === null) return null;
|
|
264
|
+
cwd = match[1] ?? "";
|
|
265
|
+
} catch (error) {
|
|
266
|
+
throw new Error(`lsof cwd unavailable: ${String(error)}`);
|
|
267
|
+
}
|
|
268
|
+
const TRANSIENT = /* @__PURE__ */ new Set([
|
|
269
|
+
"DSH_ANKH_RESTART_DRIVER",
|
|
270
|
+
"DSH_SESSION_ID",
|
|
271
|
+
"DSH_SESSION_JSONL",
|
|
272
|
+
"DSH_WEB_URL",
|
|
273
|
+
"DSH_SHELL"
|
|
274
|
+
]);
|
|
275
|
+
const env = {};
|
|
276
|
+
try {
|
|
277
|
+
const out = execFileSync(PS, [
|
|
278
|
+
"eww",
|
|
279
|
+
"-o",
|
|
280
|
+
"command",
|
|
281
|
+
"-p",
|
|
282
|
+
pid
|
|
283
|
+
], {
|
|
284
|
+
encoding: "utf8",
|
|
285
|
+
stdio: "pipe"
|
|
286
|
+
});
|
|
287
|
+
for (const token of out.split(/\s+/)) {
|
|
288
|
+
const eq = token.indexOf("=");
|
|
289
|
+
if (eq > 0 && token.slice(0, eq).startsWith("DSH_") && !TRANSIENT.has(token.slice(0, eq))) env[token.slice(0, eq)] = token.slice(eq + 1);
|
|
290
|
+
}
|
|
291
|
+
} catch {}
|
|
292
|
+
const envPart = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
|
|
293
|
+
return `cd ${shellQuote(cwd)} && ${envPart !== "" ? `${envPart} ` : ""}${argv}`;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Kill a pid AND its descendants, deepest first (best effort). The supervised
|
|
297
|
+
* instance may have forked children; a plain signal on the pid alone would
|
|
298
|
+
* orphan them (the EADDRINUSE race the watchdog's EADDRINUSE branch exists
|
|
299
|
+
* for). The process-group model is NOT assumed — the instance is not
|
|
300
|
+
* setsid'd — so the sweep walks `pgrep -P` instead. `pgrep` missing or
|
|
301
|
+
* returning nothing is fine: the pid itself still gets the signal.
|
|
302
|
+
*/
|
|
303
|
+
function signalFrozenTree(pid, signal) {
|
|
304
|
+
let safe = true;
|
|
305
|
+
let children = [];
|
|
306
|
+
try {
|
|
307
|
+
const out = execFileSync(PGREP, ["-P", String(pid)], {
|
|
308
|
+
encoding: "utf8",
|
|
309
|
+
stdio: "pipe"
|
|
310
|
+
}).trim();
|
|
311
|
+
children = out === "" ? [] : out.split("\n");
|
|
312
|
+
} catch {}
|
|
313
|
+
for (const raw of children) {
|
|
314
|
+
const child = Number(raw);
|
|
315
|
+
if (!Number.isInteger(child) || child <= 0) continue;
|
|
316
|
+
try {
|
|
317
|
+
process.kill(child, "SIGSTOP");
|
|
318
|
+
} catch {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (parentPid(child) !== pid) {
|
|
322
|
+
safe = false;
|
|
323
|
+
try {
|
|
324
|
+
process.kill(child, "SIGCONT");
|
|
325
|
+
} catch {}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
safe = signalFrozenTree(child, signal) && safe;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
process.kill(pid, signal);
|
|
332
|
+
if (signal !== "SIGKILL") process.kill(pid, "SIGCONT");
|
|
333
|
+
} catch {}
|
|
334
|
+
return safe;
|
|
335
|
+
}
|
|
336
|
+
function killPidTree(pid, signal) {
|
|
337
|
+
try {
|
|
338
|
+
process.kill(pid, "SIGSTOP");
|
|
339
|
+
} catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
signalFrozenTree(pid, signal);
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region lib/types/test-seam.js
|
|
346
|
+
/**
|
|
347
|
+
* Explicit test-only process/event registration seam. Production execution is
|
|
348
|
+
* inert unless a test runner supplies all ANKH_GUARD_TEST_* ownership values.
|
|
349
|
+
*/
|
|
350
|
+
const TEST_RUN_DIR_ENV = "ANKH_GUARD_TEST_RUN_DIR";
|
|
351
|
+
const TEST_RUN_TOKEN_ENV = "ANKH_GUARD_TEST_RUN_TOKEN";
|
|
352
|
+
const TEST_PROCESS_ROLE_ENV = "ANKH_GUARD_TEST_PROCESS_ROLE";
|
|
353
|
+
const TEST_PROCESS_PORT_ENV = "ANKH_GUARD_TEST_PROCESS_PORT";
|
|
354
|
+
const TEST_PROCESS_TEMP_ROOT_ENV = "ANKH_GUARD_TEST_PROCESS_TEMP_ROOT";
|
|
355
|
+
const TEST_REGISTER_BIN_ENV = "ANKH_GUARD_TEST_REGISTER_BIN";
|
|
356
|
+
const TEST_SLEEP_SCALE_ENV = "ANKH_GUARD_TEST_SLEEP_SCALE";
|
|
357
|
+
function testCoordinates(env) {
|
|
358
|
+
const runDir = env[TEST_RUN_DIR_ENV];
|
|
359
|
+
const runToken = env[TEST_RUN_TOKEN_ENV];
|
|
360
|
+
if (runDir === void 0 || runDir === "" || runToken === void 0 || !/^[a-f0-9-]{16,}$/.test(runToken)) return null;
|
|
361
|
+
return {
|
|
362
|
+
runDir,
|
|
363
|
+
runToken
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function safeRole(value) {
|
|
367
|
+
const role = value.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80);
|
|
368
|
+
return role === "" ? "unknown" : role;
|
|
369
|
+
}
|
|
370
|
+
function appendTestLifecycleEventForProcess(subjectPid, roleValue, event, detail, source = "child-self", env = process.env) {
|
|
371
|
+
const coordinates = testCoordinates(env);
|
|
372
|
+
if (coordinates === null) return;
|
|
373
|
+
const role = safeRole(roleValue);
|
|
374
|
+
const identity = processIdentity(subjectPid);
|
|
375
|
+
const pgid = processGroupId(subjectPid);
|
|
376
|
+
const record = {
|
|
377
|
+
version: 1,
|
|
378
|
+
runToken: coordinates.runToken,
|
|
379
|
+
source,
|
|
380
|
+
role,
|
|
381
|
+
event: safeRole(event),
|
|
382
|
+
pid: subjectPid,
|
|
383
|
+
...pgid === null ? {} : { pgid },
|
|
384
|
+
...identity === null ? {} : { startToken: identity.startToken },
|
|
385
|
+
wallTimeMs: Date.now(),
|
|
386
|
+
monotonicNs: process.hrtime.bigint().toString(),
|
|
387
|
+
...detail === void 0 ? {} : { detail }
|
|
388
|
+
};
|
|
389
|
+
try {
|
|
390
|
+
const dir = join(coordinates.runDir, "events");
|
|
391
|
+
mkdirSync(dir, {
|
|
392
|
+
recursive: true,
|
|
393
|
+
mode: 448
|
|
394
|
+
});
|
|
395
|
+
appendFileSync(join(dir, `${subjectPid}-${source}.jsonl`), `${JSON.stringify(record)}\n`, { mode: 384 });
|
|
396
|
+
} catch {}
|
|
397
|
+
}
|
|
398
|
+
/** Record one credential-free event in this process's append-only event file. */
|
|
399
|
+
function appendTestLifecycleEvent(event, detail, source = "child-self", env = process.env) {
|
|
400
|
+
appendTestLifecycleEventForProcess(process.pid, env["ANKH_GUARD_TEST_PROCESS_ROLE"] ?? "unknown", event, detail, source, env);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Atomically publish an immutable PID/PGID/start-identity lease. The same PID
|
|
404
|
+
* may be observed by its parent and then self-register; the first complete
|
|
405
|
+
* record wins because both describe the same kernel start identity.
|
|
406
|
+
*/
|
|
407
|
+
function registerTestProcess(pid, role, options = {}) {
|
|
408
|
+
const env = options.env ?? process.env;
|
|
409
|
+
const coordinates = testCoordinates(env);
|
|
410
|
+
if (coordinates === null) return null;
|
|
411
|
+
const identity = options.identity ?? processIdentity(pid);
|
|
412
|
+
const pgid = processGroupId(pid);
|
|
413
|
+
if (identity === null || pgid === null) return null;
|
|
414
|
+
const portText = env[TEST_PROCESS_PORT_ENV];
|
|
415
|
+
const envPort = portText === void 0 ? void 0 : Number(portText);
|
|
416
|
+
const port = options.port ?? (Number.isInteger(envPort) && (envPort ?? 0) > 0 ? envPort : void 0);
|
|
417
|
+
const tempRoot = options.tempRoot ?? env["ANKH_GUARD_TEST_PROCESS_TEMP_ROOT"];
|
|
418
|
+
const record = {
|
|
419
|
+
version: 1,
|
|
420
|
+
runToken: coordinates.runToken,
|
|
421
|
+
role: safeRole(role),
|
|
422
|
+
pid,
|
|
423
|
+
pgid,
|
|
424
|
+
startToken: identity.startToken,
|
|
425
|
+
groupRoot: pgid === pid,
|
|
426
|
+
registeredAt: Date.now(),
|
|
427
|
+
source: options.source ?? (pid === process.pid ? "child-self" : "parent-observer"),
|
|
428
|
+
...tempRoot === void 0 || tempRoot === "" ? {} : { tempRoot },
|
|
429
|
+
...port === void 0 ? {} : { port }
|
|
430
|
+
};
|
|
431
|
+
try {
|
|
432
|
+
const dir = join(coordinates.runDir, "processes");
|
|
433
|
+
mkdirSync(dir, {
|
|
434
|
+
recursive: true,
|
|
435
|
+
mode: 448
|
|
436
|
+
});
|
|
437
|
+
const identityHash = createHash("sha256").update(identity.startToken).digest("hex").slice(0, 16);
|
|
438
|
+
writeFileSync(join(dir, `${pid}-${identityHash}.json`), `${JSON.stringify(record)}\n`, {
|
|
439
|
+
flag: "wx",
|
|
440
|
+
mode: 384
|
|
441
|
+
});
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error.code !== "EEXIST") return null;
|
|
444
|
+
}
|
|
445
|
+
return record;
|
|
446
|
+
}
|
|
447
|
+
/** Self-register only when an explicit test role accompanies the run lease. */
|
|
448
|
+
function registerCurrentTestProcess(env = process.env) {
|
|
449
|
+
const role = env[TEST_PROCESS_ROLE_ENV];
|
|
450
|
+
if (role === void 0 || role === "") return null;
|
|
451
|
+
const record = registerTestProcess(process.pid, role, {
|
|
452
|
+
env,
|
|
453
|
+
source: "child-self"
|
|
454
|
+
});
|
|
455
|
+
if (record !== null) appendTestLifecycleEvent("process-registered", { pgid: record.pgid }, "child-self", env);
|
|
456
|
+
return record;
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
export { processIdentity as _, TEST_RUN_DIR_ENV as a, appendTestLifecycleEvent as c, registerTestProcess as d, discoverLaunchCommand as f, listeningPortsForPid as g, killPidTree as h, TEST_REGISTER_BIN_ENV as i, appendTestLifecycleEventForProcess as l, findPidOnPort as m, TEST_PROCESS_ROLE_ENV as n, TEST_RUN_TOKEN_ENV as o, findOwnedListener as p, TEST_PROCESS_TEMP_ROOT_ENV as r, TEST_SLEEP_SCALE_ENV as s, TEST_PROCESS_PORT_ENV as t, registerCurrentTestProcess as u, processIdentityMatches as v };
|
package/lib/test-seam.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as TEST_RUN_DIR_ENV, c as appendTestLifecycleEvent, d as registerTestProcess, i as TEST_REGISTER_BIN_ENV, l as appendTestLifecycleEventForProcess, n as TEST_PROCESS_ROLE_ENV, o as TEST_RUN_TOKEN_ENV, r as TEST_PROCESS_TEMP_ROOT_ENV, s as TEST_SLEEP_SCALE_ENV, t as TEST_PROCESS_PORT_ENV, u as registerCurrentTestProcess } from "./test-seam-dwvaKjRp.js";
|
|
2
|
+
export { TEST_PROCESS_PORT_ENV, TEST_PROCESS_ROLE_ENV, TEST_PROCESS_TEMP_ROOT_ENV, TEST_REGISTER_BIN_ENV, TEST_RUN_DIR_ENV, TEST_RUN_TOKEN_ENV, TEST_SLEEP_SCALE_ENV, appendTestLifecycleEvent, appendTestLifecycleEventForProcess, registerCurrentTestProcess, registerTestProcess };
|