openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
999 lines (998 loc) • 43.5 kB
JavaScript
import { n as sliceUtf16Safe, r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs";
import { a as writeRuntimeJson } from "./runtime-DlO3PTAd.mjs";
import { a as canonicalPathFromExistingAncestor, c as isPathInside } from "./fs-safe-BUXBSN-4.mjs";
import { o as redactSensitiveUrlLikeString } from "./redact-sensitive-url-BnHakqqk.mjs";
import { p as shortenHomePath } from "./utils-DgZn2dLu.mjs";
import { r as resolveStateDir } from "./state-dir-BHJORNly.mjs";
import "./paths-DGIwWbpu.mjs";
import { t as formatErrorMessage } from "./errors-O33D01XW.mjs";
import { n as findSqlCharacter, o as normalizeSqlWhitespace, s as quoteSqliteIdentifier } from "./sqlite-schema-sql-CKIqAlrf.mjs";
import { t as openNodeSqliteDatabase } from "./node-sqlite-XIcwmOrl.mjs";
import { n as resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir-DR7bHNl9.mjs";
import { f as createPrivateSqliteTempDirectory } from "./sqlite-snapshot-staging-BIgaAYlp.mjs";
import { c as resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths-DfifnSGp.mjs";
import { t as applyPrivateModeSync } from "./private-mode-B6dWGRb2.mjs";
import { j as getOpenClawStateRuntimeSchema } from "./openclaw-state-db-fast-path-CPAamWzU.mjs";
import { l as withCommandProcessScope, o as spawnCommand } from "./exec-spawn-IxJJNQFo.mjs";
import { i as formatCommandResult, r as formatCommandOutput } from "./command-error-CFVNEvkJ.mjs";
import { d as requireGitCommandOutput, i as executeGitCommand, l as normalizeGitPathForFilesystem, t as GIT_TIMEOUT_MS, u as requireGitCommand } from "./git-exec-DXEiUR5e.mjs";
import { t as _usingCtx } from "./usingCtx-DwOBYOJf.mjs";
import { n as assertSqliteIntegrity } from "./sqlite-integrity-GtqcmwG3.mjs";
import { t as OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema-BcJs-SM4.mjs";
import { n as normalizeAgentId } from "./agent-id-D1-Q6vR4.mjs";
import { m as resolveConfiguredAgentId } from "./agent-scope-config-CDYpzA0r.mjs";
import "./session-key-C09ow4h0.mjs";
import { i as listAgentIds } from "./agent-roster-BXD1uMJD.mjs";
import { r as getRuntimeConfig } from "./io.runtime-BsUXSsD4.mjs";
import "./config-DLycWFvB.mjs";
import { i as stripPluginModelCatalogCredentials } from "./plugin-model-catalog-repair-gbFum2dR.mjs";
import { t as assertNotUpdateCapturePath } from "./update-capture-paths-BuDS3zdN.mjs";
import { r as publishVerifiedSqliteFile } from "./sqlite-snapshot-c6hBDowh.mjs";
import { t as beginLifecycleWriteCustody } from "./lifecycle-write-custody-C2y9H7om.mjs";
import { o as recordBackupOutcomeBestEffort, s as resolveBackupAgentRoot, u as resolveRequiredBackupPath } from "./backup-shared-D8dux81d.mjs";
import { t as BACKUP_RUN_ERROR_MAX_LENGTH } from "./backup-run-records.contract.js";
import { a as buildSnapshotValidator, c as SNAPSHOT_SQLITE_FILENAME, i as assertNoSqliteSidecarsSync, n as ensurePrivateSnapshotRepositoryRoot, o as createOpenClawSnapshotCopy, r as assertFreshRestoreTarget, s as hashSnapshotArtifact } from "./local-repository-BbEpPfia.mjs";
import fs from "node:fs";
import path from "node:path";
import fs$1 from "node:fs/promises";
import { createHash } from "node:crypto";
import { tempWorkspace } from "@openclaw/fs-safe/temp";
import { finished } from "node:stream/promises";
import { createInterface } from "node:readline";
//#region src/state/secret-state-tables.ts
/** Redaction policy surface: Git snapshots may omit these credential-bearing tables. */
const STATE_SECRET_TABLE_NAMES = [
"audit_identity_keys",
"apns_registrations",
"channel_ingress_events",
"channel_pairing_requests",
"clawhub_promotion_claims",
"config_revision_keys",
"device_auth_tokens",
"device_bootstrap_tokens",
"device_identities",
"device_pairing_join_codes",
"device_pairing_paired",
"gateway_origin_device_tokens",
"mcp_oauth_pending_authorizations",
"mcp_oauth_stores",
"native_hook_relay_bridges",
"secret_store_entries",
"web_push_subscriptions",
"worker_environment_credentials"
];
/** Secret-redacted Git backups must never carry machine-state values under these prefixes. */
const STATE_SECRET_CONFIG_STATE_KEY_PREFIXES = [
"authProfiles.",
"nodeHost.",
"webPush.vapidKeys"
];
/** Redaction policy surface for credential-bearing per-agent database tables. */
const AGENT_SECRET_TABLE_NAMES = [
"auth_profile_state",
"auth_profile_store",
"session_suggestions"
];
//#endregion
//#region src/snapshot/git-backup-codec.ts
const GIT_BACKUP_MANIFEST = "manifest.json";
const GIT_BACKUP_SCHEMA = "schema.sql";
const GIT_BACKUP_TABLES = "tables";
const SAFE_TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const GIT_BACKUP_PROJECTION_TABLES = [
"backup_runs",
"session_transcript_fts_rows",
"session_transcript_index_state"
];
function requireSafeTableName(value) {
if (!SAFE_TABLE_NAME.test(value)) throw new Error(`Git backup table name is not filesystem-safe: ${value}`);
return value;
}
function normalizeIdentity(identity) {
if (identity.role === "global") return identity;
const agentId = normalizeAgentId(identity.agentId);
if (agentId !== identity.agentId) throw new Error(`Git backup agent id must be canonical: ${identity.agentId}`);
return {
role: "agent",
agentId
};
}
function gitBackupScopePath(identity) {
const normalized = normalizeIdentity(identity);
return normalized.role === "global" ? "global" : path.join("agents", normalized.agentId);
}
function readSchemaEntries(database) {
return database.prepare(`SELECT type, name, tbl_name AS tableName, sql
FROM sqlite_master
WHERE type IN ('table', 'index', 'trigger')
AND name NOT LIKE 'sqlite_%'
AND sql IS NOT NULL
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name`).all().map((row) => row);
}
function isVirtualShadow(name, virtualTables) {
return virtualTables.some((virtualTable) => name === virtualTable || name.startsWith(`${virtualTable}_`));
}
const TABLE_BATCH_BYTES = 1048576;
function readTableColumns(database, table) {
return database.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(table)})`).all().map((row) => {
const value = row;
if (typeof value.name !== "string" || typeof value.pk !== "number") throw new Error(`Unable to read columns for Git backup table ${table}.`);
return {
name: value.name,
pk: value.pk
};
});
}
function encodeSqliteValue(value) {
if (value === null || typeof value === "string") return value;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error("Git backup cannot encode a non-finite SQLite REAL value.");
return value;
}
if (typeof value === "bigint") return value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER ? Number(value) : { $int: value.toString() };
if (value instanceof Uint8Array) return { $hex: Buffer.from(value).toString("hex") };
throw new Error(`Git backup cannot encode SQLite value type ${typeof value}.`);
}
async function serializeGitBackupTable(database, table, outputPath, rowFilter, redactCatalogCredentials = false) {
const columns = readTableColumns(database, table);
if (columns.length === 0) throw new Error(`Git backup table has no readable columns: ${table}`);
const primaryKey = columns.filter((column) => column.pk > 0).toSorted((left, right) => left.pk - right.pk).map((column) => `source.${quoteSqliteIdentifier(column.name)}`);
const orderBy = primaryKey.length > 0 ? primaryKey.join(", ") : "source.rowid";
const projection = columns.map(({ name }) => {
const column = quoteSqliteIdentifier(name);
return `CASE WHEN typeof(${column}) = 'text' THEN json_quote(${column}) ELSE ${column} END AS ${column}`;
});
const statement = database.prepare(`SELECT ${projection.join(", ")}
FROM ${quoteSqliteIdentifier(table)} AS source ORDER BY ${orderBy}`);
statement.setReadBigInts(true);
const output = outputPath ? await fs$1.open(outputPath, "wx", 384) : void 0;
const hash = createHash("sha256");
const pending = [];
let pendingBytes = 0;
let rows = 0;
const flush = async () => {
if (output && pending.length > 0) {
await output.writeFile(pending.join(""));
pending.length = 0;
pendingBytes = 0;
}
};
try {
for (const rawRow of statement.iterate()) {
const source = {};
for (const [name, value] of Object.entries(rawRow)) source[name] = typeof value === "string" ? JSON.parse(value) : value;
if (rowFilter && !rowFilter(source)) continue;
if (redactCatalogCredentials && (source.scope === "plugin-model-catalog-v1" || source.scope === "plugin-model-catalog-migration-v1") && typeof source.value_json === "string") {
source.value_json = stripPluginModelCatalogCredentials(source.value_json);
if (source.value_json === null) continue;
}
const encoded = {};
for (const column of columns) encoded[column.name] = encodeSqliteValue(source[column.name]);
const line = `${JSON.stringify(encoded)}\n`;
hash.update(line);
rows += 1;
if (output) {
pending.push(line);
pendingBytes += Buffer.byteLength(line);
if (pendingBytes >= TABLE_BATCH_BYTES) await flush();
}
}
await flush();
return {
rows,
sha256: hash.digest("hex")
};
} finally {
await output?.close();
}
}
function schemaText(entries, userVersion) {
return `${entries.map((entry) => entry.sql.trimEnd().endsWith(";") ? entry.sql : `${entry.sql};`).join("\n\n")}\n-- PRAGMA user_version = ${userVersion}\n`;
}
/** Dump one verified SQLite copy into the deterministic Git repository layout. */
async function dumpGitBackupDatabase(params) {
const identity = normalizeIdentity(params.identity);
const database = openNodeSqliteDatabase(params.snapshotPath, { readOnly: true });
try {
const entries = readSchemaEntries(database);
const virtualTables = entries.filter((entry) => /^\s*CREATE\s+VIRTUAL\s+TABLE\b/iu.test(entry.sql)).map((entry) => entry.name);
const redacted = params.excludeSecrets === true ? identity.role === "global" ? STATE_SECRET_TABLE_NAMES : AGENT_SECRET_TABLE_NAMES : [];
const existingTables = new Set(entries.filter((entry) => entry.type === "table").map((entry) => entry.name));
const excludedTables = [...redacted].filter((table) => existingTables.has(table)).toSorted();
const excludedConfigStateKeyPrefixes = identity.role === "global" && params.excludeSecrets === true && existingTables.has("config_machine_state") ? [...STATE_SECRET_CONFIG_STATE_KEY_PREFIXES] : [];
const excluded = /* @__PURE__ */ new Set([...excludedTables, ...GIT_BACKUP_PROJECTION_TABLES]);
const includedSchema = entries.filter((entry) => !excluded.has(entry.name) && !excluded.has(entry.tableName));
const dataTables = entries.filter((entry) => entry.type === "table" && !isVirtualShadow(entry.name, virtualTables) && !excluded.has(entry.name)).map((entry) => requireSafeTableName(entry.name)).toSorted();
const userVersionRow = database.prepare("PRAGMA user_version").get();
if (typeof userVersionRow.user_version !== "number") throw new Error("Unable to read SQLite user_version for Git backup.");
await fs$1.rm(params.outputPath, {
recursive: true,
force: true
});
const tablesPath = path.join(params.outputPath, GIT_BACKUP_TABLES);
await fs$1.mkdir(tablesPath, {
recursive: true,
mode: 448
});
const tables = {};
for (const table of dataTables) {
const rowFilter = table === "config_machine_state" && excludedConfigStateKeyPrefixes.length > 0 ? (row) => {
const stateKey = row.state_key;
return typeof stateKey === "string" && !excludedConfigStateKeyPrefixes.some((prefix) => stateKey.startsWith(prefix));
} : void 0;
tables[table] = await serializeGitBackupTable(database, table, path.join(tablesPath, `${table}.jsonl`), rowFilter, identity.role === "agent" && params.excludeSecrets === true && table === "cache_entries");
}
const manifest = {
schemaVersion: 1,
identity,
userVersion: userVersionRow.user_version,
excludedTables,
excludedConfigStateKeyPrefixes,
tables
};
await fs$1.writeFile(path.join(params.outputPath, GIT_BACKUP_SCHEMA), schemaText(includedSchema, manifest.userVersion), {
encoding: "utf8",
mode: 384
});
await fs$1.writeFile(path.join(params.outputPath, GIT_BACKUP_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: "utf8",
mode: 384
});
return manifest;
} finally {
database.close();
}
}
function parseGitBackupManifest(value, source) {
let parsed;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error(`Git backup manifest is invalid JSON: ${source}`, { cause: error });
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Git backup manifest is invalid: ${source}`);
const manifest = parsed;
if (manifest.schemaVersion !== 1 || !manifest.identity || manifest.identity.role !== "global" && manifest.identity.role !== "agent" || !Number.isSafeInteger(manifest.userVersion) || !Array.isArray(manifest.excludedTables) || manifest.excludedConfigStateKeyPrefixes !== void 0 && (!Array.isArray(manifest.excludedConfigStateKeyPrefixes) || manifest.excludedConfigStateKeyPrefixes.some((prefix) => typeof prefix !== "string")) || !manifest.tables || typeof manifest.tables !== "object") throw new Error(`Git backup manifest has unsupported fields: ${source}`);
const validated = {
...manifest,
excludedConfigStateKeyPrefixes: manifest.excludedConfigStateKeyPrefixes ?? []
};
normalizeIdentity(validated.identity);
for (const [table, entry] of Object.entries(validated.tables)) {
requireSafeTableName(table);
if (!Number.isSafeInteger(entry.rows) || entry.rows < 0 || !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error(`Git backup manifest has an invalid table entry: ${table}`);
}
return validated;
}
function splitSchemaStatements(schema) {
const statements = [];
let start = 0;
let cursor = 0;
while (cursor < schema.length) {
const end = findSqlCharacter(schema.slice(cursor), ";");
if (end === -1) break;
const segment = schema.slice(cursor, cursor + end + 1);
cursor += end + 1;
const candidate = schema.slice(start, cursor).trim();
if (/^CREATE\s+TRIGGER\b/iu.test(candidate) && !/^END\s*;$/iu.test(normalizeSqlWhitespace(segment))) continue;
if (candidate && !candidate.startsWith("-- PRAGMA user_version")) statements.push(candidate);
start = cursor;
}
const trailing = schema.slice(start).trim();
if (/^CREATE\s+TRIGGER\b/iu.test(trailing)) statements.push(trailing);
return statements;
}
function unquoteSqlIdentifier(value) {
const quote = value[0];
if (quote === "'" || quote === "\"" || quote === "`") return value.slice(1, -1).replaceAll(quote + quote, quote);
if (quote === "[") return value.slice(1, -1);
return value;
}
function schemaObjectName(statement, kind) {
const match = new RegExp(`^${kind === "virtual" ? "CREATE\\s+VIRTUAL\\s+TABLE" : "CREATE\\s+TABLE"}\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?('(?:[^']|'')*'|"(?:[^"]|"")*"|\\[[^\\]]+\\]|\`(?:[^\`]|\`\`)*\`|[^\\s(]+)`, "iu").exec(statement);
return match?.[1] ? unquoteSqlIdentifier(match[1]) : void 0;
}
function decodeSqliteValue(value) {
if (value === null || typeof value === "string" || typeof value === "number") return value;
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Git backup row contains an invalid encoded value.");
const record = value;
if (Object.keys(record).length === 1 && typeof record.$int === "string") return BigInt(record.$int);
if (Object.keys(record).length === 1 && typeof record.$hex === "string" && /^(?:[a-f0-9]{2})*$/u.test(record.$hex)) return Buffer.from(record.$hex, "hex");
throw new Error("Git backup row contains an invalid encoded object.");
}
function convergeRestoredSchema(database, identity) {
database.exec(identity.role === "global" ? getOpenClawStateRuntimeSchema({ includeVersionLazyAdditiveTables: false }) : OPENCLAW_AGENT_SCHEMA_SQL);
}
function validateRestoredOwner(database, databasePath, identity) {
assertSqliteIntegrity(database, databasePath);
buildSnapshotValidator(identity)(database, databasePath);
}
/** Load and hash one JSONL table without retaining the whole artifact. */
async function loadGitBackupTable(database, table, inputPath) {
const columns = readTableColumns(database, table);
const statement = database.prepare(`INSERT INTO ${quoteSqliteIdentifier(table)} (${columns.map((column) => quoteSqliteIdentifier(column.name)).join(", ")})
VALUES (${columns.map(() => "?").join(", ")})`);
const input = fs.createReadStream(inputPath);
const lines = createInterface({
input,
crlfDelay: Infinity
});
const hash = createHash("sha256");
input.on("data", (chunk) => hash.update(chunk));
const pending = [];
let pendingBytes = 0;
let rows = 0;
const flush = () => {
if (pending.length === 0) return;
database.exec("BEGIN IMMEDIATE;");
try {
for (const values of pending) statement.run(...values);
database.exec("COMMIT;");
} catch (error) {
database.exec("ROLLBACK;");
throw error;
}
pending.length = 0;
pendingBytes = 0;
};
try {
for await (const line of lines) {
if (!line) continue;
const parsed = JSON.parse(line);
pending.push(columns.map((column) => decodeSqliteValue(parsed[column.name])));
pendingBytes += Buffer.byteLength(line);
rows += 1;
if (pendingBytes >= TABLE_BATCH_BYTES) flush();
}
flush();
return {
rows,
sha256: hash.digest("hex")
};
} finally {
lines.close();
input.destroy();
await finished(input).catch(() => void 0);
}
}
/** Restore one materialized Git snapshot scope into a fresh SQLite file. */
async function restoreGitBackupDirectory(params) {
const targetPath = path.resolve(params.targetPath);
await assertFreshRestoreTarget(targetPath);
const manifest = parseGitBackupManifest(await fs$1.readFile(path.join(params.sourcePath, GIT_BACKUP_MANIFEST), "utf8"), params.sourcePath);
const restoreIdentity = normalizeIdentity(params.expectedIdentity ?? manifest.identity);
if (params.expectedIdentity && JSON.stringify(normalizeIdentity(manifest.identity)) !== JSON.stringify(restoreIdentity)) throw new Error("Git backup manifest database identity does not match the requested scope.");
const statements = splitSchemaStatements(await fs$1.readFile(path.join(params.sourcePath, GIT_BACKUP_SCHEMA), "utf8"));
const virtual = statements.filter((statement) => /^CREATE\s+VIRTUAL\s+TABLE\b/iu.test(statement));
const triggers = statements.filter((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement));
const virtualNames = virtual.map((statement) => schemaObjectName(statement, "virtual")).filter((value) => Boolean(value));
const plainTables = statements.filter((statement) => {
if (!/^CREATE\s+TABLE\b/iu.test(statement)) return false;
const name = schemaObjectName(statement, "table");
return !name || !isVirtualShadow(name, virtualNames);
});
const indexes = statements.filter((statement) => /^CREATE\s+(?:UNIQUE\s+)?INDEX\b/iu.test(statement));
const targetDirectory = path.dirname(targetPath);
await fs$1.mkdir(targetDirectory, {
recursive: true,
mode: 448
});
const stagingDirectory = await createPrivateSqliteTempDirectory(targetDirectory, ".git-backup-restore-");
applyPrivateModeSync(stagingDirectory, 448);
const stagedPath = path.join(stagingDirectory, SNAPSHOT_SQLITE_FILENAME);
await (await fs$1.open(stagedPath, "wx", 384)).close();
const database = openNodeSqliteDatabase(stagedPath);
try {
database.exec("PRAGMA foreign_keys = OFF; PRAGMA journal_mode = DELETE;");
for (const statement of [...plainTables, ...indexes]) database.exec(statement);
for (const [table, expected] of Object.entries(manifest.tables)) {
requireSafeTableName(table);
const actual = await loadGitBackupTable(database, table, path.join(params.sourcePath, GIT_BACKUP_TABLES, `${table}.jsonl`));
if (actual.sha256 !== expected.sha256) throw new Error(`Git backup table hash mismatch: ${table}`);
if (actual.rows !== expected.rows) throw new Error(`Git backup table row count mismatch: ${table}`);
}
for (const statement of virtual) {
if (/\bUSING\s+vec0\b/iu.test(statement)) continue;
database.exec(statement);
}
for (const statement of triggers) database.exec(statement);
for (const statement of virtual) {
const name = schemaObjectName(statement, "virtual");
if (name && /\bUSING\s+fts5\b/iu.test(statement) && /\bcontent\s*=/iu.test(statement)) database.prepare(`INSERT INTO ${quoteSqliteIdentifier(name)} (${quoteSqliteIdentifier(name)}) VALUES ('rebuild')`).run();
}
database.exec(`PRAGMA user_version = ${manifest.userVersion};`);
convergeRestoredSchema(database, restoreIdentity);
validateRestoredOwner(database, stagedPath, restoreIdentity);
const tables = [];
for (const [table, expected] of Object.entries(manifest.tables)) {
const actual = await serializeGitBackupTable(database, table);
tables.push({
table,
...actual,
ok: actual.rows === expected.rows && actual.sha256 === expected.sha256
});
}
if (tables.some((table) => !table.ok)) throw new Error(`Restored Git backup does not match its table manifest: ${stagedPath}`);
database.close();
applyPrivateModeSync(stagedPath, 384);
const artifact = await hashSnapshotArtifact(stagingDirectory);
await publishVerifiedSqliteFile({
sourceIdentity: artifact.stat,
sourcePath: stagedPath,
targetPath,
expectedContent: artifact,
requireAtomicPublication: true,
beforePublish: async () => await assertFreshRestoreTarget(targetPath),
validatePublished: async (publishedPath) => {
const published = openNodeSqliteDatabase(publishedPath, { readOnly: true });
try {
validateRestoredOwner(published, publishedPath, restoreIdentity);
} finally {
published.close();
}
},
afterPublish: (guard) => {
guard.assertTargetMatchesExpectedContent(() => assertNoSqliteSidecarsSync(targetPath, "Fresh SQLite restore path already exists"));
}
});
return {
manifest,
targetPath,
tables,
excludedTables: manifest.excludedTables,
excludedConfigStateKeyPrefixes: manifest.excludedConfigStateKeyPrefixes
};
} catch (error) {
if (database.isOpen) database.close();
throw error;
} finally {
await fs$1.rm(stagingDirectory, {
recursive: true,
force: true
}).catch(() => void 0);
}
}
//#endregion
//#region src/snapshot/git-backup.ts
const GIT_BACKUP_DIAGNOSTIC_MAX_LENGTH = 500;
const GIT_BACKUP_NON_BACKUP_HISTORY_WARNING = "repository history contains non-backup commits; use a dedicated backup repository";
function redactGitBackupText(value) {
return value.split("\n").map((line) => redactSensitiveUrlLikeString(line)).join("\n");
}
function sanitizeGitBackupDiagnostic(value) {
return truncateUtf16Safe(redactGitBackupText(value), GIT_BACKUP_DIAGNOSTIC_MAX_LENGTH);
}
function formatGitBackupCommandResult(command, result) {
const redacted = {
...result,
stderr: redactGitBackupText(result.stderr),
stdout: redactGitBackupText(result.stdout)
};
const header = formatCommandResult(command, {
...redacted,
stderr: "",
stdout: ""
});
const streams = ["stderr", "stdout"].flatMap((stream) => {
const output = formatCommandOutput(redacted[stream]);
return output ? [{
stream,
output
}] : [];
});
const fixedLength = header.length + streams.reduce((total, { stream }) => total + 1 + `${stream}: `.length, 0);
if (streams.length === 0 || fixedLength >= 1200) return truncateUtf16Safe(header, BACKUP_RUN_ERROR_MAX_LENGTH);
const outputBudget = BACKUP_RUN_ERROR_MAX_LENGTH - fixedLength;
const lengths = streams.map(({ output }) => output.length);
const first = Math.min(lengths[0] ?? 0, Math.max(Math.ceil(outputBudget / 2), outputBudget - (lengths[1] ?? 0)));
const allocations = [first, Math.min(lengths[1] ?? 0, outputBudget - first)];
const fit = (output, maxLength) => {
if (output.length <= maxLength) return output;
if (maxLength <= 1) return truncateUtf16Safe("…", maxLength);
const source = output.startsWith("…\n") ? output.slice(2) : output;
return `…\n${sliceUtf16Safe(source, Math.max(0, source.length - (maxLength - 2)))}`;
};
return [header, ...streams.map(({ stream, output }, index) => `${stream}: ${fit(output, allocations[index] ?? 0)}`)].join("\n");
}
function gitBackupRepositoryPrivacyRemediation(repositoryPath, cause) {
if (process.platform === "win32") return `${cause instanceof Error && cause.message ? ` ${sanitizeGitBackupDiagnostic(cause.message)}` : ""} Remove non-user ACL grants from ${repositoryPath} or choose a private local directory. Do not use a shared or synced folder for SQLite backups.`;
return `Fix its ownership and run chmod 700 ${repositoryPath}.`;
}
async function assertGitRepository(repositoryPath, env) {
const topLevel = await requireGitCommand(repositoryPath, ["rev-parse", "--show-toplevel"], { env });
const [canonicalTopLevel, canonicalRepository] = await Promise.all([fs$1.realpath(normalizeGitPathForFilesystem(topLevel)), fs$1.realpath(repositoryPath)]);
if (canonicalTopLevel !== canonicalRepository) throw new Error(`Backup repository must be the Git worktree root: ${repositoryPath}`);
}
/** Initialize or adopt an operator-owned Git backup repository. */
async function initializeGitBackupRepository(params) {
const repositoryPath = path.resolve(params.repositoryPath);
const stateDir = path.resolve(params.stateDir);
const [canonicalRepositoryPath, canonicalStateDir] = await Promise.all([canonicalPathFromExistingAncestor(repositoryPath), canonicalPathFromExistingAncestor(stateDir)]);
if (isPathInside(canonicalStateDir, canonicalRepositoryPath) || isPathInside(canonicalRepositoryPath, canonicalStateDir)) throw new Error(`Git backup repository must be outside the OpenClaw state directory: ${stateDir}`);
try {
await ensurePrivateSnapshotRepositoryRoot(repositoryPath);
} catch (error) {
throw new Error(`Git backup repository must be owned by the current user and not writable by other users: ${repositoryPath}. ${gitBackupRepositoryPrivacyRemediation(repositoryPath, error)}`, { cause: error });
}
if ((await executeGitCommand(repositoryPath, ["rev-parse", "--show-toplevel"], { env: params.gitEnv })).code !== 0) await requireGitCommand(repositoryPath, ["init"], { env: params.gitEnv });
await assertGitRepository(repositoryPath, params.gitEnv);
const remote = params.remote?.trim();
if (remote) {
const existing = await executeGitCommand(repositoryPath, [
"remote",
"get-url",
"origin"
], { env: params.gitEnv });
if (existing.code === 0 && existing.stdout.trim() !== remote) throw new Error(`Git backup repository already has a different origin: ${sanitizeGitBackupDiagnostic(existing.stdout.trim())}`);
if (existing.code !== 0) await requireGitCommand(repositoryPath, [
"remote",
"add",
"origin",
remote
], { env: params.gitEnv });
}
return { repositoryPath };
}
async function isBackupOwnedScope(scopePath) {
const identity = await fs$1.lstat(scopePath).catch((error) => error.code === "ENOENT" ? void 0 : null);
if (identity === void 0) return true;
if (!identity?.isDirectory()) return false;
try {
if ((await fs$1.readdir(scopePath)).length === 0) return true;
parseGitBackupManifest(await fs$1.readFile(path.join(scopePath, GIT_BACKUP_MANIFEST), "utf8"), scopePath);
return true;
} catch {
return false;
}
}
async function assertBackupOwnedScope(scopePath) {
if (!await isBackupOwnedScope(scopePath)) throw new Error(`Refusing to replace non-backup-owned path ${scopePath}; the repository must be dedicated to OpenClaw backups.`);
}
async function removeStaleAgentScopes(repositoryPath, retainedScopes) {
const agentsPath = path.join(repositoryPath, "agents");
let entries;
try {
entries = await fs$1.readdir(agentsPath);
} catch (error) {
if (error.code === "ENOENT") return;
throw error;
}
const scopes = entries.map((entry) => path.join(agentsPath, entry));
await Promise.all(scopes.map(async (scope) => await assertBackupOwnedScope(scope)));
await Promise.all(scopes.filter((scope) => !retainedScopes.has(path.relative(repositoryPath, scope))).map(async (scope) => await fs$1.rm(scope, { recursive: true })));
}
async function copyStagedScope(stagingRoot, repositoryPath, identity) {
const relative = gitBackupScopePath(identity);
const source = path.join(stagingRoot, relative);
const target = path.join(repositoryPath, relative);
await assertBackupOwnedScope(target);
await fs$1.rm(target, {
recursive: true,
force: true
});
await fs$1.mkdir(path.dirname(target), {
recursive: true,
mode: 448
});
await fs$1.cp(source, target, {
recursive: true,
force: false
});
}
async function commitGitBackup(params) {
const email = await executeGitCommand(params.repositoryPath, [
"config",
"--get",
"user.email"
], { env: params.env });
const identityArgs = email.code === 0 && email.stdout.trim() ? [] : [
"-c",
"user.name=OpenClaw",
"-c",
"user.email=backup@openclaw.local"
];
await requireGitCommand(params.repositoryPath, [
...identityArgs,
"commit",
"-m",
params.message,
"--",
...params.scopes
], { env: params.env });
return await requireGitCommand(params.repositoryPath, ["rev-parse", "HEAD"], { env: params.env });
}
/** Snapshot selected databases, update the deterministic tree, and commit one Git revision. */
async function createGitBackup(params) {
for (const database of params.databases) assertNotUpdateCapturePath(database.path, params.stateDir);
const repositoryPath = path.resolve(params.repositoryPath);
await initializeGitBackupRepository({
repositoryPath,
stateDir: params.stateDir,
gitEnv: params.gitEnv
});
const staging = await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-git-backup-"
});
const manifests = [];
const warnings = [];
try {
for (const [index, database] of params.databases.entries()) {
const outputPath = path.join(staging.dir, gitBackupScopePath(database.identity));
await fs$1.mkdir(path.dirname(outputPath), {
recursive: true,
mode: 448
});
const copyPath = staging.path(`${database.identity.role}-${index}.sqlite`);
try {
await createOpenClawSnapshotCopy({
database: {
...database,
path: await fs$1.realpath(database.path)
},
targetPath: copyPath
});
} catch (error) {
if (!params.all || database.identity.role !== "agent") throw error;
warnings.push(`Agent ${database.identity.agentId} degraded; keeping previous backup scope if present: ${sanitizeGitBackupDiagnostic(formatErrorMessage(error))}`);
continue;
}
manifests.push(await dumpGitBackupDatabase({
snapshotPath: copyPath,
outputPath,
identity: database.identity,
excludeSecrets: params.excludeSecrets
}));
await fs$1.rm(copyPath, { force: true });
}
if (manifests.length === 0) throw new Error("No Git backup databases were found for the selected scope.");
if (params.all) await removeStaleAgentScopes(repositoryPath, new Set(params.databases.map(({ identity }) => gitBackupScopePath(identity))));
for (const { identity } of manifests) await copyStagedScope(staging.dir, repositoryPath, identity);
} finally {
await staging.cleanup().catch(() => void 0);
}
await Promise.all(["global", "agents"].map(async (scope) => fs$1.mkdir(path.join(repositoryPath, scope), {
recursive: true,
mode: 448
})));
await requireGitCommand(repositoryPath, [
"add",
"-A",
"--",
"global",
"agents"
], { env: params.gitEnv });
const changed = await requireGitCommand(repositoryPath, [
"status",
"--porcelain",
"--",
"global",
"agents"
], { env: params.gitEnv });
let commit;
if (changed) {
const now = params.now ?? /* @__PURE__ */ new Date();
if (!Number.isFinite(now.getTime())) throw new Error("Git backup timestamp is invalid.");
const stagedBackupPaths = await requireGitCommand(repositoryPath, [
"diff",
"--cached",
"--name-only",
"--",
"global",
"agents"
], { env: params.gitEnv });
const commitScopes = ["global", "agents"].filter((scope) => stagedBackupPaths.split("\n").some((entry) => entry.startsWith(`${scope}/`)));
commit = await commitGitBackup({
repositoryPath,
message: `openclaw backup ${now.toISOString()}`,
scopes: commitScopes,
env: params.gitEnv
});
}
let pushed = false;
let pushWarning;
if (params.push) {
if (await requireGitCommand(repositoryPath, [
"rev-list",
"HEAD",
"--invert-grep",
"--grep=^openclaw backup ",
"--count"
], { env: params.gitEnv }) !== "0") pushWarning = GIT_BACKUP_NON_BACKUP_HISTORY_WARNING;
else {
const pushedResult = await executeGitCommand(repositoryPath, [
"push",
"-u",
"origin",
"HEAD"
], { env: params.gitEnv });
if (pushedResult.code === 0) pushed = true;
else pushWarning = formatGitBackupCommandResult("git push", pushedResult);
}
}
return {
repositoryPath,
...commit ? { commit } : {},
noChanges: !changed,
pushed,
...pushWarning ? { pushWarning } : {},
manifests,
warnings
};
}
async function resolveGitCommit(repositoryPath, ref) {
return await requireGitCommand(repositoryPath, [
"rev-parse",
"--verify",
`${ref?.trim() || "HEAD"}^{commit}`
]);
}
/** Materialize one database scope from a Git ref into a private temporary directory. */
async function materializeGitBackupRef(params) {
const repositoryPath = path.resolve(params.repositoryPath);
await assertGitRepository(repositoryPath);
const commit = await resolveGitCommit(repositoryPath, params.ref);
const scope = gitBackupScopePath(params.identity).split(path.sep).join("/");
const files = (await requireGitCommand(repositoryPath, [
"ls-tree",
"-r",
"--name-only",
commit,
"--",
scope
])).split("\n").filter(Boolean);
if ([.../* @__PURE__ */ new Set([`${scope}/manifest.json`, `${scope}/schema.sql`])].some((entry) => !files.includes(entry))) throw new Error(`Git backup ref ${commit} does not contain ${scope}.`);
const workspace = await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-git-restore-"
});
const outputPath = path.join(workspace.dir, scope);
try {
for (const file of files) {
if (file !== `${scope}/manifest.json` && file !== `${scope}/schema.sql` && !file.startsWith(`${scope}/tables/`)) throw new Error(`Git backup ref contains an unexpected file: ${file}`);
const relative = file.slice(scope.length + 1);
const destination = path.join(outputPath, relative);
await fs$1.mkdir(path.dirname(destination), {
recursive: true,
mode: 448
});
await fs$1.writeFile(destination, "", {
flag: "wx",
mode: 384
});
await spawnCommand([
"git",
"-C",
repositoryPath,
"show",
`${commit}:${file}`
], {
stdin: "ignore",
stdout: { file: destination },
buffer: { stdout: false },
maxBuffer: { stderr: 1048576 },
timeout: GIT_TIMEOUT_MS
});
}
return {
commit,
path: outputPath,
[Symbol.asyncDispose]: workspace[Symbol.asyncDispose]
};
} catch (error) {
await workspace.cleanup().catch(() => void 0);
throw error;
}
}
/** Restore one database from a Git ref to a caller-selected fresh path. */
async function restoreGitBackupRef(params) {
try {
var _usingCtx$1 = _usingCtx();
const materialized = _usingCtx$1.a(await materializeGitBackupRef(params));
return {
...await restoreGitBackupDirectory({
sourcePath: materialized.path,
targetPath: params.targetPath,
expectedIdentity: params.identity
}),
commit: materialized.commit
};
} catch (_) {
_usingCtx$1.e = _;
} finally {
await _usingCtx$1.d();
}
}
/** Verify a Git snapshot by restoring it privately and comparing every table digest. */
async function verifyGitBackupRef(params) {
const scratch = await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-git-verify-"
});
try {
return await restoreGitBackupRef({
...params,
targetPath: scratch.path("database.sqlite")
});
} finally {
await scratch.cleanup().catch(() => void 0);
}
}
/** Return bounded Git backup log entries for CLI rendering. */
async function readGitBackupLog(params) {
await assertGitRepository(params.repositoryPath);
const symbolicHead = await executeGitCommand(params.repositoryPath, [
"symbolic-ref",
"--quiet",
"HEAD"
]);
if (symbolicHead.code === 0) {
const headRef = symbolicHead.stdout.trim();
const headExists = await executeGitCommand(params.repositoryPath, [
"show-ref",
"--verify",
"--quiet",
headRef
]);
if (headExists.code === 1 && headRef.startsWith("refs/heads/")) return [];
if (headExists.code !== 0) throw new Error(formatGitBackupCommandResult("git show-ref HEAD", headExists));
} else if (symbolicHead.code !== 1) throw new Error(formatGitBackupCommandResult("git symbolic-ref HEAD", symbolicHead));
const result = await executeGitCommand(params.repositoryPath, [
"log",
`--max-count=${params.limit}`,
"--pretty=format:%H%x09%cI%x09%s"
]);
return requireGitCommandOutput("git log", result, (command, failure) => new Error(formatGitBackupCommandResult(command, failure))).split("\n").filter(Boolean).map((line) => {
const [commit = "", date = "", ...message] = line.split(" ");
return {
commit,
date,
message: message.join(" ")
};
});
}
//#endregion
//#region src/commands/backup-git.ts
const GIT_BACKUP_PUSH_CREDENTIAL_WARNING = "Warning: pushed backup history contains credential material; keep the Git remote private.";
async function resolveCreateDatabases(options) {
const normalizedAgents = [...new Set((options.agents ?? []).map((agent) => {
const trimmed = agent.trim();
if (!trimmed) throw new Error("--agent must not be blank");
return normalizeAgentId(trimmed);
}))];
const explicit = options.global === true || normalizedAgents.length > 0;
if (options.all && explicit) throw new Error("Use --all by itself, or select --global and --agent scopes explicitly.");
if (!options.all && !explicit) throw new Error("Choose at least one Git backup scope: --all, --global, or --agent <id>.");
let agents = [];
if (options.all || normalizedAgents.length > 0) {
const config = getRuntimeConfig({ skipPluginValidation: true });
const agentIds = options.all ? listAgentIds(config).toSorted() : normalizedAgents.map((agent) => resolveConfiguredAgentId(config, agent));
agents = await Promise.all(agentIds.map((agentId) => resolveBackupAgentRoot(config, agentId)));
}
const databases = [];
if (options.all || options.global) {
const selectedPath = resolveOpenClawStateSqlitePath();
assertNotUpdateCapturePath(selectedPath, resolveStateDir());
databases.push({
path: selectedPath,
identity: { role: "global" }
});
}
for (const { agentId, databasePath } of agents) {
assertNotUpdateCapturePath(databasePath, resolveStateDir());
databases.push({
path: databasePath,
identity: {
role: "agent",
agentId
}
});
}
return databases;
}
function resolveOneIdentity(options) {
const agent = options.agent?.trim();
if (options.global === true && agent) throw new Error("Choose exactly one Git backup scope: --global or --agent <id>.");
if (options.global !== true && !agent) throw new Error("Choose a Git backup scope: --global or --agent <id>.");
return options.global === true ? { role: "global" } : {
role: "agent",
agentId: normalizeAgentId(agent)
};
}
async function backupGitInitCommand(runtime, options) {
const result = await initializeGitBackupRepository({
repositoryPath: resolveRequiredBackupPath(options.repository, "--repository"),
stateDir: resolveStateDir(),
remote: options.remote
});
if (options.json) writeRuntimeJson(runtime, result);
else runtime.log(`Git backup repository ready: ${shortenHomePath(result.repositoryPath)}`);
return result;
}
async function backupGitCreateCommand(runtime, options) {
const repositoryPath = resolveRequiredBackupPath(options.repository, "--repository");
if (options.push && !options.excludeSecrets) runtime.error(GIT_BACKUP_PUSH_CREDENTIAL_WARNING);
const releaseCustody = beginLifecycleWriteCustody("backup");
let failure;
try {
const result = await withCommandProcessScope(async () => createGitBackup({
repositoryPath,
stateDir: resolveStateDir(),
databases: await resolveCreateDatabases(options),
all: options.all,
excludeSecrets: options.excludeSecrets,
push: options.push
}));
await recordBackupOutcomeBestEffort(runtime, {
kind: "git",
archivePath: repositoryPath,
status: "ok",
target: result.commit,
error: [...result.warnings, ...result.pushWarning ? [result.pushWarning] : []].join("\n") || void 0,
...result.pushWarning ? { pushFailed: true } : {}
});
if (options.json) writeRuntimeJson(runtime, result);
else if (result.noChanges) runtime.log(`Git backup: no changes (${shortenHomePath(repositoryPath)})`);
else runtime.log(`Git backup committed: ${result.commit}`);
if (result.pushWarning) runtime.error(`Warning: Git backup committed, but push failed: ${result.pushWarning}`);
for (const warning of result.warnings) runtime.error(`Warning: ${warning}`);
return result;
} catch (error) {
failure = error;
await recordBackupOutcomeBestEffort(runtime, {
kind: "git",
archivePath: repositoryPath,
status: "failed",
error: formatErrorMessage(error)
});
throw error;
} finally {
releaseCustody(failure);
}
}
async function backupGitLogCommand(runtime, options) {
const repositoryPath = resolveRequiredBackupPath(options.repository, "--repository");
const limit = options.limit ?? 20;
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer.");
const entries = await readGitBackupLog({
repositoryPath,
limit
});
if (options.json) writeRuntimeJson(runtime, {
repositoryPath,
entries
});
else if (entries.length === 0) runtime.log(`No Git backup commits in ${shortenHomePath(repositoryPath)}.`);
else runtime.log(entries.map((entry) => `${entry.commit}\t${entry.date}\t${entry.message}`).join("\n"));
return entries;
}
async function backupGitVerifyCommand(runtime, options) {
const result = await verifyGitBackupRef({
repositoryPath: resolveRequiredBackupPath(options.repository, "--repository"),
identity: resolveOneIdentity(options),
ref: options.ref
});
if (options.json) writeRuntimeJson(runtime, result);
else {
for (const table of result.tables) runtime.log(`${table.ok ? "ok" : "failed"}\t${table.table}\t${table.rows}\t${table.sha256}`);
runtime.log(`Git backup verified: ${result.commit}`);
}
return result;
}
async function backupGitRestoreCommand(runtime, options) {
const result = await restoreGitBackupRef({
repositoryPath: resolveRequiredBackupPath(options.repository, "--repository"),
identity: resolveOneIdentity(options),
ref: options.ref,
targetPath: resolveRequiredBackupPath(options.target, "--target")
});
if (options.json) writeRuntimeJson(runtime, result);
else {
runtime.log(`Git backup restored: ${shortenHomePath(result.targetPath)} (${result.commit})`);
if (result.excludedTables.length > 0) runtime.error(`Warning: this redacted backup omits tables: ${result.excludedTables.join(", ")}`);
if (result.excludedConfigStateKeyPrefixes.length > 0) runtime.error(`Warning: this redacted backup omits machine-state values under: ${result.excludedConfigStateKeyPrefixes.join(", ")}`);
}
return result;
}
//#endregion
export { backupGitRestoreCommand as a, backupGitLogCommand as i, backupGitCreateCommand as n, backupGitVerifyCommand as o, backupGitInitCommand as r, GIT_BACKUP_PUSH_CREDENTIAL_WARNING as t };