@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/README.md +12 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +405 -69
- package/dist/index.js.map +4 -4
- package/dist/relay-client.d.ts +7 -0
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +89 -18
- package/dist/relay-client.js.map +1 -1
- package/dist/relay.d.ts +1 -1
- package/dist/relay.d.ts.map +1 -1
- package/dist/relay.js +21 -3
- package/dist/relay.js.map +1 -1
- package/dist/repair-cli.js +31 -2
- package/dist/repair-cli.js.map +1 -1
- package/dist/repair.d.ts +37 -4
- package/dist/repair.d.ts.map +1 -1
- package/dist/repair.js +377 -51
- package/dist/repair.js.map +1 -1
- package/dist/runtime-evidence.d.ts +42 -0
- package/dist/runtime-evidence.d.ts.map +1 -0
- package/dist/runtime-evidence.js +81 -0
- package/dist/runtime-evidence.js.map +1 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +160 -13
- package/dist/server.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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:
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
const
|
|
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() <
|
|
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", "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (!
|
|
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
|
-
|
|
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 {
|