fix(backup): keep temp manifest outside source paths

The backup temp manifest is created via os.tmpdir() and passed to tar.c
alongside the included asset paths. When TMPDIR resolves inside a backed-up
asset (for example a sandboxed cron run with TMPDIR=~/.openclaw/tmp), the
recursive walk of that asset visits the same manifest a second time and both
copies are remapped to <archiveRoot>/manifest.json. backup-verify then fails
with 'Expected exactly one backup manifest entry, found 2'.

Add chooseBackupTempRoot() that prefers os.tmpdir() and falls back to the
output directory (already validated as outside every asset and writable by
the caller) when the system tempdir overlaps a source path. A defensive
guard re-checks the fallback. A tar filter alone cannot fix this because
the filter fires for both the explicit-arg and the traversed entry, so
excluding by path drops the manifest entirely.

Add regression tests for tmpdir nested in the state dir and tmpdir equal
to the state dir.
This commit is contained in:
Soham Patankar
2026-05-09 16:29:20 +05:30
parent f21b93e896
commit 00ec151f68
3 changed files with 115 additions and 1 deletions

View File

@@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai
- Agents/failover: persist overloaded auth-profile cooldown marks before exhausted fallback summaries surface, so immediate fallback retries honor the recorded cooldown state.
- Docs/Subagents: correct the listed sub-agent bootstrap context files to include `SOUL.md`, `IDENTITY.md`, and `USER.md`. (#79470) Thanks @lastguru-net.
- Backup: keep live backup archives from copying current agent session transcripts, cron run logs, and delivery queues while preserving workspace lock/temp files and keeping `--json` output parseable when volatile files are skipped. Fixes #72249. (#72251) Thanks @abnershang.
- Backup: place the temp manifest outside every backed-up asset so `backup create --verify` still passes when `TMPDIR` resolves inside a source path (for example `~/.openclaw/tmp`), avoiding the duplicate root manifest that otherwise tripped `Expected exactly one backup manifest entry, found 2`. Thanks @YaanFPV.
- OpenAI/Codex: install the Codex runtime plugin from npm during OpenAI onboarding and load it automatically for implicit OpenAI model routes, while preserving manual PI runtime overrides. Fixes #79358.
- OpenAI/realtime voice: defer `response.create` while a realtime response is still active, retry after `response.done`/`response.cancelled`, and align GA input transcription/noise-reduction defaults with the Codex realtime reference so Discord/Voice Call consult results can resume speaking instead of tripping the active-response race.
- OpenAI/realtime voice: avoid duplicate barge-in cancellation requests, log realtime model interruption/cutoff events in Discord voice logs, and treat OpenAI's no-active-response cancellation reply as a completed cancel so Discord voice sessions do not wedge pending speech after fast interruptions.

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { describe, expect, it, vi } from "vitest";
@@ -409,4 +410,77 @@ describe("createBackupArchive", () => {
},
);
});
it("does not duplicate the root manifest when the system tempdir lives inside the state dir", async () => {
await withOpenClawTestState(
{
layout: "state-only",
prefix: "openclaw-backup-tmp-overlap-",
scenario: "minimal",
},
async (state) => {
const stateDir = state.stateDir;
const outputDir = state.path("backups");
const overlappingTmp = path.join(stateDir, "tmp");
await fs.mkdir(overlappingTmp, { recursive: true });
await fs.mkdir(outputDir, { recursive: true });
const tmpdirSpy = vi.spyOn(os, "tmpdir").mockReturnValue(overlappingTmp);
try {
const result = await createBackupArchive({
output: outputDir,
includeWorkspace: false,
nowMs: Date.UTC(2026, 4, 9, 12, 0, 0),
});
const entries = await listArchiveEntries(result.archivePath);
const rootManifestEntries = entries.filter(
(entry) => entry.endsWith("/manifest.json") && !entry.includes("/payload/"),
);
expect(rootManifestEntries).toHaveLength(1);
const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await expect(
backupVerifyCommand(runtime, { archive: result.archivePath }),
).resolves.toMatchObject({ ok: true });
} finally {
tmpdirSpy.mockRestore();
}
},
);
});
it("does not duplicate the root manifest when the system tempdir is the state dir itself", async () => {
await withOpenClawTestState(
{
layout: "state-only",
prefix: "openclaw-backup-tmp-equals-state-",
scenario: "minimal",
},
async (state) => {
const outputDir = state.path("backups");
await fs.mkdir(outputDir, { recursive: true });
const tmpdirSpy = vi.spyOn(os, "tmpdir").mockReturnValue(state.stateDir);
try {
const result = await createBackupArchive({
output: outputDir,
includeWorkspace: false,
nowMs: Date.UTC(2026, 4, 9, 12, 0, 0),
});
const entries = await listArchiveEntries(result.archivePath);
const rootManifestEntries = entries.filter(
(entry) => entry.endsWith("/manifest.json") && !entry.includes("/payload/"),
);
expect(rootManifestEntries).toHaveLength(1);
const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await expect(
backupVerifyCommand(runtime, { archive: result.archivePath }),
).resolves.toMatchObject({ ok: true });
} finally {
tmpdirSpy.mockRestore();
}
},
);
});
});

View File

@@ -226,6 +226,43 @@ function buildTempArchivePath(outputPath: string): string {
return `${outputPath}.${randomUUID()}.tmp`;
}
// The temp manifest is passed to `tar.c` alongside the asset source paths. If
// the temp file lives inside any asset, recursive traversal pulls it in a
// second time and both copies remap to `<archiveRoot>/manifest.json`, which
// makes verify reject the archive. A `tar` filter cannot fix this in place: it
// fires for both the explicit-arg and the traversed entry, so excluding by
// path drops the manifest entirely. We instead place the temp dir somewhere
// guaranteed to be outside every asset.
async function chooseBackupTempRoot(params: {
assets: readonly BackupAsset[];
outputPath: string;
}): Promise<string> {
const systemTmp = os.tmpdir();
const canonicalSystemTmp = await canonicalizePathForContainment(systemTmp);
const systemTmpInsideAsset = params.assets.some((asset) =>
isPathWithin(canonicalSystemTmp, asset.sourcePath),
);
if (!systemTmpInsideAsset) {
return systemTmp;
}
// Fallback: the directory holding the output archive. The earlier
// output-containment check guarantees `outputPath` is outside every asset,
// so its parent is too. The caller must already have write access there to
// write the archive itself, so this stays within the existing sandbox.
const fallback = path.dirname(params.outputPath);
const canonicalFallback = await canonicalizePathForContainment(fallback);
const fallbackInsideAsset = params.assets.find((asset) =>
isPathWithin(canonicalFallback, asset.sourcePath),
);
if (fallbackInsideAsset) {
throw new Error(
`Backup temp root cannot be placed outside every source path: ${systemTmp} and ${fallback} both overlap ${fallbackInsideAsset.sourcePath}.`,
);
}
return fallback;
}
function isLinkUnsupportedError(code: string | undefined): boolean {
return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM";
}
@@ -449,7 +486,9 @@ export async function createBackupArchive(
}
await fs.mkdir(path.dirname(outputPath), { recursive: true });
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-"));
const tempRoot = await chooseBackupTempRoot({ assets: result.assets, outputPath });
await fs.mkdir(tempRoot, { recursive: true });
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-backup-"));
const manifestPath = path.join(tempDir, "manifest.json");
const tempArchivePath = buildTempArchivePath(outputPath);
try {