From 2695c38612517e10d892920884a78e9191c0a347 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 5 Jun 2026 11:50:53 +0800 Subject: [PATCH] feat(artifacts): add xworkmate.artifacts.collect-and-snapshot gateway method Adds a new gateway method that copies recent outputs from the OpenClaw media and tmp directories into the current task scope's artifacts directory, returning a snapshot manifest. XWorkmate Bridge can then call the existing xworkmate.artifacts.export to hand the snapshot to the XWorkmate APP. --- README.md | 3 +- dist/index.js | 18 +++- dist/src/exportArtifacts.d.ts | 20 ++-- dist/src/exportArtifacts.js | 169 ++++++++++++++++++++++++++++- index.test.ts | 1 + index.ts | 16 +++ src/exportArtifacts.test.ts | 57 ++++++++++ src/exportArtifacts.ts | 193 ++++++++++++++++++++++++++++++++++ 8 files changed, 466 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9d18623..7d5d739 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,8 @@ Gateway clients can use: - `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table. - `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file. - `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task file. -- `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path. +- `xworkmate.artifacts.collect-and-snapshot` after `agent.wait` to copy `~/.openclaw/media/` and `/tmp/openclaw/` outputs into the current task scope. +- `xworkmate.artifacts.export` with `artifactScope` after collect-and-snapshot for the XWorkmate APP sync path. Large files are metadata-only in the export payload, but XWorkmate Bridge can generate its own signed download URL and call `xworkmate.artifacts.read` as the diff --git a/dist/index.js b/dist/index.js index 9c031f0..93f4ab9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,5 +1,5 @@ import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime"; -import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js"; +import { collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js"; import { runXWorkmateBridgeAgents } from "./src/bridgeAgents.js"; function scopedGatewayParams(params) { const sessionScope = getPluginRuntimeGatewayRequestScope()?.sessionScope; @@ -69,6 +69,22 @@ function register(api) { }); } }); + api.registerGatewayMethod("xworkmate.artifacts.collect-and-snapshot", async (opts) => { + try { + const payload = await collectAndSnapshotXWorkmateArtifacts({ + params: scopedGatewayParams(opts.params), + config: api.config, + pluginConfig: api.pluginConfig, + }); + opts.respond(true, payload, undefined); + } + catch (error) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: error instanceof Error ? error.message : String(error), + }); + } + }); api.registerGatewayMethod("xworkmate.artifacts.list", async (opts) => { try { const payload = await exportXWorkmateArtifacts({ diff --git a/dist/src/exportArtifacts.d.ts b/dist/src/exportArtifacts.d.ts index c407940..eebb6b3 100644 --- a/dist/src/exportArtifacts.d.ts +++ b/dist/src/exportArtifacts.d.ts @@ -33,6 +33,18 @@ export type XWorkmateArtifactPrepare = { relativeArtifactDirectory: string; warnings: string[]; }; +export type XWorkmateArtifactSnapshot = { + runId: string; + sessionKey: string; + remoteWorkingDirectory: string; + remoteWorkspaceRefKind: "remotePath"; + artifactScope: string; + scopeKind: "task"; + artifactDirectory: string; + snapshotDirectory: string; + copiedFiles: string[]; + warnings: string[]; +}; type ExportInput = { params: Record; config?: unknown; @@ -44,13 +56,7 @@ type ReadInput = { pluginConfig?: Record; }; export declare function prepareXWorkmateArtifacts(input: ExportInput): Promise; +export declare function collectAndSnapshotXWorkmateArtifacts(input: ExportInput): Promise; export declare function exportXWorkmateArtifacts(input: ExportInput): Promise; export declare function readXWorkmateArtifact(input: ReadInput): Promise; -export declare function formatArtifactManifestMarkdown(input: { - remoteWorkingDirectory: string; - artifactScope?: string; - scopeKind?: XWorkmateArtifactScopeKind; - artifacts: XWorkmateArtifact[]; - warnings: string[]; -}): string; export {}; diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index 3f0c753..240fce3 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -49,6 +49,73 @@ export async function prepareXWorkmateArtifacts(input) { warnings: [], }; } +export async function collectAndSnapshotXWorkmateArtifacts(input) { + const params = input.params ?? {}; + const pluginConfig = input.pluginConfig ?? {}; + const runId = requiredString(params.runId, "runId required"); + const sessionKey = requiredString(params.sessionKey, "sessionKey required"); + const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); + const maxFiles = positiveInteger(params.maxFiles, pluginConfig.snapshotMaxFiles, DEFAULT_MAX_FILES); + const expectedArtifactScope = artifactScopeFor(sessionKey, runId); + const requestedArtifactScope = optionalArtifactScope(params.artifactScope); + if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) { + throw new Error("artifactScope does not match sessionKey/runId"); + } + const workspaceDir = resolveWorkspaceDir({ + config: input.config, + pluginConfig, + params, + sessionKey, + }); + const workspaceRoot = await fs.realpath(workspaceDir); + const artifactScope = requestedArtifactScope || expectedArtifactScope; + const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope); + const snapshotRoot = path.join(scopeRoot, "artifacts"); + if (!isWithinRoot(scopeRoot, snapshotRoot)) { + throw new Error("snapshotDirectory must stay inside artifactScope"); + } + await fs.mkdir(snapshotRoot, { recursive: true }); + const warnings = []; + const copiedFiles = []; + for (const source of openClawSnapshotSources(params, pluginConfig)) { + if (copiedFiles.length >= maxFiles) { + warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`); + break; + } + const candidates = await collectSnapshotSourceCandidates({ + source, + sinceUnixMs, + warnings, + }); + for (const candidate of candidates) { + if (copiedFiles.length >= maxFiles) { + warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`); + break; + } + const destinationRelativePath = safeSnapshotDestinationRelativePath(source.label, candidate.relativePath); + const destination = path.join(snapshotRoot, destinationRelativePath.split("/").join(path.sep)); + if (!isWithinRoot(snapshotRoot, destination)) { + warnings.push(`skipped unsafe snapshot path ${destinationRelativePath}`); + continue; + } + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(candidate.absolutePath, destination); + copiedFiles.push(`artifacts/${destinationRelativePath}`); + } + } + return { + runId, + sessionKey, + remoteWorkingDirectory: workspaceRoot, + remoteWorkspaceRefKind: "remotePath", + artifactScope, + scopeKind: "task", + artifactDirectory: scopeRoot, + snapshotDirectory: snapshotRoot, + copiedFiles, + warnings, + }; +} export async function exportXWorkmateArtifacts(input) { const params = input.params ?? {}; const pluginConfig = input.pluginConfig ?? {}; @@ -260,7 +327,7 @@ export async function readXWorkmateArtifact(input) { manifestMarkdown: formatArtifactManifestMarkdown(result), }; } -export function formatArtifactManifestMarkdown(input) { +function formatArtifactManifestMarkdown(input) { const lines = [ "## XWorkmate artifacts", "", @@ -347,6 +414,75 @@ async function collectCandidates(input) { } } } +async function collectSnapshotSourceCandidates(input) { + let sourceRoot = ""; + try { + sourceRoot = await fs.realpath(input.source.root); + } + catch (error) { + if (error?.code !== "ENOENT") { + input.warnings.push(`cannot read ${input.source.label}: ${String(error)}`); + } + return []; + } + const candidates = []; + await walk(sourceRoot); + candidates.sort((left, right) => { + if (right.mtimeMs !== left.mtimeMs) { + return right.mtimeMs - left.mtimeMs; + } + return left.relativePath.localeCompare(right.relativePath); + }); + return candidates; + async function walk(currentDir) { + let entries; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } + catch (error) { + input.warnings.push(`cannot read ${input.source.label}/${safeDisplayPath(sourceRoot, currentDir)}: ${String(error)}`); + return; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (entry.name === "." || entry.name === "..") { + continue; + } + const absolutePath = path.join(currentDir, entry.name); + if (entry.isSymbolicLink()) { + input.warnings.push(`skipped symlink ${input.source.label}/${safeDisplayPath(sourceRoot, absolutePath)}`); + continue; + } + if (entry.isDirectory()) { + await walk(absolutePath); + continue; + } + if (!entry.isFile()) { + continue; + } + const stat = await fs.stat(absolutePath); + const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs); + if (changedAtMs < input.sinceUnixMs) { + continue; + } + const realPath = await fs.realpath(absolutePath); + if (!isWithinRoot(sourceRoot, realPath)) { + input.warnings.push(`skipped path outside ${input.source.label}: ${entry.name}`); + continue; + } + const relativePath = safeRelativePath(sourceRoot, realPath); + if (!relativePath) { + continue; + } + candidates.push({ + absolutePath: realPath, + relativePath, + sizeBytes: stat.size, + mtimeMs: changedAtMs, + }); + } + } +} async function loadArtifactIgnoreRules(scopeRoot, warnings) { const rules = [{ kind: "exact", path: ARTIFACT_IGNORE_FILE }]; const ignorePath = path.join(scopeRoot, ARTIFACT_IGNORE_FILE); @@ -648,6 +784,36 @@ function contentTypeForPath(relativePath) { return "application/octet-stream"; } } +function openClawSnapshotSources(params, pluginConfig) { + const configured = Array.isArray(params.snapshotSourceRoots) + ? params.snapshotSourceRoots + : Array.isArray(pluginConfig.snapshotSourceRoots) + ? pluginConfig.snapshotSourceRoots + : undefined; + if (configured) { + return configured + .map((entry, index) => { + const record = objectRecord(entry); + const root = optionalString(record.root); + const label = safeScopeSegment(optionalString(record.label) || `source-${index + 1}`); + return root ? { label, root: expandUserPath(root) } : undefined; + }) + .filter((entry) => Boolean(entry)); + } + return [ + { + label: "media", + root: expandUserPath(optionalString(pluginConfig.openClawMediaDir) || path.join("~", ".openclaw", "media")), + }, + { + label: "tmp-openclaw", + root: expandUserPath(optionalString(pluginConfig.openClawTmpDir) || path.join(os.tmpdir(), "openclaw")), + }, + ]; +} +function safeSnapshotDestinationRelativePath(sourceLabel, sourceRelativePath) { + return [safeScopeSegment(sourceLabel), safeInputRelativePath(sourceRelativePath, "snapshot source path")].join("/"); +} function objectRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value @@ -753,7 +919,6 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) { function artifactRefSigningSecret(pluginConfig) { return (optionalString(pluginConfig.artifactRefSigningSecret) || optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) || - optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) || GENERATED_ARTIFACT_REF_SECRET); } function workspaceRootHash(workspaceRoot) { diff --git a/index.test.ts b/index.test.ts index 34c679c..bcd41a2 100644 --- a/index.test.ts +++ b/index.test.ts @@ -49,6 +49,7 @@ describe("plugin registration", () => { expect(methods.map((entry) => entry.method)).toEqual([ "xworkmate.artifacts.prepare", "xworkmate.artifacts.export", + "xworkmate.artifacts.collect-and-snapshot", "xworkmate.artifacts.list", "xworkmate.artifacts.read", "xworkmate.agents.run", diff --git a/index.ts b/index.ts index 6663044..436b8a8 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,7 @@ import type { } from "openclaw/plugin-sdk/core"; import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime"; import { + collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, @@ -113,6 +114,21 @@ function register(api: OpenClawPluginApi) { }); } }); + api.registerGatewayMethod("xworkmate.artifacts.collect-and-snapshot", async (opts: GatewayRequestHandlerOptions) => { + try { + const payload = await collectAndSnapshotXWorkmateArtifacts({ + params: scopedGatewayParams(opts.params), + config: api.config, + pluginConfig: api.pluginConfig, + }); + opts.respond(true, payload, undefined); + } catch (error) { + opts.respond(false, undefined, { + code: "INVALID_REQUEST", + message: error instanceof Error ? error.message : String(error), + }); + } + }); api.registerGatewayMethod("xworkmate.artifacts.list", async (opts: GatewayRequestHandlerOptions) => { try { const payload = await exportXWorkmateArtifacts({ diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index 5f0e914..1d5f553 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { + collectAndSnapshotXWorkmateArtifacts, exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, @@ -102,6 +103,62 @@ describe("exportXWorkmateArtifacts", () => { expect(result.artifacts).toEqual([]); }); + it("snapshots OpenClaw media and tmp outputs into the current task artifact scope", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); + const mediaRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-media-")); + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-global-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "run-1" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.mkdir(path.join(mediaRoot, "browser"), { recursive: true }); + await fs.mkdir(path.join(tmpRoot, "renders"), { recursive: true }); + const oldFile = path.join(mediaRoot, "browser", "old.png"); + await fs.writeFile(oldFile, "old"); + const snapshotSinceUnixMs = Date.now() + 20; + await new Promise((resolve) => setTimeout(resolve, 30)); + await fs.writeFile(path.join(mediaRoot, "browser", "current.png"), "png"); + await fs.writeFile(path.join(tmpRoot, "renders", "final.mp4"), "mp4"); + + const snapshot = await collectAndSnapshotXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "run-1", + artifactScope: prepared.artifactScope, + sinceUnixMs: snapshotSinceUnixMs, + }, + pluginConfig: { + workspaceDir: root, + snapshotSourceRoots: [ + { label: "media", root: mediaRoot }, + { label: "tmp-openclaw", root: tmpRoot }, + ], + }, + }); + + expect(snapshot.artifactScope).toBe(prepared.artifactScope); + expect(snapshot.copiedFiles.sort()).toEqual([ + "artifacts/media/browser/current.png", + "artifacts/tmp-openclaw/renders/final.mp4", + ]); + await expect(fs.stat(path.join(prepared.artifactDirectory, "artifacts", "media", "browser", "old.png"))).rejects.toThrow(); + + const result = await exportXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "run-1", + artifactScope: prepared.artifactScope, + includeContent: false, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.artifacts.map((artifact) => artifact.relativePath).sort()).toEqual([ + "artifacts/media/browser/current.png", + "artifacts/tmp-openclaw/renders/final.mp4", + ]); + }); + it("skips excluded directories and symlinks", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const prepared = await prepareXWorkmateArtifacts({ diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index d128b68..7aa302e 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -59,6 +59,19 @@ export type XWorkmateArtifactPrepare = { warnings: string[]; }; +export type XWorkmateArtifactSnapshot = { + runId: string; + sessionKey: string; + remoteWorkingDirectory: string; + remoteWorkspaceRefKind: "remotePath"; + artifactScope: string; + scopeKind: "task"; + artifactDirectory: string; + snapshotDirectory: string; + copiedFiles: string[]; + warnings: string[]; +}; + type ExportInput = { params: Record; config?: unknown; @@ -125,6 +138,76 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise { + const params = input.params ?? {}; + const pluginConfig = input.pluginConfig ?? {}; + const runId = requiredString(params.runId, "runId required"); + const sessionKey = requiredString(params.sessionKey, "sessionKey required"); + const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); + const maxFiles = positiveInteger(params.maxFiles, pluginConfig.snapshotMaxFiles, DEFAULT_MAX_FILES); + const expectedArtifactScope = artifactScopeFor(sessionKey, runId); + const requestedArtifactScope = optionalArtifactScope(params.artifactScope); + if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) { + throw new Error("artifactScope does not match sessionKey/runId"); + } + const workspaceDir = resolveWorkspaceDir({ + config: input.config, + pluginConfig, + params, + sessionKey, + }); + const workspaceRoot = await fs.realpath(workspaceDir); + const artifactScope = requestedArtifactScope || expectedArtifactScope; + const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope); + const snapshotRoot = path.join(scopeRoot, "artifacts"); + if (!isWithinRoot(scopeRoot, snapshotRoot)) { + throw new Error("snapshotDirectory must stay inside artifactScope"); + } + await fs.mkdir(snapshotRoot, { recursive: true }); + + const warnings: string[] = []; + const copiedFiles: string[] = []; + for (const source of openClawSnapshotSources(params, pluginConfig)) { + if (copiedFiles.length >= maxFiles) { + warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`); + break; + } + const candidates = await collectSnapshotSourceCandidates({ + source, + sinceUnixMs, + warnings, + }); + for (const candidate of candidates) { + if (copiedFiles.length >= maxFiles) { + warnings.push(`snapshot file limit reached; skipped remaining files after ${maxFiles}`); + break; + } + const destinationRelativePath = safeSnapshotDestinationRelativePath(source.label, candidate.relativePath); + const destination = path.join(snapshotRoot, destinationRelativePath.split("/").join(path.sep)); + if (!isWithinRoot(snapshotRoot, destination)) { + warnings.push(`skipped unsafe snapshot path ${destinationRelativePath}`); + continue; + } + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(candidate.absolutePath, destination); + copiedFiles.push(`artifacts/${destinationRelativePath}`); + } + } + + return { + runId, + sessionKey, + remoteWorkingDirectory: workspaceRoot, + remoteWorkspaceRefKind: "remotePath", + artifactScope, + scopeKind: "task", + artifactDirectory: scopeRoot, + snapshotDirectory: snapshotRoot, + copiedFiles, + warnings, + }; +} + export async function exportXWorkmateArtifacts(input: ExportInput): Promise { const params = input.params ?? {}; const pluginConfig = input.pluginConfig ?? {}; @@ -460,6 +543,84 @@ async function collectCandidates(input: { } } +type SnapshotSource = { + label: string; + root: string; +}; + +async function collectSnapshotSourceCandidates(input: { + source: SnapshotSource; + sinceUnixMs: number; + warnings: string[]; +}): Promise { + let sourceRoot = ""; + try { + sourceRoot = await fs.realpath(input.source.root); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + input.warnings.push(`cannot read ${input.source.label}: ${String(error)}`); + } + return []; + } + const candidates: Candidate[] = []; + await walk(sourceRoot); + candidates.sort((left, right) => { + if (right.mtimeMs !== left.mtimeMs) { + return right.mtimeMs - left.mtimeMs; + } + return left.relativePath.localeCompare(right.relativePath); + }); + return candidates; + + async function walk(currentDir: string): Promise { + let entries; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch (error) { + input.warnings.push(`cannot read ${input.source.label}/${safeDisplayPath(sourceRoot, currentDir)}: ${String(error)}`); + return; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (entry.name === "." || entry.name === "..") { + continue; + } + const absolutePath = path.join(currentDir, entry.name); + if (entry.isSymbolicLink()) { + input.warnings.push(`skipped symlink ${input.source.label}/${safeDisplayPath(sourceRoot, absolutePath)}`); + continue; + } + if (entry.isDirectory()) { + await walk(absolutePath); + continue; + } + if (!entry.isFile()) { + continue; + } + const stat = await fs.stat(absolutePath); + const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs); + if (changedAtMs < input.sinceUnixMs) { + continue; + } + const realPath = await fs.realpath(absolutePath); + if (!isWithinRoot(sourceRoot, realPath)) { + input.warnings.push(`skipped path outside ${input.source.label}: ${entry.name}`); + continue; + } + const relativePath = safeRelativePath(sourceRoot, realPath); + if (!relativePath) { + continue; + } + candidates.push({ + absolutePath: realPath, + relativePath, + sizeBytes: stat.size, + mtimeMs: changedAtMs, + }); + } + } +} + type ArtifactIgnoreRule = | { kind: "directory"; path: string } | { kind: "exact"; path: string } @@ -799,6 +960,38 @@ function contentTypeForPath(relativePath: string): string { } } +function openClawSnapshotSources(params: Record, pluginConfig: Record): SnapshotSource[] { + const configured = Array.isArray(params.snapshotSourceRoots) + ? params.snapshotSourceRoots + : Array.isArray(pluginConfig.snapshotSourceRoots) + ? pluginConfig.snapshotSourceRoots + : undefined; + if (configured) { + return configured + .map((entry, index) => { + const record = objectRecord(entry); + const root = optionalString(record.root); + const label = safeScopeSegment(optionalString(record.label) || `source-${index + 1}`); + return root ? { label, root: expandUserPath(root) } : undefined; + }) + .filter((entry): entry is SnapshotSource => Boolean(entry)); + } + return [ + { + label: "media", + root: expandUserPath(optionalString(pluginConfig.openClawMediaDir) || path.join("~", ".openclaw", "media")), + }, + { + label: "tmp-openclaw", + root: expandUserPath(optionalString(pluginConfig.openClawTmpDir) || path.join(os.tmpdir(), "openclaw")), + }, + ]; +} + +function safeSnapshotDestinationRelativePath(sourceLabel: string, sourceRelativePath: string): string { + return [safeScopeSegment(sourceLabel), safeInputRelativePath(sourceRelativePath, "snapshot source path")].join("/"); +} + function objectRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record)