mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 14:12:28 +08:00
Related: #134607 ## What Problem This Solves Git snapshot backup failures lost useful operator diagnostics. - Failed push kept stderr or stdout, but not both. - Failed history exposed raw Git errors. - Empty history depended on one English Git message. - Persisted diagnostics could split a UTF-16 surrogate pair. ## Why This Change Was Made The Git snapshot owner now formats every failed Git result through one bounded path. That path redacts each output line before it reserves space for both streams. This keeps multi-line URL query credentials from consuming later diagnostics. Unborn repositories are detected from symbolic `HEAD` and exact branch-ref state. Corrupt refs and missing non-branch refs remain visible failures. The generic process formatter is unchanged. The backup record keeps the same 1,200 UTF-16-unit storage contract. ## User Impact Operators keep recent stderr and stdout details after a failed push or history read. Diagnostics remain bounded and remove URL user information and sensitive query values. Empty repositories still show an empty history. Successful create, push, history, and restore behavior is unchanged. ## Evidence - Current main `4fc24e6e0f81121cef0fb4395db29fb87700e7f6`: real `openclaw backup git create --push` lost stdout, and corrupt `backup git log` returned raw repeated Git errors. - Repaired tree: a real rejected `git push` retained both stream tails and persisted exactly 1,200 UTF-16 units with all synthetic URL credentials removed. - Repaired tree: real unborn history returned no commits; corrupt history returned a structured redacted error; successful push, log, and restore passed against a local bare remote. - Focused tests: 59 passed across process, snapshot, state, and command owners. - Final test-audit run: 28 snapshot and persistence tests passed with an emoji placed on the actual formatter cut boundary. - Packaged CLI E2E test: real init, failed push, unborn history, and corrupt history passed through `openclaw.mjs`. - Formatting and `git diff --check` passed. ### Redacted exact-head terminal trace ```text Git backup committed: <backup-commit> Warning: Git backup committed, but push failed: git push failed (code=1, termination=exit) stderr: … stderr-old-89 https://***:***@example.invalid/backup.git?access_token=*** stderr-tail-🦞 https://***:***@example.invalid/backup.git?access_token=*** error: failed to push some refs to '<temp>/remote.git' stdout: … stdout-old-89 https://***:***@example.invalid/backup.git?access_token=*** stdout-tail-🐚 https://***:***@example.invalid/backup.git?access_token=*** PASS failed-push persisted warning length=1200 UTF-16 units TRACE freshness equals create warning: true ``` Co-authored-by: Ayaan Zaidi <hi@obviy.us>
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
// SQLite CLI E2E tests cover startup and target ownership before offline maintenance.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { withTempHome } from "openclaw/plugin-sdk/test-env";
|
|
import { describe, expect, it } from "vitest";
|
|
import { requireGitCommand as requireGit } from "../src/infra/git-exec.js";
|
|
import {
|
|
closeOpenClawAgentDatabaseByPath,
|
|
openOpenClawAgentDatabase,
|
|
} from "../src/state/openclaw-agent-db.js";
|
|
import {
|
|
closeOpenClawStateDatabase,
|
|
openOpenClawStateDatabase,
|
|
} from "../src/state/openclaw-state-db.js";
|
|
|
|
function runDoctorCli(args: string[], env: NodeJS.ProcessEnv) {
|
|
return spawnSync(process.execPath, ["openclaw.mjs", "doctor", ...args], {
|
|
cwd: process.cwd(),
|
|
env,
|
|
encoding: "utf8",
|
|
timeout: 60_000,
|
|
});
|
|
}
|
|
|
|
describe("SQLite CLI maintenance ownership", () => {
|
|
it("compacts after full CLI startup without retaining a config-health database handle", async () => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const stateDir = path.join(tempHome, ".openclaw");
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
USERPROFILE: tempHome,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
};
|
|
delete env.OPENCLAW_CONFIG_PATH;
|
|
delete env.OPENCLAW_HOME;
|
|
delete env.VITEST;
|
|
|
|
try {
|
|
const database = openOpenClawStateDatabase({ env });
|
|
database.db.exec(`
|
|
CREATE TABLE compact_cli_payload (
|
|
id INTEGER PRIMARY KEY,
|
|
payload TEXT NOT NULL
|
|
);
|
|
BEGIN IMMEDIATE;
|
|
`);
|
|
const insert = database.db.prepare(
|
|
"INSERT INTO compact_cli_payload (payload) VALUES (?)",
|
|
);
|
|
for (let index = 0; index < 256; index += 1) {
|
|
insert.run(`${index}:${"x".repeat(8_192)}`);
|
|
}
|
|
database.db.exec(`
|
|
COMMIT;
|
|
DELETE FROM compact_cli_payload;
|
|
PRAGMA wal_checkpoint(TRUNCATE);
|
|
`);
|
|
} finally {
|
|
closeOpenClawStateDatabase();
|
|
}
|
|
|
|
const result = runDoctorCli(["--state-sqlite", "compact", "--json"], env);
|
|
|
|
expect(result.status, result.stderr || result.stdout).toBe(0);
|
|
const report = JSON.parse(result.stdout.trim()) as {
|
|
after: { autoVacuum: number; freelistPages: number };
|
|
before: { freelistPages: number };
|
|
integrityCheck: string;
|
|
skipped: boolean;
|
|
};
|
|
expect(report).toMatchObject({
|
|
after: {
|
|
autoVacuum: 2,
|
|
freelistPages: 0,
|
|
},
|
|
integrityCheck: "ok",
|
|
skipped: false,
|
|
});
|
|
expect(report.before.freelistPages).toBeGreaterThan(0);
|
|
expect(fs.existsSync(path.join(stateDir, "state", "openclaw.sqlite"))).toBe(true);
|
|
},
|
|
{ prefix: "openclaw-state-sqlite-cli-" },
|
|
);
|
|
}, 90_000);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects hard-linked shared-state SQLite sidecars before compaction",
|
|
async () => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const stateDir = path.join(tempHome, ".openclaw");
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
USERPROFILE: tempHome,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
};
|
|
delete env.OPENCLAW_CONFIG_PATH;
|
|
delete env.OPENCLAW_HOME;
|
|
delete env.VITEST;
|
|
|
|
const database = openOpenClawStateDatabase({ env });
|
|
const walPath = `${database.path}-wal`;
|
|
const externalWalPath = path.join(tempHome, "external-state", "openclaw.sqlite-wal");
|
|
try {
|
|
database.db.exec(`
|
|
PRAGMA wal_autocheckpoint = 0;
|
|
CREATE TABLE compact_sidecar_payload (
|
|
id INTEGER PRIMARY KEY,
|
|
payload TEXT NOT NULL
|
|
);
|
|
PRAGMA wal_checkpoint(TRUNCATE);
|
|
INSERT INTO compact_sidecar_payload (payload) VALUES ('committed wal frame');
|
|
`);
|
|
fs.mkdirSync(path.dirname(externalWalPath), { recursive: true });
|
|
fs.linkSync(walPath, externalWalPath);
|
|
const externalWalBefore = fs.readFileSync(externalWalPath);
|
|
expect(externalWalBefore.byteLength).toBeGreaterThan(0);
|
|
|
|
const result = runDoctorCli(["--state-sqlite", "compact", "--json"], env);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(`${result.stderr}\n${result.stdout}`).toContain("hard-linked path");
|
|
expect(fs.readFileSync(externalWalPath)).toEqual(externalWalBefore);
|
|
} finally {
|
|
closeOpenClawStateDatabase();
|
|
}
|
|
},
|
|
{ prefix: "openclaw-state-sqlite-sidecar-cli-" },
|
|
);
|
|
},
|
|
90_000,
|
|
);
|
|
|
|
it("rejects destructive explicit session stores outside the active state owner", async () => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const stateDir = path.join(tempHome, ".openclaw");
|
|
const externalStorePath = path.join(
|
|
tempHome,
|
|
"external-state",
|
|
"agents",
|
|
"main",
|
|
"sessions",
|
|
"sessions.json",
|
|
);
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
USERPROFILE: tempHome,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
};
|
|
delete env.OPENCLAW_CONFIG_PATH;
|
|
delete env.OPENCLAW_HOME;
|
|
delete env.VITEST;
|
|
|
|
const result = runDoctorCli(
|
|
["--session-sqlite", "compact", "--session-sqlite-store", externalStorePath, "--json"],
|
|
env,
|
|
);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(`${result.stderr}\n${result.stdout}`).toContain(
|
|
"outside the active OpenClaw state directory",
|
|
);
|
|
expect(fs.existsSync(externalStorePath)).toBe(false);
|
|
},
|
|
{ prefix: "openclaw-session-sqlite-cli-" },
|
|
);
|
|
}, 90_000);
|
|
|
|
it("rejects hard-linked SQLite sidecars before destructive maintenance", async () => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const stateDir = path.join(tempHome, ".openclaw");
|
|
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
|
const sqlitePath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite");
|
|
const externalWalPath = path.join(tempHome, "external-state", "openclaw-agent.sqlite-wal");
|
|
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(externalWalPath), { recursive: true });
|
|
fs.writeFileSync(storePath, "{}\n", "utf8");
|
|
fs.writeFileSync(externalWalPath, "external wal\n", "utf8");
|
|
fs.linkSync(externalWalPath, `${sqlitePath}-wal`);
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
USERPROFILE: tempHome,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
};
|
|
delete env.OPENCLAW_CONFIG_PATH;
|
|
delete env.OPENCLAW_HOME;
|
|
delete env.VITEST;
|
|
|
|
const result = runDoctorCli(
|
|
["--session-sqlite", "compact", "--session-sqlite-store", storePath, "--json"],
|
|
env,
|
|
);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(`${result.stderr}\n${result.stdout}`).toContain("hard-linked path");
|
|
expect(fs.readFileSync(externalWalPath, "utf8")).toBe("external wal\n");
|
|
},
|
|
{ prefix: "openclaw-session-sqlite-sidecar-cli-" },
|
|
);
|
|
}, 90_000);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects symbolic-linked SQLite sidecars before destructive maintenance",
|
|
async () => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const stateDir = path.join(tempHome, ".openclaw");
|
|
const storePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
|
|
const sqlitePath = path.join(
|
|
stateDir,
|
|
"agents",
|
|
"main",
|
|
"agent",
|
|
"openclaw-agent.sqlite",
|
|
);
|
|
const targetPath = path.join(stateDir, "agents", "main", "agent", "sidecar-target");
|
|
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
|
fs.writeFileSync(storePath, "{}\n", "utf8");
|
|
fs.writeFileSync(targetPath, "owned target\n", "utf8");
|
|
fs.symlinkSync(targetPath, `${sqlitePath}-wal`);
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
USERPROFILE: tempHome,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
};
|
|
delete env.OPENCLAW_CONFIG_PATH;
|
|
delete env.OPENCLAW_HOME;
|
|
delete env.VITEST;
|
|
|
|
const result = runDoctorCli(
|
|
["--session-sqlite", "compact", "--session-sqlite-store", storePath, "--json"],
|
|
env,
|
|
);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(`${result.stderr}\n${result.stdout}`).toContain("symbolic-link path");
|
|
expect(fs.readFileSync(targetPath, "utf8")).toBe("owned target\n");
|
|
},
|
|
{ prefix: "openclaw-session-sqlite-symlink-sidecar-cli-" },
|
|
);
|
|
},
|
|
90_000,
|
|
);
|
|
|
|
it("rejects hard-linked SQLite sidecars discovered through configured session stores", async () => {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const stateDir = path.join(tempHome, ".openclaw");
|
|
const storePath = path.join(tempHome, "external-sessions", "sessions.json");
|
|
const sqlitePath = path.join(path.dirname(storePath), "openclaw-agent.sqlite");
|
|
const externalWalPath = path.join(tempHome, "external-alias", "openclaw-agent.sqlite-wal");
|
|
const configPath = path.join(stateDir, "openclaw.json");
|
|
fs.mkdirSync(path.dirname(storePath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(externalWalPath), { recursive: true });
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
fs.writeFileSync(storePath, "{}\n", "utf8");
|
|
fs.writeFileSync(configPath, JSON.stringify({ session: { store: storePath } }), "utf8");
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
USERPROFILE: tempHome,
|
|
OPENCLAW_CONFIG_PATH: configPath,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
};
|
|
delete env.OPENCLAW_HOME;
|
|
delete env.VITEST;
|
|
|
|
const database = openOpenClawAgentDatabase({
|
|
agentId: "main",
|
|
env,
|
|
path: sqlitePath,
|
|
});
|
|
const walPath = `${sqlitePath}-wal`;
|
|
try {
|
|
database.db.exec(`
|
|
PRAGMA wal_autocheckpoint = 0;
|
|
CREATE TABLE compact_sidecar_payload (
|
|
id INTEGER PRIMARY KEY,
|
|
payload TEXT NOT NULL
|
|
);
|
|
PRAGMA wal_checkpoint(TRUNCATE);
|
|
INSERT INTO compact_sidecar_payload (payload) VALUES ('committed wal frame');
|
|
`);
|
|
fs.linkSync(walPath, externalWalPath);
|
|
const externalWalBefore = fs.readFileSync(externalWalPath);
|
|
expect(externalWalBefore.byteLength).toBeGreaterThan(0);
|
|
|
|
const result = runDoctorCli(["--session-sqlite", "compact", "--json"], env);
|
|
|
|
expect(result.status).not.toBe(0);
|
|
expect(`${result.stderr}\n${result.stdout}`).toContain("hard-linked path");
|
|
expect(fs.readFileSync(externalWalPath)).toEqual(externalWalBefore);
|
|
} finally {
|
|
closeOpenClawAgentDatabaseByPath(sqlitePath);
|
|
}
|
|
},
|
|
{ prefix: "openclaw-configured-session-sqlite-sidecar-cli-" },
|
|
);
|
|
}, 90_000);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps Git backup failures useful through the shipped CLI",
|
|
async () => {
|
|
const repository = fs.mkdtempSync(
|
|
path.join(fs.realpathSync("/var/tmp"), "openclaw-backup-git-cli-"),
|
|
);
|
|
try {
|
|
await withTempHome(
|
|
async (tempHome) => {
|
|
const stateDir = path.join(tempHome, ".openclaw");
|
|
const remote = path.join(tempHome, "remote.git");
|
|
const unborn = path.join(tempHome, "unborn");
|
|
const hooks = path.join(tempHome, "hooks");
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
HOME: tempHome,
|
|
USERPROFILE: tempHome,
|
|
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_TEST_FAST: "1",
|
|
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
NO_COLOR: "1",
|
|
};
|
|
delete env.OPENCLAW_CONFIG_PATH;
|
|
delete env.OPENCLAW_HOME;
|
|
delete env.VITEST;
|
|
|
|
openOpenClawStateDatabase({ env });
|
|
closeOpenClawStateDatabase();
|
|
await requireGit(tempHome, ["init", "--bare", remote]);
|
|
|
|
const entry = path.resolve(process.cwd(), "openclaw.mjs");
|
|
const runCli = (args: string[], childEnv: NodeJS.ProcessEnv = env) =>
|
|
spawnSync(process.execPath, [entry, ...args], {
|
|
cwd: process.cwd(),
|
|
env: childEnv,
|
|
encoding: "utf8",
|
|
timeout: 60_000,
|
|
});
|
|
const expectExit = (result: ReturnType<typeof runCli>, code: number) => {
|
|
expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(code);
|
|
};
|
|
|
|
expectExit(
|
|
runCli(["backup", "git", "init", "--repository", repository, "--remote", remote]),
|
|
0,
|
|
);
|
|
await requireGit(repository, ["config", "user.name", "OpenClaw Backup Test"]);
|
|
await requireGit(repository, ["config", "user.email", "backup@example.invalid"]);
|
|
fs.mkdirSync(hooks);
|
|
const username = ["synthetic", "cli", "user"].join("-");
|
|
const password = ["synthetic", "cli", "password"].join("-");
|
|
const querySecret = ["synthetic", "cli", "query"].join("-");
|
|
const credentialUrl = `https://${username}:${password}@example.invalid/backup.git?access_token=${querySecret}`;
|
|
fs.writeFileSync(
|
|
path.join(hooks, "pre-push"),
|
|
[
|
|
"#!/bin/sh",
|
|
...Array.from(
|
|
{ length: 20 },
|
|
(_, index) => `printf 'stderr-old-${index} ${credentialUrl}\\n' >&2`,
|
|
),
|
|
`printf 'stderr-tail-🦞 ${credentialUrl}\\n' >&2`,
|
|
...Array.from(
|
|
{ length: 20 },
|
|
(_, index) => `printf 'stdout-old-${index} ${credentialUrl}\\n'`,
|
|
),
|
|
`printf 'stdout-tail-🐚 ${credentialUrl}\\n'`,
|
|
"exit 1",
|
|
].join("\n"),
|
|
{ mode: 0o700 },
|
|
);
|
|
await requireGit(repository, ["config", "core.hooksPath", hooks]);
|
|
|
|
const failedPush = runCli([
|
|
"backup",
|
|
"git",
|
|
"create",
|
|
"--repository",
|
|
repository,
|
|
"--global",
|
|
"--push",
|
|
"--exclude-secrets",
|
|
]);
|
|
expectExit(failedPush, 0);
|
|
const pushOutput = `${failedPush.stdout}\n${failedPush.stderr}`;
|
|
expect(pushOutput).toContain("git push failed (code=1, termination=exit)");
|
|
expect(pushOutput).toContain("stderr-tail-🦞");
|
|
expect(pushOutput).toContain("stdout-tail-🐚");
|
|
expect(pushOutput).toContain(
|
|
"https://***:***@example.invalid/backup.git?access_token=***",
|
|
);
|
|
expect(pushOutput).not.toContain(username);
|
|
expect(pushOutput).not.toContain(password);
|
|
expect(pushOutput).not.toContain(querySecret);
|
|
|
|
await requireGit(tempHome, ["init", unborn]);
|
|
const emptyHistory = runCli(["backup", "git", "log", "--repository", unborn]);
|
|
expectExit(emptyHistory, 0);
|
|
expect(emptyHistory.stdout).toContain("No Git backup commits");
|
|
|
|
const blob = await requireGit(repository, ["hash-object", "-w", "--stdin"], {
|
|
input: "not a commit\n",
|
|
});
|
|
await requireGit(repository, ["update-ref", "refs/tags/broken", blob]);
|
|
const quarantine = path.join(repository, ".git", "quarantine");
|
|
fs.mkdirSync(quarantine);
|
|
fs.renameSync(
|
|
path.join(repository, ".git", "objects", blob.slice(0, 2), blob.slice(2)),
|
|
path.join(quarantine, blob),
|
|
);
|
|
await requireGit(repository, ["symbolic-ref", "HEAD", "refs/tags/broken"]);
|
|
const historyUsername = ["synthetic", "history", "user"].join("-");
|
|
const historyPassword = ["synthetic", "history", "password"].join("-");
|
|
const historyQuery = ["synthetic", "history", "query"].join("-");
|
|
const failedHistory = runCli(
|
|
["backup", "git", "log", "--repository", repository, "--json"],
|
|
{
|
|
...env,
|
|
GIT_ALTERNATE_OBJECT_DIRECTORIES: `"/tmp/https://${historyUsername}:${historyPassword}@example.invalid/objects?access_token=${historyQuery}"`,
|
|
},
|
|
);
|
|
expectExit(failedHistory, 1);
|
|
const historyOutput = `${failedHistory.stdout}\n${failedHistory.stderr}`;
|
|
expect(historyOutput).toContain(
|
|
"git show-ref HEAD failed (code=128, termination=exit)",
|
|
);
|
|
expect(historyOutput).toContain("stderr:");
|
|
expect(historyOutput).toContain(
|
|
"https://***:***@example.invalid/objects?access_token=***",
|
|
);
|
|
expect(historyOutput).not.toContain(historyUsername);
|
|
expect(historyOutput).not.toContain(historyPassword);
|
|
expect(historyOutput).not.toContain(historyQuery);
|
|
},
|
|
{ prefix: "openclaw-backup-git-cli-" },
|
|
);
|
|
} finally {
|
|
fs.rmSync(repository, { recursive: true, force: true });
|
|
}
|
|
},
|
|
120_000,
|
|
);
|
|
});
|