@fabioforest/openclaw 3.0.0
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 +88 -0
- package/bin/openclaw.js +152 -0
- package/lib/channels.js +84 -0
- package/lib/cli/debug.js +12 -0
- package/lib/cli/doctor.js +266 -0
- package/lib/cli/init.js +145 -0
- package/lib/cli/orchestrate.js +43 -0
- package/lib/cli/status.js +128 -0
- package/lib/cli/update.js +122 -0
- package/lib/config.js +68 -0
- package/lib/detect.js +49 -0
- package/lib/ops/audit.js +156 -0
- package/lib/ops/enroll.js +133 -0
- package/lib/ops/exec.js +140 -0
- package/lib/ops/healthcheck.js +167 -0
- package/lib/ops/policy.js +153 -0
- package/lib/ops/transfer.js +152 -0
- package/lib/ops/update-safe.js +173 -0
- package/lib/ops/vpn.js +131 -0
- package/lib/security.js +48 -0
- package/lib/setup/config_wizard.js +186 -0
- package/package.json +47 -0
- package/templates/.agent/agents/setup-specialist.md +24 -0
- package/templates/.agent/agents/sysadmin-proativo.md +31 -0
- package/templates/.agent/hooks/pre-tool-use.js +109 -0
- package/templates/.agent/rules/SECURITY.md +7 -0
- package/templates/.agent/skills/openclaw-installation-debugger/SKILL.md +37 -0
- package/templates/.agent/skills/openclaw-installation-debugger/scripts/debug.js +165 -0
- package/templates/.agent/skills/openclaw-ops/01-openclaw-vpn-wireguard/SKILL.md +20 -0
- package/templates/.agent/skills/openclaw-ops/02-openclaw-enroll-host/SKILL.md +14 -0
- package/templates/.agent/skills/openclaw-ops/03-openclaw-policy-baseline/SKILL.md +17 -0
- package/templates/.agent/skills/openclaw-ops/04-openclaw-remote-exec-runbooks/SKILL.md +13 -0
- package/templates/.agent/skills/openclaw-ops/05-openclaw-file-transfer-safe/SKILL.md +10 -0
- package/templates/.agent/skills/openclaw-ops/06-openclaw-audit-logging/SKILL.md +13 -0
- package/templates/.agent/skills/openclaw-ops/07-openclaw-safe-update/SKILL.md +9 -0
- package/templates/.agent/skills/openclaw-ops/08-openclaw-healthchecks/SKILL.md +10 -0
- package/templates/.agent/skills/universal-setup/SKILL.md +26 -0
- package/templates/.agent/workflows/doctor.md +20 -0
- package/templates/.agent/workflows/healthcheck.md +22 -0
- package/templates/.agent/workflows/healthcheck.runbook.md +9 -0
- package/templates/.agent/workflows/restart.md +20 -0
- package/templates/.agent/workflows/restart.runbook.md +8 -0
- package/templates/.agent/workflows/setup.md +18 -0
package/lib/ops/audit.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: Audit Logging
|
|
5
|
+
*
|
|
6
|
+
* Auditoria estruturada (JSON) para todos os eventos do OpenClaw.
|
|
7
|
+
* Inclui request_id, redaction de segredos e rotação de logs.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/06-openclaw-audit-logging/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { mask } = require("../security");
|
|
15
|
+
|
|
16
|
+
// Campos que devem ter seus valores mascarados no log
|
|
17
|
+
const SENSITIVE_FIELDS = ["token", "password", "secret", "privateKey", "apiKey", "auth_token"];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mascara campos sensíveis recursivamente em um objeto.
|
|
21
|
+
* @param {object} obj — objeto a ser processado
|
|
22
|
+
* @returns {object} cópia com valores sensíveis mascarados
|
|
23
|
+
*/
|
|
24
|
+
function redactSensitive(obj) {
|
|
25
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
26
|
+
|
|
27
|
+
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
28
|
+
|
|
29
|
+
for (const [key, value] of Object.entries(redacted)) {
|
|
30
|
+
if (typeof value === "object" && value !== null) {
|
|
31
|
+
redacted[key] = redactSensitive(value);
|
|
32
|
+
} else if (typeof value === "string" && SENSITIVE_FIELDS.includes(key)) {
|
|
33
|
+
redacted[key] = mask(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return redacted;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Cria uma entrada de log de auditoria.
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {string} params.event — tipo do evento (ex: "exec.started")
|
|
44
|
+
* @param {string} params.requestId — ID da requisição
|
|
45
|
+
* @param {string} [params.operator] — quem iniciou a ação
|
|
46
|
+
* @param {string} [params.hostId] — host onde ocorreu
|
|
47
|
+
* @param {object} [params.details] — detalhes adicionais
|
|
48
|
+
* @returns {object} entrada de auditoria formatada
|
|
49
|
+
*/
|
|
50
|
+
function createAuditEntry({ event, requestId, operator, hostId, details = {} }) {
|
|
51
|
+
return {
|
|
52
|
+
timestamp: new Date().toISOString(),
|
|
53
|
+
event,
|
|
54
|
+
requestId,
|
|
55
|
+
operator: operator || "system",
|
|
56
|
+
hostId: hostId || "local",
|
|
57
|
+
details: redactSensitive(details),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Escreve uma entrada de auditoria no arquivo de log.
|
|
63
|
+
* @param {string} logDir — diretório de logs
|
|
64
|
+
* @param {object} entry — entrada de auditoria
|
|
65
|
+
*/
|
|
66
|
+
function writeAuditLog(logDir, entry) {
|
|
67
|
+
if (!fs.existsSync(logDir)) {
|
|
68
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Arquivo de log diário
|
|
72
|
+
const date = new Date().toISOString().split("T")[0];
|
|
73
|
+
const logFile = path.join(logDir, `audit-${date}.jsonl`);
|
|
74
|
+
|
|
75
|
+
// Append em formato JSONL (uma linha JSON por entrada)
|
|
76
|
+
fs.appendFileSync(logFile, JSON.stringify(entry) + "\n", "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Lê entradas de auditoria de um período.
|
|
81
|
+
* @param {string} logDir — diretório de logs
|
|
82
|
+
* @param {object} [filter] — filtros opcionais
|
|
83
|
+
* @param {string} [filter.date] — data específica (YYYY-MM-DD)
|
|
84
|
+
* @param {string} [filter.event] — filtrar por tipo de evento
|
|
85
|
+
* @param {string} [filter.requestId] — filtrar por request_id
|
|
86
|
+
* @param {number} [filter.limit=100] — limite de entradas
|
|
87
|
+
* @returns {object[]} entradas de auditoria
|
|
88
|
+
*/
|
|
89
|
+
function readAuditLogs(logDir, filter = {}) {
|
|
90
|
+
if (!fs.existsSync(logDir)) return [];
|
|
91
|
+
|
|
92
|
+
const date = filter.date || new Date().toISOString().split("T")[0];
|
|
93
|
+
const logFile = path.join(logDir, `audit-${date}.jsonl`);
|
|
94
|
+
|
|
95
|
+
if (!fs.existsSync(logFile)) return [];
|
|
96
|
+
|
|
97
|
+
const lines = fs.readFileSync(logFile, "utf8")
|
|
98
|
+
.split("\n")
|
|
99
|
+
.filter((line) => line.trim());
|
|
100
|
+
|
|
101
|
+
let entries = lines.map((line) => {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}).filter(Boolean);
|
|
108
|
+
|
|
109
|
+
// Aplicar filtros
|
|
110
|
+
if (filter.event) {
|
|
111
|
+
entries = entries.filter((e) => e.event === filter.event);
|
|
112
|
+
}
|
|
113
|
+
if (filter.requestId) {
|
|
114
|
+
entries = entries.filter((e) => e.requestId === filter.requestId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Limite
|
|
118
|
+
const limit = filter.limit || 100;
|
|
119
|
+
return entries.slice(-limit);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Rotaciona logs mais antigos que o período de retenção.
|
|
124
|
+
* @param {string} logDir — diretório de logs
|
|
125
|
+
* @param {number} [retentionDays=30] — dias de retenção
|
|
126
|
+
* @returns {number} quantidade de arquivos removidos
|
|
127
|
+
*/
|
|
128
|
+
function rotateLogs(logDir, retentionDays = 30) {
|
|
129
|
+
if (!fs.existsSync(logDir)) return 0;
|
|
130
|
+
|
|
131
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
132
|
+
let removed = 0;
|
|
133
|
+
|
|
134
|
+
const files = fs.readdirSync(logDir).filter((f) => f.startsWith("audit-") && f.endsWith(".jsonl"));
|
|
135
|
+
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const filePath = path.join(logDir, file);
|
|
138
|
+
const stats = fs.statSync(filePath);
|
|
139
|
+
|
|
140
|
+
if (stats.mtimeMs < cutoff) {
|
|
141
|
+
fs.unlinkSync(filePath);
|
|
142
|
+
removed++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return removed;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
redactSensitive,
|
|
151
|
+
createAuditEntry,
|
|
152
|
+
writeAuditLog,
|
|
153
|
+
readAuditLogs,
|
|
154
|
+
rotateLogs,
|
|
155
|
+
SENSITIVE_FIELDS,
|
|
156
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: Enroll Host
|
|
5
|
+
*
|
|
6
|
+
* Onboarding seguro de host na malha WireGuard + OpenClaw
|
|
7
|
+
* com aprovação humana, identidade e revogação rápida.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/02-openclaw-enroll-host/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require("crypto");
|
|
13
|
+
const os = require("os");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { readJsonSafe, writeJsonSafe } = require("../config");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gera um identificador único para o host.
|
|
19
|
+
* @returns {{ hostId: string, fingerprint: string, hostname: string }}
|
|
20
|
+
*/
|
|
21
|
+
function generateHostIdentity() {
|
|
22
|
+
const hostId = crypto.randomUUID();
|
|
23
|
+
const hostname = os.hostname();
|
|
24
|
+
|
|
25
|
+
// Fingerprint baseado em hostname + timestamp + random
|
|
26
|
+
const raw = `${hostname}-${Date.now()}-${crypto.randomBytes(8).toString("hex")}`;
|
|
27
|
+
const fingerprint = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
28
|
+
|
|
29
|
+
return { hostId, fingerprint, hostname };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Registra um host no registro de hosts pendentes.
|
|
34
|
+
* @param {string} registryPath — caminho do arquivo de registro
|
|
35
|
+
* @param {object} hostInfo — informações do host (hostId, fingerprint, hostname)
|
|
36
|
+
* @returns {{ success: boolean, host: object }}
|
|
37
|
+
*/
|
|
38
|
+
function registerHost(registryPath, hostInfo) {
|
|
39
|
+
const registry = readJsonSafe(registryPath) || { hosts: [] };
|
|
40
|
+
|
|
41
|
+
const entry = {
|
|
42
|
+
...hostInfo,
|
|
43
|
+
status: "pending",
|
|
44
|
+
registeredAt: new Date().toISOString(),
|
|
45
|
+
approvedAt: null,
|
|
46
|
+
approvedBy: null,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
registry.hosts.push(entry);
|
|
50
|
+
writeJsonSafe(registryPath, registry);
|
|
51
|
+
|
|
52
|
+
return { success: true, host: entry };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Aprova um host pendente.
|
|
57
|
+
* @param {string} registryPath — caminho do arquivo de registro
|
|
58
|
+
* @param {string} hostId — ID do host a aprovar
|
|
59
|
+
* @param {string} approvedBy — quem aprovou
|
|
60
|
+
* @returns {{ success: boolean, host?: object, error?: string }}
|
|
61
|
+
*/
|
|
62
|
+
function approveHost(registryPath, hostId, approvedBy) {
|
|
63
|
+
const registry = readJsonSafe(registryPath);
|
|
64
|
+
if (!registry || !registry.hosts) {
|
|
65
|
+
return { success: false, error: "Registro de hosts não encontrado" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const host = registry.hosts.find((h) => h.hostId === hostId);
|
|
69
|
+
if (!host) {
|
|
70
|
+
return { success: false, error: `Host ${hostId} não encontrado` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (host.status === "approved") {
|
|
74
|
+
return { success: false, error: `Host ${hostId} já está aprovado` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
host.status = "approved";
|
|
78
|
+
host.approvedAt = new Date().toISOString();
|
|
79
|
+
host.approvedBy = approvedBy;
|
|
80
|
+
|
|
81
|
+
writeJsonSafe(registryPath, registry);
|
|
82
|
+
return { success: true, host };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Revoga um host (remove acesso).
|
|
87
|
+
* @param {string} registryPath — caminho do arquivo de registro
|
|
88
|
+
* @param {string} hostId — ID do host a revogar
|
|
89
|
+
* @param {string} revokedBy — quem revogou
|
|
90
|
+
* @returns {{ success: boolean, host?: object, error?: string }}
|
|
91
|
+
*/
|
|
92
|
+
function revokeHost(registryPath, hostId, revokedBy) {
|
|
93
|
+
const registry = readJsonSafe(registryPath);
|
|
94
|
+
if (!registry || !registry.hosts) {
|
|
95
|
+
return { success: false, error: "Registro de hosts não encontrado" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const host = registry.hosts.find((h) => h.hostId === hostId);
|
|
99
|
+
if (!host) {
|
|
100
|
+
return { success: false, error: `Host ${hostId} não encontrado` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
host.status = "revoked";
|
|
104
|
+
host.revokedAt = new Date().toISOString();
|
|
105
|
+
host.revokedBy = revokedBy;
|
|
106
|
+
|
|
107
|
+
writeJsonSafe(registryPath, registry);
|
|
108
|
+
return { success: true, host };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Lista hosts filtrados por status.
|
|
113
|
+
* @param {string} registryPath — caminho do arquivo de registro
|
|
114
|
+
* @param {string} [statusFilter] — filtrar por status (pending, approved, revoked)
|
|
115
|
+
* @returns {object[]} lista de hosts
|
|
116
|
+
*/
|
|
117
|
+
function listHosts(registryPath, statusFilter) {
|
|
118
|
+
const registry = readJsonSafe(registryPath);
|
|
119
|
+
if (!registry || !registry.hosts) return [];
|
|
120
|
+
|
|
121
|
+
if (statusFilter) {
|
|
122
|
+
return registry.hosts.filter((h) => h.status === statusFilter);
|
|
123
|
+
}
|
|
124
|
+
return registry.hosts;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
generateHostIdentity,
|
|
129
|
+
registerHost,
|
|
130
|
+
approveHost,
|
|
131
|
+
revokeHost,
|
|
132
|
+
listHosts,
|
|
133
|
+
};
|
package/lib/ops/exec.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: Remote Exec (Runbooks)
|
|
5
|
+
*
|
|
6
|
+
* Estrutura execução remota via VPN usando runbooks nomeados,
|
|
7
|
+
* idempotentes, com timeout, cancel e trilha auditável.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/04-openclaw-remote-exec-runbooks/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { execSync, spawn } = require("child_process");
|
|
13
|
+
const crypto = require("crypto");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Gera um request_id único para rastrear a execução.
|
|
17
|
+
* @returns {string} request_id no formato "req-<uuid>"
|
|
18
|
+
*/
|
|
19
|
+
function generateRequestId() {
|
|
20
|
+
return `req-${crypto.randomUUID()}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Valida as entradas de um runbook antes da execução.
|
|
25
|
+
* @param {object} runbook — definição do runbook
|
|
26
|
+
* @param {string} runbook.name — nome do runbook
|
|
27
|
+
* @param {string} runbook.command — comando a executar
|
|
28
|
+
* @param {string[]} [runbook.allowedArgs] — args permitidos
|
|
29
|
+
* @param {object} [inputs] — inputs do usuário
|
|
30
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
31
|
+
*/
|
|
32
|
+
function validateInputs(runbook, inputs = {}) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
|
|
35
|
+
if (!runbook.name) errors.push("Nome do runbook é obrigatório");
|
|
36
|
+
if (!runbook.command) errors.push("Comando do runbook é obrigatório");
|
|
37
|
+
|
|
38
|
+
// Validar que inputs não contêm injection
|
|
39
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
40
|
+
if (typeof value === "string" && /[;&|`$()]/.test(value)) {
|
|
41
|
+
errors.push(`Input '${key}' contém caracteres proibidos: ${value}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { valid: errors.length === 0, errors };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Executa um runbook de forma segura com timeout e captura de saída.
|
|
50
|
+
* @param {object} params
|
|
51
|
+
* @param {string} params.command — comando a executar
|
|
52
|
+
* @param {number} [params.timeoutMs=30000] — timeout em ms
|
|
53
|
+
* @param {string} [params.cwd] — diretório de trabalho
|
|
54
|
+
* @returns {{ requestId: string, exitCode: number, stdout: string, stderr: string, timedOut: boolean, duration: number }}
|
|
55
|
+
*/
|
|
56
|
+
function executeRunbook({ command, timeoutMs = 30000, cwd }) {
|
|
57
|
+
const requestId = generateRequestId();
|
|
58
|
+
const startTime = Date.now();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const stdout = execSync(command, {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
timeout: timeoutMs,
|
|
64
|
+
cwd: cwd || undefined,
|
|
65
|
+
maxBuffer: 1024 * 1024, // 1MB
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
requestId,
|
|
70
|
+
exitCode: 0,
|
|
71
|
+
stdout: stdout.trim(),
|
|
72
|
+
stderr: "",
|
|
73
|
+
timedOut: false,
|
|
74
|
+
duration: Date.now() - startTime,
|
|
75
|
+
};
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return {
|
|
78
|
+
requestId,
|
|
79
|
+
exitCode: err.status || 1,
|
|
80
|
+
stdout: (err.stdout || "").trim(),
|
|
81
|
+
stderr: (err.stderr || err.message || "").trim(),
|
|
82
|
+
timedOut: err.killed || false,
|
|
83
|
+
duration: Date.now() - startTime,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Executa um runbook em background com possibilidade de cancelamento.
|
|
90
|
+
* @param {object} params
|
|
91
|
+
* @param {string} params.command — comando a executar
|
|
92
|
+
* @param {number} [params.timeoutMs=30000] — timeout em ms
|
|
93
|
+
* @returns {{ requestId: string, process: object, cancel: function }}
|
|
94
|
+
*/
|
|
95
|
+
function executeAsync({ command, timeoutMs = 30000 }) {
|
|
96
|
+
const requestId = generateRequestId();
|
|
97
|
+
const parts = command.split(" ");
|
|
98
|
+
const child = spawn(parts[0], parts.slice(1), {
|
|
99
|
+
timeout: timeoutMs,
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const cancel = () => {
|
|
104
|
+
if (!child.killed) {
|
|
105
|
+
child.kill("SIGTERM");
|
|
106
|
+
// Force kill após 5s se não responder
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
109
|
+
}, 5000);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return { requestId, process: child, cancel };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Formata o resultado de uma execução para log.
|
|
118
|
+
* @param {object} result — resultado da execução
|
|
119
|
+
* @returns {object} objeto formatado para auditoria
|
|
120
|
+
*/
|
|
121
|
+
function formatForAudit(result) {
|
|
122
|
+
return {
|
|
123
|
+
requestId: result.requestId,
|
|
124
|
+
exitCode: result.exitCode,
|
|
125
|
+
timedOut: result.timedOut,
|
|
126
|
+
duration: `${result.duration}ms`,
|
|
127
|
+
timestamp: new Date().toISOString(),
|
|
128
|
+
// Trunca saída para auditoria (max 500 chars)
|
|
129
|
+
stdoutPreview: result.stdout.slice(0, 500),
|
|
130
|
+
stderrPreview: result.stderr.slice(0, 500),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
generateRequestId,
|
|
136
|
+
validateInputs,
|
|
137
|
+
executeRunbook,
|
|
138
|
+
executeAsync,
|
|
139
|
+
formatForAudit,
|
|
140
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: Healthchecks
|
|
5
|
+
*
|
|
6
|
+
* Heartbeat via VPN, alertas e autocura com limites.
|
|
7
|
+
* Circuit breaker para bloquear exec quando instável.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/08-openclaw-healthchecks/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { portInUse } = require("../security");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Estado do circuit breaker.
|
|
16
|
+
* @typedef {'closed'|'open'|'half-open'} CircuitState
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cria uma instância de circuit breaker.
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {number} [options.failureThreshold=2] — falhas consecutivas para abrir
|
|
23
|
+
* @param {number} [options.resetTimeoutMs=60000] — tempo para tentar half-open (ms)
|
|
24
|
+
* @returns {object} circuit breaker com métodos record, canExecute, getState, reset
|
|
25
|
+
*/
|
|
26
|
+
function createCircuitBreaker({ failureThreshold = 2, resetTimeoutMs = 60000 } = {}) {
|
|
27
|
+
let state = "closed";
|
|
28
|
+
let failures = 0;
|
|
29
|
+
let lastFailureTime = null;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
/**
|
|
33
|
+
* Registra resultado de uma operação.
|
|
34
|
+
* @param {boolean} success — se a operação teve sucesso
|
|
35
|
+
* @returns {CircuitState} estado atual após o registro
|
|
36
|
+
*/
|
|
37
|
+
record(success) {
|
|
38
|
+
if (success) {
|
|
39
|
+
failures = 0;
|
|
40
|
+
state = "closed";
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
failures++;
|
|
45
|
+
lastFailureTime = Date.now();
|
|
46
|
+
|
|
47
|
+
if (failures >= failureThreshold) {
|
|
48
|
+
state = "open";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return state;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Verifica se é seguro executar uma operação.
|
|
56
|
+
* @returns {{ allowed: boolean, state: CircuitState, reason?: string }}
|
|
57
|
+
*/
|
|
58
|
+
canExecute() {
|
|
59
|
+
if (state === "closed") {
|
|
60
|
+
return { allowed: true, state };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (state === "open") {
|
|
64
|
+
// Verificar se já passou o timeout para half-open
|
|
65
|
+
const elapsed = Date.now() - (lastFailureTime || 0);
|
|
66
|
+
if (elapsed >= resetTimeoutMs) {
|
|
67
|
+
state = "half-open";
|
|
68
|
+
return { allowed: true, state, reason: "Testando recuperação (half-open)" };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
allowed: false,
|
|
72
|
+
state,
|
|
73
|
+
reason: `Circuit breaker aberto — ${failures} falhas consecutivas. Aguardando ${Math.ceil((resetTimeoutMs - elapsed) / 1000)}s`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// half-open: permite uma tentativa
|
|
78
|
+
return { allowed: true, state, reason: "Half-open: tentativa de recuperação" };
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/** Retorna o estado atual. */
|
|
82
|
+
getState() {
|
|
83
|
+
return { state, failures, lastFailureTime };
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** Reseta o circuit breaker. */
|
|
87
|
+
reset() {
|
|
88
|
+
state = "closed";
|
|
89
|
+
failures = 0;
|
|
90
|
+
lastFailureTime = null;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Executa um heartbeat verificando a saúde básica do sistema.
|
|
97
|
+
* @param {object} [options]
|
|
98
|
+
* @param {number} [options.port=18789] — porta a verificar
|
|
99
|
+
* @param {string} [options.host=127.0.0.1] — host a verificar
|
|
100
|
+
* @returns {Promise<{ healthy: boolean, checks: object[] }>}
|
|
101
|
+
*/
|
|
102
|
+
async function heartbeat({ port = 18789, host = "127.0.0.1" } = {}) {
|
|
103
|
+
const checks = [];
|
|
104
|
+
|
|
105
|
+
// Check 1: Porta do serviço
|
|
106
|
+
try {
|
|
107
|
+
const inUse = await portInUse(port, host);
|
|
108
|
+
checks.push({
|
|
109
|
+
name: `port:${port}`,
|
|
110
|
+
status: inUse ? "ok" : "fail",
|
|
111
|
+
message: inUse ? `Serviço respondendo em ${host}:${port}` : `Porta ${port} livre — serviço pode estar parado`,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
checks.push({
|
|
115
|
+
name: `port:${port}`,
|
|
116
|
+
status: "fail",
|
|
117
|
+
message: `Erro ao verificar porta: ${err.message}`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const healthy = checks.every((c) => c.status === "ok");
|
|
122
|
+
return { healthy, checks, timestamp: new Date().toISOString() };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Tenta auto-restart com limites de tentativas.
|
|
127
|
+
* @param {object} params
|
|
128
|
+
* @param {function} params.restartFn — função que executa o restart
|
|
129
|
+
* @param {function} params.healthFn — função que verifica saúde
|
|
130
|
+
* @param {number} [params.maxRetries=3] — máximo de tentativas
|
|
131
|
+
* @param {number} [params.delayMs=5000] — delay entre tentativas (ms)
|
|
132
|
+
* @returns {Promise<{ success: boolean, attempts: number, error?: string }>}
|
|
133
|
+
*/
|
|
134
|
+
async function autoRestart({ restartFn, healthFn, maxRetries = 3, delayMs = 5000 }) {
|
|
135
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
await restartFn();
|
|
138
|
+
|
|
139
|
+
// Aguardar antes de verificar
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
141
|
+
|
|
142
|
+
const healthy = await healthFn();
|
|
143
|
+
if (healthy) {
|
|
144
|
+
return { success: true, attempts: attempt };
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (attempt === maxRetries) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
attempts: attempt,
|
|
151
|
+
error: `Falha após ${maxRetries} tentativas: ${err.message}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Delay incremental entre tentativas
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { success: false, attempts: maxRetries, error: "Excedido limite de tentativas" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
createCircuitBreaker,
|
|
165
|
+
heartbeat,
|
|
166
|
+
autoRestart,
|
|
167
|
+
};
|