mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
37b4fc8621
* refactor(infra): extract shared git exec and verified snapshot-copy helpers Moves the worktrees git wrapper to src/infra/git-exec.ts (with optional maxOutputBytes for large buffered reads) and the online-backup/sanitize/ VACUUM/verify snapshot step into src/snapshot/openclaw-snapshot-copy.ts so snapshot backends share one hardened copy path. Behavior-identical moves; all importers updated. * feat(snapshot): git-backed versioned SQLite snapshot engine Deterministic per-table JSONL dumps (PK-ordered, lossless bigint/blob encoding), verbatim DDL preservation, virtual/shadow-table skipping with FTS rebuild on restore, secret-table redaction policy, manifest with per-table row counts and content hashes, and restore verification by re-serialization. Unchanged data produces no commit. * feat(backup): recorded runs, freshness surfacing, and scheduled git backups Every backup attempt is recorded in the previously writer-less backup_runs table (bounded to 200 rows). openclaw status gains a Backups overview row and JSON payload; doctor prints an informational hint when no successful backup is recorded or the newest is stale. New commands: backup git init/create/log/verify/restore and backup enable/disable, which provision one idempotent gateway cron job running scheduled git backups. * fix(state): stop bumping schema_meta.updated_at on unchanged opens updated_at now records when schema metadata actually changed instead of when the database was last opened; unconditional bumps dirtied the row on every open and defeated no-change backup detection. * docs: document versioned git backups, scheduling, and backup freshness * fix(backup): satisfy CI ownership checks * fix(backup): complete CI contract coverage * fix(backup): complete credential table redaction * fix(backup): isolate git repository ownership * fix(backup): persist push degradation * fix(backup): atomically converge schedules * fix(status): isolate backup freshness environment * fix(status): carry scan environment to freshness reads * fix(backup): harden Git repository ownership * docs(backup): document Git repository safety * fix(backup): non-creating outcome log and origin preflight for pushed schedules Recording a backup outcome never bootstraps an absent state database (a failed backup on a fresh host would otherwise create a blank DB that a retry treats as real input), and backup enable --push now requires the repository to have an origin remote, pointing at backup git init --remote instead of scheduling permanently degraded pushes. * refactor(worktrees): use shared git exec helpers * refactor(worktrees): remove unused git buffer wrapper * refactor(worktrees): consume buffered git helper * feat(backup): redact pushed schedules by default Unattended recurring pushes retain credential-bearing tables durably in remote Git history, so backup enable --push now defaults to --exclude-secrets; --include-secrets is the explicit full-fidelity override (still warned). Local non-push schedules keep full fidelity for complete restores. * fix(backup): redact audit HMAC and OAuth pending state; tolerate absent backup_runs Adds audit_identity_keys (audit HMAC key) and mcp_oauth_pending_authorizations (live OAuth callback state) to the redaction inventory, and makes read-only backup freshness treat a same-version database without the additive backup_runs table as no recorded backups instead of failing before a writable open converges the schema. * fix(backup): restrict schedules to local gateways * fix(snapshot): harden Git restore and redaction * fix(backup): block pushes of adopted history * fix(backup): contain commits and pairing secrets
208 lines
7.0 KiB
TypeScript
208 lines
7.0 KiB
TypeScript
// Status JSON runtime tests cover runtime status payload construction and command dependencies.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { resolveStatusJsonOutput } from "./status-json-runtime.ts";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
buildStatusJsonPayload: vi.fn((input) => ({ built: true, input })),
|
|
readBackupFreshness: vi.fn(() => ({
|
|
latest: {
|
|
id: "backup-1",
|
|
createdAt: 123,
|
|
archivePath: "/backups/git",
|
|
status: "ok" as const,
|
|
kind: "git" as const,
|
|
},
|
|
})),
|
|
resolveStatusRuntimeSnapshot: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./backup-health.js", () => ({
|
|
readBackupFreshness: mocks.readBackupFreshness,
|
|
}));
|
|
|
|
vi.mock("./status-json-payload.ts", () => ({
|
|
buildStatusJsonPayload: mocks.buildStatusJsonPayload,
|
|
}));
|
|
|
|
vi.mock("./status-runtime-shared.ts", () => ({
|
|
resolveStatusRuntimeSnapshot: mocks.resolveStatusRuntimeSnapshot,
|
|
}));
|
|
|
|
function createScan() {
|
|
return {
|
|
env: { OPENCLAW_STATE_DIR: "/tmp/status-json-runtime-state" },
|
|
cfg: { update: { channel: "stable" }, gateway: {} },
|
|
sourceConfig: { gateway: {} },
|
|
summary: { ok: true },
|
|
update: {
|
|
root: "/tmp/openclaw",
|
|
installKind: "package",
|
|
packageManager: "npm",
|
|
},
|
|
osSummary: { platform: "linux" },
|
|
memory: null,
|
|
memoryPlugin: { enabled: true },
|
|
gatewayMode: "local" as const,
|
|
gatewayConnection: { url: "ws://127.0.0.1:18789", urlSource: "config" },
|
|
remoteUrlMissing: false,
|
|
gatewayReachable: true,
|
|
gatewayProbe: { connectLatencyMs: 42, error: null },
|
|
gatewayProbeAuth: { token: "tok" },
|
|
gatewaySelf: { host: "gateway" },
|
|
gatewayProbeAuthWarning: null,
|
|
agentStatus: { agents: [{ id: "main" }], defaultId: "main" },
|
|
secretDiagnostics: [],
|
|
pluginCompatibility: [
|
|
{
|
|
pluginId: "legacy",
|
|
code: "deprecated-memory-embedding-provider-api",
|
|
severity: "warn",
|
|
message: "warn",
|
|
},
|
|
],
|
|
} satisfies Parameters<typeof resolveStatusJsonOutput>[0]["scan"];
|
|
}
|
|
|
|
function requireStatusPayloadInput() {
|
|
const call = mocks.buildStatusJsonPayload.mock.calls[0];
|
|
if (!call) {
|
|
throw new Error("expected status json payload call");
|
|
}
|
|
const [payloadInput] = call;
|
|
return payloadInput;
|
|
}
|
|
|
|
describe("status-json-runtime", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.resolveStatusRuntimeSnapshot.mockResolvedValue({
|
|
securityAudit: { summary: { critical: 1 } },
|
|
usage: { providers: [] },
|
|
health: { ok: true },
|
|
lastHeartbeat: { status: "ok" },
|
|
gatewayService: { label: "LaunchAgent" },
|
|
nodeService: { label: "node" },
|
|
});
|
|
});
|
|
|
|
it("builds the full json output for status --json", async () => {
|
|
const scan = createScan();
|
|
const result = await resolveStatusJsonOutput({
|
|
scan,
|
|
opts: { deep: true, usage: true, timeoutMs: 1234 },
|
|
includeSecurityAudit: true,
|
|
includePluginCompatibility: true,
|
|
});
|
|
|
|
expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({
|
|
config: { update: { channel: "stable" }, gateway: {} },
|
|
sourceConfig: { gateway: {} },
|
|
timeoutMs: 1234,
|
|
usage: true,
|
|
deep: true,
|
|
gatewayReachable: true,
|
|
includeSecurityAudit: true,
|
|
suppressHealthErrors: undefined,
|
|
});
|
|
expect(mocks.buildStatusJsonPayload).toHaveBeenCalledOnce();
|
|
expect(mocks.readBackupFreshness).toHaveBeenCalledWith(scan.env);
|
|
const payloadInput = requireStatusPayloadInput();
|
|
expect(payloadInput.surface.gatewayConnection).toStrictEqual({
|
|
url: "ws://127.0.0.1:18789",
|
|
urlSource: "config",
|
|
});
|
|
expect(payloadInput.surface.gatewayProbeAuth).toStrictEqual({ token: "tok" });
|
|
expect(payloadInput.surface.gatewayService).toStrictEqual({ label: "LaunchAgent" });
|
|
expect(payloadInput.surface.nodeService).toStrictEqual({ label: "node" });
|
|
expect(payloadInput.securityAudit).toStrictEqual({ summary: { critical: 1 } });
|
|
expect(payloadInput.usage).toStrictEqual({ providers: [] });
|
|
expect(payloadInput.health).toStrictEqual({ ok: true });
|
|
expect(payloadInput.lastHeartbeat).toStrictEqual({ status: "ok" });
|
|
expect(payloadInput.pluginCompatibility).toStrictEqual([
|
|
{
|
|
pluginId: "legacy",
|
|
code: "deprecated-memory-embedding-provider-api",
|
|
severity: "warn",
|
|
message: "warn",
|
|
},
|
|
]);
|
|
expect(result).toEqual({
|
|
built: true,
|
|
input: payloadInput,
|
|
backups: mocks.readBackupFreshness(),
|
|
});
|
|
});
|
|
|
|
it("skips optional sections when flags are off", async () => {
|
|
mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({
|
|
securityAudit: undefined,
|
|
usage: undefined,
|
|
health: undefined,
|
|
lastHeartbeat: null,
|
|
gatewayService: { label: "LaunchAgent" },
|
|
nodeService: { label: "node" },
|
|
});
|
|
|
|
const { env: _env, ...scanWithoutEnv } = createScan();
|
|
await resolveStatusJsonOutput({
|
|
scan: scanWithoutEnv,
|
|
opts: { deep: false, usage: false, timeoutMs: 500 },
|
|
includeSecurityAudit: false,
|
|
includePluginCompatibility: false,
|
|
});
|
|
|
|
expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({
|
|
config: { update: { channel: "stable" }, gateway: {} },
|
|
sourceConfig: { gateway: {} },
|
|
timeoutMs: 500,
|
|
usage: false,
|
|
deep: false,
|
|
gatewayReachable: true,
|
|
includeSecurityAudit: false,
|
|
suppressHealthErrors: undefined,
|
|
});
|
|
expect(mocks.buildStatusJsonPayload).toHaveBeenCalledOnce();
|
|
expect(mocks.readBackupFreshness).toHaveBeenCalledWith({});
|
|
const payloadInput = requireStatusPayloadInput();
|
|
expect(payloadInput.surface.gatewayProbeAuth).toStrictEqual({ token: "tok" });
|
|
expect(payloadInput.securityAudit).toBeUndefined();
|
|
expect(payloadInput.usage).toBeUndefined();
|
|
expect(payloadInput.health).toBeUndefined();
|
|
expect(payloadInput.lastHeartbeat).toBeNull();
|
|
expect(payloadInput.pluginCompatibility).toBeUndefined();
|
|
});
|
|
|
|
it("preserves failed deep health probes in nonthrowing JSON output", async () => {
|
|
mocks.resolveStatusRuntimeSnapshot.mockResolvedValueOnce({
|
|
securityAudit: undefined,
|
|
usage: undefined,
|
|
health: { error: "gateway health probe timed out" },
|
|
lastHeartbeat: { status: "ok" },
|
|
gatewayService: { label: "LaunchAgent" },
|
|
nodeService: { label: "node" },
|
|
});
|
|
|
|
await resolveStatusJsonOutput({
|
|
scan: createScan(),
|
|
opts: { deep: true, timeoutMs: 500 },
|
|
includeSecurityAudit: false,
|
|
suppressHealthErrors: true,
|
|
});
|
|
|
|
expect(mocks.buildStatusJsonPayload).toHaveBeenCalledOnce();
|
|
const payloadInput = requireStatusPayloadInput();
|
|
expect(payloadInput.surface.gatewayProbeAuth).toStrictEqual({ token: "tok" });
|
|
expect(payloadInput.health).toEqual({ error: "gateway health probe timed out" });
|
|
expect(mocks.resolveStatusRuntimeSnapshot).toHaveBeenCalledWith({
|
|
config: { update: { channel: "stable" }, gateway: {} },
|
|
sourceConfig: { gateway: {} },
|
|
timeoutMs: 500,
|
|
usage: undefined,
|
|
deep: true,
|
|
gatewayReachable: true,
|
|
includeSecurityAudit: false,
|
|
suppressHealthErrors: true,
|
|
});
|
|
});
|
|
});
|