diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcc6122eae..e8a8861254c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 53e3fafee53..07844450619 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -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(); + } + }, + ); + }); }); diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 70b9094497c..ea19279e54c 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -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 `/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 { + 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 {