1171 lines
47 KiB
TypeScript
1171 lines
47 KiB
TypeScript
import { createHash, createHmac } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
collectAndSnapshotXWorkmateArtifacts,
|
|
exportXWorkmateArtifacts,
|
|
prepareXWorkmateArtifacts,
|
|
readXWorkmateArtifact,
|
|
} from "./exportArtifacts.js";
|
|
|
|
describe("exportXWorkmateArtifacts", () => {
|
|
it("prepares isolated task artifact scopes", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
|
|
const first = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const second = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(first.artifactScope).toBe("tasks/thread-main/turn-1");
|
|
expect(second.artifactScope).toBe("tasks/thread-main/turn-2");
|
|
expect(first.artifactScope).not.toBe(second.artifactScope);
|
|
expect((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true);
|
|
expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
|
expect(first.scopeKind).toBe("task");
|
|
});
|
|
|
|
it("rejects legacy sessionKey artifact params", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
|
|
await expect(
|
|
prepareXWorkmateArtifacts({
|
|
params: { sessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("openclawSessionKey required");
|
|
});
|
|
|
|
it("normalizes task scope segments like the OpenClaw session scope runtime", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "agent::main:main", runId: "run alpha" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(prepared.artifactScope).toBe("tasks/agent_main_main/run_alpha");
|
|
});
|
|
|
|
it("exports changed files with metadata and base64 content", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
|
|
const filePath = path.join(prepared.artifactDirectory, "reports", "final.md");
|
|
await fs.writeFile(filePath, "# Done\n");
|
|
const stat = await fs.stat(filePath);
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
sinceUnixMs: stat.mtimeMs - 1,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
|
expect(result.remoteWorkspaceRefKind).toBe("remotePath");
|
|
expect(result.scopeKind).toBe("task");
|
|
expect(result.artifactScope).toBe(prepared.artifactScope);
|
|
expect(result.artifacts).toHaveLength(1);
|
|
expect(result.artifacts[0]).toMatchObject({
|
|
relativePath: "reports/final.md",
|
|
label: "final.md",
|
|
contentType: "text/markdown",
|
|
sizeBytes: Buffer.byteLength("# Done\n"),
|
|
sha256: createHash("sha256").update("# Done\n").digest("hex"),
|
|
encoding: "base64",
|
|
content: Buffer.from("# Done\n").toString("base64"),
|
|
});
|
|
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
|
});
|
|
|
|
it("preserves expected artifact directories even when they do not exist", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-expected",
|
|
expectedArtifactDirs: ["artifacts/", "assets/images"],
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-expected",
|
|
artifactScope: prepared.artifactScope,
|
|
expectedArtifactDirs: ["artifacts/", "assets/images"],
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(prepared.expectedArtifactDirs).toEqual(["artifacts/", "assets/images/"]);
|
|
expect(result.expectedArtifactDirs).toEqual(["artifacts/", "assets/images/"]);
|
|
expect(result.expectedArtifactDirStatus).toEqual([
|
|
{ relativePath: "artifacts/", exists: false },
|
|
{ relativePath: "assets/images/", exists: false },
|
|
]);
|
|
});
|
|
|
|
it("rejects unsafe expected artifact directories", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
|
|
await expect(
|
|
prepareXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-unsafe",
|
|
expectedArtifactDirs: ["../outside"],
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("expectedArtifactDir must stay inside the workspace");
|
|
});
|
|
|
|
it("satisfies required artifact extensions when a matching artifact is exported", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-required" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "final.PDF"), "pdf");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-required",
|
|
requiredArtifactExtensions: [".pdf"],
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.constraintSatisfied).toBe(true);
|
|
expect(result.missingRequiredExtensions).toEqual([]);
|
|
});
|
|
|
|
it("reports missing required artifact extensions", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-missing-required" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-missing-required",
|
|
requiredArtifactExtensions: ["pdf"],
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.constraintSatisfied).toBe(false);
|
|
expect(result.missingRequiredExtensions).toEqual(["pdf"]);
|
|
});
|
|
|
|
it("reports missing required artifact file counts", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-missing-count" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "assets", "images"), { recursive: true });
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "assets", "images", "001.png"), "png");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-missing-count",
|
|
requiredArtifactExtensions: ["png"],
|
|
expectedFileCountByExtension: { png: 7 },
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.constraintSatisfied).toBe(false);
|
|
expect(result.missingRequiredExtensions).toEqual([]);
|
|
expect(result.missingRequiredFileCounts).toEqual({ png: { expected: 7, actual: 1 } });
|
|
});
|
|
|
|
it("treats an empty required artifact extension list as satisfied", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-empty-required" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "notes.md"), "notes");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-empty-required",
|
|
requiredArtifactExtensions: [],
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.constraintSatisfied).toBe(true);
|
|
expect(result.missingRequiredExtensions).toEqual([]);
|
|
});
|
|
|
|
it("keeps required-extension artifacts before truncating export results", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-required-limit" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "final.mp4"), "mp4");
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
for (let index = 0; index < 64; index += 1) {
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, `part-${String(index).padStart(2, "0")}.txt`), "txt");
|
|
}
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-required-limit",
|
|
requiredArtifactExtensions: ["mp4"],
|
|
maxFiles: 64,
|
|
maxInlineBytes: 0,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts).toHaveLength(64);
|
|
expect(result.artifacts.some((entry) => entry.relativePath === "final.mp4")).toBe(true);
|
|
expect(result.constraintSatisfied).toBe(true);
|
|
expect(result.missingRequiredExtensions).toEqual([]);
|
|
expect(result.warnings).toContain("artifact limit reached; skipped remaining files after 64");
|
|
});
|
|
|
|
it("keeps mtime and relative path ordering when no required extensions are provided", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-no-required-order" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "older.txt"), "older");
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "newer-b.txt"), "newer");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "newer-a.txt"), "newer");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-no-required-order",
|
|
maxInlineBytes: 0,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([
|
|
"newer-a.txt",
|
|
"newer-b.txt",
|
|
"older.txt",
|
|
]);
|
|
expect(result.constraintSatisfied).toBe(true);
|
|
expect(result.missingRequiredExtensions).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: { openclawSessionKey: "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: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
artifactScope: prepared.artifactScope,
|
|
sinceUnixMs: snapshotSinceUnixMs,
|
|
},
|
|
pluginConfig: {
|
|
workspaceDir: root,
|
|
openClawMediaDir: mediaRoot,
|
|
openClawTmpDir: 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: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
artifactScope: prepared.artifactScope,
|
|
includeContent: false,
|
|
},
|
|
pluginConfig: { workspaceDir: root, openClawMediaDir: mediaRoot, openClawTmpDir: tmpRoot },
|
|
});
|
|
|
|
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({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, ".git"), { recursive: true });
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, ".xworkmate", "artifacts"), { recursive: true });
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, ".git", "secret.txt"), "secret");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, ".xworkmate", "artifacts", "index.json"), "{}");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "real.txt"), "real");
|
|
await fs.symlink(path.join(prepared.artifactDirectory, "real.txt"), path.join(prepared.artifactDirectory, "linked.txt"));
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["real.txt"]);
|
|
expect(result.warnings.some((entry) => entry.includes("linked.txt"))).toBe(true);
|
|
});
|
|
|
|
it("applies artifact-ignore.md inside the current task scope", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "tmp"), { recursive: true });
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "reports", "debug"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(prepared.artifactDirectory, "artifact-ignore.md"),
|
|
[
|
|
"# Artifact Ignore",
|
|
"",
|
|
"```artifact-ignore",
|
|
"tmp/",
|
|
"*.log",
|
|
"**/*.tmp",
|
|
"reports/debug/trace.json",
|
|
"```",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "tmp", "scratch.md"), "scratch");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "root.log"), "log");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "draft.tmp"), "tmp");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "debug", "trace.json"), "{}");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "final.md"), "final");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/final.md"]);
|
|
});
|
|
|
|
it("exports only files inside a task artifact scope", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const first = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const second = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(first.artifactDirectory, "reports"), { recursive: true });
|
|
await fs.writeFile(path.join(first.artifactDirectory, "reports", "first.txt"), "first");
|
|
await fs.writeFile(path.join(second.artifactDirectory, "second.txt"), "second");
|
|
await fs.writeFile(path.join(root, "global.txt"), "global");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: first.artifactScope,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.scopeKind).toBe("task");
|
|
expect(result.artifactScope).toBe(first.artifactScope);
|
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/first.txt"]);
|
|
expect(result.artifacts[0]).toMatchObject({
|
|
artifactScope: first.artifactScope,
|
|
scopeKind: "task",
|
|
});
|
|
});
|
|
|
|
it("exports nested dist and build deliverables inside the task scope", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "dist"), { recursive: true });
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "build", "assets"), { recursive: true });
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "dist", "final.pdf"), "pdf");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "build", "assets", "cover.png"), "png");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
maxInlineBytes: 0,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([
|
|
"build/assets/cover.png",
|
|
"dist/final.pdf",
|
|
]);
|
|
});
|
|
|
|
it("uses the current task scope when artifactScope is omitted", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const current = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const other = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(root, "global.txt"), "global");
|
|
await fs.writeFile(path.join(current.artifactDirectory, "current.txt"), "current");
|
|
await fs.writeFile(path.join(other.artifactDirectory, "other.txt"), "other");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.scopeKind).toBe("task");
|
|
expect(result.artifactScope).toBe(current.artifactScope);
|
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["current.txt"]);
|
|
});
|
|
|
|
it("does not scan the workspace root without a current-run timestamp", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(root, "global.txt"), "global");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.scopeKind).toBe("task");
|
|
expect(result.artifacts).toEqual([]);
|
|
});
|
|
|
|
it("does not adopt workspace root files even with a current-run timestamp", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const sinceUnixMs = Date.now() - 1_000;
|
|
await fs.writeFile(path.join(root, "article.docx"), "docx-content");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "draft-article",
|
|
runId: "openclaw-run-1",
|
|
sinceUnixMs,
|
|
maxInlineBytes: 0,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifactScope).toBe("tasks/draft-article/openclaw-run-1");
|
|
expect(result.artifacts).toEqual([]);
|
|
await expect(fs.stat(path.join(root, "tasks", "draft-article", "openclaw-run-1", "article.docx"))).rejects.toThrow();
|
|
});
|
|
|
|
it("exports explicitly expected artifact dirs when the task scope is empty", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "draft-article", runId: "openclaw-run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(root, "assets", "images"), { recursive: true });
|
|
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
|
await fs.writeFile(path.join(root, "assets", "images", "cover.png"), "png");
|
|
await fs.writeFile(path.join(root, "reports", "final.md"), "final");
|
|
await fs.writeFile(path.join(root, "scratch.txt"), "scratch");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "draft-article",
|
|
runId: "openclaw-run-1",
|
|
artifactScope: prepared.artifactScope,
|
|
expectedArtifactDirs: ["assets/images", "reports"],
|
|
sinceUnixMs: Date.now() - 1_000,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts.map((entry) => entry.relativePath).sort()).toEqual([
|
|
"assets/images/cover.png",
|
|
"reports/final.md",
|
|
]);
|
|
expect(result.artifacts.every((entry) => entry.artifactScope === prepared.artifactScope)).toBe(true);
|
|
expect(result.artifacts.every((entry) => entry.scopeKind === "task")).toBe(true);
|
|
});
|
|
|
|
it("keeps scoped artifacts authoritative over expected artifact dirs", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "draft-article", runId: "openclaw-run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "scoped.md"), "scoped");
|
|
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
|
await fs.writeFile(path.join(root, "reports", "root.md"), "root");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "draft-article",
|
|
runId: "openclaw-run-1",
|
|
artifactScope: prepared.artifactScope,
|
|
expectedArtifactDirs: ["reports"],
|
|
sinceUnixMs: Date.now() - 1_000,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/scoped.md"]);
|
|
});
|
|
|
|
it("does not adopt old workspace root files into a later task scope", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(root, "old-root.md"), "old");
|
|
const stat = await fs.stat(path.join(root, "old-root.md"));
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
sinceUnixMs: stat.mtimeMs + 10_000,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts).toEqual([]);
|
|
await expect(fs.stat(path.join(root, "tasks", "thread-main", "turn-1", "old-root.md"))).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects scoped exports that do not match the requested session/run", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const first = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
await expect(
|
|
exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-2",
|
|
artifactScope: first.artifactScope,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
|
});
|
|
|
|
it("does not adopt old workspace files when the scoped directory is empty", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const otherTask = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-2" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(root, "existing.pdf"), "pdf");
|
|
await fs.writeFile(path.join(otherTask.artifactDirectory, "other-task.txt"), "other");
|
|
await fs.mkdir(path.join(root, ".xworkmate", "metadata"), { recursive: true });
|
|
await fs.writeFile(path.join(root, ".xworkmate", "metadata", "internal.json"), "{}");
|
|
const stat = await fs.stat(path.join(root, "existing.pdf"));
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: prepared.artifactScope,
|
|
sinceUnixMs: stat.mtimeMs + 10_000,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.scopeKind).toBe("task");
|
|
expect(result.artifactScope).toBe(prepared.artifactScope);
|
|
expect(result.artifacts).toEqual([]);
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it("does not borrow previous session task files when current task scope is empty", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const previousTask = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-previous" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-follow-up" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.pdf"), "pdf");
|
|
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.docx"), "docx");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-follow-up",
|
|
sinceUnixMs: Date.now() + 10_000,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.scopeKind).toBe("task");
|
|
expect(result.artifactScope).toBe("tasks/thread-main/turn-follow-up");
|
|
expect(result.artifacts).toEqual([]);
|
|
expect(result.warnings).toEqual([]);
|
|
});
|
|
|
|
it("does not adopt same-thread delivery files when the prepared task scope is empty", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
await prepareXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "draft:1779524982823421-3",
|
|
runId: "turn-1779685283403237342",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const threadRoot = path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:1779524982823421-3");
|
|
await fs.mkdir(path.join(threadRoot, "renders"), { recursive: true });
|
|
await fs.mkdir(path.join(threadRoot, "assets", "images", "security-identity-evolution"), { recursive: true });
|
|
await fs.writeFile(path.join(threadRoot, "renders", "cloud-native-servicemesh-network.mp4"), "mp4");
|
|
await fs.writeFile(
|
|
path.join(threadRoot, "assets", "images", "security-identity-evolution", "001-local-permission.png"),
|
|
"png",
|
|
);
|
|
await fs.writeFile(path.join(threadRoot, "assets", "images", "manifest.md"), "manifest");
|
|
await fs.writeFile(path.join(threadRoot, "DELIVERY.md"), "delivered");
|
|
await fs.writeFile(path.join(threadRoot, "scratch.txt"), "scratch");
|
|
await fs.symlink(threadRoot, path.join(threadRoot, "venv"));
|
|
await fs.mkdir(path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:other", "renders"), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:other", "renders", "other.mp4"),
|
|
"other",
|
|
);
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "draft:1779524982823421-3",
|
|
runId: "turn-1779685283403237342",
|
|
sinceUnixMs: Date.now() + 10_000,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifactScope).toBe("tasks/draft_1779524982823421-3/turn-1779685283403237342");
|
|
expect(result.artifacts).toEqual([]);
|
|
await expect(
|
|
fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "scratch.txt")),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
fs.stat(
|
|
path.join(
|
|
root,
|
|
"tasks",
|
|
"draft_1779524982823421-3",
|
|
"turn-1779685283403237342",
|
|
"renders",
|
|
"cloud-native-servicemesh-network.mp4",
|
|
),
|
|
),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "renders", "other.mp4")),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("does not adopt same-thread delivery files without a current-run timestamp", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
await prepareXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "draft:1779524982823421-3",
|
|
runId: "turn-1779685283403237342",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const threadRoot = path.join(root, "owners", "local", "user", "owner-hash", "threads", "draft:1779524982823421-3");
|
|
await fs.mkdir(path.join(threadRoot, "renders"), { recursive: true });
|
|
await fs.writeFile(path.join(threadRoot, "renders", "cloud-native-servicemesh-network.mp4"), "mp4");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "draft:1779524982823421-3",
|
|
runId: "turn-1779685283403237342",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts).toEqual([]);
|
|
await expect(
|
|
fs.stat(
|
|
path.join(
|
|
root,
|
|
"tasks",
|
|
"draft_1779524982823421-3",
|
|
"turn-1779685283403237342",
|
|
"renders",
|
|
"cloud-native-servicemesh-network.mp4",
|
|
),
|
|
),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("exports concurrent task scopes independently", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await Promise.all([
|
|
prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-a", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-b", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-a", runId: "turn-2" },
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
]);
|
|
await fs.writeFile(path.join(prepared[0].artifactDirectory, "a-1.txt"), "a1");
|
|
await fs.writeFile(path.join(prepared[1].artifactDirectory, "b-1.txt"), "b1");
|
|
await fs.writeFile(path.join(prepared[2].artifactDirectory, "a-2.txt"), "a2");
|
|
|
|
const results = await Promise.all([
|
|
exportXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-a", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
exportXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-b", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
exportXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-a", runId: "turn-2" },
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
]);
|
|
|
|
expect(results.map((result) => result.artifacts.map((entry) => entry.relativePath))).toEqual([
|
|
["a-1.txt"],
|
|
["b-1.txt"],
|
|
["a-2.txt"],
|
|
]);
|
|
expect(results.map((result) => result.artifactScope)).toEqual(prepared.map((entry) => entry.artifactScope));
|
|
});
|
|
|
|
it("leaves oversized artifacts out of inline content", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "large.pdf"), Buffer.from("large-content"));
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
maxInlineBytes: 2,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts[0]?.relativePath).toBe("large.pdf");
|
|
expect(result.artifacts[0]?.encoding).toBeUndefined();
|
|
expect(result.artifacts[0]?.content).toBeUndefined();
|
|
expect(result.warnings).toContain("large.pdf exceeds maxInlineBytes and was not inlined");
|
|
});
|
|
|
|
it("can list artifacts without inline content", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "small.txt"), "small");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
maxInlineBytes: 0,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts[0]?.relativePath).toBe("small.txt");
|
|
expect(result.artifacts[0]?.encoding).toBeUndefined();
|
|
expect(result.artifacts[0]?.content).toBeUndefined();
|
|
expect(result.warnings).toContain("small.txt exceeds maxInlineBytes and was not inlined");
|
|
});
|
|
|
|
it("limits exported files", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "a.txt"), "a");
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "b.txt"), "b");
|
|
|
|
const result = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
maxFiles: 1,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts).toHaveLength(1);
|
|
expect(result.warnings).toContain("artifact limit reached; skipped remaining files after 1");
|
|
});
|
|
|
|
it("rejects unscoped artifact reads by relative path", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
|
await fs.writeFile(path.join(root, "reports", "final.txt"), "final");
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
relativePath: "reports/final.txt",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("artifactScope or artifactRef required");
|
|
});
|
|
|
|
it("reads one artifact inside a task artifact scope", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "reports", "final.txt"), "final");
|
|
|
|
const result = await readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: prepared.artifactScope,
|
|
relativePath: "reports/final.txt",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifactScope).toBe(prepared.artifactScope);
|
|
expect(result.scopeKind).toBe("task");
|
|
expect(result.artifacts[0]).toMatchObject({
|
|
artifactScope: prepared.artifactScope,
|
|
relativePath: "reports/final.txt",
|
|
scopeKind: "task",
|
|
encoding: "base64",
|
|
content: Buffer.from("final").toString("base64"),
|
|
});
|
|
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
|
});
|
|
|
|
it("rejects direct reads from another run artifact scope", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const first = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(first.artifactDirectory, "first.txt"), "first");
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-2",
|
|
artifactScope: first.artifactScope,
|
|
relativePath: "first.txt",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
|
});
|
|
|
|
it("rejects signed task artifact refs from another session", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "first.txt"), "first");
|
|
const exported = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: prepared.artifactScope,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-other",
|
|
runId: "turn-1",
|
|
artifactRef: exported.artifacts[0]?.artifactRef,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("artifactRef does not match sessionKey/runId");
|
|
});
|
|
|
|
it("rejects signed task artifact refs from another run", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
|
|
|
|
const exported = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: prepared.artifactScope,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-2",
|
|
artifactRef: exported.artifacts[0]?.artifactRef,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("artifactRef does not match sessionKey/runId");
|
|
});
|
|
|
|
it("rejects tampered artifact refs", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "run-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
|
|
const exported = await exportXWorkmateArtifacts({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
const artifactRef = exported.artifacts[0]?.artifactRef ?? "";
|
|
const tampered = `${artifactRef}x`;
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
artifactRef: tampered,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("invalid artifactRef");
|
|
});
|
|
|
|
it("rejects legacy v1 artifact refs", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const secret = "test-secret";
|
|
const legacyPayload = Buffer.from(
|
|
JSON.stringify({
|
|
v: 1,
|
|
workspaceRootHash: createHash("sha256").update(path.resolve(root)).digest("hex"),
|
|
scopeKind: "task",
|
|
relativePath: "existing.txt",
|
|
sizeBytes: 8,
|
|
sha256: createHash("sha256").update("existing").digest("hex"),
|
|
}),
|
|
"utf8",
|
|
).toString("base64url");
|
|
const signature = createHmac("sha256", secret).update(legacyPayload).digest("base64url");
|
|
const legacyRef = `${legacyPayload}.${signature}`;
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
artifactRef: legacyRef,
|
|
},
|
|
pluginConfig: { workspaceDir: root, artifactRefSigningSecret: secret },
|
|
}),
|
|
).rejects.toThrow("invalid artifactRef");
|
|
});
|
|
|
|
it("reads artifact metadata without inline content when the file exceeds the limit", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.writeFile(path.join(prepared.artifactDirectory, "large.bin"), Buffer.from("large-content"));
|
|
|
|
const result = await readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: prepared.artifactScope,
|
|
relativePath: "large.bin",
|
|
maxInlineBytes: 2,
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
expect(result.artifacts).toHaveLength(1);
|
|
expect(result.artifacts[0]).toMatchObject({
|
|
relativePath: "large.bin",
|
|
contentType: "application/octet-stream",
|
|
sizeBytes: Buffer.byteLength("large-content"),
|
|
sha256: createHash("sha256").update("large-content").digest("hex"),
|
|
});
|
|
expect(result.artifacts[0]?.encoding).toBeUndefined();
|
|
expect(result.artifacts[0]?.content).toBeUndefined();
|
|
expect(result.warnings).toContain("large.bin exceeds maxInlineBytes and was not inlined");
|
|
});
|
|
|
|
it("rejects relative path traversal when reading artifacts", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: prepared.artifactScope,
|
|
relativePath: "../outside.txt",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("relativePath must stay inside the workspace");
|
|
});
|
|
|
|
it("rejects artifact scope traversal when reading artifacts", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "run-1",
|
|
artifactScope: "../outside",
|
|
relativePath: "secret.txt",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("artifactScope must stay inside the workspace");
|
|
});
|
|
|
|
it("rejects symlink escapes when reading artifacts", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
|
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-outside-"));
|
|
const outsideFile = path.join(outsideRoot, "secret.txt");
|
|
await fs.writeFile(outsideFile, "secret");
|
|
const prepared = await prepareXWorkmateArtifacts({
|
|
params: { openclawSessionKey: "thread-main", runId: "turn-1" },
|
|
pluginConfig: { workspaceDir: root },
|
|
});
|
|
await fs.symlink(outsideFile, path.join(prepared.artifactDirectory, "linked-secret.txt"));
|
|
|
|
await expect(
|
|
readXWorkmateArtifact({
|
|
params: {
|
|
openclawSessionKey: "thread-main",
|
|
runId: "turn-1",
|
|
artifactScope: prepared.artifactScope,
|
|
relativePath: "linked-secret.txt",
|
|
},
|
|
pluginConfig: { workspaceDir: root },
|
|
}),
|
|
).rejects.toThrow("relativePath must stay inside the workspace");
|
|
});
|
|
});
|