pi-daddy 0.45.0 → 0.46.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 +10 -0
- package/README.md +391 -45
- package/contracts/ledger-record/v1/governance-event.schema.json +2 -0
- 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/approval-prompt.d.ts +4 -11
- package/dist/governance/approval-prompt.d.ts.map +1 -1
- package/dist/governance/approval-prompt.js +83 -24
- package/dist/governance/approval-prompt.js.map +1 -1
- package/dist/governance/ledger-v3-validation.js +1 -1
- package/dist/governance/ledger-v3-validation.js.map +1 -1
- package/dist/governance/ledger.d.ts +3 -4
- package/dist/governance/ledger.d.ts.map +1 -1
- package/dist/governance/ledger.js +1 -1
- package/dist/governance/ledger.js.map +1 -1
- 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/activity-timeline.ts +94 -31
- package/extensions/execute-child.ts +98 -74
- package/extensions/execution-controls.ts +42 -0
- package/extensions/executor-session.ts +2 -1
- package/extensions/grants-command.ts +4 -0
- package/extensions/grants.ts +4 -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/approval-prompt.ts +86 -29
- package/src/governance/ledger-v3-validation.ts +1 -1
- package/src/governance/ledger.ts +3 -4
- package/src/governance/runtime-settlement-lock.ts +76 -0
- package/src/governance/runtime-settlement.ts +271 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -43,21 +43,22 @@ export interface PromptRequest {
|
|
|
43
43
|
signal?: AbortSignal;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
/** Only declined means a human chose Deny. Expiry, cancellation and dismissal grant nothing. */
|
|
47
|
+
export const PROMPT_OUTCOME_KINDS = [
|
|
48
|
+
"granted",
|
|
49
|
+
"declined",
|
|
50
|
+
"dismissed",
|
|
51
|
+
"expired",
|
|
52
|
+
"aborted",
|
|
53
|
+
"no-ui",
|
|
54
|
+
"error",
|
|
55
|
+
] as const;
|
|
55
56
|
export type PromptOutcomeKind = (typeof PROMPT_OUTCOME_KINDS)[number];
|
|
56
57
|
|
|
57
58
|
export interface PromptOutcome {
|
|
58
59
|
/** The scope the human chose, or null for any form of no. */
|
|
59
60
|
scope: ApprovalScope | null;
|
|
60
|
-
/** Which
|
|
61
|
+
/** Which outcome this was. Set on every return path. */
|
|
61
62
|
kind: PromptOutcomeKind;
|
|
62
63
|
/** Why, when the answer was no. */
|
|
63
64
|
reason?: string;
|
|
@@ -87,17 +88,14 @@ export interface ApprovalGate {
|
|
|
87
88
|
request(request: PromptRequest): Promise<PromptOutcome>;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
91
|
-
|
|
92
91
|
/**
|
|
93
92
|
* Read `PI_DADDY_APPROVAL_TIMEOUT`, in SECONDS, into the milliseconds pi expects.
|
|
94
93
|
*
|
|
95
|
-
*
|
|
94
|
+
* Absent or literal `0` waits for the human without a deadline. Other values must be canonical whole seconds
|
|
96
95
|
* within Node's signed 32-bit millisecond timer limit; malformed settings refuse a needed prompt.
|
|
97
96
|
*/
|
|
98
97
|
export function timeoutMsFromEnv(raw: string | undefined): number | undefined {
|
|
99
|
-
if (raw === undefined) return
|
|
100
|
-
if (raw === "0") return undefined;
|
|
98
|
+
if (raw === undefined || raw === "0") return undefined;
|
|
101
99
|
if (!/^[1-9][0-9]*$/.test(raw) || Number(raw) > 2_147_483)
|
|
102
100
|
throw new Error(
|
|
103
101
|
"PI_DADDY_APPROVAL_TIMEOUT must be literal 0 or whole seconds 1..2147483 without signs, padding or units",
|
|
@@ -108,6 +106,64 @@ function labelToScope(label: string, scopes: ApprovalScope[]): ApprovalScope | n
|
|
|
108
106
|
return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
|
|
109
107
|
}
|
|
110
108
|
|
|
109
|
+
const interrupted = (request: PromptRequest, kind: "expired" | "aborted"): PromptOutcome => ({
|
|
110
|
+
scope: null,
|
|
111
|
+
kind,
|
|
112
|
+
reason:
|
|
113
|
+
`approval for ${request.capability} ${kind === "expired" ? "expired before an answer" : "was canceled"}. ` +
|
|
114
|
+
"No permission was granted. Retry the delegation to open a new approval prompt.",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/** Own the deadline so Pi's undefined dismissal cannot erase whether our timer or cancellation fired. */
|
|
118
|
+
async function selectApproval(
|
|
119
|
+
options: ApprovalGateOptions,
|
|
120
|
+
request: PromptRequest,
|
|
121
|
+
title: string,
|
|
122
|
+
labels: string[],
|
|
123
|
+
): Promise<string | undefined | PromptOutcome> {
|
|
124
|
+
const timeout = typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs;
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
let outcome: PromptOutcome | undefined;
|
|
127
|
+
let wake!: (value: PromptOutcome) => void;
|
|
128
|
+
const stopped = new Promise<PromptOutcome>((resolve) => {
|
|
129
|
+
wake = resolve;
|
|
130
|
+
});
|
|
131
|
+
const stop = (kind: "expired" | "aborted") => {
|
|
132
|
+
if (outcome) return;
|
|
133
|
+
outcome = interrupted(request, kind);
|
|
134
|
+
// Resolve our tagged outcome before a UI reacts to abort by returning undefined.
|
|
135
|
+
wake(outcome);
|
|
136
|
+
controller.abort();
|
|
137
|
+
};
|
|
138
|
+
const abort = () => stop("aborted");
|
|
139
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
140
|
+
const timer = timeout === undefined ? undefined : setTimeout(() => stop("expired"), timeout);
|
|
141
|
+
try {
|
|
142
|
+
if (request.signal?.aborted) stop("aborted");
|
|
143
|
+
if (outcome) return outcome;
|
|
144
|
+
const chosen = await Promise.race([options.ui.select(title, labels, { signal: controller.signal }), stopped]);
|
|
145
|
+
return outcome ?? chosen;
|
|
146
|
+
} finally {
|
|
147
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
148
|
+
request.signal?.removeEventListener("abort", abort);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function joinApproval(pending: Promise<PromptOutcome>, request: PromptRequest): Promise<PromptOutcome> {
|
|
153
|
+
if (!request.signal) return pending;
|
|
154
|
+
let abort!: () => void;
|
|
155
|
+
const stopped = new Promise<PromptOutcome>((resolve) => {
|
|
156
|
+
abort = () => resolve(interrupted(request, "aborted"));
|
|
157
|
+
request.signal!.addEventListener("abort", abort, { once: true });
|
|
158
|
+
if (request.signal!.aborted) abort();
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
return await Promise.race([pending, stopped]);
|
|
162
|
+
} finally {
|
|
163
|
+
request.signal.removeEventListener("abort", abort);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
111
167
|
/**
|
|
112
168
|
* The single-flight queue: one dialog per approval key at a time.
|
|
113
169
|
*
|
|
@@ -160,24 +216,27 @@ export function createApprovalGate(
|
|
|
160
216
|
`grants: approve ${request.capability} for ${request.subject}?` +
|
|
161
217
|
(request.task ? `\n task: ${request.task}` : "");
|
|
162
218
|
|
|
163
|
-
let chosen: string | undefined;
|
|
219
|
+
let chosen: string | undefined | PromptOutcome;
|
|
164
220
|
try {
|
|
165
|
-
chosen = await options
|
|
166
|
-
timeout: typeof options.timeoutMs === "function" ? options.timeoutMs() : options.timeoutMs,
|
|
167
|
-
signal: request.signal,
|
|
168
|
-
});
|
|
221
|
+
chosen = await selectApproval(options, request, title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])]);
|
|
169
222
|
} catch (error) {
|
|
170
223
|
// A governance layer that errors must deny, not permit. This is a dialog malfunction, not a
|
|
171
224
|
// person's answer — `kind: "error"` keeps it out of "declined".
|
|
172
225
|
return { scope: null, kind: "error", reason: `approval dialog failed, denying (${String(error)})` };
|
|
173
226
|
}
|
|
174
227
|
|
|
175
|
-
if (request.signal?.aborted)
|
|
176
|
-
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
|
|
228
|
+
if (request.signal?.aborted) return interrupted(request, "aborted");
|
|
177
229
|
|
|
178
|
-
|
|
230
|
+
if (typeof chosen === "object") return chosen;
|
|
231
|
+
// Only an ordinary UI dismissal reaches undefined; our deadline and abort carry explicit outcomes.
|
|
179
232
|
if (chosen === undefined) {
|
|
180
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
scope: null,
|
|
235
|
+
kind: "dismissed",
|
|
236
|
+
reason:
|
|
237
|
+
`approval for ${request.capability} was dismissed. No permission was granted. ` +
|
|
238
|
+
"Retry the delegation to open a new approval prompt.",
|
|
239
|
+
};
|
|
181
240
|
}
|
|
182
241
|
if (chosen === DENY_LABEL) {
|
|
183
242
|
return { scope: null, kind: "declined", reason: `${request.capability} was denied by a human` };
|
|
@@ -191,8 +250,7 @@ export function createApprovalGate(
|
|
|
191
250
|
|
|
192
251
|
return {
|
|
193
252
|
async request(request: PromptRequest): Promise<PromptOutcome> {
|
|
194
|
-
if (request.signal?.aborted)
|
|
195
|
-
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
|
|
253
|
+
if (request.signal?.aborted) return interrupted(request, "aborted");
|
|
196
254
|
if (!options.hasUI) {
|
|
197
255
|
// Nobody was there to ask — distinct from a person declining, which is why this is its own kind
|
|
198
256
|
// rather than being folded into "declined" (see PromptOutcomeKind).
|
|
@@ -215,9 +273,8 @@ export function createApprovalGate(
|
|
|
215
273
|
for (;;) {
|
|
216
274
|
const existing = inFlight.get(key);
|
|
217
275
|
if (!existing) break;
|
|
218
|
-
const outcome = await existing;
|
|
219
|
-
if (request.signal?.aborted)
|
|
220
|
-
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was canceled` };
|
|
276
|
+
const outcome = await joinApproval(existing, request);
|
|
277
|
+
if (request.signal?.aborted) return interrupted(request, "aborted");
|
|
221
278
|
// **`joined` marks the rider, and the ledger depends on it (R-66).** Sharing a non-`once` outcome
|
|
222
279
|
// is correct — the human authorised the capability for the session, not for one child — but the
|
|
223
280
|
// caller then stamped `approvalSource: "prompt"` on every one of them, so a fan-out of eight wrote
|
|
@@ -23,7 +23,7 @@ const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
|
23
23
|
const EXECUTORS = new Set(["process", "herdr"]);
|
|
24
24
|
const APPROVAL_SOURCES = new Set(["prompt", "session", "persisted", "inherited"]);
|
|
25
25
|
const APPROVAL_SCOPES = new Set(["once", "session", "always"]);
|
|
26
|
-
const GATE_OUTCOMES = new Set(["declined", "dismissed", "no-ui", "error"]);
|
|
26
|
+
const GATE_OUTCOMES = new Set(["declined", "dismissed", "expired", "aborted", "no-ui", "error"]);
|
|
27
27
|
const LEASE_OUTCOMES = new Set([
|
|
28
28
|
"acquired",
|
|
29
29
|
"uncontended",
|
package/src/governance/ledger.ts
CHANGED
|
@@ -44,7 +44,7 @@ export const LEDGER_EVENT_KINDS = [
|
|
|
44
44
|
"episode_outcome",
|
|
45
45
|
] as const;
|
|
46
46
|
export type LedgerEventKind = (typeof LEDGER_EVENT_KINDS)[number];
|
|
47
|
-
export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
|
|
47
|
+
export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "expired", "aborted", "no-ui", "error"] as const;
|
|
48
48
|
export type LedgerGateOutcome = (typeof LEDGER_GATE_OUTCOMES)[number];
|
|
49
49
|
|
|
50
50
|
export interface LedgerEventBase {
|
|
@@ -137,9 +137,8 @@ export interface GrantRecord extends LedgerEventBase {
|
|
|
137
137
|
* The discriminant was already computed and thrown away. ADR-0026 leans on this vocabulary being able to
|
|
138
138
|
* say *"nobody was there to ask"* and be believed, so it is recorded rather than inferred.
|
|
139
139
|
*
|
|
140
|
-
* **Privacy is unchanged**: this is a fixed
|
|
141
|
-
*
|
|
142
|
-
* four-member ledger enum because the approval source/scope fields already record a yes.
|
|
140
|
+
* **Privacy is unchanged**: this is a fixed enum, not task-authored text. Explicit expiry and caller
|
|
141
|
+
* cancellation have their own outcomes; `granted` is omitted because approval source/scope records a yes.
|
|
143
142
|
*/
|
|
144
143
|
gateOutcome?: LedgerGateOutcome;
|
|
145
144
|
/**
|