pi-daddy 0.44.4 → 0.46.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 +10 -0
- package/README.md +383 -25
- package/dist/executors/captured-execution.d.ts +2 -2
- package/dist/executors/captured-execution.d.ts.map +1 -1
- package/dist/executors/captured-execution.js +2 -2
- package/dist/executors/captured-execution.js.map +1 -1
- package/dist/executors/executor.d.ts.map +1 -1
- package/dist/executors/executor.js +4 -2
- package/dist/executors/executor.js.map +1 -1
- package/dist/executors/herdr-cli.d.ts +2 -0
- package/dist/executors/herdr-cli.d.ts.map +1 -1
- package/dist/executors/herdr-cli.js +2 -2
- package/dist/executors/herdr-cli.js.map +1 -1
- package/dist/executors/herdr-launcher.d.ts +2 -0
- package/dist/executors/herdr-launcher.d.ts.map +1 -0
- package/dist/executors/herdr-launcher.js +123 -0
- package/dist/executors/herdr-launcher.js.map +1 -0
- package/dist/executors/herdr-owned.d.ts +13 -0
- package/dist/executors/herdr-owned.d.ts.map +1 -0
- package/dist/executors/herdr-owned.js +229 -0
- package/dist/executors/herdr-owned.js.map +1 -0
- package/dist/executors/herdr-qualification.d.ts +6 -0
- package/dist/executors/herdr-qualification.d.ts.map +1 -0
- package/dist/executors/herdr-qualification.js +33 -0
- package/dist/executors/herdr-qualification.js.map +1 -0
- package/dist/executors/herdr-wire.d.ts +5 -0
- package/dist/executors/herdr-wire.d.ts.map +1 -0
- package/dist/executors/herdr-wire.js +47 -0
- package/dist/executors/herdr-wire.js.map +1 -0
- package/dist/governance/runtime-settlement-lock.d.ts +5 -0
- package/dist/governance/runtime-settlement-lock.d.ts.map +1 -0
- package/dist/governance/runtime-settlement-lock.js +72 -0
- package/dist/governance/runtime-settlement-lock.js.map +1 -0
- package/dist/governance/runtime-settlement.d.ts +22 -0
- package/dist/governance/runtime-settlement.d.ts.map +1 -0
- package/dist/governance/runtime-settlement.js +236 -0
- package/dist/governance/runtime-settlement.js.map +1 -0
- package/dist/kernel/definition-sources.d.ts +13 -0
- package/dist/kernel/definition-sources.d.ts.map +1 -0
- package/dist/kernel/definition-sources.js +2 -0
- package/dist/kernel/definition-sources.js.map +1 -0
- package/dist/kernel/definitions.d.ts +2 -0
- package/dist/kernel/definitions.d.ts.map +1 -1
- package/dist/kernel/definitions.js.map +1 -1
- package/dist/kernel/env-names.d.ts +2 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +3 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/propagation.d.ts +1 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +2 -0
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/kernel/selected-definitions.d.ts +1 -1
- package/dist/kernel/selected-definitions.d.ts.map +1 -1
- package/dist/kernel/selected-definitions.js +19 -10
- package/dist/kernel/selected-definitions.js.map +1 -1
- package/dist/products/public-evidence.d.ts +62 -0
- package/dist/products/public-evidence.d.ts.map +1 -0
- package/dist/products/public-evidence.js +116 -0
- package/dist/products/public-evidence.js.map +1 -0
- package/extensions/definition-describe.ts +4 -1
- package/extensions/delegate-chain.ts +21 -15
- package/extensions/delegation.ts +57 -27
- package/extensions/execute-child.ts +98 -74
- package/extensions/execution-evidence.ts +10 -1
- package/extensions/executor-session.ts +2 -1
- package/extensions/grants.ts +3 -0
- package/extensions/public-evidence.ts +90 -0
- package/extensions/reload-environment.ts +8 -0
- package/extensions/run-delegation.ts +20 -4
- package/extensions/runtime-snapshot.ts +123 -0
- package/extensions/session.ts +11 -2
- package/package.json +1 -1
- package/src/executors/captured-execution.ts +2 -1
- package/src/executors/executor.ts +4 -2
- package/src/executors/herdr-cli.ts +19 -12
- package/src/executors/herdr-launcher.ts +124 -0
- package/src/executors/herdr-owned.ts +260 -0
- package/src/executors/herdr-qualification.ts +33 -0
- package/src/executors/herdr-wire.ts +43 -0
- package/src/governance/runtime-settlement-lock.ts +76 -0
- package/src/governance/runtime-settlement.ts +271 -0
- package/src/kernel/definition-sources.ts +12 -0
- package/src/kernel/definitions.ts +2 -0
- package/src/kernel/env-names.ts +3 -0
- package/src/kernel/propagation.ts +2 -0
- package/src/kernel/selected-definitions.ts +31 -10
- package/src/products/public-evidence.ts +181 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** Governed work runs in its own Herdr pane, under the same independently settling native owner as captured work. */
|
|
2
|
+
import { createServer, type Socket } from "node:net";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { chmod, mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import type { OwnedChildRunRequest, OwnedChildRunResult } from "./owned-worker.ts";
|
|
9
|
+
import type { CapturedWorkerIdentity } from "../kernel/captured-worker-contract.ts";
|
|
10
|
+
import {
|
|
11
|
+
isCapturedWorkerIdentity,
|
|
12
|
+
readCapturedWorkerReceipt,
|
|
13
|
+
validateLiveWorker,
|
|
14
|
+
} from "../governance/captured-worker-record.ts";
|
|
15
|
+
import { defaultExec, parseReply, type HerdrExec } from "./herdr-cli.ts";
|
|
16
|
+
import { WORKER_SHA256 } from "./worker-artifact.ts";
|
|
17
|
+
import { wire } from "./herdr-wire.ts";
|
|
18
|
+
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
19
|
+
export interface HerdrOwnedOptions {
|
|
20
|
+
exec?: HerdrExec;
|
|
21
|
+
workspace?: string;
|
|
22
|
+
keepPane?: boolean;
|
|
23
|
+
onPane?: (paneId: string, tabId: string) => void;
|
|
24
|
+
onDisplay?: (display: (text: string) => void) => void;
|
|
25
|
+
/** Tests may select a freshly built launcher. Production always uses the packaged build. */
|
|
26
|
+
launcherPath?: string;
|
|
27
|
+
}
|
|
28
|
+
export async function runHerdrOwned(
|
|
29
|
+
request: OwnedChildRunRequest,
|
|
30
|
+
options: HerdrOwnedOptions = {},
|
|
31
|
+
): Promise<OwnedChildRunResult> {
|
|
32
|
+
const exec = options.exec ?? defaultExec;
|
|
33
|
+
const directory = await mkdtemp(join(tmpdir(), "pd-herdr-"));
|
|
34
|
+
const path = join(directory, "owner.sock"),
|
|
35
|
+
token = randomUUID();
|
|
36
|
+
const launcher =
|
|
37
|
+
options.launcherPath ?? fileURLToPath(new URL("../../dist/executors/herdr-launcher.js", import.meta.url));
|
|
38
|
+
const helper = fileURLToPath(new URL("../../native/linux-x64/worker", import.meta.url));
|
|
39
|
+
let socket: Socket | undefined, send: ((value: unknown) => void) | undefined;
|
|
40
|
+
let identity: CapturedWorkerIdentity | undefined, outcome: OwnedChildRunResult | undefined, error: string | undefined;
|
|
41
|
+
let tabId: string | undefined,
|
|
42
|
+
entered = false,
|
|
43
|
+
done = false,
|
|
44
|
+
authenticated = false;
|
|
45
|
+
let finish!: () => void;
|
|
46
|
+
const startupTimer = setTimeout(
|
|
47
|
+
() => {
|
|
48
|
+
fail("Herdr launcher did not connect before startup deadline");
|
|
49
|
+
socket?.destroy();
|
|
50
|
+
finish();
|
|
51
|
+
},
|
|
52
|
+
Math.min(request.timeoutMs ?? 10000, 10000),
|
|
53
|
+
);
|
|
54
|
+
const completed = new Promise<void>((resolve) => {
|
|
55
|
+
finish = resolve;
|
|
56
|
+
});
|
|
57
|
+
const abort = () => {
|
|
58
|
+
try {
|
|
59
|
+
send?.({ type: "cancel" });
|
|
60
|
+
} catch {}
|
|
61
|
+
};
|
|
62
|
+
const fail = (cause: unknown) => {
|
|
63
|
+
error ??= String(cause);
|
|
64
|
+
abort();
|
|
65
|
+
};
|
|
66
|
+
const server = createServer((candidate) => {
|
|
67
|
+
if (socket) {
|
|
68
|
+
candidate.destroy();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
socket = candidate;
|
|
72
|
+
send = wire(
|
|
73
|
+
candidate,
|
|
74
|
+
(message) => {
|
|
75
|
+
if (!authenticated) {
|
|
76
|
+
if (message?.type !== "hello" || message.token !== token) throw Error("Herdr launcher nonce mismatch");
|
|
77
|
+
authenticated = true;
|
|
78
|
+
clearTimeout(startupTimer);
|
|
79
|
+
const {
|
|
80
|
+
signal: _signal,
|
|
81
|
+
onOwnership: _owner,
|
|
82
|
+
onSpawn: _spawn,
|
|
83
|
+
onOutput: _out,
|
|
84
|
+
onObservation: _observation,
|
|
85
|
+
onStreamEnd: _end,
|
|
86
|
+
activityProbe: _probe,
|
|
87
|
+
...serializable
|
|
88
|
+
} = request;
|
|
89
|
+
send!({ type: "request", request: serializable });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (message?.type === "ownership") {
|
|
93
|
+
if (identity || !isCapturedWorkerIdentity(message.identity)) throw Error("ambiguous Herdr worker ownership");
|
|
94
|
+
const actual = message.identity as CapturedWorkerIdentity;
|
|
95
|
+
if (
|
|
96
|
+
actual.executionId !== request.executionId ||
|
|
97
|
+
actual.root !== request.cwd ||
|
|
98
|
+
actual.helperSha256 !== WORKER_SHA256 ||
|
|
99
|
+
actual.ownershipPath !== join(request.ownershipDir, "ownership.json")
|
|
100
|
+
)
|
|
101
|
+
throw Error("Herdr worker identity differs from requested execution");
|
|
102
|
+
identity = actual;
|
|
103
|
+
void (async () => {
|
|
104
|
+
try {
|
|
105
|
+
await validateLiveWorker(actual, helper);
|
|
106
|
+
await request.onOwnership?.(actual);
|
|
107
|
+
if (!done && !request.signal?.aborted && !error) {
|
|
108
|
+
request.onSpawn?.(actual.workerPid);
|
|
109
|
+
send!({ type: "start" });
|
|
110
|
+
} else abort();
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
fail(cause);
|
|
113
|
+
abort();
|
|
114
|
+
}
|
|
115
|
+
})();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
message?.type === "bytes" &&
|
|
120
|
+
["stdout", "stderr"].includes(message.stream) &&
|
|
121
|
+
typeof message.data === "string"
|
|
122
|
+
) {
|
|
123
|
+
if (
|
|
124
|
+
!identity ||
|
|
125
|
+
message.data.length > 90000 ||
|
|
126
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(message.data)
|
|
127
|
+
)
|
|
128
|
+
throw Error("invalid Herdr output frame");
|
|
129
|
+
try {
|
|
130
|
+
request.onObservation?.(message.stream, Buffer.from(message.data, "base64"));
|
|
131
|
+
} catch {}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (message?.type === "stream-end" && ["stdout", "stderr"].includes(message.stream)) {
|
|
135
|
+
try {
|
|
136
|
+
request.onStreamEnd?.(message.stream);
|
|
137
|
+
} catch {}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (message?.type === "spawned" && identity && message.pid === identity.workerPid) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (message?.type === "result" && !outcome) {
|
|
144
|
+
outcome = message.result;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (message?.type === "failure") {
|
|
148
|
+
fail(message.reason);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw Error("invalid Herdr worker transport message");
|
|
152
|
+
},
|
|
153
|
+
fail,
|
|
154
|
+
);
|
|
155
|
+
options.onDisplay?.((text) => {
|
|
156
|
+
if (authenticated && send) {
|
|
157
|
+
for (let at = 0; at < text.length; at += 16000) send({ type: "display", text: text.slice(at, at + 16000) });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
candidate.on("close", finish);
|
|
161
|
+
});
|
|
162
|
+
const timeout = setTimeout(
|
|
163
|
+
() => {
|
|
164
|
+
fail("Herdr execution exceeded its transport deadline");
|
|
165
|
+
socket?.destroy();
|
|
166
|
+
finish();
|
|
167
|
+
},
|
|
168
|
+
(request.timeoutMs ?? 3600000) + Math.min(request.killGraceMs ?? 1500, 60000) + 7000,
|
|
169
|
+
);
|
|
170
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
171
|
+
try {
|
|
172
|
+
await new Promise<void>((resolve, reject) => {
|
|
173
|
+
server.once("error", reject);
|
|
174
|
+
server.listen(path, resolve);
|
|
175
|
+
});
|
|
176
|
+
await chmod(path, 0o600);
|
|
177
|
+
if (request.signal?.aborted || (request.hardDeadlineAt !== undefined && request.hardDeadlineAt <= Date.now()))
|
|
178
|
+
return {
|
|
179
|
+
code: null,
|
|
180
|
+
text: "",
|
|
181
|
+
truncated: false,
|
|
182
|
+
timedOut: false,
|
|
183
|
+
aborted: true,
|
|
184
|
+
cleanup: { state: "not-started", reason: "cancelled before Herdr pane launch" },
|
|
185
|
+
};
|
|
186
|
+
const created = parseReply(
|
|
187
|
+
await exec([
|
|
188
|
+
"tab",
|
|
189
|
+
"create",
|
|
190
|
+
"--cwd",
|
|
191
|
+
request.cwd,
|
|
192
|
+
"--label",
|
|
193
|
+
"pi-daddy governed child",
|
|
194
|
+
"--no-focus",
|
|
195
|
+
...(options.workspace ? ["--workspace", options.workspace] : []),
|
|
196
|
+
]),
|
|
197
|
+
);
|
|
198
|
+
if (created.error) throw Error(created.error);
|
|
199
|
+
const pane = created.result?.root_pane as { pane_id?: string; tab_id?: string } | undefined;
|
|
200
|
+
tabId = pane?.tab_id;
|
|
201
|
+
if (!pane?.pane_id || !tabId) throw Error("Herdr creation returned no exact pane/tab identity");
|
|
202
|
+
options.onPane?.(pane.pane_id, tabId);
|
|
203
|
+
if (error || request.signal?.aborted) throw Error(error ?? "cancelled before Herdr command launch");
|
|
204
|
+
entered = true;
|
|
205
|
+
const launchReply = await exec([
|
|
206
|
+
"pane",
|
|
207
|
+
"run",
|
|
208
|
+
pane.pane_id,
|
|
209
|
+
(options.keepPane ? "" : "exec ") + [process.execPath, launcher, path, token].map(quote).join(" "),
|
|
210
|
+
]);
|
|
211
|
+
if (launchReply.code !== 0) throw Error(parseReply(launchReply).error ?? "Herdr pane launch failed");
|
|
212
|
+
if (launchReply.stdout.trim()) {
|
|
213
|
+
const launched = parseReply(launchReply);
|
|
214
|
+
if (launched.error) throw Error(launched.error);
|
|
215
|
+
}
|
|
216
|
+
await completed;
|
|
217
|
+
if (outcome?.cleanup?.state === "not-started" && !identity) return outcome;
|
|
218
|
+
if (!outcome || !identity) throw Error(error ?? "Herdr launcher closed without a complete result/ownership");
|
|
219
|
+
const receipt = await readCapturedWorkerReceipt(identity);
|
|
220
|
+
if (!receipt) throw Error("Herdr worker supplied no independently verified cleanup receipt");
|
|
221
|
+
return { ...outcome, cleanup: { state: "settled", identity, receipt }, ...(error ? { spawnError: error } : {}) };
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
fail(cause);
|
|
224
|
+
socket?.destroy();
|
|
225
|
+
// Lost transport is never evidence of cleanup. Reconcile only the original bound receipt.
|
|
226
|
+
let receipt = identity ? await readCapturedWorkerReceipt(identity) : null;
|
|
227
|
+
const deadline = Date.now() + Math.min(request.killGraceMs ?? 1500, 60000) + 5500;
|
|
228
|
+
while (identity && !receipt && Date.now() < deadline) {
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
230
|
+
receipt = await readCapturedWorkerReceipt(identity);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
code: receipt?.workerCode ?? null,
|
|
234
|
+
text: "",
|
|
235
|
+
truncated: false,
|
|
236
|
+
timedOut: false,
|
|
237
|
+
aborted: Boolean(request.signal?.aborted),
|
|
238
|
+
spawnError: error ?? String(cause),
|
|
239
|
+
cleanup:
|
|
240
|
+
identity && receipt
|
|
241
|
+
? { state: "settled", identity, receipt }
|
|
242
|
+
: entered
|
|
243
|
+
? { state: "unknown", ...(identity ? { identity } : {}), reason: error ?? String(cause) }
|
|
244
|
+
: { state: "not-started", reason: error ?? String(cause) },
|
|
245
|
+
};
|
|
246
|
+
} finally {
|
|
247
|
+
done = true;
|
|
248
|
+
clearTimeout(timeout);
|
|
249
|
+
clearTimeout(startupTimer);
|
|
250
|
+
request.signal?.removeEventListener("abort", abort);
|
|
251
|
+
socket?.destroy();
|
|
252
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
253
|
+
if (tabId && !options.keepPane) {
|
|
254
|
+
const closed = parseReply(await exec(["tab", "close", tabId]));
|
|
255
|
+
if (closed.error && !/tab .* not found|tab_not_found/.test(closed.error))
|
|
256
|
+
console.error("pi-daddy: settled worker pane could not close:", closed.error);
|
|
257
|
+
}
|
|
258
|
+
await rm(directory, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Measured transport matrix. Reachability alone never qualifies a backend or changes execution location. */
|
|
2
|
+
import { defaultExec, type HerdrExec, type HerdrProbe } from "./herdr-cli.ts";
|
|
3
|
+
export const QUALIFIED_HERDR_VERSION = "0.8.2";
|
|
4
|
+
export const QUALIFIED_HERDR_PROTOCOL = 20;
|
|
5
|
+
export async function qualifyHerdr(probe: HerdrProbe, exec: HerdrExec = defaultExec): Promise<HerdrProbe> {
|
|
6
|
+
if (!probe.ok) return probe;
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform !== "linux" || process.arch !== "x64") throw Error("Herdr owned execution requires Linux x64");
|
|
9
|
+
const [client, server] = await Promise.all([exec(["--version"]), exec(["status", "server"])]);
|
|
10
|
+
if (client.code !== 0 || client.stdout.trim() !== "herdr " + QUALIFIED_HERDR_VERSION)
|
|
11
|
+
throw Error("Herdr client version is not qualified");
|
|
12
|
+
const fields = Object.fromEntries(
|
|
13
|
+
server.stdout
|
|
14
|
+
.trim()
|
|
15
|
+
.split("\n")
|
|
16
|
+
.map((line) => {
|
|
17
|
+
const at = line.indexOf(":");
|
|
18
|
+
return [line.slice(0, at).trim(), line.slice(at + 1).trim()];
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
if (
|
|
22
|
+
server.code !== 0 ||
|
|
23
|
+
fields.status !== "running" ||
|
|
24
|
+
fields.version !== QUALIFIED_HERDR_VERSION ||
|
|
25
|
+
fields.protocol !== String(QUALIFIED_HERDR_PROTOCOL) ||
|
|
26
|
+
fields.compatible !== "yes"
|
|
27
|
+
)
|
|
28
|
+
throw Error("Herdr server version/protocol is not qualified");
|
|
29
|
+
return { ...probe, qualified: true };
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { ...probe, qualified: false, qualificationReason: String(error) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Bounded local framing; this channel carries private execution input only between its two owners. */
|
|
2
|
+
import type { Socket } from "node:net";
|
|
3
|
+
export const HERDR_WIRE_LIMIT = 8 * 1024 * 1024;
|
|
4
|
+
export function wire(socket: Socket, receive: (value: any) => void, failed: (error: Error) => void) {
|
|
5
|
+
let pending = "";
|
|
6
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
7
|
+
const fail = (error: unknown) => {
|
|
8
|
+
failed(error instanceof Error ? error : new Error(String(error)));
|
|
9
|
+
socket.destroy();
|
|
10
|
+
};
|
|
11
|
+
socket.on("data", (bytes) => {
|
|
12
|
+
try {
|
|
13
|
+
pending += decoder.decode(bytes, { stream: true });
|
|
14
|
+
let end: number;
|
|
15
|
+
while ((end = pending.indexOf("\n")) >= 0) {
|
|
16
|
+
if (Buffer.byteLength(pending.slice(0, end)) > HERDR_WIRE_LIMIT)
|
|
17
|
+
throw Error("Herdr transport record exceeds bound");
|
|
18
|
+
const line = pending.slice(0, end);
|
|
19
|
+
pending = pending.slice(end + 1);
|
|
20
|
+
receive(JSON.parse(line));
|
|
21
|
+
}
|
|
22
|
+
if (Buffer.byteLength(pending) > HERDR_WIRE_LIMIT) throw Error("Herdr transport record exceeds bound");
|
|
23
|
+
} catch (error) {
|
|
24
|
+
fail(error);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
socket.on("end", () => {
|
|
28
|
+
try {
|
|
29
|
+
pending += decoder.decode();
|
|
30
|
+
if (pending) throw Error("Herdr transport record is incomplete");
|
|
31
|
+
} catch (error) {
|
|
32
|
+
fail(error);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
socket.on("error", (error) => failed(error));
|
|
36
|
+
return (value: unknown) => {
|
|
37
|
+
const text = JSON.stringify(value) + "\n";
|
|
38
|
+
if (Buffer.byteLength(text) > HERDR_WIRE_LIMIT || socket.writableLength > HERDR_WIRE_LIMIT * 2)
|
|
39
|
+
throw Error("Herdr transport buffering exceeds bound");
|
|
40
|
+
if (socket.destroyed || !socket.writable) throw Error("Herdr transport is closed");
|
|
41
|
+
socket.write(text);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** An OS-held single-writer lock; parent pipe loss releases it without stale-age guesses. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { constants, openSync, closeSync, fstatSync } from "node:fs";
|
|
4
|
+
export async function lockRuntimeSettlement(path: string): Promise<{ healthy(): boolean; release(): Promise<void> }> {
|
|
5
|
+
const fd = openSync(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
6
|
+
const info = fstatSync(fd);
|
|
7
|
+
if (!info.isFile() || info.uid !== process.getuid!() || info.mode & 0o077) {
|
|
8
|
+
closeSync(fd);
|
|
9
|
+
throw Error("runtime lock is not a private ordinary file");
|
|
10
|
+
}
|
|
11
|
+
const child = spawn(
|
|
12
|
+
"/usr/bin/flock",
|
|
13
|
+
[
|
|
14
|
+
"--no-fork",
|
|
15
|
+
"--wait",
|
|
16
|
+
"1",
|
|
17
|
+
"/proc/self/fd/3",
|
|
18
|
+
process.execPath,
|
|
19
|
+
"-e",
|
|
20
|
+
'process.stdout.write("locked\\n");process.stdin.resume();process.stdin.on("end",()=>process.exit(0));',
|
|
21
|
+
],
|
|
22
|
+
{ stdio: ["pipe", "pipe", "ignore", fd] },
|
|
23
|
+
);
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
let healthy = false,
|
|
26
|
+
line = "";
|
|
27
|
+
await new Promise<void>((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
child.stdin?.destroy();
|
|
30
|
+
child.kill();
|
|
31
|
+
reject(Error("runtime ownership lock timed out"));
|
|
32
|
+
}, 3000);
|
|
33
|
+
child.on("error", (error) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
reject(error);
|
|
36
|
+
});
|
|
37
|
+
child.on("exit", () => {
|
|
38
|
+
healthy = false;
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
reject(Error("another live runtime owns this Pi session"));
|
|
41
|
+
});
|
|
42
|
+
child.stdout?.on("data", (bytes) => {
|
|
43
|
+
line += bytes.toString();
|
|
44
|
+
if (line === "locked\n") {
|
|
45
|
+
healthy = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve();
|
|
48
|
+
} else if (line.length > 16) {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
child.stdin?.destroy();
|
|
51
|
+
reject(Error("runtime lock returned malformed readiness"));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
child.unref();
|
|
56
|
+
(child.stdin as import("node:net").Socket).unref();
|
|
57
|
+
(child.stdout as import("node:net").Socket).unref();
|
|
58
|
+
let releasing: Promise<void> | undefined;
|
|
59
|
+
return {
|
|
60
|
+
healthy: () => healthy && child.exitCode === null && child.signalCode === null,
|
|
61
|
+
release: () =>
|
|
62
|
+
(releasing ??= new Promise<void>((resolve) => {
|
|
63
|
+
healthy = false;
|
|
64
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
65
|
+
resolve();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 1000);
|
|
69
|
+
child.once("close", () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
child.stdin?.end();
|
|
74
|
+
})),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/** Local runtime facts for explicitly armed resume; no task text, grants, approval or model verdicts. */
|
|
2
|
+
import { lockRuntimeSettlement } from "./runtime-settlement-lock.ts";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
mkdirSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
openSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
fsyncSync,
|
|
11
|
+
closeSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
constants,
|
|
15
|
+
readFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { readBoundedTextSync } from "../kernel/bounded-read-sync.ts";
|
|
19
|
+
import type { CapturedWorkerCleanup, CapturedWorkerIdentity } from "../kernel/captured-worker-contract.ts";
|
|
20
|
+
import { isCapturedWorkerIdentity, readCapturedWorkerReceipt, sameWorkerIdentity } from "./captured-worker-record.ts";
|
|
21
|
+
const MAX_RECORD = 4 * 1024 * 1024;
|
|
22
|
+
interface Execution {
|
|
23
|
+
id: string;
|
|
24
|
+
state: "pending" | "settled" | "not-started" | "unknown";
|
|
25
|
+
identity?: CapturedWorkerIdentity;
|
|
26
|
+
receiptSha256?: string;
|
|
27
|
+
problem?: string;
|
|
28
|
+
}
|
|
29
|
+
interface Journal {
|
|
30
|
+
version: 1;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
cwd: string;
|
|
33
|
+
ownerScope: string;
|
|
34
|
+
ownerId: string;
|
|
35
|
+
executions: Execution[];
|
|
36
|
+
}
|
|
37
|
+
export const settlementHash = (value: unknown): string =>
|
|
38
|
+
createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
39
|
+
const ownerId = (): string => {
|
|
40
|
+
const stat = readFileSync("/proc/self/stat", "utf8");
|
|
41
|
+
return [
|
|
42
|
+
readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(),
|
|
43
|
+
process.pid,
|
|
44
|
+
stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19],
|
|
45
|
+
].join(":");
|
|
46
|
+
};
|
|
47
|
+
function ownerAlive(id: string): boolean {
|
|
48
|
+
const [boot, pid, ticks] = id.split(":");
|
|
49
|
+
if (!/^\d+$/.test(pid ?? "") || !/^\d+$/.test(ticks ?? "")) throw Error("invalid runtime journal owner");
|
|
50
|
+
if (readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() !== boot) return false;
|
|
51
|
+
try {
|
|
52
|
+
const text = readFileSync("/proc/" + pid + "/stat", "utf8");
|
|
53
|
+
return text.slice(text.lastIndexOf(")") + 2).split(" ")[19] === ticks;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function validate(value: unknown, sessionId: string, cwd: string): Journal {
|
|
60
|
+
const j = value as Journal;
|
|
61
|
+
if (
|
|
62
|
+
!j ||
|
|
63
|
+
j.version !== 1 ||
|
|
64
|
+
j.sessionId !== sessionId ||
|
|
65
|
+
j.cwd !== cwd ||
|
|
66
|
+
!/^[a-f0-9-]{36}$/.test(j.ownerScope) ||
|
|
67
|
+
typeof j.ownerId !== "string" ||
|
|
68
|
+
!Array.isArray(j.executions) ||
|
|
69
|
+
j.executions.length > 10000
|
|
70
|
+
)
|
|
71
|
+
throw Error("invalid runtime journal identity");
|
|
72
|
+
const ids = new Set<string>();
|
|
73
|
+
for (const e of j.executions) {
|
|
74
|
+
if (
|
|
75
|
+
!e ||
|
|
76
|
+
typeof e.id !== "string" ||
|
|
77
|
+
!e.id ||
|
|
78
|
+
e.id.length > 512 ||
|
|
79
|
+
ids.has(e.id) ||
|
|
80
|
+
!["pending", "settled", "not-started", "unknown"].includes(e.state) ||
|
|
81
|
+
(e.identity !== undefined && (!isCapturedWorkerIdentity(e.identity) || e.identity.executionId !== e.id)) ||
|
|
82
|
+
(e.receiptSha256 !== undefined && !/^[a-f0-9]{64}$/.test(e.receiptSha256)) ||
|
|
83
|
+
(e.state === "settled" && (!e.identity || !e.receiptSha256)) ||
|
|
84
|
+
(e.state === "not-started" && e.identity) ||
|
|
85
|
+
(e.problem !== undefined && typeof e.problem !== "string")
|
|
86
|
+
)
|
|
87
|
+
throw Error("invalid runtime journal execution");
|
|
88
|
+
ids.add(e.id);
|
|
89
|
+
}
|
|
90
|
+
return j;
|
|
91
|
+
}
|
|
92
|
+
export interface RuntimeSettlement {
|
|
93
|
+
readonly ownerScope: string;
|
|
94
|
+
readonly ownerId: string;
|
|
95
|
+
readonly sessionId: string;
|
|
96
|
+
readonly cwd: string;
|
|
97
|
+
begin(id: string): void;
|
|
98
|
+
bind(identity: CapturedWorkerIdentity): void;
|
|
99
|
+
finish(id: string, cleanup: CapturedWorkerCleanup, problem?: string): Promise<void>;
|
|
100
|
+
snapshot(): Promise<{
|
|
101
|
+
ownerScope: string;
|
|
102
|
+
ownerId: string;
|
|
103
|
+
evidenceDigest: string;
|
|
104
|
+
state: "idle" | "busy" | "unknown";
|
|
105
|
+
settledExecutionIds: string[];
|
|
106
|
+
outstandingExecutionIds: string[];
|
|
107
|
+
reason?: string;
|
|
108
|
+
}>;
|
|
109
|
+
}
|
|
110
|
+
export async function openRuntimeSettlement(
|
|
111
|
+
directory: string,
|
|
112
|
+
sessionId: string,
|
|
113
|
+
cwd: string,
|
|
114
|
+
): Promise<RuntimeSettlement> {
|
|
115
|
+
if (process.platform !== "linux" || process.arch !== "x64") throw Error("runtime settlement requires Linux x64");
|
|
116
|
+
if (!sessionId || sessionId.length > 512 || cwd !== realpathSync(cwd))
|
|
117
|
+
throw Error("runtime session/cwd identity is unavailable");
|
|
118
|
+
const root = join(realpathSync(directory), "pi-daddy", "runtime-settlement");
|
|
119
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
120
|
+
if (realpathSync(root) !== root) throw Error("runtime settlement root must not contain symlinks");
|
|
121
|
+
const info = lstatSync(root);
|
|
122
|
+
if (!info.isDirectory() || info.uid !== process.getuid!() || info.mode & 0o077)
|
|
123
|
+
throw Error("runtime settlement root must be private");
|
|
124
|
+
const path = join(root, settlementHash([sessionId, cwd]) + ".json");
|
|
125
|
+
const lock = await lockRuntimeSettlement(path + ".lock");
|
|
126
|
+
try {
|
|
127
|
+
let journal: Journal;
|
|
128
|
+
try {
|
|
129
|
+
journal = validate(JSON.parse(readBoundedTextSync(path, { maxBytes: MAX_RECORD }).text), sessionId, cwd);
|
|
130
|
+
if (ownerAlive(journal.ownerId)) throw Error("prior runtime owner is still alive");
|
|
131
|
+
journal.ownerId = ownerId();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
134
|
+
journal = { version: 1, sessionId, cwd, ownerScope: randomUUID(), ownerId: ownerId(), executions: [] };
|
|
135
|
+
}
|
|
136
|
+
let failed: string | undefined,
|
|
137
|
+
active = new Set<string>();
|
|
138
|
+
const save = () => {
|
|
139
|
+
let temporary: string | undefined, fd: number | undefined;
|
|
140
|
+
try {
|
|
141
|
+
if (!lock.healthy()) throw Error("runtime ownership lock was lost");
|
|
142
|
+
const text = JSON.stringify(journal) + "\n";
|
|
143
|
+
if (Buffer.byteLength(text) > MAX_RECORD) throw Error("runtime settlement journal exceeds its bound");
|
|
144
|
+
temporary = path + "." + randomUUID();
|
|
145
|
+
fd = openSync(
|
|
146
|
+
temporary,
|
|
147
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
148
|
+
0o600,
|
|
149
|
+
);
|
|
150
|
+
writeFileSync(fd, text);
|
|
151
|
+
fsyncSync(fd);
|
|
152
|
+
closeSync(fd);
|
|
153
|
+
fd = undefined;
|
|
154
|
+
renameSync(temporary, path);
|
|
155
|
+
const parent = openSync(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
156
|
+
try {
|
|
157
|
+
fsyncSync(parent);
|
|
158
|
+
} finally {
|
|
159
|
+
closeSync(parent);
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
failed = String(error);
|
|
163
|
+
throw error;
|
|
164
|
+
} finally {
|
|
165
|
+
if (fd !== undefined) closeSync(fd);
|
|
166
|
+
if (temporary)
|
|
167
|
+
try {
|
|
168
|
+
unlinkSync(temporary);
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const entry = (id: string) => {
|
|
173
|
+
const e = journal.executions.find((e) => e.id === id);
|
|
174
|
+
if (!e) throw Error("runtime execution was not registered");
|
|
175
|
+
return e;
|
|
176
|
+
};
|
|
177
|
+
save();
|
|
178
|
+
return {
|
|
179
|
+
ownerScope: journal.ownerScope,
|
|
180
|
+
ownerId: journal.ownerId,
|
|
181
|
+
sessionId,
|
|
182
|
+
cwd,
|
|
183
|
+
begin(id) {
|
|
184
|
+
if (failed) throw Error(failed);
|
|
185
|
+
if (
|
|
186
|
+
typeof id !== "string" ||
|
|
187
|
+
!id ||
|
|
188
|
+
id.length > 512 ||
|
|
189
|
+
journal.executions.some((e) => e.id === id) ||
|
|
190
|
+
journal.executions.length >= 10000
|
|
191
|
+
)
|
|
192
|
+
throw Error("runtime execution id must be fresh and bounded");
|
|
193
|
+
journal.executions.push({ id, state: "pending" });
|
|
194
|
+
active.add(id);
|
|
195
|
+
save();
|
|
196
|
+
},
|
|
197
|
+
bind(identity) {
|
|
198
|
+
const e = entry(identity.executionId);
|
|
199
|
+
if (e.state !== "pending" || (e.identity && !sameWorkerIdentity(e.identity, identity)))
|
|
200
|
+
throw Error("runtime ownership changed");
|
|
201
|
+
e.identity = { ...identity };
|
|
202
|
+
save();
|
|
203
|
+
},
|
|
204
|
+
async finish(id, cleanup, problem) {
|
|
205
|
+
const e = entry(id);
|
|
206
|
+
if (problem) e.problem = problem.slice(0, 2000);
|
|
207
|
+
try {
|
|
208
|
+
if (cleanup.state === "not-started" && !e.identity) e.state = "not-started";
|
|
209
|
+
else if (cleanup.state === "settled" && e.identity && sameWorkerIdentity(e.identity, cleanup.identity)) {
|
|
210
|
+
const receipt = await readCapturedWorkerReceipt(e.identity);
|
|
211
|
+
if (receipt) {
|
|
212
|
+
e.state = "settled";
|
|
213
|
+
e.receiptSha256 = settlementHash(receipt);
|
|
214
|
+
} else e.state = "unknown";
|
|
215
|
+
} else e.state = "unknown";
|
|
216
|
+
save();
|
|
217
|
+
} finally {
|
|
218
|
+
active.delete(id);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
async snapshot() {
|
|
222
|
+
if (!lock.healthy()) failed = "runtime ownership lock was lost";
|
|
223
|
+
let changed = false;
|
|
224
|
+
for (const e of journal.executions) {
|
|
225
|
+
if (active.has(e.id) || !e.identity) continue;
|
|
226
|
+
const receipt = await readCapturedWorkerReceipt(e.identity);
|
|
227
|
+
if (!receipt || (e.receiptSha256 && e.receiptSha256 !== settlementHash(receipt))) {
|
|
228
|
+
if (e.state === "settled") {
|
|
229
|
+
e.state = "unknown";
|
|
230
|
+
changed = true;
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (e.state !== "settled") {
|
|
235
|
+
e.state = "settled";
|
|
236
|
+
e.receiptSha256 = settlementHash(receipt);
|
|
237
|
+
changed = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (changed) {
|
|
241
|
+
try {
|
|
242
|
+
save();
|
|
243
|
+
} catch {}
|
|
244
|
+
}
|
|
245
|
+
const outstanding = journal.executions.filter(
|
|
246
|
+
(e) => !["settled", "not-started"].includes(e.state) || e.problem,
|
|
247
|
+
);
|
|
248
|
+
const state =
|
|
249
|
+
failed || outstanding.some((e) => !active.has(e.id))
|
|
250
|
+
? "unknown"
|
|
251
|
+
: active.size
|
|
252
|
+
? "busy"
|
|
253
|
+
: outstanding.length
|
|
254
|
+
? "unknown"
|
|
255
|
+
: "idle";
|
|
256
|
+
return {
|
|
257
|
+
ownerScope: journal.ownerScope,
|
|
258
|
+
ownerId: journal.ownerId,
|
|
259
|
+
evidenceDigest: settlementHash({ ownerScope: journal.ownerScope, executions: journal.executions }),
|
|
260
|
+
state,
|
|
261
|
+
settledExecutionIds: journal.executions.filter((e) => e.state === "settled").map((e) => e.id),
|
|
262
|
+
outstandingExecutionIds: outstanding.map((e) => e.id),
|
|
263
|
+
...(failed ? { reason: failed } : {}),
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
} catch (error) {
|
|
268
|
+
await lock.release();
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|