@khorsheed/dsh-ankh-guard 0.1.1 → 0.2.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 +24 -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 +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +721 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- 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 +264 -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 +157 -39
- 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 +106 -0
- 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
package/lib/invariant.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { i as resolveStateDir } from "./defaults-CduvOcGq.js";
|
|
2
|
-
import {
|
|
2
|
+
import { n as loadState, o as stateFilePath } from "./state-CZMypGkB.js";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
//#region lib/types/invariant.js
|
|
5
5
|
/**
|
package/lib/preflight-runner.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { n as isDirectInvocation } from "./defaults-CduvOcGq.js";
|
|
2
|
-
import {
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { existsSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
6
|
import { pathToFileURL } from "node:url";
|
|
6
7
|
//#region lib/types/preflight-runner.js
|
|
7
8
|
/**
|
|
@@ -11,10 +12,11 @@ import { pathToFileURL } from "node:url";
|
|
|
11
12
|
* deploy line) was wiped by the upstream reset and cannot be re-applied
|
|
12
13
|
* without re-forking apps/cli on every release — which we cannot upstream
|
|
13
14
|
* (no PR access). This runner replaces it WITHOUT touching the harness: it
|
|
14
|
-
* resolves the official
|
|
15
|
-
* `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
15
|
+
* resolves the official packages (`@deepseek-ai/dsh-app-boot`,
|
|
16
|
+
* `dsh-home-paths`, `dsh-launch-environment`, `dsh-cmdline`) from an explicit
|
|
17
|
+
* execution binding: source files in the host checkout for a source launch,
|
|
18
|
+
* or the npm toolchain selected by a built launch's dsh package.json. It never
|
|
19
|
+
* infers that choice from Node's execArgv.
|
|
18
20
|
*
|
|
19
21
|
* What it verifies (same contract as the old patch):
|
|
20
22
|
* - the profile's full patch stack composes (bundle layers from
|
|
@@ -33,8 +35,8 @@ import { pathToFileURL } from "node:url";
|
|
|
33
35
|
* error, env load failure): NOT a verdict on the composition.
|
|
34
36
|
*
|
|
35
37
|
* Run directly (any profile) or via the guard CLI:
|
|
36
|
-
* node
|
|
37
|
-
*
|
|
38
|
+
* node packages/ankh-guard/lib/preflight-runner.js --profile web \
|
|
39
|
+
* --host-surface built --install-anchor <toolchain>/node_modules/@deepseek-ai/dsh/package.json
|
|
38
40
|
*
|
|
39
41
|
* @module @khorsheed/dsh-ankh-guard/preflight-runner
|
|
40
42
|
*/
|
|
@@ -49,6 +51,17 @@ const PROFILE_ROOT_FILENAME = "cordis.yml";
|
|
|
49
51
|
const HOME_PATCH_FILENAME = "cordis.patch.yml";
|
|
50
52
|
const TELEMETRY_ROW_ID = "session-telemetry-otel";
|
|
51
53
|
const DSH_HARNESS_ENV = "DSH_HARNESS";
|
|
54
|
+
/**
|
|
55
|
+
* The empty root entry list every profile tree patches over — the exact
|
|
56
|
+
* bytes the launcher's prepareProfile rewrites on every boot (the vendored
|
|
57
|
+
* Loader's tree write-back can bake composed rows into this file, so both
|
|
58
|
+
* the launcher and this dry-run always start from the empty root).
|
|
59
|
+
*/
|
|
60
|
+
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
|
|
61
|
+
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
|
|
62
|
+
# --patch overlays. Edit cordis.patch.yml, not this file.
|
|
63
|
+
[]
|
|
64
|
+
`;
|
|
52
65
|
/** The guarded (or tracking) harness checkout the live instance boots from. */
|
|
53
66
|
function resolveHarnessRoot(env = process.env) {
|
|
54
67
|
const fromEnv = env[DSH_HARNESS_ENV];
|
|
@@ -66,18 +79,9 @@ const HARNESS_PACKAGE_DIRS = {
|
|
|
66
79
|
"@deepseek-ai/dsh-cmdline": "packages/boot/cmdline"
|
|
67
80
|
};
|
|
68
81
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
|
|
72
|
-
function canImportTypeScript() {
|
|
73
|
-
return process.execArgv.some((arg) => arg.includes("tsx"));
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Dynamically import a harness package from the live checkout, SOURCE FIRST:
|
|
77
|
-
* a source-launched prod instance (tsx … apps/cli/src/bin.ts) boots the
|
|
78
|
-
* source, so the preflight engine must be the source too — a stale `lib/`
|
|
79
|
-
* must never make the preflight greener than the real boot. The built entry
|
|
80
|
-
* is the fallback for harnesses that ship only artifacts.
|
|
82
|
+
* Dynamically import one host package from the explicitly selected surface.
|
|
83
|
+
* A source launch uses checkout source and fails rather than falling back to
|
|
84
|
+
* stale lib; a built launch resolves only through its npm install anchor.
|
|
81
85
|
*
|
|
82
86
|
* The composition layers below (bundle layers, user layers, overlays, the
|
|
83
87
|
* agent-presets roots, the telemetry switch) mirror the launcher's private
|
|
@@ -89,27 +93,38 @@ function canImportTypeScript() {
|
|
|
89
93
|
* @param name - package name (a key of {@link HARNESS_PACKAGE_DIRS}).
|
|
90
94
|
* @returns the imported module.
|
|
91
95
|
*/
|
|
92
|
-
async function loadHarnessPackage(root, name) {
|
|
96
|
+
async function loadHarnessPackage(root, name, binding) {
|
|
97
|
+
if (binding.surface === "built") {
|
|
98
|
+
let entry;
|
|
99
|
+
try {
|
|
100
|
+
entry = createRequire(binding.installAnchor).resolve(name);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new Error(`built host package ${name} is not resolvable from ${binding.installAnchor}: ${String(error)}`, { cause: error });
|
|
103
|
+
}
|
|
104
|
+
return await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href));
|
|
105
|
+
}
|
|
93
106
|
const relative = HARNESS_PACKAGE_DIRS[name];
|
|
94
107
|
if (relative === void 0) throw new Error(`no known harness layout entry for ${name}`);
|
|
95
108
|
const base = join(root, relative);
|
|
96
109
|
const source = join(base, "src", "index.ts");
|
|
97
|
-
if (
|
|
110
|
+
if (!existsSync(source)) throw new Error(`harness source entry does not exist: ${source}`);
|
|
111
|
+
try {
|
|
98
112
|
return await import(__rewriteRelativeImportExtension(pathToFileURL(source).href));
|
|
99
113
|
} catch (error) {
|
|
100
114
|
throw new Error(`harness source ${source} failed to import (this is what a source boot would hit): ${String(error)}`, { cause: error });
|
|
101
115
|
}
|
|
102
|
-
const entry = JSON.parse(readFileSync(join(base, "package.json"), "utf8")).main ?? "lib/index.js";
|
|
103
|
-
return await import(__rewriteRelativeImportExtension(pathToFileURL(join(base, entry)).href));
|
|
104
116
|
}
|
|
105
117
|
/** Thrown for harness-side load failures — preflight infrastructure, never a composition verdict. */
|
|
106
118
|
var PreflightInfraError = class extends Error {};
|
|
107
119
|
/**
|
|
108
120
|
* Compose one profile's full patch stack through the launcher's layering —
|
|
109
121
|
* bundle layers in `dsh.profile.bundles` order, the profile user layer, the
|
|
110
|
-
* home-level user layer, `--patch` overlays, the agent-presets roots overlay
|
|
111
|
-
*
|
|
112
|
-
*
|
|
122
|
+
* home-level user layer, `--patch` overlays, the agent-presets roots overlay
|
|
123
|
+
* (rc host line only; the 0.1.2 line's preset package self-ships its root),
|
|
124
|
+
* then the telemetry switch. Two host API generations are mirrored and
|
|
125
|
+
* feature-detected per run — see the `hostLine` branch below. Exported so
|
|
126
|
+
* the drift tripwire can compare this assembly against the launcher's own
|
|
127
|
+
* dump without booting anything.
|
|
113
128
|
* @param profile - the profile name (same resolution as `--profile`).
|
|
114
129
|
* @param patchFiles - `--patch` overlay paths, in argv order.
|
|
115
130
|
* @param root - harness checkout root.
|
|
@@ -118,12 +133,15 @@ var PreflightInfraError = class extends Error {};
|
|
|
118
133
|
* specific deployment must not silently compose a different home's tree.
|
|
119
134
|
* @returns the patch stack and composed rows.
|
|
120
135
|
*/
|
|
121
|
-
async function composePreflightPatches(profile, patchFiles, root, home
|
|
136
|
+
async function composePreflightPatches(profile, patchFiles, root, home, binding = {
|
|
137
|
+
surface: "source",
|
|
138
|
+
installAnchor: join(root, "apps", "cli", "package.json")
|
|
139
|
+
}) {
|
|
122
140
|
let appBoot;
|
|
123
141
|
let homePaths;
|
|
124
142
|
try {
|
|
125
|
-
appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot");
|
|
126
|
-
homePaths = await loadHarnessPackage(root, "@deepseek-ai/dsh-home-paths");
|
|
143
|
+
appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot", binding);
|
|
144
|
+
homePaths = await loadHarnessPackage(root, "@deepseek-ai/dsh-home-paths", binding);
|
|
127
145
|
} catch (error) {
|
|
128
146
|
throw new PreflightInfraError(`harness packages unavailable under ${root}: ${String(error)}`, { cause: error });
|
|
129
147
|
}
|
|
@@ -133,10 +151,17 @@ async function composePreflightPatches(profile, patchFiles, root, home) {
|
|
|
133
151
|
const loadOverlayPatches = appBoot.loadOverlayPatches;
|
|
134
152
|
const loadProfile = appBoot.loadProfile;
|
|
135
153
|
const resolveDshHome = homePaths.resolveDshHome;
|
|
136
|
-
const anchor =
|
|
154
|
+
const anchor = binding.installAnchor;
|
|
137
155
|
const resolvedHome = resolveDshHome(home);
|
|
138
|
-
|
|
156
|
+
const hostLine = "DEFAULT_PROFILE_PATCH_RELOAD" in appBoot ? "0.1.2" : "rc";
|
|
157
|
+
if (hostLine === "rc") healProfilesModuleFallback(anchor, resolvedHome);
|
|
139
158
|
const composed = loadProfile(NAME, profile, anchor, resolvedHome, { userLayer: true });
|
|
159
|
+
writeFileSync(join(composed.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG);
|
|
160
|
+
if (hostLine === "0.1.2") await healProfilesModuleFallback({
|
|
161
|
+
installAnchor: anchor,
|
|
162
|
+
profile: composed,
|
|
163
|
+
home: resolvedHome
|
|
164
|
+
});
|
|
140
165
|
const homePatches = loadOptionalPatches(NAME, join(resolvedHome, HOME_PATCH_FILENAME)) ?? [];
|
|
141
166
|
const overlays = patchFiles.flatMap((file) => loadOverlayPatches(NAME, resolve(file)));
|
|
142
167
|
const bundlePatches = composed.layers.flatMap((layer) => layer.patches);
|
|
@@ -154,12 +179,13 @@ async function composePreflightPatches(profile, patchFiles, root, home) {
|
|
|
154
179
|
overlays
|
|
155
180
|
])) if (typeof row.id === "string") rows.set(row.id, row);
|
|
156
181
|
const composedOverlays = [...overlays];
|
|
157
|
-
|
|
182
|
+
const shippedPresetRoot = binding.surface === "built" ? join(dirname(binding.installAnchor), "config", "agent-presets") : join(root, "apps", "cli", "config", "agent-presets");
|
|
183
|
+
if (rows.has("agent-presets") && existsSync(shippedPresetRoot)) composedOverlays.push({
|
|
158
184
|
id: "agent-presets",
|
|
159
185
|
config: {
|
|
160
186
|
...rows.get("agent-presets")?.config ?? {},
|
|
161
187
|
roots: [{
|
|
162
|
-
path:
|
|
188
|
+
path: shippedPresetRoot,
|
|
163
189
|
trust: "system"
|
|
164
190
|
}]
|
|
165
191
|
}
|
|
@@ -190,6 +216,34 @@ async function composePreflightPatches(profile, patchFiles, root, home) {
|
|
|
190
216
|
profileDir: composed.dir
|
|
191
217
|
};
|
|
192
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The launcher's readiness signal, mirrored: 0.1.2's runProfile provides an
|
|
221
|
+
* `appReady` service through provideCmdline and commits it once boot and host
|
|
222
|
+
* setup settle. The rc line's provideCmdline ignores the field, so one
|
|
223
|
+
* implementation serves both host lines.
|
|
224
|
+
*/
|
|
225
|
+
function createAppReadyStub() {
|
|
226
|
+
let committed = false;
|
|
227
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
228
|
+
return {
|
|
229
|
+
service: { onReady(listener) {
|
|
230
|
+
if (committed) {
|
|
231
|
+
listener();
|
|
232
|
+
return () => {};
|
|
233
|
+
}
|
|
234
|
+
listeners.add(listener);
|
|
235
|
+
return () => {
|
|
236
|
+
listeners.delete(listener);
|
|
237
|
+
};
|
|
238
|
+
} },
|
|
239
|
+
commit() {
|
|
240
|
+
if (committed) return;
|
|
241
|
+
committed = true;
|
|
242
|
+
for (const listener of [...listeners]) listener();
|
|
243
|
+
listeners.clear();
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
193
247
|
/** Stat every registered client bundle; report one line per missing/unreadable artifact. */
|
|
194
248
|
function missingClientArtifacts(ctx) {
|
|
195
249
|
const registry = ctx.get?.("clientModules");
|
|
@@ -218,14 +272,17 @@ function missingClientArtifacts(ctx) {
|
|
|
218
272
|
* @param root - harness checkout root (default: DSH_HARNESS or ~/code/deepseek-harness).
|
|
219
273
|
* @returns the process exit code (see the module contract).
|
|
220
274
|
*/
|
|
221
|
-
async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot()
|
|
275
|
+
async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(), binding = {
|
|
276
|
+
surface: "source",
|
|
277
|
+
installAnchor: join(root, "apps", "cli", "package.json")
|
|
278
|
+
}) {
|
|
222
279
|
let appBoot;
|
|
223
280
|
let launchEnvironment;
|
|
224
281
|
let cmdline;
|
|
225
282
|
try {
|
|
226
|
-
appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot");
|
|
227
|
-
launchEnvironment = await loadHarnessPackage(root, "@deepseek-ai/dsh-launch-environment");
|
|
228
|
-
cmdline = await loadHarnessPackage(root, "@deepseek-ai/dsh-cmdline");
|
|
283
|
+
appBoot = await loadHarnessPackage(root, "@deepseek-ai/dsh-app-boot", binding);
|
|
284
|
+
launchEnvironment = await loadHarnessPackage(root, "@deepseek-ai/dsh-launch-environment", binding);
|
|
285
|
+
cmdline = await loadHarnessPackage(root, "@deepseek-ai/dsh-cmdline", binding);
|
|
229
286
|
} catch (error) {
|
|
230
287
|
process.stderr.write(`preflight could not execute (harness packages unavailable under ${root}): ${error instanceof Error ? error.message : String(error)}\n`);
|
|
231
288
|
return 3;
|
|
@@ -242,7 +299,7 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
242
299
|
return 3;
|
|
243
300
|
}
|
|
244
301
|
try {
|
|
245
|
-
const composed = await composePreflightPatches(profile, patchFiles, root);
|
|
302
|
+
const composed = await composePreflightPatches(profile, patchFiles, root, void 0, binding);
|
|
246
303
|
const patches = [...composed.patches];
|
|
247
304
|
const webserver = composed.rows.get("webserver");
|
|
248
305
|
if (webserver !== void 0) patches.push({
|
|
@@ -253,13 +310,16 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
253
310
|
}
|
|
254
311
|
});
|
|
255
312
|
const rootConfig = join(composed.profileDir, PROFILE_ROOT_FILENAME);
|
|
313
|
+
const appReady = createAppReadyStub();
|
|
256
314
|
const ctx = await boot(NAME, rootConfig, structuredClone(patches), (hostCtx) => {
|
|
257
315
|
hostCtx.provide?.(launchEnvironmentKey, environment);
|
|
258
316
|
provideCmdline(hostCtx, {
|
|
259
317
|
args: [],
|
|
260
|
-
exit: () => {}
|
|
318
|
+
exit: () => {},
|
|
319
|
+
ready: appReady.service
|
|
261
320
|
});
|
|
262
321
|
});
|
|
322
|
+
appReady.commit();
|
|
263
323
|
const missing = missingClientArtifacts(ctx);
|
|
264
324
|
await ctx.fiber.dispose();
|
|
265
325
|
if (missing.length > 0) {
|
|
@@ -277,20 +337,24 @@ async function runPreflight(profile, patchFiles = [], root = resolveHarnessRoot(
|
|
|
277
337
|
return 1;
|
|
278
338
|
}
|
|
279
339
|
}
|
|
280
|
-
/** Minimal argv parse
|
|
340
|
+
/** Minimal argv parse; the host execution surface is mandatory and explicit. */
|
|
281
341
|
function parsePreflightArgs(argv) {
|
|
282
342
|
const patchFiles = [];
|
|
283
343
|
let profile = "";
|
|
344
|
+
let surface = "";
|
|
345
|
+
let installAnchor = "";
|
|
284
346
|
for (let i = 0; i < argv.length; i++) {
|
|
285
347
|
const arg = argv[i];
|
|
286
348
|
if (arg === "--profile") profile = argv[++i] ?? "";
|
|
287
349
|
else if (arg === "--patch") {
|
|
288
350
|
const file = argv[++i];
|
|
289
351
|
if (file !== void 0) patchFiles.push(file);
|
|
290
|
-
} else if (arg === "--
|
|
352
|
+
} else if (arg === "--host-surface") surface = argv[++i] ?? "";
|
|
353
|
+
else if (arg === "--install-anchor") installAnchor = argv[++i] ?? "";
|
|
354
|
+
else if (arg === "--help" || arg === "-h") return {
|
|
291
355
|
profile: "",
|
|
292
356
|
patchFiles: [],
|
|
293
|
-
error: "usage: preflight-runner --profile <name> [--patch FILE]..."
|
|
357
|
+
error: "usage: preflight-runner --profile <name> --host-surface source|built --install-anchor FILE [--patch FILE]..."
|
|
294
358
|
};
|
|
295
359
|
}
|
|
296
360
|
if (profile === "") return {
|
|
@@ -298,18 +362,32 @@ function parsePreflightArgs(argv) {
|
|
|
298
362
|
patchFiles: [],
|
|
299
363
|
error: "preflight requires --profile <name>"
|
|
300
364
|
};
|
|
365
|
+
if (surface !== "source" && surface !== "built") return {
|
|
366
|
+
profile,
|
|
367
|
+
patchFiles,
|
|
368
|
+
error: "preflight requires --host-surface source|built"
|
|
369
|
+
};
|
|
370
|
+
if (installAnchor === "") return {
|
|
371
|
+
profile,
|
|
372
|
+
patchFiles,
|
|
373
|
+
error: "preflight requires --install-anchor FILE"
|
|
374
|
+
};
|
|
301
375
|
return {
|
|
302
376
|
profile,
|
|
303
|
-
patchFiles
|
|
377
|
+
patchFiles,
|
|
378
|
+
binding: {
|
|
379
|
+
surface,
|
|
380
|
+
installAnchor
|
|
381
|
+
}
|
|
304
382
|
};
|
|
305
383
|
}
|
|
306
384
|
if (isDirectInvocation(import.meta.url)) {
|
|
307
|
-
const { profile, patchFiles, error } = parsePreflightArgs(process.argv.slice(2));
|
|
308
|
-
if (error !== void 0) {
|
|
385
|
+
const { profile, patchFiles, binding, error } = parsePreflightArgs(process.argv.slice(2));
|
|
386
|
+
if (error !== void 0 || binding === void 0) {
|
|
309
387
|
process.stderr.write(`${error}\n`);
|
|
310
388
|
process.exit(2);
|
|
311
389
|
}
|
|
312
|
-
process.exitCode = await runPreflight(profile, patchFiles);
|
|
390
|
+
process.exitCode = await runPreflight(profile, patchFiles, resolveHarnessRoot(), binding);
|
|
313
391
|
}
|
|
314
392
|
//#endregion
|
|
315
393
|
export { composePreflightPatches, parsePreflightArgs, resolveHarnessRoot, runPreflight };
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
//#region lib/types/processes.js
|
|
5
|
+
/**
|
|
6
|
+
* Process primitives shared by the guard CLI and the exit agent: locating the
|
|
7
|
+
* listener on a port and killing a process tree. One owner — the watchdog's
|
|
8
|
+
* bash side keeps its own copy (different runtime), but every TypeScript
|
|
9
|
+
* caller goes through here.
|
|
10
|
+
*/
|
|
11
|
+
function executable(name, candidates) {
|
|
12
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? name;
|
|
13
|
+
}
|
|
14
|
+
const LSOF = executable("lsof", ["/usr/sbin/lsof", "/usr/bin/lsof"]);
|
|
15
|
+
const PS = executable("ps", ["/bin/ps", "/usr/bin/ps"]);
|
|
16
|
+
const PGREP = executable("pgrep", ["/usr/bin/pgrep", "/bin/pgrep"]);
|
|
17
|
+
const SYSCTL = executable("sysctl", ["/usr/sbin/sysctl", "/sbin/sysctl"]);
|
|
18
|
+
const PYTHON = executable("python3", ["/usr/bin/python3", "/opt/homebrew/bin/python3"]);
|
|
19
|
+
const DARWIN_START_TOKEN = [
|
|
20
|
+
"import ctypes,struct,sys",
|
|
21
|
+
"p=int(sys.argv[1])",
|
|
22
|
+
"b=ctypes.create_string_buffer(136)",
|
|
23
|
+
"n=ctypes.CDLL(\"/usr/lib/libproc.dylib\").proc_pidinfo(p,3,0,b,136)",
|
|
24
|
+
"n == 136 or sys.exit(1)",
|
|
25
|
+
"s,u=struct.unpack_from(\"QQ\",b.raw,120)",
|
|
26
|
+
"print(f\"darwin:{s}:{u}\",end=\"\")"
|
|
27
|
+
].join(";");
|
|
28
|
+
/** Every process listening on a TCP port. An empty list also covers unavailable lsof. */
|
|
29
|
+
function findPidsOnPort(port) {
|
|
30
|
+
try {
|
|
31
|
+
const out = execFileSync(LSOF, [
|
|
32
|
+
`-tiTCP:${port}`,
|
|
33
|
+
"-sTCP:LISTEN",
|
|
34
|
+
"-P"
|
|
35
|
+
], {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: "pipe"
|
|
38
|
+
}).trim();
|
|
39
|
+
return [...new Set(out.split("\n").map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** TCP listen ports owned directly by one PID, using the same absolute lsof resolution. */
|
|
45
|
+
function listeningPortsForPid(pid) {
|
|
46
|
+
try {
|
|
47
|
+
const out = execFileSync(LSOF, [
|
|
48
|
+
"-nP",
|
|
49
|
+
"-a",
|
|
50
|
+
"-p",
|
|
51
|
+
String(pid),
|
|
52
|
+
"-iTCP",
|
|
53
|
+
"-sTCP:LISTEN"
|
|
54
|
+
], {
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
stdio: "pipe"
|
|
57
|
+
});
|
|
58
|
+
return [...new Set([...out.matchAll(/:(\d+) \(LISTEN\)/g)].map((match) => Number(match[1])).filter(Number.isInteger))];
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The first process listening on a TCP port, or null when none is (via lsof). */
|
|
64
|
+
function findPidOnPort(port) {
|
|
65
|
+
return findPidsOnPort(port)[0]?.toString() ?? null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Current identity for a live process, or null when it cannot be proved.
|
|
69
|
+
* Linux exposes a boot-scoped kernel start tick. Other POSIX hosts do not,
|
|
70
|
+
* so bind the start instant to the boot, uid, session, and settled command.
|
|
71
|
+
* This materially strengthens macOS ps(1)'s second-granularity lstart; all
|
|
72
|
+
* guard captures occur after the watchdog/child has reached its steady argv.
|
|
73
|
+
*/
|
|
74
|
+
function processIdentity(pid) {
|
|
75
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
76
|
+
try {
|
|
77
|
+
const status = execFileSync(PS, [
|
|
78
|
+
"-o",
|
|
79
|
+
"stat=",
|
|
80
|
+
"-p",
|
|
81
|
+
String(pid)
|
|
82
|
+
], {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
stdio: "pipe"
|
|
85
|
+
}).trim();
|
|
86
|
+
if (status === "" || status.startsWith("Z")) return null;
|
|
87
|
+
if (process.platform === "darwin") try {
|
|
88
|
+
const nativeStart = execFileSync(PYTHON, [
|
|
89
|
+
"-c",
|
|
90
|
+
DARWIN_START_TOKEN,
|
|
91
|
+
String(pid)
|
|
92
|
+
], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
stdio: "pipe"
|
|
95
|
+
}).trim();
|
|
96
|
+
if (/^darwin:\d+:\d+$/.test(nativeStart)) return {
|
|
97
|
+
pid,
|
|
98
|
+
startToken: nativeStart
|
|
99
|
+
};
|
|
100
|
+
} catch {}
|
|
101
|
+
const procStat = `/proc/${pid}/stat`;
|
|
102
|
+
if (existsSync(procStat)) {
|
|
103
|
+
const stat = readFileSync(procStat, "utf8");
|
|
104
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
105
|
+
const startTicks = (commandEnd < 0 ? [] : stat.slice(commandEnd + 1).trim().split(/\s+/))[19];
|
|
106
|
+
if (startTicks === void 0 || startTicks === "") return null;
|
|
107
|
+
let bootId = "unknown-boot";
|
|
108
|
+
try {
|
|
109
|
+
bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || bootId;
|
|
110
|
+
} catch {}
|
|
111
|
+
return {
|
|
112
|
+
pid,
|
|
113
|
+
startToken: `linux:${bootId}:${startTicks}`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const processEvidence = execFileSync(PS, [
|
|
117
|
+
"-o",
|
|
118
|
+
"sess=",
|
|
119
|
+
"-o",
|
|
120
|
+
"uid=",
|
|
121
|
+
"-o",
|
|
122
|
+
"lstart=",
|
|
123
|
+
"-o",
|
|
124
|
+
"command=",
|
|
125
|
+
"-p",
|
|
126
|
+
String(pid)
|
|
127
|
+
], {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
stdio: "pipe"
|
|
130
|
+
}).trim();
|
|
131
|
+
if (processEvidence === "") return null;
|
|
132
|
+
let bootEvidence = "unknown-boot";
|
|
133
|
+
try {
|
|
134
|
+
bootEvidence = execFileSync(SYSCTL, ["-n", "kern.boottime"], {
|
|
135
|
+
encoding: "utf8",
|
|
136
|
+
stdio: "pipe"
|
|
137
|
+
}).trim() || bootEvidence;
|
|
138
|
+
} catch {}
|
|
139
|
+
return {
|
|
140
|
+
pid,
|
|
141
|
+
startToken: `posix:${createHash("sha256").update(bootEvidence).update("\0").update(processEvidence).digest("hex")}`
|
|
142
|
+
};
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Whether the same, non-recycled process is still alive. */
|
|
148
|
+
function processIdentityMatches(identity) {
|
|
149
|
+
return processIdentity(identity.pid)?.startToken === identity.startToken;
|
|
150
|
+
}
|
|
151
|
+
function parentPid(pid) {
|
|
152
|
+
try {
|
|
153
|
+
const value = Number(execFileSync(PS, [
|
|
154
|
+
"-o",
|
|
155
|
+
"ppid=",
|
|
156
|
+
"-p",
|
|
157
|
+
String(pid)
|
|
158
|
+
], {
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
stdio: "pipe"
|
|
161
|
+
}).trim());
|
|
162
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** The live POSIX process-group id for one PID, or null when unavailable. */
|
|
168
|
+
function processGroupId(pid) {
|
|
169
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
170
|
+
try {
|
|
171
|
+
const value = Number(execFileSync(PS, [
|
|
172
|
+
"-o",
|
|
173
|
+
"pgid=",
|
|
174
|
+
"-p",
|
|
175
|
+
String(pid)
|
|
176
|
+
], {
|
|
177
|
+
encoding: "utf8",
|
|
178
|
+
stdio: "pipe"
|
|
179
|
+
}).trim());
|
|
180
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Prove that exactly one port listener belongs to a supervisor and return the
|
|
187
|
+
* direct child root through which that supervisor owns it. This is captured
|
|
188
|
+
* before cutover; the successor must stop this identity, never an arbitrary
|
|
189
|
+
* process discovered later from the shared port.
|
|
190
|
+
*/
|
|
191
|
+
function findOwnedListener(port, supervisorPid) {
|
|
192
|
+
const listeners = findPidsOnPort(port);
|
|
193
|
+
if (listeners.length !== 1) return null;
|
|
194
|
+
const listenerPid = listeners[0];
|
|
195
|
+
if (listenerPid === void 0) return null;
|
|
196
|
+
const listener = processIdentity(listenerPid);
|
|
197
|
+
if (listener === null) return null;
|
|
198
|
+
let cursor = listenerPid;
|
|
199
|
+
let childRoot = null;
|
|
200
|
+
const seen = /* @__PURE__ */ new Set();
|
|
201
|
+
while (cursor > 0 && !seen.has(cursor)) {
|
|
202
|
+
seen.add(cursor);
|
|
203
|
+
const parent = parentPid(cursor);
|
|
204
|
+
if (parent === supervisorPid) {
|
|
205
|
+
childRoot = cursor;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
if (parent === null) return null;
|
|
209
|
+
cursor = parent;
|
|
210
|
+
}
|
|
211
|
+
if (childRoot === null) return null;
|
|
212
|
+
const child = processIdentity(childRoot);
|
|
213
|
+
return child === null ? null : {
|
|
214
|
+
child,
|
|
215
|
+
listener
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** POSIX single-quote one word for a shell command line. */
|
|
219
|
+
function shellQuote(word) {
|
|
220
|
+
return `'${word.replace(/'/g, "'\\''")}'`;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Discover how the process on a port was launched — its exact argv from
|
|
224
|
+
* `ps -o command=`, its cwd from lsof, and its DSH_* environment from
|
|
225
|
+
* `ps eww` — rendered as a shell command. This exists because the agent's
|
|
226
|
+
* sandbox blocks ps entirely, so every fresh-machine agent fell into a
|
|
227
|
+
* process-tree archaeology loop before its first restart; the CLI (running
|
|
228
|
+
* unsandboxed) answers the same question mechanically and reliably. Returns
|
|
229
|
+
* null when the process is gone or ps/lsof are unavailable.
|
|
230
|
+
* @param pid - the listener's pid.
|
|
231
|
+
*/
|
|
232
|
+
function discoverLaunchCommand(pid) {
|
|
233
|
+
let argv;
|
|
234
|
+
let cwd;
|
|
235
|
+
try {
|
|
236
|
+
argv = execFileSync(PS, [
|
|
237
|
+
"-o",
|
|
238
|
+
"command=",
|
|
239
|
+
"-p",
|
|
240
|
+
pid
|
|
241
|
+
], {
|
|
242
|
+
encoding: "utf8",
|
|
243
|
+
stdio: "pipe"
|
|
244
|
+
}).trim();
|
|
245
|
+
if (argv === "") return null;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
throw new Error(`ps unavailable: ${String(error)}`);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const out = execFileSync(LSOF, [
|
|
251
|
+
"-a",
|
|
252
|
+
"-p",
|
|
253
|
+
pid,
|
|
254
|
+
"-d",
|
|
255
|
+
"cwd",
|
|
256
|
+
"-Fn"
|
|
257
|
+
], {
|
|
258
|
+
encoding: "utf8",
|
|
259
|
+
stdio: "pipe"
|
|
260
|
+
});
|
|
261
|
+
const match = /^n(.+)$/m.exec(out);
|
|
262
|
+
if (match === null) return null;
|
|
263
|
+
cwd = match[1] ?? "";
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw new Error(`lsof cwd unavailable: ${String(error)}`);
|
|
266
|
+
}
|
|
267
|
+
const TRANSIENT = /* @__PURE__ */ new Set([
|
|
268
|
+
"DSH_ANKH_RESTART_DRIVER",
|
|
269
|
+
"DSH_SESSION_ID",
|
|
270
|
+
"DSH_SESSION_JSONL",
|
|
271
|
+
"DSH_WEB_URL",
|
|
272
|
+
"DSH_SHELL"
|
|
273
|
+
]);
|
|
274
|
+
const env = {};
|
|
275
|
+
try {
|
|
276
|
+
const out = execFileSync(PS, [
|
|
277
|
+
"eww",
|
|
278
|
+
"-o",
|
|
279
|
+
"command",
|
|
280
|
+
"-p",
|
|
281
|
+
pid
|
|
282
|
+
], {
|
|
283
|
+
encoding: "utf8",
|
|
284
|
+
stdio: "pipe"
|
|
285
|
+
});
|
|
286
|
+
for (const token of out.split(/\s+/)) {
|
|
287
|
+
const eq = token.indexOf("=");
|
|
288
|
+
if (eq > 0 && token.slice(0, eq).startsWith("DSH_") && !TRANSIENT.has(token.slice(0, eq))) env[token.slice(0, eq)] = token.slice(eq + 1);
|
|
289
|
+
}
|
|
290
|
+
} catch {}
|
|
291
|
+
const envPart = Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ");
|
|
292
|
+
return `cd ${shellQuote(cwd)} && ${envPart !== "" ? `${envPart} ` : ""}${argv}`;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Kill a pid AND its descendants, deepest first (best effort). The supervised
|
|
296
|
+
* instance may have forked children; a plain signal on the pid alone would
|
|
297
|
+
* orphan them (the EADDRINUSE race the watchdog's EADDRINUSE branch exists
|
|
298
|
+
* for). The process-group model is NOT assumed — the instance is not
|
|
299
|
+
* setsid'd — so the sweep walks `pgrep -P` instead. `pgrep` missing or
|
|
300
|
+
* returning nothing is fine: the pid itself still gets the signal.
|
|
301
|
+
*/
|
|
302
|
+
function signalFrozenTree(pid, signal) {
|
|
303
|
+
let safe = true;
|
|
304
|
+
let children = [];
|
|
305
|
+
try {
|
|
306
|
+
const out = execFileSync(PGREP, ["-P", String(pid)], {
|
|
307
|
+
encoding: "utf8",
|
|
308
|
+
stdio: "pipe"
|
|
309
|
+
}).trim();
|
|
310
|
+
children = out === "" ? [] : out.split("\n");
|
|
311
|
+
} catch {}
|
|
312
|
+
for (const raw of children) {
|
|
313
|
+
const child = Number(raw);
|
|
314
|
+
if (!Number.isInteger(child) || child <= 0) continue;
|
|
315
|
+
try {
|
|
316
|
+
process.kill(child, "SIGSTOP");
|
|
317
|
+
} catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (parentPid(child) !== pid) {
|
|
321
|
+
safe = false;
|
|
322
|
+
try {
|
|
323
|
+
process.kill(child, "SIGCONT");
|
|
324
|
+
} catch {}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
safe = signalFrozenTree(child, signal) && safe;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
process.kill(pid, signal);
|
|
331
|
+
if (signal !== "SIGKILL") process.kill(pid, "SIGCONT");
|
|
332
|
+
} catch {}
|
|
333
|
+
return safe;
|
|
334
|
+
}
|
|
335
|
+
function killPidTree(pid, signal) {
|
|
336
|
+
try {
|
|
337
|
+
process.kill(pid, "SIGSTOP");
|
|
338
|
+
} catch {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
signalFrozenTree(pid, signal);
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
export { listeningPortsForPid as a, processIdentityMatches as c, killPidTree as i, findOwnedListener as n, processGroupId as o, findPidOnPort as r, processIdentity as s, discoverLaunchCommand as t };
|