pi-daddy 0.45.0 → 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 +4 -0
- package/README.md +355 -45
- 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/extensions/execute-child.ts +98 -74
- package/extensions/executor-session.ts +2 -1
- package/extensions/grants.ts +3 -0
- package/extensions/reload-environment.ts +8 -0
- package/extensions/runtime-snapshot.ts +123 -0
- package/extensions/session.ts +1 -1
- 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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Trusted extension event bridge. Tool calls and model-produced text cannot supply runtime facts. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { openRuntimeSettlement, settlementHash } from "../src/governance/runtime-settlement.ts";
|
|
5
|
+
import { reconcileDelegationCapacity } from "./session-capacity.ts";
|
|
6
|
+
import { agentDir } from "../src/kernel/project-paths.ts";
|
|
7
|
+
import type { GrantsSession } from "./session.ts";
|
|
8
|
+
export const RUNTIME_SNAPSHOT_EVENT = "pi-daddy:runtime-snapshot:v1";
|
|
9
|
+
export function registerRuntimeSnapshot(
|
|
10
|
+
pi: ExtensionAPI,
|
|
11
|
+
session: GrantsSession,
|
|
12
|
+
): {
|
|
13
|
+
bind(ctx: ExtensionContext): Promise<void>;
|
|
14
|
+
} {
|
|
15
|
+
let bound: { sessionId: string; cwd: string; lifecycle: object } | undefined, problem: string | undefined;
|
|
16
|
+
const generation = {};
|
|
17
|
+
let bindSequence = 0;
|
|
18
|
+
const unsubscribe = pi.events?.on?.(RUNTIME_SNAPSHOT_EVENT, (input: unknown) => {
|
|
19
|
+
const request = input as {
|
|
20
|
+
version?: number;
|
|
21
|
+
requestId?: string;
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
reply?: (value: unknown) => void;
|
|
25
|
+
};
|
|
26
|
+
if (
|
|
27
|
+
request?.version !== 1 ||
|
|
28
|
+
typeof request.requestId !== "string" ||
|
|
29
|
+
request.requestId.length > 512 ||
|
|
30
|
+
typeof request.reply !== "function"
|
|
31
|
+
)
|
|
32
|
+
return;
|
|
33
|
+
if (bound && session.reloadLifecycle.runtimeSnapshotGeneration !== generation) return;
|
|
34
|
+
void (async () => {
|
|
35
|
+
const current = bound,
|
|
36
|
+
lifecycle = session.reloadLifecycle;
|
|
37
|
+
const unavailable = (reason: string) => ({
|
|
38
|
+
version: 1,
|
|
39
|
+
requestId: request.requestId,
|
|
40
|
+
sessionId: current?.sessionId ?? "",
|
|
41
|
+
cwd: current?.cwd ?? "",
|
|
42
|
+
ownerScope: "",
|
|
43
|
+
ownerId: "",
|
|
44
|
+
backend: session.executor.kind,
|
|
45
|
+
qualified: false,
|
|
46
|
+
state: "unknown",
|
|
47
|
+
evidenceDigest: settlementHash({ reason }),
|
|
48
|
+
settledExecutionIds: [],
|
|
49
|
+
outstandingExecutionIds: [],
|
|
50
|
+
reason,
|
|
51
|
+
});
|
|
52
|
+
if (
|
|
53
|
+
!current ||
|
|
54
|
+
current.lifecycle !== lifecycle ||
|
|
55
|
+
request.sessionId !== current.sessionId ||
|
|
56
|
+
request.cwd !== current.cwd ||
|
|
57
|
+
!lifecycle.runtimeSettlement
|
|
58
|
+
) {
|
|
59
|
+
request.reply!(unavailable(problem ?? "runtime owner is not ready"));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
if (Array.isArray(session.capacity.retainedReservations)) await reconcileDelegationCapacity(session);
|
|
64
|
+
const snapshot = await lifecycle.runtimeSettlement.snapshot();
|
|
65
|
+
if (
|
|
66
|
+
bound !== current ||
|
|
67
|
+
session.reloadLifecycle !== lifecycle ||
|
|
68
|
+
lifecycle.runtimeSnapshotGeneration !== generation
|
|
69
|
+
) {
|
|
70
|
+
request.reply!(unavailable("runtime owner changed during snapshot"));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const qualified = !session.executor.refusal && !session.discoveryCleanupFailure && !session.capacityRefusal;
|
|
74
|
+
request.reply!({
|
|
75
|
+
version: 1,
|
|
76
|
+
requestId: request.requestId,
|
|
77
|
+
sessionId: current.sessionId,
|
|
78
|
+
cwd: current.cwd,
|
|
79
|
+
...snapshot,
|
|
80
|
+
backend: session.executor.kind,
|
|
81
|
+
qualified,
|
|
82
|
+
state: !qualified
|
|
83
|
+
? "unknown"
|
|
84
|
+
: session.capacity.reserved > 0 && snapshot.state === "idle"
|
|
85
|
+
? "busy"
|
|
86
|
+
: snapshot.state,
|
|
87
|
+
...(problem ? { reason: problem } : {}),
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
request.reply!(unavailable(String(error)));
|
|
91
|
+
}
|
|
92
|
+
})().catch(() => undefined);
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
async bind(ctx) {
|
|
96
|
+
const sequence = ++bindSequence,
|
|
97
|
+
lifecycle = session.reloadLifecycle;
|
|
98
|
+
lifecycle.runtimeSettlement = undefined;
|
|
99
|
+
bound = undefined;
|
|
100
|
+
problem = undefined;
|
|
101
|
+
try {
|
|
102
|
+
const sessionId = ctx.sessionManager.getSessionId(),
|
|
103
|
+
cwd = await realpath(ctx.cwd);
|
|
104
|
+
if (sequence !== bindSequence || session.reloadLifecycle !== lifecycle) return;
|
|
105
|
+
if (lifecycle.runtimeSnapshotUnsubscribe !== unsubscribe) lifecycle.runtimeSnapshotUnsubscribe?.();
|
|
106
|
+
lifecycle.runtimeSnapshotUnsubscribe = unsubscribe;
|
|
107
|
+
lifecycle.runtimeSnapshotGeneration = generation;
|
|
108
|
+
lifecycle.runtimeSettlements ??= new Map();
|
|
109
|
+
const key = JSON.stringify([sessionId, cwd]);
|
|
110
|
+
let runtime = lifecycle.runtimeSettlements.get(key);
|
|
111
|
+
if (!runtime) {
|
|
112
|
+
runtime = await openRuntimeSettlement(agentDir(), sessionId, cwd);
|
|
113
|
+
lifecycle.runtimeSettlements.set(key, runtime);
|
|
114
|
+
}
|
|
115
|
+
if (sequence !== bindSequence || session.reloadLifecycle !== lifecycle) return;
|
|
116
|
+
lifecycle.runtimeSettlement = runtime;
|
|
117
|
+
bound = { sessionId, cwd, lifecycle: session.reloadLifecycle };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (sequence === bindSequence && session.reloadLifecycle === lifecycle) problem = String(error);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
package/extensions/session.ts
CHANGED
|
@@ -693,7 +693,7 @@ export function createGrantsSession(
|
|
|
693
693
|
// Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
|
|
694
694
|
// has not run when this session object is built, so a captured value would plan `--print` for a session
|
|
695
695
|
// that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
|
|
696
|
-
interactive:
|
|
696
|
+
interactive: false, // Both qualified backends use the same one-shot Pi JSON protocol.
|
|
697
697
|
...(approved ? { approved } : {}),
|
|
698
698
|
}),
|
|
699
699
|
storeCwd,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -15,6 +15,7 @@ export async function runCapturedExecution(
|
|
|
15
15
|
sessionPath: string;
|
|
16
16
|
onReadCleanup?: (error: BoundedReadCleanupError) => void;
|
|
17
17
|
},
|
|
18
|
+
runOwned: typeof runOwnedChild = runOwnedChild,
|
|
18
19
|
): Promise<CapturedExecutionResult> {
|
|
19
20
|
let directory: string;
|
|
20
21
|
try {
|
|
@@ -36,7 +37,7 @@ export async function runCapturedExecution(
|
|
|
36
37
|
}
|
|
37
38
|
const capture = new ChildFinalCapture(request.onOutput, request.onReadCleanup);
|
|
38
39
|
const args = [...request.args.slice(0, -1), "--mode", "json", request.args.at(-1)!];
|
|
39
|
-
const output = await
|
|
40
|
+
const output = await runOwned({
|
|
40
41
|
...request,
|
|
41
42
|
args,
|
|
42
43
|
ownershipDir: join(directory, "ownership"),
|
|
@@ -44,15 +44,17 @@ export function needsProbe(raw: string | undefined): boolean {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export const HERDR_UNQUALIFIED_REASON =
|
|
47
|
-
"Herdr governed execution is not qualified for complete current finals and subtree cleanup
|
|
47
|
+
"Herdr governed execution is not qualified for complete current finals and subtree cleanup on the detected transport. " +
|
|
48
48
|
"Delegation is refused; set PI_DADDY_HERDR=0 explicitly to select captured execution. No backend fallback occurs.";
|
|
49
49
|
|
|
50
50
|
export function chooseExecutor(raw: string | undefined, probe: HerdrProbe | null): ExecutorChoice {
|
|
51
51
|
const choice = selectExecutor(raw, probe);
|
|
52
52
|
if (choice.kind !== "herdr") return choice;
|
|
53
|
+
if (!choice.refusal && probe?.qualified === true)
|
|
54
|
+
return { ...choice, disclosure: `${choice.disclosure} — qualified native ownership` };
|
|
53
55
|
return {
|
|
54
56
|
...choice,
|
|
55
|
-
refusal: [choice.refusal, HERDR_UNQUALIFIED_REASON].filter(Boolean).join(" "),
|
|
57
|
+
refusal: [choice.refusal, HERDR_UNQUALIFIED_REASON, probe?.qualificationReason].filter(Boolean).join(" "),
|
|
56
58
|
disclosure: `${choice.disclosure} — unqualified; governed delegation will refuse`,
|
|
57
59
|
};
|
|
58
60
|
}
|
|
@@ -19,17 +19,22 @@ export type HerdrExec = (args: string[]) => Promise<{ code: number | null; stdou
|
|
|
19
19
|
|
|
20
20
|
export const defaultExec: HerdrExec = (args) =>
|
|
21
21
|
new Promise((settle) => {
|
|
22
|
-
execFile(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
execFile(
|
|
23
|
+
"herdr",
|
|
24
|
+
args,
|
|
25
|
+
{ maxBuffer: 32 * 1024 * 1024, timeout: 10000, killSignal: "SIGKILL" },
|
|
26
|
+
(error, stdout, stderr) => {
|
|
27
|
+
const raw = (error as { code?: unknown } | null)?.code;
|
|
28
|
+
const code = typeof raw === "number" ? raw : error ? 1 : 0;
|
|
29
|
+
// **A string `code` is a spawn failure, and it used to be thrown away.** `ENOENT` — herdr not installed —
|
|
30
|
+
// arrives as `code: "ENOENT"`, so the numeric test failed, the message was dropped, and an operator with
|
|
31
|
+
// `PI_DADDY_HERDR=1` on a machine without herdr was told *"herdr is not answering (unparseable herdr
|
|
32
|
+
// reply: (no output))"* rather than that the binary is missing. Rule 8 wants the loud version, and this is
|
|
33
|
+
// the first diagnostic such an operator meets.
|
|
34
|
+
const spawnFailure = typeof raw === "string" ? `herdr could not be run (${raw}): ${error?.message ?? ""}` : "";
|
|
35
|
+
settle({ code, stdout: String(stdout), stderr: spawnFailure || String(stderr) });
|
|
36
|
+
},
|
|
37
|
+
);
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
/**
|
|
@@ -44,7 +49,7 @@ export function parseReply(reply: { stdout: string; stderr: string }): {
|
|
|
44
49
|
error?: string;
|
|
45
50
|
} {
|
|
46
51
|
try {
|
|
47
|
-
const parsed = JSON.parse(reply.stdout) as {
|
|
52
|
+
const parsed = JSON.parse(reply.stdout.trim() || reply.stderr.trim()) as {
|
|
48
53
|
result?: Record<string, unknown>;
|
|
49
54
|
error?: { message?: string; code?: string };
|
|
50
55
|
};
|
|
@@ -63,6 +68,8 @@ export const PROBE_TIMEOUT_MS = 2000;
|
|
|
63
68
|
|
|
64
69
|
export interface HerdrProbe {
|
|
65
70
|
ok: boolean;
|
|
71
|
+
qualified?: boolean;
|
|
72
|
+
qualificationReason?: string;
|
|
66
73
|
/** herdr's own words when it is not reachable. Carried so the disclosure line can name the reason. */
|
|
67
74
|
error?: string;
|
|
68
75
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Packaged pane entrypoint. Parent liveness is a private socket, native worker liveness is its control pipe. */
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { processTreeActivity } from "./process-activity.ts";
|
|
4
|
+
import { createConnection } from "node:net";
|
|
5
|
+
import { runOwnedChild, type OwnedChildRunRequest } from "./owned-worker.ts";
|
|
6
|
+
import { wire } from "./herdr-wire.ts";
|
|
7
|
+
export async function launchHerdrWorker(socketPath: string, token: string): Promise<void> {
|
|
8
|
+
const socket = createConnection(socketPath);
|
|
9
|
+
const abort = new AbortController();
|
|
10
|
+
let started = false,
|
|
11
|
+
authenticated = false,
|
|
12
|
+
acknowledge: (() => void) | undefined,
|
|
13
|
+
rejectGate: ((error: Error) => void) | undefined;
|
|
14
|
+
const lost = (error: Error) => {
|
|
15
|
+
abort.abort();
|
|
16
|
+
rejectGate?.(error);
|
|
17
|
+
};
|
|
18
|
+
socket.on("close", () => lost(Error("Herdr coordinator connection closed")));
|
|
19
|
+
const send = wire(
|
|
20
|
+
socket,
|
|
21
|
+
(message) => {
|
|
22
|
+
if (message?.type === "cancel") {
|
|
23
|
+
abort.abort();
|
|
24
|
+
rejectGate?.(Error("Herdr coordinator cancelled startup"));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (message?.type === "start" && authenticated) {
|
|
28
|
+
acknowledge?.();
|
|
29
|
+
acknowledge = undefined;
|
|
30
|
+
rejectGate = undefined;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (message?.type === "display" && authenticated && typeof message.text === "string") {
|
|
34
|
+
process.stdout.write(message.text);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (message?.type !== "request" || started) {
|
|
38
|
+
lost(Error("invalid Herdr coordinator request"));
|
|
39
|
+
socket.destroy();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
started = true;
|
|
43
|
+
authenticated = true;
|
|
44
|
+
const request = message.request as OwnedChildRunRequest & { sessionPath?: string };
|
|
45
|
+
let childPid: number | undefined;
|
|
46
|
+
void (async () => {
|
|
47
|
+
try {
|
|
48
|
+
const result = await runOwnedChild({
|
|
49
|
+
...request,
|
|
50
|
+
signal: abort.signal,
|
|
51
|
+
activityProbe: async () => {
|
|
52
|
+
const file = request.sessionPath
|
|
53
|
+
? await stat(request.sessionPath)
|
|
54
|
+
.then((s) => String(s.size) + ":" + s.mtimeMs)
|
|
55
|
+
.catch(() => undefined)
|
|
56
|
+
: undefined;
|
|
57
|
+
const tree = childPid === undefined ? undefined : await processTreeActivity(childPid);
|
|
58
|
+
return file === undefined && tree === undefined ? undefined : String(file) + "|" + tree;
|
|
59
|
+
},
|
|
60
|
+
onOwnership: (identity) =>
|
|
61
|
+
new Promise<void>((resolve, reject) => {
|
|
62
|
+
acknowledge = resolve;
|
|
63
|
+
rejectGate = reject;
|
|
64
|
+
if (abort.signal.aborted) {
|
|
65
|
+
reject(Error("Herdr coordinator unavailable before ownership gate"));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
send({ type: "ownership", identity });
|
|
69
|
+
}),
|
|
70
|
+
onObservation: (stream, bytes) => {
|
|
71
|
+
try {
|
|
72
|
+
const input = Buffer.from(bytes);
|
|
73
|
+
for (let offset = 0; offset < input.length; offset += 65536)
|
|
74
|
+
send({ type: "bytes", stream, data: input.subarray(offset, offset + 65536).toString("base64") });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
lost(error instanceof Error ? error : Error(String(error)));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
onStreamEnd: (stream) => {
|
|
80
|
+
try {
|
|
81
|
+
send({ type: "stream-end", stream });
|
|
82
|
+
} catch {}
|
|
83
|
+
},
|
|
84
|
+
onSpawn: (pid) => {
|
|
85
|
+
childPid = pid;
|
|
86
|
+
send({ type: "spawned", pid });
|
|
87
|
+
process.stdout.write("pi-daddy: governed child running\n");
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
if (!socket.destroyed) {
|
|
91
|
+
send({ type: "result", result });
|
|
92
|
+
socket.end();
|
|
93
|
+
}
|
|
94
|
+
process.stdout.write("\npi-daddy: " + result.cleanup.state + "\n");
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!socket.destroyed) {
|
|
97
|
+
try {
|
|
98
|
+
send({ type: "failure", reason: String(error) });
|
|
99
|
+
} finally {
|
|
100
|
+
socket.end();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
},
|
|
106
|
+
lost,
|
|
107
|
+
);
|
|
108
|
+
await new Promise<void>((resolve, reject) => {
|
|
109
|
+
socket.once("connect", () => {
|
|
110
|
+
try {
|
|
111
|
+
send({ type: "hello", token });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
reject(error);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
socket.once("close", resolve);
|
|
117
|
+
socket.once("error", reject);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (/herdr-launcher\.(?:js|ts)$/.test(process.argv[1] ?? "")) {
|
|
121
|
+
const [, , path, token] = process.argv;
|
|
122
|
+
if (!path || !token) throw Error("Herdr launcher requires its private socket and nonce");
|
|
123
|
+
await launchHerdrWorker(path, token);
|
|
124
|
+
}
|
|
@@ -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
|
+
}
|