@perkos/perkos-a2a 0.12.29 → 0.12.31

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/dist/repair.js CHANGED
@@ -1,9 +1,24 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { mkdir, open, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, open, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { configFingerprint, runtimeAttemptPath, runtimeStatusPath, } from "./runtime-evidence.js";
8
+ export class RepairFailure extends Error {
9
+ code;
10
+ phase;
11
+ retryable;
12
+ requiresProductFix;
13
+ constructor(code, phase, message, retryable, requiresProductFix) {
14
+ super(message);
15
+ this.code = code;
16
+ this.phase = phase;
17
+ this.retryable = retryable;
18
+ this.requiresProductFix = requiresProductFix;
19
+ this.name = "RepairFailure";
20
+ }
21
+ }
7
22
  const PROTECTED_PATHS = [
8
23
  "agentName",
9
24
  "relay.apiKey",
@@ -58,6 +73,16 @@ function assertProtected(before, after) {
58
73
  }
59
74
  }
60
75
  }
76
+ function redactProtectedValues(message, config) {
77
+ let redacted = message;
78
+ for (const dotted of PROTECTED_PATHS) {
79
+ const value = getAt(config, dotted);
80
+ if (typeof value === "string" && value.length >= 4) {
81
+ redacted = redacted.split(value).join("[REDACTED]");
82
+ }
83
+ }
84
+ return redacted;
85
+ }
61
86
  async function pathExists(path) {
62
87
  try {
63
88
  await stat(path);
@@ -83,6 +108,7 @@ export async function writeJsonConfig(path, value, renameFile = rename) {
83
108
  await writeFile(temporary, serialized, { mode: 0o600 });
84
109
  try {
85
110
  await renameFile(temporary, path);
111
+ return "atomic";
86
112
  }
87
113
  catch (error) {
88
114
  const code = errorCode(error);
@@ -98,6 +124,7 @@ export async function writeJsonConfig(path, value, renameFile = rename) {
98
124
  finally {
99
125
  await handle.close();
100
126
  }
127
+ return "inplace-ebusy-fallback";
101
128
  }
102
129
  finally {
103
130
  await rm(temporary, { force: true });
@@ -221,26 +248,58 @@ export function postRestartStateFromLogs(output, agentName, startedAt) {
221
248
  return verifyLogState(logTextSince(output, startedAt), agentName);
222
249
  }
223
250
  function verifyLogState(text, agentName) {
251
+ const heartbeatCount = text.match(/\[perkos-heartbeat\].*status=200/gu)?.length ?? 0;
224
252
  return {
253
+ configLoaded: false,
225
254
  connected: text.includes("Connected to relay hub"),
226
255
  registered: text.includes("Registered with relay hub"),
227
- heartbeat200: /\[perkos-heartbeat\].*status=200/u.test(text),
256
+ heartbeat200: heartbeatCount > 0,
257
+ heartbeatCount,
228
258
  chatAuthed: text.includes(`[perkos-chat] authed as agent:${agentName}`),
229
259
  duplicatePlugin: text.toLowerCase().includes("duplicate plugin id detected"),
230
260
  duplicateRegistration: text.toLowerCase().includes("duplicate registration replaced"),
261
+ stableSeconds: 0,
231
262
  };
232
263
  }
233
- export const defaultCommandRunner = (command, args) => new Promise((resolveResult, reject) => {
234
- const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
264
+ export const defaultCommandRunner = (command, args, options = {}) => new Promise((resolveResult, reject) => {
265
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], signal: options.signal });
235
266
  let stdout = "";
236
267
  let stderr = "";
268
+ let settled = false;
269
+ let killTimer;
270
+ const finish = (callback) => {
271
+ if (settled)
272
+ return;
273
+ settled = true;
274
+ if (timeoutTimer)
275
+ clearTimeout(timeoutTimer);
276
+ if (killTimer)
277
+ clearTimeout(killTimer);
278
+ options.signal?.removeEventListener("abort", terminate);
279
+ callback();
280
+ };
281
+ const terminate = () => {
282
+ if (child.exitCode !== null)
283
+ return;
284
+ child.kill("SIGTERM");
285
+ killTimer = setTimeout(() => {
286
+ if (child.exitCode === null)
287
+ child.kill("SIGKILL");
288
+ }, 750);
289
+ killTimer.unref?.();
290
+ };
291
+ const timeoutTimer = options.timeoutMs && Number.isFinite(options.timeoutMs)
292
+ ? setTimeout(terminate, Math.max(1, options.timeoutMs))
293
+ : undefined;
294
+ timeoutTimer?.unref?.();
295
+ options.signal?.addEventListener("abort", terminate, { once: true });
237
296
  child.stdout.on("data", (chunk) => { stdout += String(chunk); });
238
297
  child.stderr.on("data", (chunk) => { stderr += String(chunk); });
239
- child.once("error", reject);
240
- child.once("close", (code) => resolveResult({ code: code ?? 1, stdout, stderr }));
298
+ child.once("error", (error) => finish(() => reject(error)));
299
+ child.once("close", (code) => finish(() => resolveResult({ code: code ?? 1, stdout, stderr })));
241
300
  });
242
301
  async function mustRun(run, command, args, label) {
243
- const result = await run(command, args);
302
+ const result = await run(command, args, { label });
244
303
  if (result.code !== 0)
245
304
  throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim().slice(0, 500)}`);
246
305
  return result;
@@ -296,10 +355,157 @@ function assertUnder(parent, child) {
296
355
  return;
297
356
  throw new Error("managed install escaped the OpenClaw npm root");
298
357
  }
358
+ async function sha256File(path) {
359
+ return createHash("sha256").update(await readFile(path)).digest("hex");
360
+ }
361
+ async function readJsonOptional(path) {
362
+ try {
363
+ return JSON.parse(await readFile(path, "utf8"));
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
368
+ }
369
+ async function writePrivateJson(path, value) {
370
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
371
+ const mode = await writeJsonConfig(path, value);
372
+ await chmod(path, 0o600);
373
+ return mode;
374
+ }
375
+ function repairStateRoot(stateDir) {
376
+ return join(stateDir, "perkos-a2a");
377
+ }
378
+ function receiptPath(stateDir, version, artifactSha256) {
379
+ return join(repairStateRoot(stateDir), "receipts", `${version}-${artifactSha256}.json`);
380
+ }
381
+ function journalPath(stateDir) {
382
+ return join(repairStateRoot(stateDir), "repair-journal.json");
383
+ }
384
+ async function inspectManagedInstall(input) {
385
+ const inspectResult = await mustRun(input.run, input.openclaw, ["plugins", "inspect", "perkos-a2a", "--json"], "plugin inspection");
386
+ const inspect = parseJsonOutput(inspectResult.stdout);
387
+ if (!isObject(inspect))
388
+ throw new Error("plugin inspection result is invalid");
389
+ const plugin = isObject(inspect.plugin) ? inspect.plugin : {};
390
+ const install = isObject(inspect.install) ? inspect.install : {};
391
+ const installPathValue = install.installPath ?? plugin.installPath;
392
+ if (plugin.id !== "perkos-a2a" || typeof installPathValue !== "string" || typeof plugin.rootDir !== "string") {
393
+ throw new Error("managed npm install was not recorded");
394
+ }
395
+ const npmRoot = await realpath(join(input.stateDir, "npm"));
396
+ const managedPath = await realpath(installPathValue);
397
+ const activeRoot = await realpath(plugin.rootDir);
398
+ assertUnder(npmRoot, managedPath);
399
+ if (activeRoot !== managedPath)
400
+ throw new Error("PerkOS is still loading from a non-managed plugin source");
401
+ const packagePath = join(managedPath, "package.json");
402
+ const manifestPath = join(managedPath, "openclaw.plugin.json");
403
+ const indexPath = join(managedPath, "dist", "index.js");
404
+ const repairCliPath = join(managedPath, "dist", "repair-cli.js");
405
+ const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
406
+ const installedManifest = JSON.parse(await readFile(manifestPath, "utf8"));
407
+ const bin = isObject(packageJson.bin) ? packageJson.bin : {};
408
+ if (packageJson.name !== "@perkos/perkos-a2a"
409
+ || packageJson.version !== input.targetVersion
410
+ || installedManifest.id !== "perkos-a2a"
411
+ || installedManifest.version !== input.targetVersion
412
+ || bin["perkos-a2a"] !== "dist/repair-cli.js"
413
+ || !await pathExists(indexPath)
414
+ || !await pathExists(repairCliPath)) {
415
+ throw new Error("installed plugin identity/version does not match the repair target");
416
+ }
417
+ return {
418
+ installRoot: managedPath,
419
+ artifactKind: typeof install.artifactKind === "string" ? install.artifactKind : undefined,
420
+ source: typeof install.source === "string" ? install.source : typeof plugin.source === "string" ? plugin.source : undefined,
421
+ spec: typeof install.spec === "string" ? install.spec : typeof plugin.spec === "string" ? plugin.spec : undefined,
422
+ packageJsonHash: await sha256File(packagePath),
423
+ manifestHash: await sha256File(manifestPath),
424
+ indexHash: await sha256File(indexPath),
425
+ repairCliHash: await sha256File(repairCliPath),
426
+ };
427
+ }
428
+ function receiptMatches(receipt, evidence, version, artifactSha256) {
429
+ return Boolean(receipt)
430
+ && receipt?.schemaVersion === 1
431
+ && receipt.targetVersion === version
432
+ && receipt.artifactSha256 === artifactSha256
433
+ && receipt.installRootRealpath === evidence.installRoot
434
+ && receipt.packageJsonHash === evidence.packageJsonHash
435
+ && receipt.manifestHash === evidence.manifestHash
436
+ && receipt.indexHash === evidence.indexHash
437
+ && receipt.repairCliHash === evidence.repairCliHash;
438
+ }
439
+ function mergeWriteMode(current, next) {
440
+ return current === "inplace-ebusy-fallback" || next === "inplace-ebusy-fallback"
441
+ ? "inplace-ebusy-fallback"
442
+ : "atomic";
443
+ }
444
+ function configLoadedLogMatches(text, input) {
445
+ return text.includes(`[perkos-a2a] config loaded version=${input.version} agentId=${input.agentId} attemptId=${input.attemptId} fingerprint=${input.fingerprint}`);
446
+ }
447
+ async function runtimeStatusMatches(path, input) {
448
+ const status = await readJsonOptional(path);
449
+ if (!status)
450
+ return false;
451
+ const loadedAt = Date.parse(status.loadedAt);
452
+ if (status.schemaVersion !== 1
453
+ || status.version !== input.version
454
+ || status.agentId !== input.agentId
455
+ || status.attemptId !== input.attemptId
456
+ || status.fingerprint !== input.fingerprint
457
+ || !Number.isFinite(loadedAt)
458
+ || loadedAt < input.startedAt - 2_000)
459
+ return false;
460
+ try {
461
+ return await realpath(status.pluginRoot) === input.installRoot;
462
+ }
463
+ catch {
464
+ return false;
465
+ }
466
+ }
299
467
  async function wait(ms) {
300
468
  await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
301
469
  }
302
- export async function repairOpenClaw(options, run = defaultCommandRunner) {
470
+ export async function repairOpenClaw(options, suppliedRun = defaultCommandRunner) {
471
+ const operationStartedAt = Date.now();
472
+ const deadlineAt = operationStartedAt + (options.timeoutMs ?? 300_000);
473
+ let currentPhase = "detect";
474
+ const run = async (command, args, runOptions = {}) => {
475
+ const remainingMs = deadlineAt - Date.now();
476
+ const label = runOptions.label ?? [command, args[0], args[1]].filter(Boolean).join(" ");
477
+ if (remainingMs <= 0) {
478
+ throw new RepairFailure("COMMAND_TIMEOUT", currentPhase, `command deadline exceeded: phase=${currentPhase} label=${label} elapsedMs=${Date.now() - operationStartedAt} remainingMs=0`, true, false);
479
+ }
480
+ const commandTimeoutMs = Math.max(1, Math.min(runOptions.timeoutMs ?? remainingMs, remainingMs));
481
+ const controller = new AbortController();
482
+ const signal = runOptions.signal
483
+ ? AbortSignal.any([runOptions.signal, controller.signal])
484
+ : controller.signal;
485
+ let timer;
486
+ try {
487
+ return await Promise.race([
488
+ suppliedRun(command, args, { ...runOptions, label, timeoutMs: commandTimeoutMs, signal }),
489
+ new Promise((_resolve, reject) => {
490
+ timer = setTimeout(() => {
491
+ controller.abort();
492
+ reject(new RepairFailure("COMMAND_TIMEOUT", currentPhase, `command timed out: phase=${currentPhase} label=${label} elapsedMs=${Date.now() - operationStartedAt} remainingMs=${Math.max(0, deadlineAt - Date.now())}`, true, false));
493
+ }, commandTimeoutMs);
494
+ timer.unref?.();
495
+ }),
496
+ ]);
497
+ }
498
+ catch (error) {
499
+ if (controller.signal.aborted && !(error instanceof RepairFailure)) {
500
+ throw new RepairFailure("COMMAND_TIMEOUT", currentPhase, `command timed out: phase=${currentPhase} label=${label} elapsedMs=${Date.now() - operationStartedAt} remainingMs=${Math.max(0, deadlineAt - Date.now())}`, true, false);
501
+ }
502
+ throw error;
503
+ }
504
+ finally {
505
+ if (timer)
506
+ clearTimeout(timer);
507
+ }
508
+ };
303
509
  const openclaw = options.openclawBin ?? "openclaw";
304
510
  const stateDir = resolve(options.stateDir ?? process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw"));
305
511
  const configPath = resolve(options.configPath ?? process.env.OPENCLAW_CONFIG_PATH ?? join(stateDir, "openclaw.json"));
@@ -312,17 +518,47 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
312
518
  throw new Error("repair CLI manifest/schema is invalid");
313
519
  if (!await pathExists(artifactPath))
314
520
  throw new Error("verified plugin artifact is missing");
315
- const artifactDigest = createHash("sha256").update(await readFile(artifactPath)).digest("hex");
521
+ const artifactDigest = await sha256File(artifactPath);
316
522
  if (artifactDigest !== options.artifactSha256.toLowerCase())
317
523
  throw new Error(`plugin artifact checksum mismatch: ${artifactDigest}`);
318
524
  const releaseRepairLock = await acquireRepairLock(stateDir);
319
525
  try {
526
+ const priorJournal = await readJsonOptional(journalPath(stateDir));
527
+ const canResume = priorJournal?.schemaVersion === 1
528
+ && priorJournal.phase !== "done"
529
+ && priorJournal.targetVersion === version
530
+ && priorJournal.artifactSha256 === artifactDigest
531
+ && priorJournal.expectedAgentName === options.expectedAgentName
532
+ && priorJournal.expectedAgentId === options.expectedAgentId;
533
+ const resumedFromPhase = canResume ? priorJournal.phase : undefined;
534
+ let journal = canResume ? priorJournal : {
535
+ schemaVersion: 1,
536
+ attemptId: randomUUID(),
537
+ hmacKeyHex: randomBytes(32).toString("hex"),
538
+ targetVersion: version,
539
+ artifactSha256: artifactDigest,
540
+ expectedAgentName: options.expectedAgentName,
541
+ expectedAgentId: options.expectedAgentId,
542
+ phase: "detect",
543
+ createdAt: new Date().toISOString(),
544
+ updatedAt: new Date().toISOString(),
545
+ };
546
+ const setPhase = async (phase) => {
547
+ currentPhase = phase;
548
+ journal = { ...journal, phase, updatedAt: new Date().toISOString() };
549
+ await writePrivateJson(journalPath(stateDir), journal);
550
+ };
551
+ await setPhase("detect");
320
552
  const backupRoot = join(stateDir, "perkos-migration-backups");
321
553
  await mkdir(backupRoot, { recursive: true, mode: 0o700 });
322
- const backupPath = join(backupRoot, `openclaw-${Date.now()}.before.json`);
554
+ const reusableBackup = canResume && priorJournal.backupPath && await pathExists(priorJournal.backupPath)
555
+ ? priorJournal.backupPath
556
+ : undefined;
557
+ const backupPath = reusableBackup ?? join(backupRoot, `openclaw-${Date.now()}.before.json`);
323
558
  const legacyExtension = join(stateDir, "extensions", "perkos-a2a");
324
559
  const quarantinePath = join(backupRoot, `perkos-a2a-extension-${Date.now()}`);
325
560
  const originalRaw = await readFile(configPath, "utf8");
561
+ const identitySourceRaw = reusableBackup ? await readFile(reusableBackup, "utf8") : originalRaw;
326
562
  let document;
327
563
  try {
328
564
  document = JSON.parse(originalRaw);
@@ -330,21 +566,38 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
330
566
  catch {
331
567
  throw new Error("OpenClaw config must be strict JSON for safe automatic repair");
332
568
  }
333
- const existingConfig = openClawConfig(document);
569
+ let identitySource;
570
+ try {
571
+ identitySource = JSON.parse(identitySourceRaw);
572
+ }
573
+ catch {
574
+ throw new Error("OpenClaw repair backup must be strict JSON");
575
+ }
576
+ const existingConfig = openClawConfig(identitySource);
334
577
  const before = structuredClone(existingConfig ?? options.initialConfig);
335
578
  if (!isObject(before))
336
579
  throw new Error("existing PerkOS plugin config is missing");
337
- await writeFile(backupPath, originalRaw, { mode: 0o600 });
580
+ if (!reusableBackup) {
581
+ await writeFile(backupPath, originalRaw, { mode: 0o600 });
582
+ await chmod(backupPath, 0o600);
583
+ }
584
+ journal = { ...journal, backupPath };
585
+ await setPhase("snapshot");
338
586
  const normalized = normalizeToSchema(before, manifest.configSchema);
339
587
  if (!isObject(normalized.value))
340
588
  throw new Error("normalized PerkOS config is invalid");
341
589
  assertProtected(before, normalized.value);
342
590
  assertIdentity(normalized.value, options.expectedAgentName, options.expectedAgentId);
343
591
  let quarantined = false;
344
- let committed = false;
592
+ let installCommitted = journal.installCommitted === true;
593
+ let configCommitted = false;
594
+ let configWriteMode = "atomic";
595
+ let receiptUsed = false;
596
+ let receiptCreated = false;
597
+ let installEvidence;
345
598
  try {
346
599
  replacePluginEntry(document, {}, false);
347
- await writeJsonConfig(configPath, document);
600
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, document));
348
601
  if (await pathExists(legacyExtension)) {
349
602
  const legacyManifest = JSON.parse(await readFile(join(legacyExtension, "openclaw.plugin.json"), "utf8"));
350
603
  if (legacyManifest.id !== "perkos-a2a")
@@ -352,43 +605,75 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
352
605
  await rename(legacyExtension, quarantinePath);
353
606
  quarantined = true;
354
607
  }
355
- const installResult = await run(openclaw, ["plugins", "install", `npm-pack:${artifactPath}`, "--force"]);
356
- const installError = `${installResult.stderr}\n${installResult.stdout}`;
357
- if (installResult.code !== 0 && !/EBUSY|resource busy|rename/iu.test(installError)) {
358
- throw new Error(`managed plugin install failed: ${installError.trim().slice(0, 500)}`);
608
+ await setPhase("install");
609
+ const receiptFile = receiptPath(stateDir, version, artifactDigest);
610
+ const receipt = await readJsonOptional(receiptFile);
611
+ if (receipt) {
612
+ try {
613
+ const candidate = await inspectManagedInstall({ run, openclaw, stateDir, targetVersion: version });
614
+ if (receiptMatches(receipt, candidate, version, artifactDigest)) {
615
+ installEvidence = candidate;
616
+ receiptUsed = true;
617
+ }
618
+ }
619
+ catch { /* reinstall and certify from the verified artifact */ }
359
620
  }
360
- const inspectResult = await mustRun(run, openclaw, ["plugins", "inspect", "perkos-a2a", "--json"], "plugin inspection");
361
- const inspect = parseJsonOutput(inspectResult.stdout);
362
- if (!isObject(inspect))
363
- throw new Error("plugin inspection result is invalid");
364
- const plugin = isObject(inspect.plugin) ? inspect.plugin : {};
365
- const install = isObject(inspect.install) ? inspect.install : {};
366
- if (plugin.id !== "perkos-a2a" || install.artifactKind !== "npm-pack" || typeof install.installPath !== "string" || typeof plugin.rootDir !== "string") {
367
- throw new Error("managed npm install was not recorded");
621
+ if (!installEvidence) {
622
+ const installResult = await run(openclaw, ["plugins", "install", `npm-pack:${artifactPath}`, "--force"], { label: "managed plugin install" });
623
+ const installError = `${installResult.stderr}\n${installResult.stdout}`;
624
+ if (installResult.code !== 0 && !/EBUSY|resource busy|rename/iu.test(installError)) {
625
+ throw new Error(`managed plugin install failed: ${installError.trim().slice(0, 500)}`);
626
+ }
627
+ installCommitted = true;
628
+ journal = { ...journal, installCommitted: true };
629
+ await setPhase("install");
630
+ installEvidence = await inspectManagedInstall({ run, openclaw, stateDir, targetVersion: version });
631
+ const newReceipt = {
632
+ schemaVersion: 1,
633
+ targetVersion: version,
634
+ artifactSha256: artifactDigest,
635
+ packageJsonHash: installEvidence.packageJsonHash,
636
+ manifestHash: installEvidence.manifestHash,
637
+ indexHash: installEvidence.indexHash,
638
+ repairCliHash: installEvidence.repairCliHash,
639
+ installRootRealpath: installEvidence.installRoot,
640
+ createdAt: new Date().toISOString(),
641
+ };
642
+ await writePrivateJson(receiptFile, newReceipt);
643
+ receiptCreated = true;
368
644
  }
369
- const npmRoot = await realpath(join(stateDir, "npm"));
370
- const managedPath = await realpath(install.installPath);
371
- const activeRoot = await realpath(plugin.rootDir);
372
- assertUnder(npmRoot, managedPath);
373
- if (activeRoot !== managedPath)
374
- throw new Error("PerkOS is still loading from a non-managed plugin source");
375
- const installedManifest = JSON.parse(await readFile(join(managedPath, "openclaw.plugin.json"), "utf8"));
376
- if (installedManifest.id !== "perkos-a2a" || installedManifest.version !== version)
377
- throw new Error("installed plugin version does not match repair CLI");
645
+ installCommitted = true;
646
+ journal = { ...journal, installCommitted: true };
647
+ await setPhase("migrate-config");
378
648
  const currentRaw = await readFile(configPath, "utf8");
379
649
  const currentDocument = JSON.parse(currentRaw);
380
650
  replacePluginEntry(currentDocument, normalized.value, true);
381
- await writeJsonConfig(configPath, currentDocument);
651
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, currentDocument));
652
+ configCommitted = true;
653
+ await setPhase("validate-config");
382
654
  await mustRun(run, openclaw, ["config", "validate"], "config validation");
383
655
  const doctor = await mustRun(run, openclaw, ["plugins", "doctor"], "plugin doctor");
384
656
  const doctorText = `${doctor.stdout}\n${doctor.stderr}`.toLowerCase();
385
657
  if (doctorText.includes("duplicate plugin id detected"))
386
658
  throw new Error("duplicate plugin source remains after migration");
387
- committed = true;
659
+ const activeEvidence = await inspectManagedInstall({ run, openclaw, stateDir, targetVersion: version });
660
+ if (activeEvidence.installRoot !== installEvidence.installRoot)
661
+ throw new Error("managed plugin root changed during config migration");
388
662
  const emptyPostRestart = verifyLogState("", options.expectedAgentName);
389
663
  const restartResult = { requested: false, verified: false };
390
664
  let postRestart = emptyPostRestart;
665
+ const expectedFingerprint = configFingerprint(normalized.value, journal.hmacKeyHex);
391
666
  if (!options.skipRestart) {
667
+ const runtimeAttempt = {
668
+ schemaVersion: 1,
669
+ attemptId: journal.attemptId,
670
+ targetVersion: version,
671
+ expectedAgentId: options.expectedAgentId,
672
+ hmacKeyHex: journal.hmacKeyHex,
673
+ createdAt: new Date().toISOString(),
674
+ };
675
+ await writePrivateJson(runtimeAttemptPath(stateDir), runtimeAttempt);
676
+ await setPhase("restart");
392
677
  const beforeStatusResult = await mustRun(run, openclaw, ["gateway", "status", "--json"], "pre-restart gateway status");
393
678
  const beforeStatus = await processIdentityWithStart(run, parseJsonOutput(beforeStatusResult.stdout));
394
679
  restartResult.beforePid = beforeStatus.pid;
@@ -400,11 +685,11 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
400
685
  const permissionRaw = await readFile(configPath, "utf8");
401
686
  const permissionDocument = JSON.parse(permissionRaw);
402
687
  const previousPermission = enableExternalRestart(permissionDocument);
403
- await writeJsonConfig(configPath, permissionDocument);
688
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, permissionDocument));
404
689
  restoreRestartPermission = async () => {
405
690
  const latest = JSON.parse(await readFile(configPath, "utf8"));
406
691
  restoreExternalRestart(latest, previousPermission);
407
- await writeJsonConfig(configPath, latest);
692
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, latest));
408
693
  };
409
694
  await wait(options.pollIntervalMs ?? 2_000);
410
695
  await mustRun(run, "kill", ["-USR1", "1"], "PID 1 in-process gateway restart");
@@ -415,12 +700,13 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
415
700
  restartResult.method = "service";
416
701
  }
417
702
  restartResult.requested = true;
418
- const stabilityWindowMs = options.stabilityWindowMs ?? 120_000;
419
- const deadline = Date.now() + (options.timeoutMs ?? stabilityWindowMs + 90_000);
703
+ await setPhase("runtime-checks");
704
+ const stabilityWindowMs = options.stabilityWindowMs ?? 180_000;
705
+ const requiredHeartbeatCount = options.requiredHeartbeatCount ?? 3;
420
706
  let healthySince;
421
707
  let recentLogs = "";
422
- while (Date.now() < deadline) {
423
- await wait(options.pollIntervalMs ?? 2_000);
708
+ while (Date.now() < deadlineAt) {
709
+ await wait(Math.min(options.pollIntervalMs ?? 2_000, Math.max(1, deadlineAt - Date.now())));
424
710
  const status = await run(openclaw, ["gateway", "status", "--json", "--require-rpc"]);
425
711
  if (status.code !== 0) {
426
712
  healthySince = undefined;
@@ -429,10 +715,24 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
429
715
  const afterStatus = await processIdentityWithStart(run, parseJsonOutput(status.stdout));
430
716
  restartResult.afterPid = afterStatus.pid;
431
717
  restartResult.afterStartTime = afterStatus.startTime;
432
- const logs = await run(openclaw, ["logs", "--json", "--limit", "1000", "--max-bytes", "2000000"]);
718
+ const logs = await run(openclaw, ["logs", "--json", "--limit", "2000", "--max-bytes", "4000000"]);
433
719
  if (logs.code === 0)
434
720
  recentLogs = logs.stdout;
435
- postRestart = postRestartStateFromLogs(recentLogs, options.expectedAgentName, restartStartedAt);
721
+ const freshText = logTextSince(recentLogs, restartStartedAt);
722
+ postRestart = verifyLogState(freshText, options.expectedAgentName);
723
+ postRestart.configLoaded = configLoadedLogMatches(freshText, {
724
+ version,
725
+ agentId: options.expectedAgentId,
726
+ attemptId: journal.attemptId,
727
+ fingerprint: expectedFingerprint,
728
+ }) || await runtimeStatusMatches(runtimeStatusPath(stateDir), {
729
+ version,
730
+ agentId: options.expectedAgentId,
731
+ attemptId: journal.attemptId,
732
+ fingerprint: expectedFingerprint,
733
+ installRoot: installEvidence.installRoot,
734
+ startedAt: restartStartedAt,
735
+ });
436
736
  const processIdentityChanged = processChanged(beforeStatus, afterStatus);
437
737
  const freshRestartLogs = restartObservedInLogs(recentLogs, restartStartedAt);
438
738
  restartResult.verified = processIdentityChanged || (restartResult.method === "in-process-sigusr1" && freshRestartLogs);
@@ -442,9 +742,10 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
442
742
  ? "fresh-restart-logs"
443
743
  : undefined;
444
744
  const healthy = restartResult.verified
745
+ && postRestart.configLoaded
445
746
  && postRestart.connected
446
747
  && postRestart.registered
447
- && postRestart.heartbeat200
748
+ && postRestart.heartbeatCount >= requiredHeartbeatCount
448
749
  && postRestart.chatAuthed
449
750
  && !postRestart.duplicatePlugin
450
751
  && !postRestart.duplicateRegistration;
@@ -453,14 +754,20 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
453
754
  continue;
454
755
  }
455
756
  healthySince ??= Date.now();
757
+ postRestart.stableSeconds = Math.floor((Date.now() - healthySince) / 1_000);
456
758
  if (Date.now() - healthySince >= stabilityWindowMs)
457
759
  break;
458
760
  }
459
761
  if (!restartResult.verified)
460
762
  throw new Error("gateway restart was not verified by process identity or fresh in-process restart logs");
461
- if (!postRestart.connected || !postRestart.registered || !postRestart.heartbeat200 || !postRestart.chatAuthed || postRestart.duplicatePlugin || postRestart.duplicateRegistration) {
763
+ if (!postRestart.configLoaded
764
+ || !postRestart.connected
765
+ || !postRestart.registered
766
+ || postRestart.heartbeatCount < requiredHeartbeatCount
767
+ || !postRestart.chatAuthed
768
+ || postRestart.duplicatePlugin
769
+ || postRestart.duplicateRegistration)
462
770
  throw new Error(`post-restart verification failed: ${JSON.stringify(postRestart)}`);
463
- }
464
771
  if (healthySince === undefined || Date.now() - healthySince < stabilityWindowMs) {
465
772
  throw new Error(`post-restart transport did not remain healthy for ${stabilityWindowMs}ms`);
466
773
  }
@@ -469,12 +776,27 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
469
776
  await restoreRestartPermission?.();
470
777
  }
471
778
  }
779
+ await setPhase("done");
472
780
  return {
781
+ schemaVersion: 1,
473
782
  ok: true,
783
+ phase: "done",
474
784
  version,
475
785
  mode: existingConfig ? "repaired" : "installed",
786
+ trustLevel: "trusted",
787
+ strictMode: true,
788
+ resumedFromPhase,
476
789
  artifactVerified: true,
477
790
  source: "managed-npm",
791
+ installEvidence: {
792
+ artifactKind: installEvidence.artifactKind,
793
+ source: installEvidence.source,
794
+ spec: installEvidence.spec,
795
+ receiptUsed,
796
+ receiptCreated,
797
+ installRoot: installEvidence.installRoot,
798
+ },
799
+ configWriteMode,
478
800
  normalized: true,
479
801
  removedUnsupportedPaths: normalized.removed,
480
802
  quarantinedLegacyExtension: quarantined,
@@ -485,14 +807,18 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
485
807
  };
486
808
  }
487
809
  catch (error) {
488
- if (!committed) {
810
+ if (!installCommitted) {
489
811
  await writeFile(configPath, originalRaw, { mode: 0o600 });
490
812
  if (quarantined && await pathExists(quarantinePath) && !await pathExists(legacyExtension)) {
491
813
  await mkdir(dirname(legacyExtension), { recursive: true });
492
814
  await rename(quarantinePath, legacyExtension);
493
815
  }
494
816
  }
495
- throw error;
817
+ const rawMessage = error instanceof Error ? error.message : String(error);
818
+ const message = redactProtectedValues(rawMessage, before);
819
+ if (error instanceof RepairFailure)
820
+ throw error;
821
+ throw new RepairFailure(installCommitted && !configCommitted ? "REPAIR_RESUME_REQUIRED" : "REPAIR_FAILED", journal.phase, message, installCommitted, false);
496
822
  }
497
823
  }
498
824
  finally {