@perkos/perkos-a2a 0.12.28 → 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 { mkdtemp, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
- import { homedir, tmpdir } from "node:os";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, open, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
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);
@@ -67,10 +92,83 @@ async function pathExists(path) {
67
92
  return false;
68
93
  }
69
94
  }
70
- async function atomicWriteJson(path, value) {
95
+ function errorCode(error) {
96
+ return typeof error === "object" && error !== null && "code" in error
97
+ ? String(error.code ?? "")
98
+ : undefined;
99
+ }
100
+ /**
101
+ * Atomically replace normal config files, but preserve the inode for Docker/
102
+ * Kubernetes bind-mounted files where rename(2) fails with EBUSY/EXDEV/EPERM.
103
+ * A caller-owned backup is always written before this function is used.
104
+ */
105
+ export async function writeJsonConfig(path, value, renameFile = rename) {
106
+ const serialized = `${JSON.stringify(value, null, 2)}\n`;
71
107
  const temporary = `${path}.perkos-repair-${process.pid}.tmp`;
72
- await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
73
- await rename(temporary, path);
108
+ await writeFile(temporary, serialized, { mode: 0o600 });
109
+ try {
110
+ await renameFile(temporary, path);
111
+ return "atomic";
112
+ }
113
+ catch (error) {
114
+ const code = errorCode(error);
115
+ if (code !== "EBUSY" && code !== "EXDEV" && code !== "EPERM")
116
+ throw error;
117
+ await rm(temporary, { force: true });
118
+ const handle = await open(path, "r+");
119
+ try {
120
+ await handle.truncate(0);
121
+ await handle.writeFile(serialized, "utf8");
122
+ await handle.sync();
123
+ }
124
+ finally {
125
+ await handle.close();
126
+ }
127
+ return "inplace-ebusy-fallback";
128
+ }
129
+ finally {
130
+ await rm(temporary, { force: true });
131
+ }
132
+ }
133
+ export async function acquireRepairLock(stateDir) {
134
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
135
+ const lockPath = join(stateDir, "perkos-a2a-connect.lock");
136
+ const attempt = async () => open(lockPath, "wx", 0o600);
137
+ let handle;
138
+ try {
139
+ handle = await attempt();
140
+ }
141
+ catch (error) {
142
+ if (errorCode(error) !== "EEXIST")
143
+ throw error;
144
+ let stale = false;
145
+ try {
146
+ const lock = JSON.parse(await readFile(lockPath, "utf8"));
147
+ const pid = typeof lock.pid === "number" ? lock.pid : Number.NaN;
148
+ const createdAt = typeof lock.createdAt === "string" ? Date.parse(lock.createdAt) : Number.NaN;
149
+ if (Number.isFinite(pid)) {
150
+ try {
151
+ process.kill(pid, 0);
152
+ }
153
+ catch (probeError) {
154
+ stale = errorCode(probeError) === "ESRCH";
155
+ }
156
+ }
157
+ if (!Number.isFinite(pid) && Number.isFinite(createdAt) && Date.now() - createdAt > 10 * 60_000)
158
+ stale = true;
159
+ }
160
+ catch { /* malformed locks are not removed unless old enough */ }
161
+ if (!stale)
162
+ throw new Error("another perkos-a2a connect/repair is already in progress");
163
+ await rm(lockPath, { force: true });
164
+ handle = await attempt();
165
+ }
166
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
167
+ await handle.sync();
168
+ return async () => {
169
+ await handle.close();
170
+ await rm(lockPath, { force: true });
171
+ };
74
172
  }
75
173
  function parseJsonOutput(output) {
76
174
  const trimmed = output.trim();
@@ -121,8 +219,8 @@ async function processIdentityWithStart(run, status) {
121
219
  return startTime ? { ...identity, startTime } : identity;
122
220
  }
123
221
  function processChanged(before, after) {
124
- if (before.pid !== undefined && after.pid !== undefined)
125
- return before.pid !== after.pid;
222
+ if (before.pid !== undefined && after.pid !== undefined && before.pid !== after.pid)
223
+ return true;
126
224
  if (before.startTime && after.startTime)
127
225
  return before.startTime !== after.startTime;
128
226
  return false;
@@ -150,13 +248,17 @@ export function postRestartStateFromLogs(output, agentName, startedAt) {
150
248
  return verifyLogState(logTextSince(output, startedAt), agentName);
151
249
  }
152
250
  function verifyLogState(text, agentName) {
251
+ const heartbeatCount = text.match(/\[perkos-heartbeat\].*status=200/gu)?.length ?? 0;
153
252
  return {
253
+ configLoaded: false,
154
254
  connected: text.includes("Connected to relay hub"),
155
255
  registered: text.includes("Registered with relay hub"),
156
- heartbeat200: /\[perkos-heartbeat\].*status=200/u.test(text),
256
+ heartbeat200: heartbeatCount > 0,
257
+ heartbeatCount,
157
258
  chatAuthed: text.includes(`[perkos-chat] authed as agent:${agentName}`),
158
259
  duplicatePlugin: text.toLowerCase().includes("duplicate plugin id detected"),
159
260
  duplicateRegistration: text.toLowerCase().includes("duplicate registration replaced"),
261
+ stableSeconds: 0,
160
262
  };
161
263
  }
162
264
  export const defaultCommandRunner = (command, args) => new Promise((resolveResult, reject) => {
@@ -191,6 +293,23 @@ function replacePluginEntry(document, config, enabled) {
191
293
  const previous = isObject(entries["perkos-a2a"]) ? entries["perkos-a2a"] : {};
192
294
  entries["perkos-a2a"] = { ...previous, enabled, config };
193
295
  }
296
+ function enableExternalRestart(document) {
297
+ const previous = structuredClone(document.commands);
298
+ if (!isObject(document.commands))
299
+ document.commands = {};
300
+ document.commands.restart = true;
301
+ return previous;
302
+ }
303
+ function restoreExternalRestart(document, previous) {
304
+ if (previous === undefined)
305
+ delete document.commands;
306
+ else
307
+ document.commands = previous;
308
+ }
309
+ function restartObservedInLogs(output, startedAt) {
310
+ const text = logTextSince(output, startedAt).toLowerCase();
311
+ return text.includes("signal sigusr1 received") || text.includes("gateway restarting");
312
+ }
194
313
  function assertIdentity(config, expectedName, expectedId) {
195
314
  if (config.agentName !== expectedName)
196
315
  throw new Error("configured agentName does not match this invitation");
@@ -208,6 +327,115 @@ function assertUnder(parent, child) {
208
327
  return;
209
328
  throw new Error("managed install escaped the OpenClaw npm root");
210
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
+ }
211
439
  async function wait(ms) {
212
440
  await new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
213
441
  }
@@ -224,136 +452,311 @@ export async function repairOpenClaw(options, run = defaultCommandRunner) {
224
452
  throw new Error("repair CLI manifest/schema is invalid");
225
453
  if (!await pathExists(artifactPath))
226
454
  throw new Error("verified plugin artifact is missing");
227
- const artifactDigest = createHash("sha256").update(await readFile(artifactPath)).digest("hex");
455
+ const artifactDigest = await sha256File(artifactPath);
228
456
  if (artifactDigest !== options.artifactSha256.toLowerCase())
229
457
  throw new Error(`plugin artifact checksum mismatch: ${artifactDigest}`);
230
- const migrationDir = await mkdtemp(join(tmpdir(), "perkos-a2a-repair-"));
231
- const backupRoot = join(stateDir, "perkos-migration-backups");
232
- await mkdir(backupRoot, { recursive: true, mode: 0o700 });
233
- const backupPath = join(migrationDir, "openclaw.before.json");
234
- const legacyExtension = join(stateDir, "extensions", "perkos-a2a");
235
- const quarantinePath = join(backupRoot, `perkos-a2a-extension-${Date.now()}`);
236
- const originalRaw = await readFile(configPath, "utf8");
237
- let document;
238
- try {
239
- document = JSON.parse(originalRaw);
240
- }
241
- catch {
242
- throw new Error("OpenClaw config must be strict JSON for safe automatic repair");
243
- }
244
- const existingConfig = openClawConfig(document);
245
- const before = structuredClone(existingConfig ?? options.initialConfig);
246
- if (!isObject(before))
247
- throw new Error("existing PerkOS plugin config is missing");
248
- await writeFile(backupPath, originalRaw, { mode: 0o600 });
249
- const normalized = normalizeToSchema(before, manifest.configSchema);
250
- if (!isObject(normalized.value))
251
- throw new Error("normalized PerkOS config is invalid");
252
- assertProtected(before, normalized.value);
253
- assertIdentity(normalized.value, options.expectedAgentName, options.expectedAgentId);
254
- let quarantined = false;
255
- let committed = false;
458
+ const releaseRepairLock = await acquireRepairLock(stateDir);
256
459
  try {
257
- replacePluginEntry(document, {}, false);
258
- await atomicWriteJson(configPath, document);
259
- if (await pathExists(legacyExtension)) {
260
- const legacyManifest = JSON.parse(await readFile(join(legacyExtension, "openclaw.plugin.json"), "utf8"));
261
- if (legacyManifest.id !== "perkos-a2a")
262
- throw new Error("refusing to quarantine a non-PerkOS extension");
263
- await rename(legacyExtension, quarantinePath);
264
- quarantined = true;
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");
485
+ const backupRoot = join(stateDir, "perkos-migration-backups");
486
+ await mkdir(backupRoot, { recursive: true, mode: 0o700 });
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`);
491
+ const legacyExtension = join(stateDir, "extensions", "perkos-a2a");
492
+ const quarantinePath = join(backupRoot, `perkos-a2a-extension-${Date.now()}`);
493
+ const originalRaw = await readFile(configPath, "utf8");
494
+ const identitySourceRaw = reusableBackup ? await readFile(reusableBackup, "utf8") : originalRaw;
495
+ let document;
496
+ try {
497
+ document = JSON.parse(originalRaw);
498
+ }
499
+ catch {
500
+ throw new Error("OpenClaw config must be strict JSON for safe automatic repair");
265
501
  }
266
- await mustRun(run, openclaw, ["plugins", "install", `npm-pack:${artifactPath}`, "--force"], "managed plugin install");
267
- const inspectResult = await mustRun(run, openclaw, ["plugins", "inspect", "perkos-a2a", "--json"], "plugin inspection");
268
- const inspect = parseJsonOutput(inspectResult.stdout);
269
- if (!isObject(inspect))
270
- throw new Error("plugin inspection result is invalid");
271
- const plugin = isObject(inspect.plugin) ? inspect.plugin : {};
272
- const install = isObject(inspect.install) ? inspect.install : {};
273
- if (plugin.id !== "perkos-a2a" || install.artifactKind !== "npm-pack" || typeof install.installPath !== "string" || typeof plugin.rootDir !== "string") {
274
- throw new Error("managed npm install was not recorded");
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);
510
+ const before = structuredClone(existingConfig ?? options.initialConfig);
511
+ if (!isObject(before))
512
+ throw new Error("existing PerkOS plugin config is missing");
513
+ if (!reusableBackup) {
514
+ await writeFile(backupPath, originalRaw, { mode: 0o600 });
515
+ await chmod(backupPath, 0o600);
275
516
  }
276
- const npmRoot = await realpath(join(stateDir, "npm"));
277
- const managedPath = await realpath(install.installPath);
278
- const activeRoot = await realpath(plugin.rootDir);
279
- assertUnder(npmRoot, managedPath);
280
- if (activeRoot !== managedPath)
281
- throw new Error("PerkOS is still loading from a non-managed plugin source");
282
- const installedManifest = JSON.parse(await readFile(join(managedPath, "openclaw.plugin.json"), "utf8"));
283
- if (installedManifest.id !== "perkos-a2a" || installedManifest.version !== version)
284
- throw new Error("installed plugin version does not match repair CLI");
285
- const currentRaw = await readFile(configPath, "utf8");
286
- const currentDocument = JSON.parse(currentRaw);
287
- replacePluginEntry(currentDocument, normalized.value, true);
288
- await atomicWriteJson(configPath, currentDocument);
289
- await mustRun(run, openclaw, ["config", "validate"], "config validation");
290
- const doctor = await mustRun(run, openclaw, ["plugins", "doctor"], "plugin doctor");
291
- const doctorText = `${doctor.stdout}\n${doctor.stderr}`.toLowerCase();
292
- if (doctorText.includes("duplicate plugin id detected"))
293
- throw new Error("duplicate plugin source remains after migration");
294
- committed = true;
295
- const emptyPostRestart = verifyLogState("", options.expectedAgentName);
296
- const restartResult = { requested: false, verified: false };
297
- let postRestart = emptyPostRestart;
298
- if (!options.skipRestart) {
299
- const beforeStatusResult = await mustRun(run, openclaw, ["gateway", "status", "--json"], "pre-restart gateway status");
300
- const beforeStatus = await processIdentityWithStart(run, parseJsonOutput(beforeStatusResult.stdout));
301
- restartResult.beforePid = beforeStatus.pid;
302
- restartResult.beforeStartTime = beforeStatus.startTime;
303
- const restartStartedAt = Date.now();
304
- await mustRun(run, openclaw, ["gateway", "restart", "--safe", "--skip-deferral", "--json"], "gateway restart");
305
- restartResult.requested = true;
306
- const deadline = Date.now() + (options.timeoutMs ?? 90_000);
307
- let recentLogs = "";
308
- while (Date.now() < deadline) {
309
- await wait(options.pollIntervalMs ?? 2_000);
310
- const status = await run(openclaw, ["gateway", "status", "--json", "--require-rpc"]);
311
- if (status.code !== 0)
312
- continue;
313
- const afterStatus = await processIdentityWithStart(run, parseJsonOutput(status.stdout));
314
- restartResult.afterPid = afterStatus.pid;
315
- restartResult.afterStartTime = afterStatus.startTime;
316
- restartResult.verified = processChanged(beforeStatus, afterStatus);
317
- const logs = await run(openclaw, ["logs", "--json", "--limit", "1000", "--max-bytes", "2000000"]);
318
- if (logs.code === 0)
319
- recentLogs = logs.stdout;
320
- postRestart = postRestartStateFromLogs(recentLogs, options.expectedAgentName, restartStartedAt);
321
- if (restartResult.verified && postRestart.connected && postRestart.registered && postRestart.heartbeat200 && postRestart.chatAuthed && !postRestart.duplicatePlugin && !postRestart.duplicateRegistration)
322
- break;
517
+ journal = { ...journal, backupPath };
518
+ await setPhase("snapshot");
519
+ const normalized = normalizeToSchema(before, manifest.configSchema);
520
+ if (!isObject(normalized.value))
521
+ throw new Error("normalized PerkOS config is invalid");
522
+ assertProtected(before, normalized.value);
523
+ assertIdentity(normalized.value, options.expectedAgentName, options.expectedAgentId);
524
+ let quarantined = 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;
531
+ try {
532
+ replacePluginEntry(document, {}, false);
533
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, document));
534
+ if (await pathExists(legacyExtension)) {
535
+ const legacyManifest = JSON.parse(await readFile(join(legacyExtension, "openclaw.plugin.json"), "utf8"));
536
+ if (legacyManifest.id !== "perkos-a2a")
537
+ throw new Error("refusing to quarantine a non-PerkOS extension");
538
+ await rename(legacyExtension, quarantinePath);
539
+ quarantined = true;
540
+ }
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 */ }
323
553
  }
324
- if (!restartResult.verified)
325
- throw new Error("gateway restart was not verified by a new PID/start time");
326
- if (!postRestart.connected || !postRestart.registered || !postRestart.heartbeat200 || !postRestart.chatAuthed || postRestart.duplicatePlugin || postRestart.duplicateRegistration) {
327
- throw new Error(`post-restart verification failed: ${JSON.stringify(postRestart)}`);
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;
328
577
  }
578
+ installCommitted = true;
579
+ journal = { ...journal, installCommitted: true };
580
+ await setPhase("migrate-config");
581
+ const currentRaw = await readFile(configPath, "utf8");
582
+ const currentDocument = JSON.parse(currentRaw);
583
+ replacePluginEntry(currentDocument, normalized.value, true);
584
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, currentDocument));
585
+ configCommitted = true;
586
+ await setPhase("validate-config");
587
+ await mustRun(run, openclaw, ["config", "validate"], "config validation");
588
+ const doctor = await mustRun(run, openclaw, ["plugins", "doctor"], "plugin doctor");
589
+ const doctorText = `${doctor.stdout}\n${doctor.stderr}`.toLowerCase();
590
+ if (doctorText.includes("duplicate plugin id detected"))
591
+ throw new Error("duplicate plugin source remains after migration");
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");
595
+ const emptyPostRestart = verifyLogState("", options.expectedAgentName);
596
+ const restartResult = { requested: false, verified: false };
597
+ let postRestart = emptyPostRestart;
598
+ const expectedFingerprint = configFingerprint(normalized.value, journal.hmacKeyHex);
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");
610
+ const beforeStatusResult = await mustRun(run, openclaw, ["gateway", "status", "--json"], "pre-restart gateway status");
611
+ const beforeStatus = await processIdentityWithStart(run, parseJsonOutput(beforeStatusResult.stdout));
612
+ restartResult.beforePid = beforeStatus.pid;
613
+ restartResult.beforeStartTime = beforeStatus.startTime;
614
+ const restartStartedAt = Date.now();
615
+ let restoreRestartPermission;
616
+ try {
617
+ if (beforeStatus.pid === 1) {
618
+ const permissionRaw = await readFile(configPath, "utf8");
619
+ const permissionDocument = JSON.parse(permissionRaw);
620
+ const previousPermission = enableExternalRestart(permissionDocument);
621
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, permissionDocument));
622
+ restoreRestartPermission = async () => {
623
+ const latest = JSON.parse(await readFile(configPath, "utf8"));
624
+ restoreExternalRestart(latest, previousPermission);
625
+ configWriteMode = mergeWriteMode(configWriteMode, await writeJsonConfig(configPath, latest));
626
+ };
627
+ await wait(options.pollIntervalMs ?? 2_000);
628
+ await mustRun(run, "kill", ["-USR1", "1"], "PID 1 in-process gateway restart");
629
+ restartResult.method = "in-process-sigusr1";
630
+ }
631
+ else {
632
+ await mustRun(run, openclaw, ["gateway", "restart", "--safe", "--skip-deferral", "--json"], "gateway restart");
633
+ restartResult.method = "service";
634
+ }
635
+ restartResult.requested = true;
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);
640
+ let healthySince;
641
+ let recentLogs = "";
642
+ while (Date.now() < deadline) {
643
+ await wait(options.pollIntervalMs ?? 2_000);
644
+ const status = await run(openclaw, ["gateway", "status", "--json", "--require-rpc"]);
645
+ if (status.code !== 0) {
646
+ healthySince = undefined;
647
+ continue;
648
+ }
649
+ const afterStatus = await processIdentityWithStart(run, parseJsonOutput(status.stdout));
650
+ restartResult.afterPid = afterStatus.pid;
651
+ restartResult.afterStartTime = afterStatus.startTime;
652
+ const logs = await run(openclaw, ["logs", "--json", "--limit", "2000", "--max-bytes", "4000000"]);
653
+ if (logs.code === 0)
654
+ recentLogs = logs.stdout;
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
+ });
670
+ const processIdentityChanged = processChanged(beforeStatus, afterStatus);
671
+ const freshRestartLogs = restartObservedInLogs(recentLogs, restartStartedAt);
672
+ restartResult.verified = processIdentityChanged || (restartResult.method === "in-process-sigusr1" && freshRestartLogs);
673
+ restartResult.verifiedBy = processIdentityChanged
674
+ ? "process-identity"
675
+ : restartResult.verified
676
+ ? "fresh-restart-logs"
677
+ : undefined;
678
+ const healthy = restartResult.verified
679
+ && postRestart.configLoaded
680
+ && postRestart.connected
681
+ && postRestart.registered
682
+ && postRestart.heartbeatCount >= requiredHeartbeatCount
683
+ && postRestart.chatAuthed
684
+ && !postRestart.duplicatePlugin
685
+ && !postRestart.duplicateRegistration;
686
+ if (!healthy) {
687
+ healthySince = undefined;
688
+ continue;
689
+ }
690
+ healthySince ??= Date.now();
691
+ postRestart.stableSeconds = Math.floor((Date.now() - healthySince) / 1_000);
692
+ if (Date.now() - healthySince >= stabilityWindowMs)
693
+ break;
694
+ }
695
+ if (!restartResult.verified)
696
+ throw new Error("gateway restart was not verified by process identity or fresh in-process restart logs");
697
+ if (!postRestart.configLoaded
698
+ || !postRestart.connected
699
+ || !postRestart.registered
700
+ || postRestart.heartbeatCount < requiredHeartbeatCount
701
+ || !postRestart.chatAuthed
702
+ || postRestart.duplicatePlugin
703
+ || postRestart.duplicateRegistration)
704
+ throw new Error(`post-restart verification failed: ${JSON.stringify(postRestart)}`);
705
+ if (healthySince === undefined || Date.now() - healthySince < stabilityWindowMs) {
706
+ throw new Error(`post-restart transport did not remain healthy for ${stabilityWindowMs}ms`);
707
+ }
708
+ }
709
+ finally {
710
+ await restoreRestartPermission?.();
711
+ }
712
+ }
713
+ await setPhase("done");
714
+ return {
715
+ schemaVersion: 1,
716
+ ok: true,
717
+ phase: "done",
718
+ version,
719
+ mode: existingConfig ? "repaired" : "installed",
720
+ trustLevel: "trusted",
721
+ strictMode: true,
722
+ resumedFromPhase,
723
+ artifactVerified: true,
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,
734
+ normalized: true,
735
+ removedUnsupportedPaths: normalized.removed,
736
+ quarantinedLegacyExtension: quarantined,
737
+ configValid: true,
738
+ pluginDoctorClean: true,
739
+ restart: restartResult,
740
+ postRestart,
741
+ };
329
742
  }
330
- return {
331
- ok: true,
332
- version,
333
- mode: existingConfig ? "repaired" : "installed",
334
- artifactVerified: true,
335
- source: "managed-npm",
336
- normalized: true,
337
- removedUnsupportedPaths: normalized.removed,
338
- quarantinedLegacyExtension: quarantined,
339
- configValid: true,
340
- pluginDoctorClean: true,
341
- restart: restartResult,
342
- postRestart,
343
- };
344
- }
345
- catch (error) {
346
- if (!committed) {
347
- await writeFile(configPath, originalRaw, { mode: 0o600 });
348
- if (quarantined && await pathExists(quarantinePath) && !await pathExists(legacyExtension)) {
349
- await mkdir(dirname(legacyExtension), { recursive: true });
350
- await rename(quarantinePath, legacyExtension);
743
+ catch (error) {
744
+ if (!installCommitted) {
745
+ await writeFile(configPath, originalRaw, { mode: 0o600 });
746
+ if (quarantined && await pathExists(quarantinePath) && !await pathExists(legacyExtension)) {
747
+ await mkdir(dirname(legacyExtension), { recursive: true });
748
+ await rename(quarantinePath, legacyExtension);
749
+ }
351
750
  }
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);
352
756
  }
353
- throw error;
354
757
  }
355
758
  finally {
356
- await rm(migrationDir, { recursive: true, force: true });
759
+ await releaseRepairLock();
357
760
  }
358
761
  }
359
762
  //# sourceMappingURL=repair.js.map