mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 23:40:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user