pi-amq 0.1.1
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 +66 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/dist/extension/guidance.d.ts +25 -0
- package/dist/extension/guidance.d.ts.map +1 -0
- package/dist/extension/guidance.js +121 -0
- package/dist/extension/guidance.js.map +1 -0
- package/dist/extension/guide.d.ts +30 -0
- package/dist/extension/guide.d.ts.map +1 -0
- package/dist/extension/guide.js +128 -0
- package/dist/extension/guide.js.map +1 -0
- package/dist/extension/index.d.ts +4 -0
- package/dist/extension/index.d.ts.map +1 -0
- package/dist/extension/index.js +6 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/runtime.d.ts +48 -0
- package/dist/extension/runtime.d.ts.map +1 -0
- package/dist/extension/runtime.js +1095 -0
- package/dist/extension/runtime.js.map +1 -0
- package/dist/extension/tools.d.ts +12 -0
- package/dist/extension/tools.d.ts.map +1 -0
- package/dist/extension/tools.js +198 -0
- package/dist/extension/tools.js.map +1 -0
- package/dist/extension/worker-runtime.d.ts +34 -0
- package/dist/extension/worker-runtime.d.ts.map +1 -0
- package/dist/extension/worker-runtime.js +974 -0
- package/dist/extension/worker-runtime.js.map +1 -0
- package/dist/extension/worker-tools.d.ts +140 -0
- package/dist/extension/worker-tools.d.ts.map +1 -0
- package/dist/extension/worker-tools.js +217 -0
- package/dist/extension/worker-tools.js.map +1 -0
- package/dist/integration/confirmed-bind-onboarding.d.ts +44 -0
- package/dist/integration/confirmed-bind-onboarding.d.ts.map +1 -0
- package/dist/integration/confirmed-bind-onboarding.js +199 -0
- package/dist/integration/confirmed-bind-onboarding.js.map +1 -0
- package/dist/integration/fleet-view.d.ts +66 -0
- package/dist/integration/fleet-view.d.ts.map +1 -0
- package/dist/integration/fleet-view.js +238 -0
- package/dist/integration/fleet-view.js.map +1 -0
- package/dist/integration/fresh-session-dispatcher.d.ts +66 -0
- package/dist/integration/fresh-session-dispatcher.d.ts.map +1 -0
- package/dist/integration/fresh-session-dispatcher.js +103 -0
- package/dist/integration/fresh-session-dispatcher.js.map +1 -0
- package/dist/integration/launch-preview.d.ts +31 -0
- package/dist/integration/launch-preview.d.ts.map +1 -0
- package/dist/integration/launch-preview.js +325 -0
- package/dist/integration/launch-preview.js.map +1 -0
- package/dist/integration/transport-port.d.ts +39 -0
- package/dist/integration/transport-port.d.ts.map +1 -0
- package/dist/integration/transport-port.js +2 -0
- package/dist/integration/transport-port.js.map +1 -0
- package/dist/integration/worker-completion-notifier.d.ts +22 -0
- package/dist/integration/worker-completion-notifier.d.ts.map +1 -0
- package/dist/integration/worker-completion-notifier.js +81 -0
- package/dist/integration/worker-completion-notifier.js.map +1 -0
- package/dist/transport/amq-cli-adapter.d.ts +41 -0
- package/dist/transport/amq-cli-adapter.d.ts.map +1 -0
- package/dist/transport/amq-cli-adapter.js +291 -0
- package/dist/transport/amq-cli-adapter.js.map +1 -0
- package/dist/transport/amq-message.d.ts +47 -0
- package/dist/transport/amq-message.d.ts.map +1 -0
- package/dist/transport/amq-message.js +173 -0
- package/dist/transport/amq-message.js.map +1 -0
- package/dist/transport/amq-operations.d.ts +105 -0
- package/dist/transport/amq-operations.d.ts.map +1 -0
- package/dist/transport/amq-operations.js +320 -0
- package/dist/transport/amq-operations.js.map +1 -0
- package/dist/transport/atomic-json.d.ts +18 -0
- package/dist/transport/atomic-json.d.ts.map +1 -0
- package/dist/transport/atomic-json.js +90 -0
- package/dist/transport/atomic-json.js.map +1 -0
- package/dist/transport/binding-resolver.d.ts +46 -0
- package/dist/transport/binding-resolver.d.ts.map +1 -0
- package/dist/transport/binding-resolver.js +389 -0
- package/dist/transport/binding-resolver.js.map +1 -0
- package/dist/transport/bridge-journal.d.ts +100 -0
- package/dist/transport/bridge-journal.d.ts.map +1 -0
- package/dist/transport/bridge-journal.js +459 -0
- package/dist/transport/bridge-journal.js.map +1 -0
- package/dist/transport/dlq-retry-audit.d.ts +28 -0
- package/dist/transport/dlq-retry-audit.d.ts.map +1 -0
- package/dist/transport/dlq-retry-audit.js +66 -0
- package/dist/transport/dlq-retry-audit.js.map +1 -0
- package/dist/transport/inbox-pump.d.ts +81 -0
- package/dist/transport/inbox-pump.d.ts.map +1 -0
- package/dist/transport/inbox-pump.js +558 -0
- package/dist/transport/inbox-pump.js.map +1 -0
- package/dist/transport/index.d.ts +12 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +12 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/presence-projector.d.ts +29 -0
- package/dist/transport/presence-projector.d.ts.map +1 -0
- package/dist/transport/presence-projector.js +73 -0
- package/dist/transport/presence-projector.js.map +1 -0
- package/dist/transport/priority-delivery-scheduler.d.ts +85 -0
- package/dist/transport/priority-delivery-scheduler.d.ts.map +1 -0
- package/dist/transport/priority-delivery-scheduler.js +210 -0
- package/dist/transport/priority-delivery-scheduler.js.map +1 -0
- package/dist/transport/receiver-lease.d.ts +102 -0
- package/dist/transport/receiver-lease.d.ts.map +1 -0
- package/dist/transport/receiver-lease.js +778 -0
- package/dist/transport/receiver-lease.js.map +1 -0
- package/dist/transport/runtime-status.d.ts +48 -0
- package/dist/transport/runtime-status.d.ts.map +1 -0
- package/dist/transport/runtime-status.js +71 -0
- package/dist/transport/runtime-status.js.map +1 -0
- package/dist/workflow/atomic-storage.d.ts +12 -0
- package/dist/workflow/atomic-storage.d.ts.map +1 -0
- package/dist/workflow/atomic-storage.js +104 -0
- package/dist/workflow/atomic-storage.js.map +1 -0
- package/dist/workflow/budget.d.ts +18 -0
- package/dist/workflow/budget.d.ts.map +1 -0
- package/dist/workflow/budget.js +74 -0
- package/dist/workflow/budget.js.map +1 -0
- package/dist/workflow/index.d.ts +17 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +17 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/lifecycle-projection.d.ts +18 -0
- package/dist/workflow/lifecycle-projection.d.ts.map +1 -0
- package/dist/workflow/lifecycle-projection.js +22 -0
- package/dist/workflow/lifecycle-projection.js.map +1 -0
- package/dist/workflow/model-fallback.d.ts +2 -0
- package/dist/workflow/model-fallback.d.ts.map +1 -0
- package/dist/workflow/model-fallback.js +17 -0
- package/dist/workflow/model-fallback.js.map +1 -0
- package/dist/workflow/pi-session-fork.d.ts +18 -0
- package/dist/workflow/pi-session-fork.d.ts.map +1 -0
- package/dist/workflow/pi-session-fork.js +80 -0
- package/dist/workflow/pi-session-fork.js.map +1 -0
- package/dist/workflow/run-artifacts.d.ts +5 -0
- package/dist/workflow/run-artifacts.d.ts.map +1 -0
- package/dist/workflow/run-artifacts.js +25 -0
- package/dist/workflow/run-artifacts.js.map +1 -0
- package/dist/workflow/run-cleanup.d.ts +70 -0
- package/dist/workflow/run-cleanup.d.ts.map +1 -0
- package/dist/workflow/run-cleanup.js +222 -0
- package/dist/workflow/run-cleanup.js.map +1 -0
- package/dist/workflow/run-gate.d.ts +43 -0
- package/dist/workflow/run-gate.d.ts.map +1 -0
- package/dist/workflow/run-gate.js +70 -0
- package/dist/workflow/run-gate.js.map +1 -0
- package/dist/workflow/run-store.d.ts +208 -0
- package/dist/workflow/run-store.d.ts.map +1 -0
- package/dist/workflow/run-store.js +2965 -0
- package/dist/workflow/run-store.js.map +1 -0
- package/dist/workflow/spawn-confirmation.d.ts +44 -0
- package/dist/workflow/spawn-confirmation.d.ts.map +1 -0
- package/dist/workflow/spawn-confirmation.js +228 -0
- package/dist/workflow/spawn-confirmation.js.map +1 -0
- package/dist/workflow/system-process-inspector.d.ts +9 -0
- package/dist/workflow/system-process-inspector.d.ts.map +1 -0
- package/dist/workflow/system-process-inspector.js +129 -0
- package/dist/workflow/system-process-inspector.js.map +1 -0
- package/dist/workflow/worker-child-extension.d.ts +3 -0
- package/dist/workflow/worker-child-extension.d.ts.map +1 -0
- package/dist/workflow/worker-child-extension.js +182 -0
- package/dist/workflow/worker-child-extension.js.map +1 -0
- package/dist/workflow/worker-gate.d.ts +74 -0
- package/dist/workflow/worker-gate.d.ts.map +1 -0
- package/dist/workflow/worker-gate.js +165 -0
- package/dist/workflow/worker-gate.js.map +1 -0
- package/dist/workflow/worker-launch.d.ts +65 -0
- package/dist/workflow/worker-launch.d.ts.map +1 -0
- package/dist/workflow/worker-launch.js +338 -0
- package/dist/workflow/worker-launch.js.map +1 -0
- package/dist/workflow/worker-model-catalog.d.ts +36 -0
- package/dist/workflow/worker-model-catalog.d.ts.map +1 -0
- package/dist/workflow/worker-model-catalog.js +105 -0
- package/dist/workflow/worker-model-catalog.js.map +1 -0
- package/dist/workflow/worker-process-supervisor.d.ts +110 -0
- package/dist/workflow/worker-process-supervisor.d.ts.map +1 -0
- package/dist/workflow/worker-process-supervisor.js +1188 -0
- package/dist/workflow/worker-process-supervisor.js.map +1 -0
- package/dist/workflow/worker-prompt-capsule.d.ts +32 -0
- package/dist/workflow/worker-prompt-capsule.d.ts.map +1 -0
- package/dist/workflow/worker-prompt-capsule.js +52 -0
- package/dist/workflow/worker-prompt-capsule.js.map +1 -0
- package/dist/workflow/worker-role-presets.d.ts +38 -0
- package/dist/workflow/worker-role-presets.d.ts.map +1 -0
- package/dist/workflow/worker-role-presets.js +201 -0
- package/dist/workflow/worker-role-presets.js.map +1 -0
- package/dist/workflow/workflow-doctor.d.ts +57 -0
- package/dist/workflow/workflow-doctor.d.ts.map +1 -0
- package/dist/workflow/workflow-doctor.js +289 -0
- package/dist/workflow/workflow-doctor.js.map +1 -0
- package/dist/workflow/workflow-lease.d.ts +125 -0
- package/dist/workflow/workflow-lease.d.ts.map +1 -0
- package/dist/workflow/workflow-lease.js +689 -0
- package/dist/workflow/workflow-lease.js.map +1 -0
- package/dist/workflow/workflow-types.d.ts +335 -0
- package/dist/workflow/workflow-types.d.ts.map +1 -0
- package/dist/workflow/workflow-types.js +11 -0
- package/dist/workflow/workflow-types.js.map +1 -0
- package/dist/workflow/workspace-paths.d.ts +4 -0
- package/dist/workflow/workspace-paths.d.ts.map +1 -0
- package/dist/workflow/workspace-paths.js +22 -0
- package/dist/workflow/workspace-paths.js.map +1 -0
- package/docs/conformance.md +116 -0
- package/docs/demo-transcript.md +34 -0
- package/docs/design.md +1481 -0
- package/docs/operator-guide.md +100 -0
- package/extension/guidance.ts +162 -0
- package/extension/guide.ts +155 -0
- package/extension/index.ts +8 -0
- package/extension/runtime.ts +1224 -0
- package/extension/tools.ts +221 -0
- package/extension/worker-runtime.ts +1150 -0
- package/extension/worker-tools.ts +276 -0
- package/integration/confirmed-bind-onboarding.ts +296 -0
- package/integration/fleet-view.ts +286 -0
- package/integration/fresh-session-dispatcher.ts +166 -0
- package/integration/launch-preview.ts +396 -0
- package/integration/transport-port.ts +29 -0
- package/integration/worker-completion-notifier.ts +98 -0
- package/package.json +55 -0
- package/roles/oracle.md +9 -0
- package/roles/researcher.md +8 -0
- package/roles/reviewer.md +9 -0
- package/roles/scout.md +8 -0
- package/roles/worker.md +10 -0
- package/scripts/demo-two-terminal.sh +59 -0
- package/skills/coordination-patterns/SKILL.md +41 -0
- package/skills/coordination-patterns/references/supervised-worker-fan-out.md +12 -0
- package/skills/coordination-patterns/references/today-mail-team.md +36 -0
- package/transport/amq-cli-adapter.ts +381 -0
- package/transport/amq-message.ts +218 -0
- package/transport/amq-operations.ts +482 -0
- package/transport/atomic-json.ts +115 -0
- package/transport/binding-resolver.ts +565 -0
- package/transport/bridge-journal.ts +588 -0
- package/transport/dlq-retry-audit.ts +97 -0
- package/transport/inbox-pump.ts +729 -0
- package/transport/index.ts +11 -0
- package/transport/presence-projector.ts +104 -0
- package/transport/priority-delivery-scheduler.ts +328 -0
- package/transport/receiver-lease.ts +945 -0
- package/transport/runtime-status.ts +112 -0
- package/workflow/.gitkeep +1 -0
- package/workflow/atomic-storage.ts +120 -0
- package/workflow/budget.ts +90 -0
- package/workflow/index.ts +16 -0
- package/workflow/lifecycle-projection.ts +37 -0
- package/workflow/model-fallback.ts +17 -0
- package/workflow/pi-session-fork.ts +96 -0
- package/workflow/run-artifacts.ts +36 -0
- package/workflow/run-cleanup.ts +315 -0
- package/workflow/run-gate.ts +122 -0
- package/workflow/run-store.ts +3890 -0
- package/workflow/spawn-confirmation.ts +305 -0
- package/workflow/system-process-inspector.ts +142 -0
- package/workflow/worker-child-extension.ts +193 -0
- package/workflow/worker-gate.ts +238 -0
- package/workflow/worker-launch.ts +485 -0
- package/workflow/worker-model-catalog.ts +165 -0
- package/workflow/worker-process-supervisor.ts +1504 -0
- package/workflow/worker-prompt-capsule.ts +81 -0
- package/workflow/worker-role-presets.ts +241 -0
- package/workflow/workflow-doctor.ts +400 -0
- package/workflow/workflow-lease.ts +905 -0
- package/workflow/workflow-types.ts +367 -0
- package/workflow/workspace-paths.ts +32 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID as nodeRandomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
4
|
+
import { hostname as localHostname } from "node:os";
|
|
5
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { readJson, syncDirectory, writeAtomicJson, } from "./atomic-json.js";
|
|
8
|
+
const OWNER_KEYS = [
|
|
9
|
+
"schema",
|
|
10
|
+
"generation",
|
|
11
|
+
"piSessionId",
|
|
12
|
+
"piSessionFile",
|
|
13
|
+
"pid",
|
|
14
|
+
"processStartIdentity",
|
|
15
|
+
"bootIdentity",
|
|
16
|
+
"executable",
|
|
17
|
+
"hostname",
|
|
18
|
+
"rootId",
|
|
19
|
+
"createdAt",
|
|
20
|
+
];
|
|
21
|
+
const GUARD_KEYS = [
|
|
22
|
+
"schema",
|
|
23
|
+
"generation",
|
|
24
|
+
"pid",
|
|
25
|
+
"processStartIdentity",
|
|
26
|
+
"bootIdentity",
|
|
27
|
+
"executable",
|
|
28
|
+
"hostname",
|
|
29
|
+
"createdAt",
|
|
30
|
+
];
|
|
31
|
+
export class LeaseFenceError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "LeaseFenceError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Owns the generation-fenced receiver lifecycle inside one extension namespace. */
|
|
38
|
+
export class ReceiverLeaseManager {
|
|
39
|
+
#namespaceDirectory;
|
|
40
|
+
#rootId;
|
|
41
|
+
#inspector;
|
|
42
|
+
#randomUUID;
|
|
43
|
+
#now;
|
|
44
|
+
#guardWaitMs;
|
|
45
|
+
constructor(options) {
|
|
46
|
+
if (!isAbsolute(options.namespaceDirectory)) {
|
|
47
|
+
throw new TypeError("Receiver lease namespace must be absolute");
|
|
48
|
+
}
|
|
49
|
+
if (options.rootId.length === 0)
|
|
50
|
+
throw new TypeError("Receiver rootId is required");
|
|
51
|
+
this.#namespaceDirectory = normalize(options.namespaceDirectory);
|
|
52
|
+
this.#rootId = options.rootId;
|
|
53
|
+
this.#inspector = options.inspector;
|
|
54
|
+
this.#randomUUID = options.randomUUID ?? nodeRandomUUID;
|
|
55
|
+
this.#now = options.now ?? (() => new Date());
|
|
56
|
+
this.#guardWaitMs = options.guardWaitMs ?? 1_000;
|
|
57
|
+
}
|
|
58
|
+
async acquire(request, priorReclaim) {
|
|
59
|
+
validateAcquireRequest(request);
|
|
60
|
+
await this.#initialize();
|
|
61
|
+
const guarded = await this.#withLifecycleGuard(async (current) => {
|
|
62
|
+
const projection = await this.inspectLease();
|
|
63
|
+
if (projection.kind === "corrupt") {
|
|
64
|
+
return {
|
|
65
|
+
kind: "refused",
|
|
66
|
+
reason: "corrupt-owner",
|
|
67
|
+
diagnostic: projection.diagnostic,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (projection.kind === "empty") {
|
|
71
|
+
const owner = ownerFromRequest(request, current, this.#rootId, this.#randomUUID(), this.#now());
|
|
72
|
+
const created = await this.#createReceiver(owner);
|
|
73
|
+
return created
|
|
74
|
+
? {
|
|
75
|
+
kind: "acquired",
|
|
76
|
+
owner,
|
|
77
|
+
...(priorReclaim ? { reclaimed: priorReclaim } : {}),
|
|
78
|
+
}
|
|
79
|
+
: { kind: "retry" };
|
|
80
|
+
}
|
|
81
|
+
const state = await classifyOwner(projection.owner, current, this.#inspector);
|
|
82
|
+
switch (state.kind) {
|
|
83
|
+
case "live":
|
|
84
|
+
return { kind: "held", owner: projection.owner };
|
|
85
|
+
case "remote":
|
|
86
|
+
return {
|
|
87
|
+
kind: "refused",
|
|
88
|
+
reason: "remote-owner",
|
|
89
|
+
diagnostic: `Receiver owner is on remote host ${projection.owner.hostname}`,
|
|
90
|
+
};
|
|
91
|
+
case "unknown":
|
|
92
|
+
return {
|
|
93
|
+
kind: "refused",
|
|
94
|
+
reason: "unverifiable-owner",
|
|
95
|
+
diagnostic: state.diagnostic,
|
|
96
|
+
};
|
|
97
|
+
case "stale": {
|
|
98
|
+
const reread = await this.inspectLease();
|
|
99
|
+
if (reread.kind !== "owned" || !sameOwner(reread.owner, projection.owner)) {
|
|
100
|
+
return { kind: "retry" };
|
|
101
|
+
}
|
|
102
|
+
const quarantinePath = join(this.#namespaceDirectory, "quarantine", `receiver.${safePathPart(projection.owner.generation)}.${this.#now().getTime()}.${safePathPart(this.#randomUUID())}`);
|
|
103
|
+
await rename(this.#receiverPath(), quarantinePath);
|
|
104
|
+
await syncDirectory(this.#namespaceDirectory);
|
|
105
|
+
return {
|
|
106
|
+
kind: "reclaimed",
|
|
107
|
+
owner: projection.owner,
|
|
108
|
+
quarantinePath,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
if (!guarded.ok) {
|
|
114
|
+
return { kind: "refused", reason: guarded.reason, diagnostic: guarded.diagnostic };
|
|
115
|
+
}
|
|
116
|
+
if (guarded.value.kind === "retry")
|
|
117
|
+
return this.acquire(request, priorReclaim);
|
|
118
|
+
if (guarded.value.kind === "reclaimed") {
|
|
119
|
+
return this.acquire(request, {
|
|
120
|
+
owner: guarded.value.owner,
|
|
121
|
+
quarantinePath: guarded.value.quarantinePath,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return guarded.value;
|
|
125
|
+
}
|
|
126
|
+
async release(generation) {
|
|
127
|
+
requireNonEmpty(generation, "lease generation");
|
|
128
|
+
await this.#initialize();
|
|
129
|
+
const guarded = await this.#withLifecycleGuard(async () => {
|
|
130
|
+
const projection = await this.inspectLease();
|
|
131
|
+
if (projection.kind === "empty")
|
|
132
|
+
return { kind: "not-owner" };
|
|
133
|
+
if (projection.kind === "corrupt") {
|
|
134
|
+
return { kind: "refused", diagnostic: projection.diagnostic };
|
|
135
|
+
}
|
|
136
|
+
if (projection.owner.generation !== generation || projection.owner.rootId !== this.#rootId) {
|
|
137
|
+
return { kind: "not-owner", owner: projection.owner };
|
|
138
|
+
}
|
|
139
|
+
const releasedPath = join(this.#namespaceDirectory, `.receiver.released-${safePathPart(generation)}-${safePathPart(this.#randomUUID())}`);
|
|
140
|
+
await rename(this.#receiverPath(), releasedPath);
|
|
141
|
+
await syncDirectory(this.#namespaceDirectory);
|
|
142
|
+
await rm(releasedPath, { recursive: true });
|
|
143
|
+
await syncDirectory(this.#namespaceDirectory);
|
|
144
|
+
return { kind: "released" };
|
|
145
|
+
});
|
|
146
|
+
return guarded.ok
|
|
147
|
+
? guarded.value
|
|
148
|
+
: { kind: "refused", diagnostic: guarded.diagnostic };
|
|
149
|
+
}
|
|
150
|
+
async inspectLease() {
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = await readJson(join(this.#receiverPath(), "owner.json"));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return { kind: "corrupt", diagnostic: `Receiver owner JSON is invalid: ${toError(error).message}` };
|
|
157
|
+
}
|
|
158
|
+
if (raw === undefined) {
|
|
159
|
+
try {
|
|
160
|
+
await stat(this.#receiverPath());
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
164
|
+
return { kind: "empty" };
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
return { kind: "corrupt", diagnostic: "receiver.lock exists without a complete owner.json" };
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return { kind: "owned", owner: parseReceiverLeaseOwnerV1(raw) };
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return { kind: "corrupt", diagnostic: toError(error).message };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async withCurrentLease(generation, operation) {
|
|
177
|
+
requireNonEmpty(generation, "lease generation");
|
|
178
|
+
await this.#initialize();
|
|
179
|
+
const guarded = await this.#withLifecycleGuard(async (current) => {
|
|
180
|
+
const projection = await this.inspectLease();
|
|
181
|
+
if (projection.kind !== "owned"
|
|
182
|
+
|| projection.owner.generation !== generation
|
|
183
|
+
|| projection.owner.rootId !== this.#rootId
|
|
184
|
+
|| !ownerIsCurrentProcess(projection.owner, current)) {
|
|
185
|
+
throw new LeaseFenceError("Receiver lease generation is not current");
|
|
186
|
+
}
|
|
187
|
+
return operation();
|
|
188
|
+
});
|
|
189
|
+
if (!guarded.ok)
|
|
190
|
+
throw new LeaseFenceError(guarded.diagnostic);
|
|
191
|
+
return guarded.value;
|
|
192
|
+
}
|
|
193
|
+
async #initialize() {
|
|
194
|
+
await mkdir(this.#namespaceDirectory, { recursive: true, mode: 0o700 });
|
|
195
|
+
await mkdir(join(this.#namespaceDirectory, "quarantine"), { recursive: true, mode: 0o700 });
|
|
196
|
+
await mkdir(join(this.#namespaceDirectory, "lifecycle-reclaims"), { recursive: true, mode: 0o700 });
|
|
197
|
+
}
|
|
198
|
+
async #createReceiver(owner) {
|
|
199
|
+
const prepared = await mkdtemp(join(this.#namespaceDirectory, ".receiver.prepare-"));
|
|
200
|
+
await chmod(prepared, 0o700);
|
|
201
|
+
let installed = false;
|
|
202
|
+
try {
|
|
203
|
+
await writeAtomicJson(join(prepared, "owner.json"), owner, { createOnly: true });
|
|
204
|
+
await syncDirectory(prepared);
|
|
205
|
+
await rename(prepared, this.#receiverPath());
|
|
206
|
+
installed = true;
|
|
207
|
+
await syncDirectory(this.#namespaceDirectory);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (isNodeError(error) && (error.code === "EEXIST" || error.code === "ENOTEMPTY")) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
if (!installed)
|
|
218
|
+
await rm(prepared, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async #withLifecycleGuard(operation) {
|
|
222
|
+
const current = await this.#inspector.current();
|
|
223
|
+
validateProcessIdentity(current, "current process identity");
|
|
224
|
+
const deadline = Date.now() + this.#guardWaitMs;
|
|
225
|
+
while (true) {
|
|
226
|
+
const beforeAcquire = await this.#waitForNoReclaimIntent(current, deadline);
|
|
227
|
+
if (!beforeAcquire.ok)
|
|
228
|
+
return beforeAcquire;
|
|
229
|
+
const guard = {
|
|
230
|
+
schema: 1,
|
|
231
|
+
generation: this.#randomUUID(),
|
|
232
|
+
pid: current.pid,
|
|
233
|
+
processStartIdentity: current.processStartIdentity,
|
|
234
|
+
bootIdentity: current.bootIdentity,
|
|
235
|
+
executable: current.executable,
|
|
236
|
+
hostname: current.hostname,
|
|
237
|
+
createdAt: this.#now().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
await writeAtomicJson(this.#guardPath(), guard, { createOnly: true });
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (!isNodeError(error) || error.code !== "EEXIST")
|
|
244
|
+
throw error;
|
|
245
|
+
const existing = await this.#readGuard();
|
|
246
|
+
if (existing.kind === "corrupt") {
|
|
247
|
+
return { ok: false, reason: "lifecycle-unverifiable", diagnostic: existing.diagnostic };
|
|
248
|
+
}
|
|
249
|
+
if (existing.kind === "empty")
|
|
250
|
+
continue;
|
|
251
|
+
const state = await classifyProcessRecord(existing.guard, current, this.#inspector);
|
|
252
|
+
if (state.kind === "stale") {
|
|
253
|
+
const reclaimed = await this.#reclaimLifecycleGuard(current, deadline);
|
|
254
|
+
if (!reclaimed.ok)
|
|
255
|
+
return reclaimed;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (state.kind === "remote" || state.kind === "unknown") {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
reason: "lifecycle-unverifiable",
|
|
262
|
+
diagnostic: state.kind === "remote"
|
|
263
|
+
? `Lifecycle guard belongs to remote host ${existing.guard.hostname}`
|
|
264
|
+
: state.diagnostic,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (Date.now() >= deadline) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
reason: "lifecycle-busy",
|
|
271
|
+
diagnostic: "Receiver lifecycle operation is busy; no owner was displaced",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
await delay(5);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const afterAcquire = await this.#scanReclaimIntents(current);
|
|
278
|
+
if (!afterAcquire.ok) {
|
|
279
|
+
await this.#releaseGuard(guard.generation);
|
|
280
|
+
return afterAcquire;
|
|
281
|
+
}
|
|
282
|
+
if (!afterAcquire.clear) {
|
|
283
|
+
await this.#releaseGuard(guard.generation);
|
|
284
|
+
if (Date.now() >= deadline) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
reason: "lifecycle-busy",
|
|
288
|
+
diagnostic: "Receiver lifecycle reclaim is active; no owner was displaced",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
await delay(5);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
let value;
|
|
295
|
+
let operationError;
|
|
296
|
+
try {
|
|
297
|
+
value = await operation(current);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
operationError = error;
|
|
301
|
+
}
|
|
302
|
+
let releaseError;
|
|
303
|
+
try {
|
|
304
|
+
await this.#releaseGuard(guard.generation);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
releaseError = error;
|
|
308
|
+
}
|
|
309
|
+
if (operationError !== undefined && releaseError !== undefined) {
|
|
310
|
+
throw new AggregateError([operationError, releaseError], "Receiver lifecycle operation and guard release both failed");
|
|
311
|
+
}
|
|
312
|
+
if (operationError !== undefined)
|
|
313
|
+
throw operationError;
|
|
314
|
+
if (releaseError !== undefined)
|
|
315
|
+
throw releaseError;
|
|
316
|
+
return { ok: true, value: value };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async #readGuard() {
|
|
320
|
+
let raw;
|
|
321
|
+
try {
|
|
322
|
+
raw = await readJson(this.#guardPath());
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
return { kind: "corrupt", diagnostic: `Lifecycle guard JSON is invalid: ${toError(error).message}` };
|
|
326
|
+
}
|
|
327
|
+
if (raw === undefined)
|
|
328
|
+
return { kind: "empty" };
|
|
329
|
+
try {
|
|
330
|
+
return { kind: "owned", guard: parseLifecycleGuardV1(raw) };
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
return { kind: "corrupt", diagnostic: toError(error).message };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async #releaseGuard(generation) {
|
|
337
|
+
const existing = await this.#readGuard();
|
|
338
|
+
if (existing.kind !== "owned" || existing.guard.generation !== generation) {
|
|
339
|
+
throw new LeaseFenceError("Receiver lifecycle guard generation changed before release");
|
|
340
|
+
}
|
|
341
|
+
await unlink(this.#guardPath());
|
|
342
|
+
await syncDirectory(this.#namespaceDirectory);
|
|
343
|
+
}
|
|
344
|
+
async #reclaimLifecycleGuard(current, deadline) {
|
|
345
|
+
const intent = {
|
|
346
|
+
schema: 1,
|
|
347
|
+
generation: this.#randomUUID(),
|
|
348
|
+
pid: current.pid,
|
|
349
|
+
processStartIdentity: current.processStartIdentity,
|
|
350
|
+
bootIdentity: current.bootIdentity,
|
|
351
|
+
executable: current.executable,
|
|
352
|
+
hostname: current.hostname,
|
|
353
|
+
createdAt: this.#now().toISOString(),
|
|
354
|
+
};
|
|
355
|
+
const intentPath = join(this.#reclaimPath(), `reclaim.${safePathPart(intent.generation)}.json`);
|
|
356
|
+
await writeAtomicJson(intentPath, intent, { createOnly: true });
|
|
357
|
+
try {
|
|
358
|
+
while (true) {
|
|
359
|
+
const existing = await this.#readGuard();
|
|
360
|
+
if (existing.kind === "empty")
|
|
361
|
+
return { ok: true, value: true };
|
|
362
|
+
if (existing.kind === "corrupt") {
|
|
363
|
+
return { ok: false, reason: "lifecycle-unverifiable", diagnostic: existing.diagnostic };
|
|
364
|
+
}
|
|
365
|
+
const state = await classifyProcessRecord(existing.guard, current, this.#inspector);
|
|
366
|
+
if (state.kind === "stale") {
|
|
367
|
+
const reread = await this.#readGuard();
|
|
368
|
+
if (reread.kind !== "owned" || !sameGuard(reread.guard, existing.guard))
|
|
369
|
+
continue;
|
|
370
|
+
const stalePath = join(this.#namespaceDirectory, "quarantine", `lifecycle.${safePathPart(existing.guard.generation)}.${this.#now().getTime()}.${safePathPart(this.#randomUUID())}.json`);
|
|
371
|
+
try {
|
|
372
|
+
await rename(this.#guardPath(), stalePath);
|
|
373
|
+
await syncDirectory(this.#namespaceDirectory);
|
|
374
|
+
return { ok: true, value: true };
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
378
|
+
continue;
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (state.kind === "remote" || state.kind === "unknown") {
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
reason: "lifecycle-unverifiable",
|
|
386
|
+
diagnostic: state.kind === "remote"
|
|
387
|
+
? `Lifecycle guard belongs to remote host ${existing.guard.hostname}`
|
|
388
|
+
: state.diagnostic,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (Date.now() >= deadline) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
reason: "lifecycle-busy",
|
|
395
|
+
diagnostic: "A live lifecycle guard appeared during stale reclaim; no owner was displaced",
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
await delay(5);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
await unlink(intentPath);
|
|
403
|
+
await syncDirectory(this.#reclaimPath());
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async #waitForNoReclaimIntent(current, deadline) {
|
|
407
|
+
while (true) {
|
|
408
|
+
const scan = await this.#scanReclaimIntents(current);
|
|
409
|
+
if (!scan.ok)
|
|
410
|
+
return scan;
|
|
411
|
+
if (scan.clear)
|
|
412
|
+
return { ok: true, value: true };
|
|
413
|
+
if (Date.now() >= deadline) {
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
reason: "lifecycle-busy",
|
|
417
|
+
diagnostic: "Receiver lifecycle reclaim is active; no owner was displaced",
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
await delay(5);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async #scanReclaimIntents(current) {
|
|
424
|
+
const entries = await readdir(this.#reclaimPath());
|
|
425
|
+
let liveIntent = false;
|
|
426
|
+
for (const entry of entries) {
|
|
427
|
+
if (!entry.startsWith("reclaim.") || !entry.endsWith(".json"))
|
|
428
|
+
continue;
|
|
429
|
+
const path = join(this.#reclaimPath(), entry);
|
|
430
|
+
let raw;
|
|
431
|
+
try {
|
|
432
|
+
raw = await readJson(path);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
reason: "lifecycle-unverifiable",
|
|
438
|
+
diagnostic: `Lifecycle reclaim intent is invalid: ${toError(error).message}`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
if (raw === undefined)
|
|
442
|
+
continue;
|
|
443
|
+
let intent;
|
|
444
|
+
try {
|
|
445
|
+
intent = parseLifecycleGuardV1(raw);
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
reason: "lifecycle-unverifiable",
|
|
451
|
+
diagnostic: toError(error).message,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const state = await classifyProcessRecord(intent, current, this.#inspector);
|
|
455
|
+
if (state.kind === "stale") {
|
|
456
|
+
try {
|
|
457
|
+
await unlink(path);
|
|
458
|
+
await syncDirectory(this.#reclaimPath());
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (!isNodeError(error) || error.code !== "ENOENT")
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (state.kind === "remote" || state.kind === "unknown") {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
reason: "lifecycle-unverifiable",
|
|
470
|
+
diagnostic: state.kind === "remote"
|
|
471
|
+
? `Lifecycle reclaim intent belongs to remote host ${intent.hostname}`
|
|
472
|
+
: state.diagnostic,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
liveIntent = true;
|
|
476
|
+
}
|
|
477
|
+
return { ok: true, clear: !liveIntent };
|
|
478
|
+
}
|
|
479
|
+
#receiverPath() {
|
|
480
|
+
return join(this.#namespaceDirectory, "receiver.lock");
|
|
481
|
+
}
|
|
482
|
+
#guardPath() {
|
|
483
|
+
return join(this.#namespaceDirectory, "receiver.lifecycle.guard.json");
|
|
484
|
+
}
|
|
485
|
+
#reclaimPath() {
|
|
486
|
+
return join(this.#namespaceDirectory, "lifecycle-reclaims");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
export function parseReceiverLeaseOwnerV1(value) {
|
|
490
|
+
const object = strictRecord(value, OWNER_KEYS, "receiver owner");
|
|
491
|
+
if (number(object, "schema") !== 1)
|
|
492
|
+
throw new Error("receiver owner schema must be 1");
|
|
493
|
+
const owner = {
|
|
494
|
+
schema: 1,
|
|
495
|
+
generation: nonEmptyString(object, "generation"),
|
|
496
|
+
piSessionId: nonEmptyString(object, "piSessionId"),
|
|
497
|
+
piSessionFile: absolutePath(object, "piSessionFile"),
|
|
498
|
+
pid: positiveInteger(object, "pid"),
|
|
499
|
+
processStartIdentity: nonEmptyString(object, "processStartIdentity"),
|
|
500
|
+
bootIdentity: nullableString(object, "bootIdentity"),
|
|
501
|
+
executable: absolutePath(object, "executable"),
|
|
502
|
+
hostname: nonEmptyString(object, "hostname"),
|
|
503
|
+
rootId: nonEmptyString(object, "rootId"),
|
|
504
|
+
createdAt: isoTimestamp(object, "createdAt"),
|
|
505
|
+
};
|
|
506
|
+
return owner;
|
|
507
|
+
}
|
|
508
|
+
function parseLifecycleGuardV1(value) {
|
|
509
|
+
const object = strictRecord(value, GUARD_KEYS, "receiver lifecycle guard");
|
|
510
|
+
if (number(object, "schema") !== 1)
|
|
511
|
+
throw new Error("receiver lifecycle guard schema must be 1");
|
|
512
|
+
return {
|
|
513
|
+
schema: 1,
|
|
514
|
+
generation: nonEmptyString(object, "generation"),
|
|
515
|
+
pid: positiveInteger(object, "pid"),
|
|
516
|
+
processStartIdentity: nonEmptyString(object, "processStartIdentity"),
|
|
517
|
+
bootIdentity: nullableString(object, "bootIdentity"),
|
|
518
|
+
executable: absolutePath(object, "executable"),
|
|
519
|
+
hostname: nonEmptyString(object, "hostname"),
|
|
520
|
+
createdAt: isoTimestamp(object, "createdAt"),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
function ownerFromRequest(request, identity, rootId, generation, now) {
|
|
524
|
+
return {
|
|
525
|
+
schema: 1,
|
|
526
|
+
generation: requireNonEmpty(generation, "lease generation"),
|
|
527
|
+
piSessionId: requireNonEmpty(request.piSessionId, "pi session ID"),
|
|
528
|
+
piSessionFile: requireAbsolute(request.piSessionFile, "pi session file"),
|
|
529
|
+
pid: identity.pid,
|
|
530
|
+
processStartIdentity: identity.processStartIdentity,
|
|
531
|
+
bootIdentity: identity.bootIdentity,
|
|
532
|
+
executable: identity.executable,
|
|
533
|
+
hostname: identity.hostname,
|
|
534
|
+
rootId,
|
|
535
|
+
createdAt: now.toISOString(),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
async function classifyOwner(owner, current, inspector) {
|
|
539
|
+
return classifyProcessRecord(owner, current, inspector);
|
|
540
|
+
}
|
|
541
|
+
async function classifyProcessRecord(record, current, inspector) {
|
|
542
|
+
if (record.hostname !== current.hostname)
|
|
543
|
+
return { kind: "remote" };
|
|
544
|
+
const inspection = await inspector.inspect(record.pid);
|
|
545
|
+
if (inspection.kind === "absent")
|
|
546
|
+
return { kind: "stale", reason: "PID is absent" };
|
|
547
|
+
if (inspection.kind === "unknown")
|
|
548
|
+
return inspection;
|
|
549
|
+
const observed = inspection.identity;
|
|
550
|
+
if (record.processStartIdentity !== observed.processStartIdentity) {
|
|
551
|
+
return { kind: "stale", reason: "PID start identity changed" };
|
|
552
|
+
}
|
|
553
|
+
if (normalize(record.executable) !== normalize(observed.executable)) {
|
|
554
|
+
return { kind: "stale", reason: "PID executable changed" };
|
|
555
|
+
}
|
|
556
|
+
if (record.bootIdentity !== null
|
|
557
|
+
&& observed.bootIdentity !== null
|
|
558
|
+
&& record.bootIdentity !== observed.bootIdentity) {
|
|
559
|
+
return { kind: "stale", reason: "host boot identity changed" };
|
|
560
|
+
}
|
|
561
|
+
return { kind: "live" };
|
|
562
|
+
}
|
|
563
|
+
function ownerIsCurrentProcess(owner, current) {
|
|
564
|
+
return owner.hostname === current.hostname
|
|
565
|
+
&& owner.pid === current.pid
|
|
566
|
+
&& owner.processStartIdentity === current.processStartIdentity
|
|
567
|
+
&& normalize(owner.executable) === normalize(current.executable)
|
|
568
|
+
&& (owner.bootIdentity === null
|
|
569
|
+
|| current.bootIdentity === null
|
|
570
|
+
|| owner.bootIdentity === current.bootIdentity);
|
|
571
|
+
}
|
|
572
|
+
function sameOwner(left, right) {
|
|
573
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
574
|
+
}
|
|
575
|
+
function sameGuard(left, right) {
|
|
576
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
577
|
+
}
|
|
578
|
+
function validateAcquireRequest(request) {
|
|
579
|
+
requireNonEmpty(request.piSessionId, "pi session ID");
|
|
580
|
+
requireAbsolute(request.piSessionFile, "pi session file");
|
|
581
|
+
}
|
|
582
|
+
function validateProcessIdentity(identity, name) {
|
|
583
|
+
if (!Number.isSafeInteger(identity.pid) || identity.pid <= 0)
|
|
584
|
+
throw new Error(`${name} PID is invalid`);
|
|
585
|
+
requireNonEmpty(identity.processStartIdentity, `${name} start identity`);
|
|
586
|
+
requireAbsolute(identity.executable, `${name} executable`);
|
|
587
|
+
requireNonEmpty(identity.hostname, `${name} hostname`);
|
|
588
|
+
}
|
|
589
|
+
function strictRecord(value, allowed, name) {
|
|
590
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
591
|
+
throw new Error(`${name} must be an object`);
|
|
592
|
+
}
|
|
593
|
+
const object = value;
|
|
594
|
+
const unknown = Object.keys(object).filter((key) => !allowed.includes(key));
|
|
595
|
+
if (unknown.length > 0)
|
|
596
|
+
throw new Error(`${name} contains unknown fields: ${unknown.join(", ")}`);
|
|
597
|
+
return object;
|
|
598
|
+
}
|
|
599
|
+
function nonEmptyString(object, key) {
|
|
600
|
+
const value = object[key];
|
|
601
|
+
if (typeof value !== "string" || value.length === 0)
|
|
602
|
+
throw new Error(`${key} must be a non-empty string`);
|
|
603
|
+
return value;
|
|
604
|
+
}
|
|
605
|
+
function nullableString(object, key) {
|
|
606
|
+
const value = object[key];
|
|
607
|
+
if (value === null)
|
|
608
|
+
return null;
|
|
609
|
+
if (typeof value !== "string" || value.length === 0)
|
|
610
|
+
throw new Error(`${key} must be null or a non-empty string`);
|
|
611
|
+
return value;
|
|
612
|
+
}
|
|
613
|
+
function absolutePath(object, key) {
|
|
614
|
+
return requireAbsolute(nonEmptyString(object, key), key);
|
|
615
|
+
}
|
|
616
|
+
function positiveInteger(object, key) {
|
|
617
|
+
const value = number(object, key);
|
|
618
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
619
|
+
throw new Error(`${key} must be a positive safe integer`);
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
function number(object, key) {
|
|
623
|
+
const value = object[key];
|
|
624
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
625
|
+
throw new Error(`${key} must be a number`);
|
|
626
|
+
return value;
|
|
627
|
+
}
|
|
628
|
+
function isoTimestamp(object, key) {
|
|
629
|
+
const value = nonEmptyString(object, key);
|
|
630
|
+
if (!Number.isFinite(Date.parse(value)))
|
|
631
|
+
throw new Error(`${key} must be an ISO timestamp`);
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
634
|
+
function requireNonEmpty(value, name) {
|
|
635
|
+
if (value.length === 0)
|
|
636
|
+
throw new TypeError(`${name} is required`);
|
|
637
|
+
return value;
|
|
638
|
+
}
|
|
639
|
+
function requireAbsolute(value, name) {
|
|
640
|
+
if (!isAbsolute(value))
|
|
641
|
+
throw new TypeError(`${name} must be absolute`);
|
|
642
|
+
return normalize(value);
|
|
643
|
+
}
|
|
644
|
+
function safePathPart(value) {
|
|
645
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
646
|
+
}
|
|
647
|
+
function isNodeError(error) {
|
|
648
|
+
return error instanceof Error && "code" in error;
|
|
649
|
+
}
|
|
650
|
+
function toError(error) {
|
|
651
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
652
|
+
}
|
|
653
|
+
/** Production process proof for Darwin and Linux. Unsupported platforms fail closed. */
|
|
654
|
+
export class LocalProcessIdentityInspector {
|
|
655
|
+
async current() {
|
|
656
|
+
const inspected = await this.inspect(process.pid);
|
|
657
|
+
if (inspected.kind !== "live") {
|
|
658
|
+
throw new Error(inspected.kind === "unknown"
|
|
659
|
+
? inspected.diagnostic
|
|
660
|
+
: "Current process identity is unexpectedly absent");
|
|
661
|
+
}
|
|
662
|
+
return inspected.identity;
|
|
663
|
+
}
|
|
664
|
+
async inspect(pid) {
|
|
665
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
666
|
+
return { kind: "unknown", diagnostic: "PID must be a positive safe integer" };
|
|
667
|
+
}
|
|
668
|
+
if (process.platform === "linux")
|
|
669
|
+
return inspectLinuxProcess(pid);
|
|
670
|
+
if (process.platform === "darwin")
|
|
671
|
+
return inspectDarwinProcess(pid);
|
|
672
|
+
return {
|
|
673
|
+
kind: "unknown",
|
|
674
|
+
diagnostic: `Process identity inspection is unsupported on ${process.platform}`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function inspectLinuxProcess(pid) {
|
|
679
|
+
try {
|
|
680
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
681
|
+
const close = stat.lastIndexOf(")");
|
|
682
|
+
if (close < 0)
|
|
683
|
+
return { kind: "unknown", diagnostic: `Malformed /proc/${pid}/stat` };
|
|
684
|
+
const fields = stat.slice(close + 2).trim().split(/\s+/);
|
|
685
|
+
const startIdentity = fields[19];
|
|
686
|
+
if (!startIdentity)
|
|
687
|
+
return { kind: "unknown", diagnostic: `Missing start time in /proc/${pid}/stat` };
|
|
688
|
+
const executable = await readlink(`/proc/${pid}/exe`);
|
|
689
|
+
let bootIdentity = null;
|
|
690
|
+
try {
|
|
691
|
+
bootIdentity = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim() || null;
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
bootIdentity = null;
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
kind: "live",
|
|
698
|
+
identity: {
|
|
699
|
+
pid,
|
|
700
|
+
processStartIdentity: startIdentity,
|
|
701
|
+
bootIdentity,
|
|
702
|
+
executable: normalize(executable),
|
|
703
|
+
hostname: localHostname(),
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ESRCH")) {
|
|
709
|
+
return { kind: "absent" };
|
|
710
|
+
}
|
|
711
|
+
return { kind: "unknown", diagnostic: `Linux process inspection failed: ${toError(error).message}` };
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
async function inspectDarwinProcess(pid) {
|
|
715
|
+
const processResult = await runBounded("/bin/ps", ["-p", String(pid), "-o", "lstart="]);
|
|
716
|
+
if (processResult.exitCode === 1 && processResult.stdout.trim().length === 0)
|
|
717
|
+
return { kind: "absent" };
|
|
718
|
+
if (processResult.exitCode !== 0) {
|
|
719
|
+
return { kind: "unknown", diagnostic: `Darwin ps failed: ${processResult.stderr.trim()}` };
|
|
720
|
+
}
|
|
721
|
+
const startIdentity = processResult.stdout.trim();
|
|
722
|
+
if (startIdentity.length === 0) {
|
|
723
|
+
return { kind: "unknown", diagnostic: "Darwin ps returned an empty start identity" };
|
|
724
|
+
}
|
|
725
|
+
const files = await runBounded("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"]);
|
|
726
|
+
if (files.exitCode !== 0) {
|
|
727
|
+
return { kind: "unknown", diagnostic: `Darwin executable inspection failed: ${files.stderr.trim()}` };
|
|
728
|
+
}
|
|
729
|
+
const executable = files.stdout
|
|
730
|
+
.split("\n")
|
|
731
|
+
.find((line) => line.startsWith("n/") && line !== "n/usr/lib/dyld")
|
|
732
|
+
?.slice(1);
|
|
733
|
+
if (!executable || !isAbsolute(executable)) {
|
|
734
|
+
return { kind: "unknown", diagnostic: "Darwin lsof did not return an absolute executable path" };
|
|
735
|
+
}
|
|
736
|
+
const bootResult = await runBounded("/usr/sbin/sysctl", ["-n", "kern.boottime"]);
|
|
737
|
+
const bootIdentity = bootResult.exitCode === 0 ? bootResult.stdout.trim() || null : null;
|
|
738
|
+
return {
|
|
739
|
+
kind: "live",
|
|
740
|
+
identity: {
|
|
741
|
+
pid,
|
|
742
|
+
processStartIdentity: startIdentity,
|
|
743
|
+
bootIdentity,
|
|
744
|
+
executable: normalize(executable),
|
|
745
|
+
hostname: localHostname(),
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
async function runBounded(binary, argv) {
|
|
750
|
+
return new Promise((resolve) => {
|
|
751
|
+
const child = spawn(binary, argv, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
752
|
+
const stdout = [];
|
|
753
|
+
const stderr = [];
|
|
754
|
+
let stdoutBytes = 0;
|
|
755
|
+
let stderrBytes = 0;
|
|
756
|
+
child.stdout.on("data", (chunk) => {
|
|
757
|
+
stdoutBytes += chunk.length;
|
|
758
|
+
if (stdoutBytes <= 64 * 1024)
|
|
759
|
+
stdout.push(Buffer.from(chunk));
|
|
760
|
+
else
|
|
761
|
+
child.kill("SIGKILL");
|
|
762
|
+
});
|
|
763
|
+
child.stderr.on("data", (chunk) => {
|
|
764
|
+
stderrBytes += chunk.length;
|
|
765
|
+
if (stderrBytes <= 64 * 1024)
|
|
766
|
+
stderr.push(Buffer.from(chunk));
|
|
767
|
+
else
|
|
768
|
+
child.kill("SIGKILL");
|
|
769
|
+
});
|
|
770
|
+
child.once("error", (error) => resolve({ exitCode: null, stdout: "", stderr: error.message }));
|
|
771
|
+
child.once("close", (exitCode) => resolve({
|
|
772
|
+
exitCode,
|
|
773
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
774
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
775
|
+
}));
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
//# sourceMappingURL=receiver-lease.js.map
|