owlrunkit 0.24.1 → 0.25.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 +22 -0
- package/LINUX-HELPERS.sha256 +2 -0
- package/NOTICE.md +17 -132
- package/README.md +84 -4
- package/SOURCE.md +26 -8
- package/docs/architecture/OWLCODA_RUNKIT_USAGE_MODE_SELECTION_PRODUCT_DECISION_20260811.md +1 -1
- package/integrations/codex/skills/owlcoda-runkit/SKILL.md +22 -9
- package/integrations/codex/skills/owlcoda-runkit/assets/templates/config.json +3 -3
- package/package.json +1 -1
- package/packages/attest/distribution-manifest-v1.json +10 -0
- package/packages/attest/src/quick.mjs +10 -0
- package/registry-release-policy-v2.json +5 -5
- package/sbom.spdx.json +5 -5
- package/scripts/runkit-contract/control-transaction-lock.mjs +108 -0
- package/scripts/runkit-contract/core-contract.mjs +5 -1
- package/scripts/runkit-contract/lease-lifecycle.mjs +14 -21
- package/scripts/runkit-contract/linux-runtime.mjs +13 -0
- package/scripts/runkit-contract/peer-linux-arm64 +0 -0
- package/scripts/runkit-contract/peer-linux-x64 +0 -0
- package/scripts/runkit-contract/peer-linux.MUSL-COPYRIGHT +193 -0
- package/scripts/runkit-contract/peer-linux.c +243 -0
- package/scripts/runkit-contract/peer-recovery.mjs +495 -0
- package/scripts/runkit-contract/project-cli-resolver.mjs +8 -0
- package/scripts/runkit-contract/resume-execution.mjs +2 -8
- package/scripts/runkit-contract/runkit-cli.mjs +57 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
accessSync, closeSync, constants, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync,
|
|
5
|
+
mkdtempSync, openSync, readSync, readdirSync, realpathSync, unlinkSync,
|
|
6
|
+
watch, writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { RUNTIME_ROOT, currentCoreIdentity } from "./core-contract.mjs";
|
|
12
|
+
import { linuxRuntimeHelper } from "./linux-runtime.mjs";
|
|
13
|
+
import { safeIdentifier } from "./provenance-common.mjs";
|
|
14
|
+
import { resolveVerificationEnvelopeBackendV1 } from "./verification-envelope.mjs";
|
|
15
|
+
|
|
16
|
+
const SCHEMA = "OwlRunKitPeerRecoveryRevisionV1";
|
|
17
|
+
const CHECKPOINT_LIMIT = 1024 * 1024;
|
|
18
|
+
const RECORD_LIMIT = 2 * CHECKPOINT_LIMIT;
|
|
19
|
+
const MAX_REVISIONS = 4096;
|
|
20
|
+
const REVISION_NAME = /^([0-9]{6})\.json$/u;
|
|
21
|
+
|
|
22
|
+
function digest(value) {
|
|
23
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function realDirectory(ref) {
|
|
27
|
+
if (!lstatSync(ref).isDirectory() || realpathSync(ref) !== ref) {
|
|
28
|
+
throw new Error("peer_recovery_redirected_directory");
|
|
29
|
+
}
|
|
30
|
+
return ref;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function directoryBelow(root, segments, create) {
|
|
34
|
+
let current = root;
|
|
35
|
+
for (const segment of segments) {
|
|
36
|
+
current = path.join(current, segment);
|
|
37
|
+
if (create && !existsSync(current)) {
|
|
38
|
+
try { mkdirSync(current, { mode: 0o700 }); }
|
|
39
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
40
|
+
}
|
|
41
|
+
realDirectory(current);
|
|
42
|
+
}
|
|
43
|
+
return current;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function taskDirectory(workspaceRoot, taskId, create = false) {
|
|
47
|
+
safeIdentifier(taskId, "peer recovery task");
|
|
48
|
+
const root = realpathSync(workspaceRoot);
|
|
49
|
+
const runtime = directoryBelow(root, RUNTIME_ROOT.split("/"), false);
|
|
50
|
+
const directory = directoryBelow(runtime, ["peer-recovery", taskId], create);
|
|
51
|
+
return { root, directory, taskId, core: currentCoreIdentity() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function regularBytes(ref, limit) {
|
|
55
|
+
const stat = lstatSync(ref);
|
|
56
|
+
if (!stat.isFile() || stat.size > limit || realpathSync(ref) !== ref) {
|
|
57
|
+
throw new Error("peer_recovery_invalid_file");
|
|
58
|
+
}
|
|
59
|
+
const fd = openSync(ref, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
60
|
+
try {
|
|
61
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
62
|
+
let length = 0;
|
|
63
|
+
while (length < buffer.length) {
|
|
64
|
+
const bytes = readSync(fd, buffer, length, buffer.length - length, null);
|
|
65
|
+
if (bytes === 0) break;
|
|
66
|
+
length += bytes;
|
|
67
|
+
}
|
|
68
|
+
if (length > limit) throw new Error("peer_recovery_file_too_large");
|
|
69
|
+
return buffer.subarray(0, length);
|
|
70
|
+
} finally { closeSync(fd); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function revisionPath(task, revision) {
|
|
74
|
+
return path.join(task.directory, `${String(revision).padStart(6, "0")}.json`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readRevision(task, revision) {
|
|
78
|
+
const record = JSON.parse(regularBytes(revisionPath(task, revision), RECORD_LIMIT).toString("utf8"));
|
|
79
|
+
if (record.schemaVersion !== SCHEMA || record.workspaceRoot !== task.root
|
|
80
|
+
|| record.taskId !== task.taskId || record.revision !== revision
|
|
81
|
+
|| record.authorizationGranted !== false || !Number.isSafeInteger(record.generation)
|
|
82
|
+
|| record.generation < 1 || !Number.isSafeInteger(record.checkpointRevision)
|
|
83
|
+
|| record.checkpointRevision < 1 || record.checkpointRevision > revision
|
|
84
|
+
|| !["ready", "running", "exited", "completed"].includes(record.phase)
|
|
85
|
+
|| JSON.stringify(validatePaths(record.paths)) !== JSON.stringify(record.paths)) {
|
|
86
|
+
throw new Error("peer_recovery_revision_invalid");
|
|
87
|
+
}
|
|
88
|
+
if (record.core?.coreManifestSha256 !== task.core.coreManifestSha256
|
|
89
|
+
|| record.core?.coreVersion !== task.core.coreVersion) {
|
|
90
|
+
throw new Error("peer_recovery_core_pin_mismatch");
|
|
91
|
+
}
|
|
92
|
+
safeIdentifier(record.owner, "recorded current peer");
|
|
93
|
+
safeIdentifier(record.successor, "recorded successor peer");
|
|
94
|
+
return record;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function current(task) {
|
|
98
|
+
const revisions = readdirSync(task.directory).map(name => REVISION_NAME.exec(name))
|
|
99
|
+
.filter(Boolean).map(match => Number(match[1])).sort((a, b) => a - b);
|
|
100
|
+
if (revisions.length === 0) throw new Error("peer_recovery_task_uninitialized");
|
|
101
|
+
if (revisions.some((revision, index) => revision !== index + 1)) {
|
|
102
|
+
throw new Error("peer_recovery_revision_gap");
|
|
103
|
+
}
|
|
104
|
+
return readRevision(task, revisions.length);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Exactly one contender can link the next revision. A crash before the link
|
|
108
|
+
// leaves only ignored scratch, and after the link the whole transition is visible.
|
|
109
|
+
function append(task, previous, fields) {
|
|
110
|
+
const revision = (previous?.revision ?? 0) + 1;
|
|
111
|
+
if (revision > MAX_REVISIONS) throw new Error("peer_recovery_revision_limit");
|
|
112
|
+
const record = {
|
|
113
|
+
...previous, files: undefined, ...fields,
|
|
114
|
+
schemaVersion: SCHEMA, workspaceRoot: task.root, taskId: task.taskId, core: task.core,
|
|
115
|
+
revision, recordedAt: new Date().toISOString(), authorizationGranted: false,
|
|
116
|
+
};
|
|
117
|
+
const bytes = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
118
|
+
if (bytes.length > RECORD_LIMIT) throw new Error("peer_recovery_record_too_large");
|
|
119
|
+
const scratch = path.join(task.directory, `.tmp-${randomUUID()}`);
|
|
120
|
+
const fd = openSync(scratch, "wx", 0o600);
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(fd, bytes);
|
|
123
|
+
fsyncSync(fd);
|
|
124
|
+
try { linkSync(scratch, revisionPath(task, revision)); }
|
|
125
|
+
catch (error) { if (error.code === "EEXIST") return null; throw error; }
|
|
126
|
+
const directoryFd = openSync(task.directory, "r");
|
|
127
|
+
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
|
|
128
|
+
return record;
|
|
129
|
+
} finally { closeSync(fd); unlinkSync(scratch); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validatePaths(paths) {
|
|
133
|
+
if (!Array.isArray(paths) || paths.length < 1 || paths.length > 64) {
|
|
134
|
+
throw new Error("peer_recovery_paths_required");
|
|
135
|
+
}
|
|
136
|
+
const normalized = paths.map(ref => {
|
|
137
|
+
if (typeof ref !== "string" || ref.length > 240 || /[\\:\p{Cc}]/u.test(ref)
|
|
138
|
+
|| ref.split("/").some(part => !part || part === "." || part === ".."
|
|
139
|
+
|| /^(?:\.git|\.owlcoda|node_modules)$/iu.test(part))) {
|
|
140
|
+
throw new Error("peer_recovery_invalid_path");
|
|
141
|
+
}
|
|
142
|
+
return ref;
|
|
143
|
+
}).sort();
|
|
144
|
+
const folded = normalized.map(ref => ref.normalize("NFC").toLowerCase()).sort();
|
|
145
|
+
if (folded.some((ref, index) => index && (ref === folded[index - 1]
|
|
146
|
+
|| folded.some(parent => ref.startsWith(`${parent}/`))))) {
|
|
147
|
+
throw new Error("peer_recovery_overlapping_paths");
|
|
148
|
+
}
|
|
149
|
+
return normalized;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkedFiles(paths, input) {
|
|
153
|
+
if (!Array.isArray(input) || input.length !== paths.length) {
|
|
154
|
+
throw new Error("peer_recovery_checkpoint_paths_mismatch");
|
|
155
|
+
}
|
|
156
|
+
let bytes = 0;
|
|
157
|
+
const files = paths.map((ref, index) => {
|
|
158
|
+
const item = input[index];
|
|
159
|
+
if (item?.path !== ref || (item.content !== null && typeof item.content !== "string")) {
|
|
160
|
+
throw new Error("peer_recovery_checkpoint_paths_mismatch");
|
|
161
|
+
}
|
|
162
|
+
if (item.content !== null) {
|
|
163
|
+
const content = Buffer.from(item.content, "base64");
|
|
164
|
+
if (content.toString("base64") !== item.content) throw new Error("peer_recovery_checkpoint_base64_invalid");
|
|
165
|
+
bytes += content.length;
|
|
166
|
+
if (bytes > CHECKPOINT_LIMIT) throw new Error("peer_recovery_checkpoint_too_large");
|
|
167
|
+
}
|
|
168
|
+
return { path: ref, content: item.content };
|
|
169
|
+
});
|
|
170
|
+
return { files, sha256: digest(JSON.stringify(files)) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function initialFile(root, ref) {
|
|
174
|
+
const target = path.join(root, ref);
|
|
175
|
+
try {
|
|
176
|
+
const content = regularBytes(target, CHECKPOINT_LIMIT).toString("base64");
|
|
177
|
+
return { path: ref, content };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.code === "ENOENT") return { path: ref, content: null };
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function checkpoint(task, state) {
|
|
185
|
+
const source = readRevision(task, state.checkpointRevision);
|
|
186
|
+
const result = checkedFiles(state.paths, source.files);
|
|
187
|
+
if (result.sha256 !== state.checkpointSha256) throw new Error("peer_recovery_checkpoint_drift");
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function projection(task, state) {
|
|
192
|
+
const { files: _files, ...record } = state;
|
|
193
|
+
return {
|
|
194
|
+
...record, checkpointPath: revisionPath(task, state.checkpointRevision),
|
|
195
|
+
status: state.event, exitCode: 0, authorizationGranted: false,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function resolvePeerRecoveryBackend() {
|
|
200
|
+
if (process.platform === "linux") {
|
|
201
|
+
try {
|
|
202
|
+
const executable = linuxRuntimeHelper();
|
|
203
|
+
if (!executable) return { available: false };
|
|
204
|
+
const result = spawnSync(executable, ["probe"], {
|
|
205
|
+
timeout: 3000, env: {}, stdio: ["ignore", "ignore", "pipe"], encoding: "utf8",
|
|
206
|
+
});
|
|
207
|
+
if (result.status === 0) {
|
|
208
|
+
return { available: true, id: "linux_landlock_seccomp_v1", executable,
|
|
209
|
+
digest: digest(regularBytes(executable, CHECKPOINT_LIMIT)) };
|
|
210
|
+
}
|
|
211
|
+
return { available: false, reason: result.stderr?.trim() || result.error?.code };
|
|
212
|
+
} catch (error) { return { available: false, reason: error.code }; }
|
|
213
|
+
}
|
|
214
|
+
return resolveVerificationEnvelopeBackendV1();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function requireBackend() {
|
|
218
|
+
const backend = resolvePeerRecoveryBackend();
|
|
219
|
+
if (!backend.available) {
|
|
220
|
+
throw new Error(["peer_recovery_enforced_backend_unavailable", backend.reason].filter(Boolean).join(": "));
|
|
221
|
+
}
|
|
222
|
+
return backend;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function createPeerRecoveryTask({ workspaceRoot, taskId, owner, successor, paths }) {
|
|
226
|
+
requireBackend();
|
|
227
|
+
safeIdentifier(owner, "initial peer");
|
|
228
|
+
safeIdentifier(successor, "successor peer");
|
|
229
|
+
if (owner === successor) throw new Error("peer_recovery_requires_distinct_peers");
|
|
230
|
+
const ownedPaths = validatePaths(paths);
|
|
231
|
+
const root = realpathSync(workspaceRoot);
|
|
232
|
+
const initial = checkedFiles(ownedPaths, ownedPaths.map(ref => initialFile(root, ref)));
|
|
233
|
+
const task = taskDirectory(root, taskId, true);
|
|
234
|
+
const created = append(task, null, {
|
|
235
|
+
generation: 1, owner, successor, paths: ownedPaths, phase: "ready", attempt: null,
|
|
236
|
+
wake: null, event: "peer_task_ready", checkpointRevision: 1,
|
|
237
|
+
checkpointSha256: initial.sha256, files: initial.files,
|
|
238
|
+
});
|
|
239
|
+
if (!created) throw new Error("peer_recovery_task_already_exists");
|
|
240
|
+
return projection(task, created);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function inspectPeerRecoveryTask({ workspaceRoot, taskId }) {
|
|
244
|
+
const task = taskDirectory(workspaceRoot, taskId);
|
|
245
|
+
return projection(task, current(task));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function observe(task, examine, timeoutMs) {
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
let watcher, timer, settled = false;
|
|
251
|
+
const finish = (error, value) => {
|
|
252
|
+
if (settled) return;
|
|
253
|
+
settled = true;
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
watcher?.close();
|
|
256
|
+
if (error) reject(error);
|
|
257
|
+
else resolve(value);
|
|
258
|
+
};
|
|
259
|
+
const check = () => {
|
|
260
|
+
try {
|
|
261
|
+
const value = examine(current(task));
|
|
262
|
+
if (value) finish(null, value);
|
|
263
|
+
} catch (error) { finish(error); }
|
|
264
|
+
};
|
|
265
|
+
try {
|
|
266
|
+
watcher = watch(task.directory, check);
|
|
267
|
+
watcher.once("error", finish);
|
|
268
|
+
timer = setTimeout(() => { check(); finish(null, null); }, timeoutMs);
|
|
269
|
+
check();
|
|
270
|
+
} catch (error) { finish(error); }
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function recoverPeerTask({ workspaceRoot, taskId, agent, generation, revision, waitMs = 5000 }) {
|
|
275
|
+
requireBackend();
|
|
276
|
+
safeIdentifier(agent, "successor peer");
|
|
277
|
+
if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(revision)
|
|
278
|
+
|| revision < 1 || !Number.isInteger(waitMs)
|
|
279
|
+
|| waitMs < 100 || waitMs > 60000) throw new Error("peer_recovery_invalid_request");
|
|
280
|
+
const task = taskDirectory(workspaceRoot, taskId);
|
|
281
|
+
let requestedWakeId;
|
|
282
|
+
for (;;) {
|
|
283
|
+
const state = current(task);
|
|
284
|
+
if (state.generation !== generation) {
|
|
285
|
+
if (state.generation === generation + 1 && state.owner === agent) return projection(task, state);
|
|
286
|
+
throw new Error("peer_recovery_generation_changed");
|
|
287
|
+
}
|
|
288
|
+
if (state.successor !== agent) throw new Error("peer_recovery_successor_mismatch");
|
|
289
|
+
if (state.phase === "completed") return projection(task, state);
|
|
290
|
+
if (state.wake?.observed && (state.wake.id === requestedWakeId
|
|
291
|
+
|| state.wake.requestedFromRevision === revision)) return projection(task, state);
|
|
292
|
+
if (state.phase === "running" && (!state.wake || state.wake.observed)) {
|
|
293
|
+
if (state.revision !== revision) throw new Error("peer_recovery_observation_stale");
|
|
294
|
+
const wake = { id: randomUUID(), requestedFromRevision: revision,
|
|
295
|
+
deadline: Date.now() + waitMs, observed: false };
|
|
296
|
+
if (append(task, state, { wake, event: "peer_wake_requested" })) requestedWakeId = wake.id;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (state.wake && !state.wake.observed && state.wake.requestedFromRevision === revision) {
|
|
300
|
+
requestedWakeId = state.wake.id;
|
|
301
|
+
}
|
|
302
|
+
if (state.revision !== revision && (!requestedWakeId || state.wake?.id !== requestedWakeId)) {
|
|
303
|
+
throw new Error("peer_recovery_observation_stale");
|
|
304
|
+
}
|
|
305
|
+
if (state.phase === "running" && Date.now() < state.wake.deadline) {
|
|
306
|
+
requestedWakeId = state.wake.id;
|
|
307
|
+
await observe(task, next => next.generation !== generation || next.phase !== "running"
|
|
308
|
+
|| next.wake?.id !== requestedWakeId || next.wake.observed,
|
|
309
|
+
Math.max(1, state.wake.deadline - Date.now()));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
checkpoint(task, state);
|
|
313
|
+
const replacement = append(task, state, {
|
|
314
|
+
owner: agent, successor: state.owner, generation: generation + 1,
|
|
315
|
+
phase: "ready", attempt: null, wake: null, processExit: null, event: "peer_takeover_ready",
|
|
316
|
+
recovery: {
|
|
317
|
+
fromGeneration: generation, fromAgent: state.owner, previousAttempt: state.attempt,
|
|
318
|
+
wakeId: state.wake?.id ?? null,
|
|
319
|
+
evidence: state.phase === "running" ? "wake_unconfirmed_isolated"
|
|
320
|
+
: state.phase === "exited" ? "managed_process_exited" : "attempt_not_started",
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
if (replacement) return projection(task, replacement);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function materialize(files, directory) {
|
|
328
|
+
for (const file of files) {
|
|
329
|
+
if (file.content === null) continue;
|
|
330
|
+
const ref = path.join(directory, file.path);
|
|
331
|
+
mkdirSync(path.dirname(ref), { recursive: true, mode: 0o700 });
|
|
332
|
+
writeFileSync(ref, Buffer.from(file.content, "base64"), { flag: "wx", mode: 0o600 });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function sandboxPolicy(executable, directory) {
|
|
337
|
+
// Only the host process receives control-store access. The worker inherits
|
|
338
|
+
// pipes, never a writable descriptor into the project or the next attempt.
|
|
339
|
+
return [
|
|
340
|
+
"(version 1)", "(allow default)", "(deny file-write*)", "(deny file-read-data)",
|
|
341
|
+
`(allow file-read-data file-write* (subpath ${JSON.stringify(directory)}))`,
|
|
342
|
+
`(allow file-read-data (literal ${JSON.stringify(executable)}))`,
|
|
343
|
+
'(allow file-read-data (literal "/") (subpath "/System") (subpath "/usr/lib"))',
|
|
344
|
+
"(deny network*)", "(deny mach-lookup)", "(deny signal)", "(deny process-fork)", "(deny process-exec)",
|
|
345
|
+
`(allow process-exec (literal ${JSON.stringify(executable)}))`,
|
|
346
|
+
].join("\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function reportCheckpoint(task, attemptId, message) {
|
|
350
|
+
for (;;) {
|
|
351
|
+
const state = current(task);
|
|
352
|
+
if (state.phase !== "running" || state.attempt?.id !== attemptId) return false;
|
|
353
|
+
const value = checkedFiles(state.paths, message.files);
|
|
354
|
+
const finish = message.type === "finish";
|
|
355
|
+
const changed = value.sha256 !== state.checkpointSha256;
|
|
356
|
+
if (!finish && !changed) return true;
|
|
357
|
+
const observed = changed && state.wake?.id === message.wakeId && state.wake !== null;
|
|
358
|
+
if (append(task, state, {
|
|
359
|
+
files: value.files, checkpointRevision: state.revision + 1, checkpointSha256: value.sha256,
|
|
360
|
+
wake: observed ? { ...state.wake, observed: true } : state.wake,
|
|
361
|
+
phase: finish ? "completed" : "running",
|
|
362
|
+
event: finish ? "peer_task_completed" : observed ? "peer_wake_progress_observed" : "peer_checkpoint_recorded",
|
|
363
|
+
})) return true;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function reportExit(task, attemptId, processExit) {
|
|
368
|
+
for (;;) {
|
|
369
|
+
const state = current(task);
|
|
370
|
+
if (state.attempt?.id !== attemptId) {
|
|
371
|
+
return { ...projection(task, state), status: "peer_attempt_superseded", exitCode: 2 };
|
|
372
|
+
}
|
|
373
|
+
if (state.phase !== "running") return projection(task, state);
|
|
374
|
+
const next = append(task, state, {
|
|
375
|
+
phase: "exited", event: "peer_attempt_exited", processExit,
|
|
376
|
+
});
|
|
377
|
+
if (next) return { ...projection(task, next), exitCode: 2 };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function workerExecutable(command) {
|
|
382
|
+
if (!Array.isArray(command) || !path.isAbsolute(command[0] ?? "")) {
|
|
383
|
+
throw new Error("peer_recovery_absolute_worker_required");
|
|
384
|
+
}
|
|
385
|
+
const executable = realpathSync(command[0]);
|
|
386
|
+
if (!lstatSync(executable).isFile()) throw new Error("peer_recovery_worker_invalid");
|
|
387
|
+
accessSync(executable, constants.X_OK);
|
|
388
|
+
return executable;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function existingHandoff(task, state, agent, generation) {
|
|
392
|
+
if (state.owner !== agent || state.generation !== generation || state.phase === "ready") return null;
|
|
393
|
+
const result = projection(task, state);
|
|
394
|
+
return state.phase === "exited" ? { ...result, exitCode: 2 } : result;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function runPeerTask({ workspaceRoot, taskId, agent, generation, command, handoff = false }) {
|
|
398
|
+
const backend = requireBackend();
|
|
399
|
+
safeIdentifier(agent, "current peer");
|
|
400
|
+
const executable = workerExecutable(command);
|
|
401
|
+
const task = taskDirectory(workspaceRoot, taskId);
|
|
402
|
+
const state = current(task);
|
|
403
|
+
if (state.phase !== "ready" || state.owner !== agent || state.generation !== generation) {
|
|
404
|
+
const prior = handoff && existingHandoff(task, state, agent, generation);
|
|
405
|
+
if (prior) return prior;
|
|
406
|
+
throw new Error("peer_recovery_attempt_not_available");
|
|
407
|
+
}
|
|
408
|
+
const id = randomUUID();
|
|
409
|
+
const directory = mkdtempSync(path.join(realpathSync(tmpdir()), "owlrunkit-peer-"));
|
|
410
|
+
materialize(checkpoint(task, state).files, directory);
|
|
411
|
+
const attempt = { id, directory, backend: backend.id,
|
|
412
|
+
...(backend.digest ? { backendSha256: backend.digest } : {}) };
|
|
413
|
+
const claimed = append(task, state, {
|
|
414
|
+
attempt, phase: "running", processExit: null, event: "peer_attempt_started",
|
|
415
|
+
});
|
|
416
|
+
if (!claimed) {
|
|
417
|
+
const prior = handoff && existingHandoff(task, current(task), agent, generation);
|
|
418
|
+
if (prior) return prior;
|
|
419
|
+
throw new Error("peer_recovery_attempt_already_claimed");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const args = backend.id === "linux_landlock_seccomp_v1"
|
|
424
|
+
? ["run", String(process.pid), directory]
|
|
425
|
+
: ["-p", sandboxPolicy(executable, directory)];
|
|
426
|
+
const child = spawn(backend.executable, [...args, executable, ...command.slice(1)], {
|
|
427
|
+
cwd: directory, env: {
|
|
428
|
+
PATH: "/usr/bin:/bin", TMPDIR: directory, OWLRUNKIT_PEER_ATTEMPT: id,
|
|
429
|
+
OWLRUNKIT_PEER_PATHS: JSON.stringify(state.paths),
|
|
430
|
+
// An isolated worker cannot read host OpenSSL configuration.
|
|
431
|
+
...(backend.id === "linux_landlock_seccomp_v1" ? { OPENSSL_CONF: "/dev/null" } : {}),
|
|
432
|
+
}, stdio: ["pipe", "pipe", "pipe"],
|
|
433
|
+
});
|
|
434
|
+
let watcher, pending = Buffer.alloc(0), sentWake, protocolError = null;
|
|
435
|
+
let diagnostic = Buffer.alloc(0);
|
|
436
|
+
const stop = error => {
|
|
437
|
+
protocolError ??= error.message;
|
|
438
|
+
child.kill("SIGKILL");
|
|
439
|
+
};
|
|
440
|
+
const wake = () => {
|
|
441
|
+
try {
|
|
442
|
+
const live = current(task);
|
|
443
|
+
if (live.attempt?.id !== id || live.phase !== "running") return;
|
|
444
|
+
if (live.wake && !live.wake.observed && live.wake.id !== sentWake) {
|
|
445
|
+
sentWake = live.wake.id;
|
|
446
|
+
child.stdin.write(`${JSON.stringify({ type: "wake", wakeId: sentWake })}\n`);
|
|
447
|
+
}
|
|
448
|
+
} catch (error) { stop(error); }
|
|
449
|
+
};
|
|
450
|
+
child.stdin.on("error", error => { if (error.code !== "EPIPE") stop(error); });
|
|
451
|
+
child.stderr.on("data", chunk => {
|
|
452
|
+
const remaining = 4096 - diagnostic.length;
|
|
453
|
+
if (remaining > 0) diagnostic = Buffer.concat([diagnostic, chunk.subarray(0, remaining)]);
|
|
454
|
+
});
|
|
455
|
+
child.stdout.on("data", chunk => {
|
|
456
|
+
if (protocolError) return;
|
|
457
|
+
try {
|
|
458
|
+
pending = Buffer.concat([pending, chunk]);
|
|
459
|
+
let end;
|
|
460
|
+
while ((end = pending.indexOf(10)) >= 0) {
|
|
461
|
+
if (end > RECORD_LIMIT) throw new Error("peer_recovery_worker_message_too_large");
|
|
462
|
+
const message = JSON.parse(pending.subarray(0, end).toString("utf8"));
|
|
463
|
+
pending = pending.subarray(end + 1);
|
|
464
|
+
if (message?.type === "ack") continue;
|
|
465
|
+
if (!new Set(["checkpoint", "finish"]).has(message?.type)) {
|
|
466
|
+
throw new Error("peer_recovery_worker_message_invalid");
|
|
467
|
+
}
|
|
468
|
+
reportCheckpoint(task, id, message);
|
|
469
|
+
}
|
|
470
|
+
if (pending.length > RECORD_LIMIT) throw new Error("peer_recovery_worker_message_too_large");
|
|
471
|
+
} catch (error) { stop(error); }
|
|
472
|
+
});
|
|
473
|
+
child.once("error", error => { protocolError ??= error.message; });
|
|
474
|
+
child.once("close", (code, signal) => {
|
|
475
|
+
watcher?.close();
|
|
476
|
+
try { resolve(reportExit(task, id, { code, signal, protocolError,
|
|
477
|
+
diagnostic: diagnostic.toString("utf8") || null })); }
|
|
478
|
+
catch (error) { reject(error); }
|
|
479
|
+
});
|
|
480
|
+
try {
|
|
481
|
+
watcher = watch(task.directory, wake);
|
|
482
|
+
watcher.once("error", stop);
|
|
483
|
+
wake();
|
|
484
|
+
} catch (error) { stop(error); }
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function recoverAndRunPeerTask(options, command) {
|
|
489
|
+
workerExecutable(command);
|
|
490
|
+
const recovered = await recoverPeerTask(options);
|
|
491
|
+
if (recovered.generation !== options.generation + 1 || recovered.owner !== options.agent) {
|
|
492
|
+
return recovered;
|
|
493
|
+
}
|
|
494
|
+
return runPeerTask({ ...options, generation: recovered.generation, command, handoff: true });
|
|
495
|
+
}
|
|
@@ -26,10 +26,18 @@ const SELF_NORMALIZED_QUICK_CORE_VERSIONS = new Set([
|
|
|
26
26
|
"0.23.0",
|
|
27
27
|
"0.24.0",
|
|
28
28
|
"0.24.1",
|
|
29
|
+
"0.25.0",
|
|
30
|
+
"0.25.1",
|
|
29
31
|
]);
|
|
30
32
|
const EXACT_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
|
|
31
33
|
const SHA256_REF = /^sha256:[a-f0-9]{64}$/;
|
|
32
34
|
const TRUSTED_PRIOR_CORE_IDENTITIES = new Map([
|
|
35
|
+
["0.25.0", new Set([
|
|
36
|
+
"sha256:20e75c386f1408f11e908a6d3ee1a39316d9728d5719fa06f8d6a5aa45abd83e",
|
|
37
|
+
])],
|
|
38
|
+
["0.24.1", new Set([
|
|
39
|
+
"sha256:94aaa2e3a6d504374fa78cfcd341e31b33087806c9ddf5f84b3d3b47a6328e08",
|
|
40
|
+
])],
|
|
33
41
|
["0.24.0", new Set([
|
|
34
42
|
"sha256:02e3607701a275014fe95e95e025aeca7cac34ebe4a47e603b8c9db0e45596ff",
|
|
35
43
|
])],
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
validateExecutionPin,
|
|
21
21
|
validateLeaseOwnedPaths,
|
|
22
22
|
} from "./core-contract.mjs";
|
|
23
|
+
import { acquireRunKitControlLock } from "./control-transaction-lock.mjs";
|
|
23
24
|
import { buildCoverageIndex } from "./coverage-adoption.mjs";
|
|
24
25
|
import { validateVerificationReceiptGate } from "./verification-receipt-gate.mjs";
|
|
25
26
|
import {
|
|
@@ -103,14 +104,7 @@ function acquireResumeLock(workspaceRoot) {
|
|
|
103
104
|
const runtimeRoot = path.join(workspaceRoot, RUNTIME_ROOT);
|
|
104
105
|
assertSafeDirectory(workspaceRoot, runtimeRoot, "RunKit runtime root");
|
|
105
106
|
assertSafeDirectory(runtimeRoot, path.join(runtimeRoot, "executions"), "Executions directory");
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
mkdirSync(lockPath);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
if (error?.code === "EEXIST") throw new Error("Another RunKit control transaction is already active.");
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
return () => rmSync(lockPath, { recursive: true, force: true });
|
|
107
|
+
return acquireRunKitControlLock(runtimeRoot);
|
|
114
108
|
}
|
|
115
109
|
|
|
116
110
|
function activeRunIds(workspaceRoot) {
|
|
@@ -92,6 +92,13 @@ import {
|
|
|
92
92
|
formatInspectHuman,
|
|
93
93
|
} from "./inspect-presentation.mjs";
|
|
94
94
|
import { buildHookRecoveryV1 } from "./hook-recovery.mjs";
|
|
95
|
+
import {
|
|
96
|
+
createPeerRecoveryTask,
|
|
97
|
+
inspectPeerRecoveryTask,
|
|
98
|
+
recoverAndRunPeerTask,
|
|
99
|
+
recoverPeerTask,
|
|
100
|
+
runPeerTask,
|
|
101
|
+
} from "./peer-recovery.mjs";
|
|
95
102
|
import { attestQuickReceiptDetails } from "./quick-attest.mjs";
|
|
96
103
|
import { readLocalQuickMetrics } from "./quick-metrics.mjs";
|
|
97
104
|
import { runQuickEvidenceReuse } from "./quick-evidence-reuse.mjs";
|
|
@@ -265,6 +272,7 @@ Agent team projects
|
|
|
265
272
|
npx --no-install owlrunkit project <init|assign|checkpoint|handoff|reject-and-return|reopen-upstream|transfer|work-item revise-scope|work-item require-evidence|reconcile|delivery record|decision|verification|capture|target-snapshot|import-delivery|status|takeover|successor|successor scaffold> [options]
|
|
266
273
|
npx --no-install owlrunkit team <recommend|status|packet validate> [options]
|
|
267
274
|
Durable project truth plus read-only Team recommendation and packet validation. No Agent dispatch.
|
|
275
|
+
Managed local peer recovery: npx --no-install owlrunkit peer --help
|
|
268
276
|
Core successor: npx --no-install owlrunkit core-successor <plan|apply|resume> --workspace . [...]
|
|
269
277
|
Other onboarding commands:
|
|
270
278
|
npx --no-install owlrunkit profiles validate --workspace .; npx --no-install owlrunkit profiles impact --workspace . --changed <path>
|
|
@@ -2443,6 +2451,54 @@ function requestCommand(options, handler) {
|
|
|
2443
2451
|
return handler({ workspaceRoot: root, runId, request: readJson(requestPath) });
|
|
2444
2452
|
}
|
|
2445
2453
|
|
|
2454
|
+
const PEER_HELP = `Usage:
|
|
2455
|
+
owlrunkit peer create --workspace <dir> --task <id> --request <json>
|
|
2456
|
+
owlrunkit peer inspect --workspace <dir> --task <id>
|
|
2457
|
+
owlrunkit peer run --workspace <dir> --task <id> --agent <id> --generation <n> -- /absolute/worker [args]
|
|
2458
|
+
owlrunkit peer recover --workspace <dir> --task <id> --agent <id> --generation <n> --revision <n> [--wait-ms <n>] [-- /absolute/worker [args]]
|
|
2459
|
+
`;
|
|
2460
|
+
|
|
2461
|
+
function runPeerCommand(rest) {
|
|
2462
|
+
const [action, ...args] = rest;
|
|
2463
|
+
const separator = args.indexOf("--");
|
|
2464
|
+
const optionArgs = separator === -1 ? args : args.slice(0, separator);
|
|
2465
|
+
if (["--help", "-h"].includes(action) || optionArgs.includes("--help") || optionArgs.includes("-h")) {
|
|
2466
|
+
return withHumanOutput({ status: "help", exitCode: 0, authorizationGranted: false }, PEER_HELP);
|
|
2467
|
+
}
|
|
2468
|
+
const flags = {
|
|
2469
|
+
create: ["request"], inspect: [], run: ["agent", "generation"],
|
|
2470
|
+
recover: ["agent", "generation", "revision", "wait-ms"],
|
|
2471
|
+
};
|
|
2472
|
+
if (!Object.hasOwn(flags, action) || (separator !== -1 && !["run", "recover"].includes(action))) {
|
|
2473
|
+
throw new Error(PEER_HELP);
|
|
2474
|
+
}
|
|
2475
|
+
const options = parseOptions(optionArgs, { boolean: ["json"] });
|
|
2476
|
+
const allowed = new Set(["workspace", "task", "json", ...flags[action]]);
|
|
2477
|
+
if (Object.keys(options).some(key => !allowed.has(key))) throw new Error(PEER_HELP);
|
|
2478
|
+
const task = { workspaceRoot: workspace(options), taskId: requireOption(options, "task") };
|
|
2479
|
+
if (action === "inspect") return inspectPeerRecoveryTask(task);
|
|
2480
|
+
if (action === "create") {
|
|
2481
|
+
const request = readJson(realpathSync(requireOption(options, "request")));
|
|
2482
|
+
return createPeerRecoveryTask({ ...task, owner: request.owner,
|
|
2483
|
+
successor: request.successor, paths: request.paths });
|
|
2484
|
+
}
|
|
2485
|
+
const peer = {
|
|
2486
|
+
...task, agent: requireOption(options, "agent"),
|
|
2487
|
+
generation: parseCanonicalNonnegativeSafeInteger(requireOption(options, "generation"), "generation"),
|
|
2488
|
+
};
|
|
2489
|
+
if (action === "run") {
|
|
2490
|
+
if (separator === -1) throw new Error(PEER_HELP);
|
|
2491
|
+
return runPeerTask({ ...peer, command: args.slice(separator + 1) });
|
|
2492
|
+
}
|
|
2493
|
+
const recovery = { ...peer,
|
|
2494
|
+
revision: parseCanonicalNonnegativeSafeInteger(requireOption(options, "revision"), "revision"),
|
|
2495
|
+
waitMs: options["wait-ms"] === undefined ? undefined
|
|
2496
|
+
: parseCanonicalNonnegativeSafeInteger(options["wait-ms"], "wait-ms"),
|
|
2497
|
+
};
|
|
2498
|
+
return separator === -1 ? recoverPeerTask(recovery)
|
|
2499
|
+
: recoverAndRunPeerTask(recovery, args.slice(separator + 1));
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2446
2502
|
export async function runCli(argv = process.argv.slice(2), runtimeHooks = {}) {
|
|
2447
2503
|
try {
|
|
2448
2504
|
const [command, ...rest] = argv;
|
|
@@ -2461,6 +2517,7 @@ export async function runCli(argv = process.argv.slice(2), runtimeHooks = {}) {
|
|
|
2461
2517
|
authorizationGranted: false,
|
|
2462
2518
|
}, `owlrunkit ${CORE_VERSION}\n`);
|
|
2463
2519
|
}
|
|
2520
|
+
if (command === "peer") return await runPeerCommand(rest);
|
|
2464
2521
|
const separatorIndex = rest.indexOf("--");
|
|
2465
2522
|
const acceptsExactCommand = command === "verify"
|
|
2466
2523
|
|| command === "quick-verify"
|