@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/README.md +22 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +128 -33
- package/dist/index.js.map +4 -4
- package/dist/repair-cli.js +31 -2
- package/dist/repair-cli.js.map +1 -1
- package/dist/repair.d.ts +40 -0
- package/dist/repair.d.ts.map +1 -1
- package/dist/repair.js +530 -127
- 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/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 {
|
|
4
|
-
import { homedir
|
|
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
|
-
|
|
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,
|
|
73
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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 (!
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
759
|
+
await releaseRepairLock();
|
|
357
760
|
}
|
|
358
761
|
}
|
|
359
762
|
//# sourceMappingURL=repair.js.map
|