Files
openclaw/src/commands/status-json-runtime.test.ts
T
Peter Steinberger 37b4fc8621 feat(backup): recorded runs, scheduled backups, and git-backed versioned snapshots (#122485)
* 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
2026-08-12 08:11:22 -07:00

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,
});
});
});