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.
@@ -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
- const lockPath = path.join(runtimeRoot, "control.lock");
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"