@perkos/perkos-a2a 0.12.29 → 0.12.30

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,13 +248,17 @@ 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
264
  export const defaultCommandRunner = (command, args) => new Promise((resolveResult, reject) => {
@@ -296,6 +327,115 @@ function assertUnder(parent, child) {
296
327
  return;
297
328
  throw new Error("managed install escaped the OpenClaw npm root");
298
329
  }
330
+ async function sha256File(path) {
331
+ return createHash("sha256").update(await readFile(path)).digest("hex");
332
+ }
333
+ async function readJsonOptional(path) {
334
+ try {
335
+ return JSON.parse(await readFile(path, "utf8"));
336
+ }
337
+ catch {
338
+ return undefined;
339
+ }
340
+ }
341
+ async function writePrivateJson(path, value) {
342
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
343
+ const mode = await writeJsonConfig(path, value);
344
+ await chmod(path, 0o600);
345
+ return mode;
346
+ }
347
+ function repairStateRoot(stateDir) {
348
+ return join(stateDir, "perkos-a2a");
349
+ }
350
+ function receiptPath(stateDir, version, artifactSha256) {
351
+ return join(repairStateRoot(stateDir), "receipts", `${version}-${artifactSha256}.json`);
352
+ }
353
+ function journalPath(stateDir) {
354
+ return join(repairStateRoot(stateDir), "repair-journal.json");
355
+ }
356
+ async function inspectManagedInstall(input) {
357
+ const inspectResult = await mustRun(input.run, input.openclaw, ["plugins", "inspect", "perkos-a2a", "--json"], "plugin inspection");
358
+ const inspect = parseJsonOutput(inspectResult.stdout);
359
+ if (!isObject(inspect))
360
+ throw new Error("plugin inspection result is invalid");
361
+ const plugin = isObject(inspect.plugin) ? inspect.plugin : {};
362
+ const install = isObject(inspect.install) ? inspect.install : {};
363
+ const installPathValue = install.installPath ?? plugin.installPath;
364
+ if (plugin.id !== "perkos-a2a" || typeof installPathValue !== "string" || typeof plugin.rootDir !== "string") {
365
+ throw new Error("managed npm install was not recorded");
366
+ }
367
+ const npmRoot = await realpath(join(input.stateDir, "npm"));
368
+ const managedPath = await realpath(installPathValue);
369
+ const activeRoot = await realpath(plugin.rootDir);
370
+ assertUnder(npmRoot, managedPath);
371
+ if (activeRoot !== managedPath)
372
+ throw new Error("PerkOS is still loading from a non-managed plugin source");
373
+ const packagePath = join(managedPath, "package.json");
374
+ const manifestPath = join(managedPath, "openclaw.plugin.json");
375
+ const indexPath = join(managedPath, "dist", "index.js");
376
+ const repairCliPath = join(managedPath, "dist", "repair-cli.js");
377
+ const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
378
+ const installedManifest = JSON.parse(await readFile(manifestPath, "utf8"));
379
+ const bin = isObject(packageJson.bin) ? packageJson.bin : {};
380
+ if (packageJson.name !== "@perkos/perkos-a2a"
381
+ || packageJson.version !== input.targetVersion
382
+ || installedManifest.id !== "perkos-a2a"
383
+ || installedManifest.version !== input.targetVersion
384
+ || bin["perkos-a2a"] !== "dist/repair-cli.js"
385
+ || !await pathExists(indexPath)
386
+ || !await pathExists(repairCliPath)) {
387
+ throw new Error("installed plugin identity/version does not match the repair target");
388
+ }
389
+ return {
390
+ installRoot: managedPath,
391
+ artifactKind: typeof install.artifactKind === "string" ? install.artifactKind : undefined,
392
+ source: typeof install.source === "string" ? install.source : typeof plugin.source === "string" ? plugin.source : undefined,
393
+ spec: typeof install.spec === "string" ? install.spec : typeof plugin.spec === "string" ? plugin.spec : undefined,
394
+ packageJsonHash: await sha256File(packagePath),
395
+ manifestHash: await sha256File(manifestPath),
396
+ indexHash: await sha256File(indexPath),
397
+ repairCliHash: await sha256File(repairCliPath),
398
+ };
399
+ }
400
+ function receiptMatches(receipt, evidence, version, artifactSha256) {
401
+ return Boolean(receipt)
402
+ && receipt?.schemaVersion === 1
403
+ && receipt.targetVersion === version
404
+ && receipt.artifactSha256 === artifactSha256
405
+ && receipt.installRootRealpath === evidence.installRoot
406
+ && receipt.packageJsonHash === evidence.packageJsonHash
407
+ && receipt.manifestHash === evidence.manifestHash
408
+ && receipt.indexHash === evidence.indexHash
409
+ && receipt.repairCliHash === evidence.repairCliHash;
410
+ }
411
+ function mergeWriteMode(current, next) {
412
+ return current === "inplace-ebusy-fallback" || next === "inplace-ebusy-fallback"
413
+ ? "inplace-ebusy-fallback"
414
+ : "atomic";
415
+ }
416
+ function configLoadedLogMatches(text, input) {
417
+ return text.includes(`[perkos-a2a] config loaded version=${input.version} agentId=${input.agentId} attemptId=${input.attemptId} fingerprint=${input.fingerprint}`);
418
+ }
419
+ async function runtimeStatusMatches(path, input) {
420
+ const status = await readJsonOptional(path);
421
+ if (!status)
422
+ return false;
423
+ const loadedAt = Date.parse(status.loadedAt);
424
+ if (status.schemaVersion !== 1
425
+ || status.version !== input.version
426
+ || status.agentId !== input.agentId
427
+ || status.attemptId !== input.attemptId
428
+ || status.fingerprint !== input.fingerprint
429
+ || !Number.isFinite(loadedAt)
430
+ || loadedAt < input.startedAt - 2_000)
431
+ return false;
432
+ try {
433
+ return await realpath(status.pluginRoot) === input.installRoot;
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ }
299
439
  async function wait(ms) {
300
440
  await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
301
441
  }
@@ -312,17 +452,46 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
312
452
  throw new Error("repair CLI manifest/schema is invalid");
313
453
  if (!await pathExists(artifactPath))
314
454
  throw new Error("verified plugin artifact is missing");
315
- const artifactDigest = createHash("sha256").update(await readFile(artifactPath)).digest("hex");
455
+ const artifactDigest = await sha256File(artifactPath);
316
456
  if (artifactDigest !== options.artifactSha256.toLowerCase())
317
457
  throw new Error(`plugin artifact checksum mismatch: ${artifactDigest}`);
318
458
  const releaseRepairLock = await acquireRepairLock(stateDir);
319
459
  try {
460
+ const priorJournal = await readJsonOptional(journalPath(stateDir));
461
+ const canResume = priorJournal?.schemaVersion === 1
462
+ && priorJournal.phase !== "done"
463
+ && priorJournal.targetVersion === version
464
+ && priorJournal.artifactSha256 === artifactDigest
465
+ && priorJournal.expectedAgentName === options.expectedAgentName
466
+ && priorJournal.expectedAgentId === options.expectedAgentId;
467
+ const resumedFromPhase = canResume ? priorJournal.phase : undefined;
468
+ let journal = canResume ? priorJournal : {
469
+ schemaVersion: 1,
470
+ attemptId: randomUUID(),
471
+ hmacKeyHex: randomBytes(32).toString("hex"),
472
+ targetVersion: version,
473
+ artifactSha256: artifactDigest,
474
+ expectedAgentName: options.expectedAgentName,
475
+ expectedAgentId: options.expectedAgentId,
476
+ phase: "detect",
477
+ createdAt: new Date().toISOString(),
478
+ updatedAt: new Date().toISOString(),
479
+ };
480
+ const setPhase = async (phase) => {
481
+ journal = { ...journal, phase, updatedAt: new Date().toISOString() };
482
+ await writePrivateJson(journalPath(stateDir), journal);
483
+ };
484
+ await setPhase("detect");
320
485
  const backupRoot = join(stateDir, "perkos-migration-backups");
321
486
  await mkdir(backupRoot, { recursive: true, mode: 0o700 });
322
- const backupPath = join(backupRoot, `openclaw-${Date.now()}.before.json`);
487
+ const reusableBackup = canResume && priorJournal.backupPath && await pathExists(priorJournal.backupPath)
488
+ ? priorJournal.backupPath
489
+ : undefined;
490
+ const backupPath = reusableBackup ?? join(backupRoot, `openclaw-${Date.now()}.before.json`);
323
491
  const legacyExtension = join(stateDir, "extensions", "perkos-a2a");
324
492
  const quarantinePath = join(backupRoot, `perkos-a2a-extension-${Date.now()}`);
325
493
  const originalRaw = await readFile(configPath, "utf8");
494
+ const identitySourceRaw = reusableBackup ? await readFile(reusableBackup, "utf8") : originalRaw;
326
495
  let document;
327
496
  try {
328
497
  document = JSON.parse(originalRaw);
@@ -330,21 +499,38 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
330
499
  catch {
331
500
  throw new Error("OpenClaw config must be strict JSON for safe automatic repair");
332
501
  }
333
- const existingConfig = openClawConfig(document);
502
+ let identitySource;
503
+ try {
504
+ identitySource = JSON.parse(identitySourceRaw);
505
+ }
506
+ catch {
507
+ throw new Error("OpenClaw repair backup must be strict JSON");
508
+ }
509
+ const existingConfig = openClawConfig(identitySource);
334
510
  const before = structuredClone(existingConfig ?? options.initialConfig);
335
511
  if (!isObject(before))
336
512
  throw new Error("existing PerkOS plugin config is missing");
337
- await writeFile(backupPath, originalRaw, { mode: 0o600 });
513
+ if (!reusableBackup) {
514
+ await writeFile(backupPath, originalRaw, { mode: 0o600 });
515
+ await chmod(backupPath, 0o600);
516
+ }
517
+ journal = { ...journal, backupPath };
518
+ await setPhase("snapshot");
338
519
  const normalized = normalizeToSchema(before, manifest.configSchema);
339
520
  if (!isObject(normalized.value))
340
521
  throw new Error("normalized PerkOS config is invalid");
341
522
  assertProtected(before, normalized.value);
342
523
  assertIdentity(normalized.value, options.expectedAgentName, options.expectedAgentId);
343
524
  let quarantined = false;
344
- let committed = false;
525
+ let installCommitted = journal.installCommitted === true;
526
+ let configCommitted = false;
527
+ let configWriteMode = "atomic";
528
+ let receiptUsed = false;
529
+ let receiptCreated = false;
530
+ let installEvidence;
345
531
  try {
346
532
  replacePluginEntry(document, {}, false);
347
- await writeJsonConfig(configPath, document);
533
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, document));
348
534
  if (await pathExists(legacyExtension)) {
349
535
  const legacyManifest = JSON.parse(await readFile(join(legacyExtension, "openclaw.plugin.json"), "utf8"));
350
536
  if (legacyManifest.id !== "perkos-a2a")
@@ -352,43 +538,75 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
352
538
  await rename(legacyExtension, quarantinePath);
353
539
  quarantined = true;
354
540
  }
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)}`);
541
+ await setPhase("install");
542
+ const receiptFile = receiptPath(stateDir, version, artifactDigest);
543
+ const receipt = await readJsonOptional(receiptFile);
544
+ if (receipt) {
545
+ try {
546
+ const candidate = await inspectManagedInstall({ run, openclaw, stateDir, targetVersion: version });
547
+ if (receiptMatches(receipt, candidate, version, artifactDigest)) {
548
+ installEvidence = candidate;
549
+ receiptUsed = true;
550
+ }
551
+ }
552
+ catch { /* reinstall and certify from the verified artifact */ }
359
553
  }
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");
554
+ if (!installEvidence) {
555
+ const installResult = await run(openclaw, ["plugins", "install", `npm-pack:${artifactPath}`, "--force"]);
556
+ const installError = `${installResult.stderr}\n${installResult.stdout}`;
557
+ if (installResult.code !== 0 && !/EBUSY|resource busy|rename/iu.test(installError)) {
558
+ throw new Error(`managed plugin install failed: ${installError.trim().slice(0, 500)}`);
559
+ }
560
+ installCommitted = true;
561
+ journal = { ...journal, installCommitted: true };
562
+ await setPhase("install");
563
+ installEvidence = await inspectManagedInstall({ run, openclaw, stateDir, targetVersion: version });
564
+ const newReceipt = {
565
+ schemaVersion: 1,
566
+ targetVersion: version,
567
+ artifactSha256: artifactDigest,
568
+ packageJsonHash: installEvidence.packageJsonHash,
569
+ manifestHash: installEvidence.manifestHash,
570
+ indexHash: installEvidence.indexHash,
571
+ repairCliHash: installEvidence.repairCliHash,
572
+ installRootRealpath: installEvidence.installRoot,
573
+ createdAt: new Date().toISOString(),
574
+ };
575
+ await writePrivateJson(receiptFile, newReceipt);
576
+ receiptCreated = true;
368
577
  }
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");
578
+ installCommitted = true;
579
+ journal = { ...journal, installCommitted: true };
580
+ await setPhase("migrate-config");
378
581
  const currentRaw = await readFile(configPath, "utf8");
379
582
  const currentDocument = JSON.parse(currentRaw);
380
583
  replacePluginEntry(currentDocument, normalized.value, true);
381
- await writeJsonConfig(configPath, currentDocument);
584
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, currentDocument));
585
+ configCommitted = true;
586
+ await setPhase("validate-config");
382
587
  await mustRun(run, openclaw, ["config", "validate"], "config validation");
383
588
  const doctor = await mustRun(run, openclaw, ["plugins", "doctor"], "plugin doctor");
384
589
  const doctorText = `${doctor.stdout}\n${doctor.stderr}`.toLowerCase();
385
590
  if (doctorText.includes("duplicate plugin id detected"))
386
591
  throw new Error("duplicate plugin source remains after migration");
387
- committed = true;
592
+ const activeEvidence = await inspectManagedInstall({ run, openclaw, stateDir, targetVersion: version });
593
+ if (activeEvidence.installRoot !== installEvidence.installRoot)
594
+ throw new Error("managed plugin root changed during config migration");
388
595
  const emptyPostRestart = verifyLogState("", options.expectedAgentName);
389
596
  const restartResult = { requested: false, verified: false };
390
597
  let postRestart = emptyPostRestart;
598
+ const expectedFingerprint = configFingerprint(normalized.value, journal.hmacKeyHex);
391
599
  if (!options.skipRestart) {
600
+ const runtimeAttempt = {
601
+ schemaVersion: 1,
602
+ attemptId: journal.attemptId,
603
+ targetVersion: version,
604
+ expectedAgentId: options.expectedAgentId,
605
+ hmacKeyHex: journal.hmacKeyHex,
606
+ createdAt: new Date().toISOString(),
607
+ };
608
+ await writePrivateJson(runtimeAttemptPath(stateDir), runtimeAttempt);
609
+ await setPhase("restart");
392
610
  const beforeStatusResult = await mustRun(run, openclaw, ["gateway", "status", "--json"], "pre-restart gateway status");
393
611
  const beforeStatus = await processIdentityWithStart(run, parseJsonOutput(beforeStatusResult.stdout));
394
612
  restartResult.beforePid = beforeStatus.pid;
@@ -400,11 +618,11 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
400
618
  const permissionRaw = await readFile(configPath, "utf8");
401
619
  const permissionDocument = JSON.parse(permissionRaw);
402
620
  const previousPermission = enableExternalRestart(permissionDocument);
403
- await writeJsonConfig(configPath, permissionDocument);
621
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, permissionDocument));
404
622
  restoreRestartPermission = async () => {
405
623
  const latest = JSON.parse(await readFile(configPath, "utf8"));
406
624
  restoreExternalRestart(latest, previousPermission);
407
- await writeJsonConfig(configPath, latest);
625
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, latest));
408
626
  };
409
627
  await wait(options.pollIntervalMs ?? 2_000);
410
628
  await mustRun(run, "kill", ["-USR1", "1"], "PID 1 in-process gateway restart");
@@ -415,8 +633,10 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
415
633
  restartResult.method = "service";
416
634
  }
417
635
  restartResult.requested = true;
418
- const stabilityWindowMs = options.stabilityWindowMs ?? 120_000;
419
- const deadline = Date.now() + (options.timeoutMs ?? stabilityWindowMs + 90_000);
636
+ await setPhase("runtime-checks");
637
+ const stabilityWindowMs = options.stabilityWindowMs ?? 180_000;
638
+ const requiredHeartbeatCount = options.requiredHeartbeatCount ?? 3;
639
+ const deadline = Date.now() + (options.timeoutMs ?? 300_000);
420
640
  let healthySince;
421
641
  let recentLogs = "";
422
642
  while (Date.now() < deadline) {
@@ -429,10 +649,24 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
429
649
  const afterStatus = await processIdentityWithStart(run, parseJsonOutput(status.stdout));
430
650
  restartResult.afterPid = afterStatus.pid;
431
651
  restartResult.afterStartTime = afterStatus.startTime;
432
- const logs = await run(openclaw, ["logs", "--json", "--limit", "1000", "--max-bytes", "2000000"]);
652
+ const logs = await run(openclaw, ["logs", "--json", "--limit", "2000", "--max-bytes", "4000000"]);
433
653
  if (logs.code === 0)
434
654
  recentLogs = logs.stdout;
435
- postRestart = postRestartStateFromLogs(recentLogs, options.expectedAgentName, restartStartedAt);
655
+ const freshText = logTextSince(recentLogs, restartStartedAt);
656
+ postRestart = verifyLogState(freshText, options.expectedAgentName);
657
+ postRestart.configLoaded = configLoadedLogMatches(freshText, {
658
+ version,
659
+ agentId: options.expectedAgentId,
660
+ attemptId: journal.attemptId,
661
+ fingerprint: expectedFingerprint,
662
+ }) || await runtimeStatusMatches(runtimeStatusPath(stateDir), {
663
+ version,
664
+ agentId: options.expectedAgentId,
665
+ attemptId: journal.attemptId,
666
+ fingerprint: expectedFingerprint,
667
+ installRoot: installEvidence.installRoot,
668
+ startedAt: restartStartedAt,
669
+ });
436
670
  const processIdentityChanged = processChanged(beforeStatus, afterStatus);
437
671
  const freshRestartLogs = restartObservedInLogs(recentLogs, restartStartedAt);
438
672
  restartResult.verified = processIdentityChanged || (restartResult.method === "in-process-sigusr1" && freshRestartLogs);
@@ -442,9 +676,10 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
442
676
  ? "fresh-restart-logs"
443
677
  : undefined;
444
678
  const healthy = restartResult.verified
679
+ && postRestart.configLoaded
445
680
  && postRestart.connected
446
681
  && postRestart.registered
447
- && postRestart.heartbeat200
682
+ && postRestart.heartbeatCount >= requiredHeartbeatCount
448
683
  && postRestart.chatAuthed
449
684
  && !postRestart.duplicatePlugin
450
685
  && !postRestart.duplicateRegistration;
@@ -453,14 +688,20 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
453
688
  continue;
454
689
  }
455
690
  healthySince ??= Date.now();
691
+ postRestart.stableSeconds = Math.floor((Date.now() - healthySince) / 1_000);
456
692
  if (Date.now() - healthySince >= stabilityWindowMs)
457
693
  break;
458
694
  }
459
695
  if (!restartResult.verified)
460
696
  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) {
697
+ if (!postRestart.configLoaded
698
+ || !postRestart.connected
699
+ || !postRestart.registered
700
+ || postRestart.heartbeatCount < requiredHeartbeatCount
701
+ || !postRestart.chatAuthed
702
+ || postRestart.duplicatePlugin
703
+ || postRestart.duplicateRegistration)
462
704
  throw new Error(`post-restart verification failed: ${JSON.stringify(postRestart)}`);
463
- }
464
705
  if (healthySince === undefined || Date.now() - healthySince < stabilityWindowMs) {
465
706
  throw new Error(`post-restart transport did not remain healthy for ${stabilityWindowMs}ms`);
466
707
  }
@@ -469,12 +710,27 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
469
710
  await restoreRestartPermission?.();
470
711
  }
471
712
  }
713
+ await setPhase("done");
472
714
  return {
715
+ schemaVersion: 1,
473
716
  ok: true,
717
+ phase: "done",
474
718
  version,
475
719
  mode: existingConfig ? "repaired" : "installed",
720
+ trustLevel: "trusted",
721
+ strictMode: true,
722
+ resumedFromPhase,
476
723
  artifactVerified: true,
477
724
  source: "managed-npm",
725
+ installEvidence: {
726
+ artifactKind: installEvidence.artifactKind,
727
+ source: installEvidence.source,
728
+ spec: installEvidence.spec,
729
+ receiptUsed,
730
+ receiptCreated,
731
+ installRoot: installEvidence.installRoot,
732
+ },
733
+ configWriteMode,
478
734
  normalized: true,
479
735
  removedUnsupportedPaths: normalized.removed,
480
736
  quarantinedLegacyExtension: quarantined,
@@ -485,14 +741,18 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
485
741
  };
486
742
  }
487
743
  catch (error) {
488
- if (!committed) {
744
+ if (!installCommitted) {
489
745
  await writeFile(configPath, originalRaw, { mode: 0o600 });
490
746
  if (quarantined && await pathExists(quarantinePath) && !await pathExists(legacyExtension)) {
491
747
  await mkdir(dirname(legacyExtension), { recursive: true });
492
748
  await rename(quarantinePath, legacyExtension);
493
749
  }
494
750
  }
495
- throw error;
751
+ const rawMessage = error instanceof Error ? error.message : String(error);
752
+ const message = redactProtectedValues(rawMessage, before);
753
+ if (error instanceof RepairFailure)
754
+ throw error;
755
+ throw new RepairFailure(installCommitted && !configCommitted ? "REPAIR_RESUME_REQUIRED" : "REPAIR_FAILED", journal.phase, message, installCommitted, false);
496
756
  }
497
757
  }
498
758
  finally {