fix: align openclaw artifact task scopes

This commit is contained in:
Haitao Pan 2026-05-07 17:12:50 +08:00
parent bbc21098f7
commit 88b073d9d3
7 changed files with 406 additions and 101 deletions

View File

@ -70,6 +70,9 @@ Prepare request params are supplied by the OpenClaw host, bridge, or APP
runtime. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
trusted mapping into OpenClaw's built-in multi-session model; it does not parse
paths from chat text and does not invent fallback session/run identities.
Gateway methods accept these fields from bridge/app runtime params. The optional
agent tool does not expose these fields to the model; it only uses host-injected
tool context.
```json
{
@ -136,20 +139,21 @@ Export response payload:
```
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
When scoped export finds no task files and `latestIfEmpty` is true, the plugin scans
the workspace root for the latest real files and returns them with `scopeKind:
"workspace-latest"`. This is a controlled recovery path for existing files already
present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and
runtime directories, including the top-level `tasks/` directory so other runs are
not exported as workspace fallback files.
When `artifactScope` is omitted, export/list defaults to the current task scope
derived from `sessionKey/runId`. When that current task scope has no files and
`latestIfEmpty` is true, the plugin scans the workspace root for the latest real
files and returns them with `scopeKind: "workspace-latest"`. This is a controlled
recovery path for existing files already present in `/home/ubuntu/.openclaw/workspace`;
it still skips plugin metadata and runtime directories, including the top-level
`tasks/` directory so other runs are not exported as workspace fallback files.
Each exported artifact includes `artifactRef`, a plugin-signed reference over
the artifact scope, path, size, and SHA-256 digest. `read` accepts
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
`artifactScope + relativePath` for the current `sessionKey/runId` task scope.
Signed task `artifactRef` values are accepted for the current session, including
same-session historical task fallback results returned by the plugin. Workspace
fallback files must be read with `artifactRef`; there is no unscoped arbitrary
workspace read API.
fallback files must be read with a same-session and same-run `artifactRef`; there
is no unscoped arbitrary workspace read API.
## View And Download
@ -196,7 +200,9 @@ only remote file access path.
- Symlinks are skipped to avoid workspace escape.
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
- `export` and `list` default to the current task scope when `artifactScope` is omitted.
- Direct `artifactScope + relativePath` reads and scoped exports must match the supplied `sessionKey/runId`.
- `artifactRef` is bound to the issued session/run and cannot be reused from another run.
- `artifactScope`, `artifactRef`, and `relativePath` must stay inside the workspace; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
## Development

21
dist/index.js vendored
View File

@ -102,18 +102,6 @@ function createXWorkmateArtifactsTool(api, ctx) {
type: "string",
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
},
sessionKey: {
type: "string",
description: "OpenClaw session key supplied by the host or bridge runtime.",
},
runId: {
type: "string",
description: "OpenClaw run id supplied by the host or bridge runtime.",
},
workspaceDir: {
type: "string",
description: "OpenClaw workspace directory supplied by the host or bridge runtime.",
},
sinceUnixMs: {
type: "number",
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
@ -131,17 +119,18 @@ function createXWorkmateArtifactsTool(api, ctx) {
},
async execute(_id, params) {
const action = typeof params.action === "string" ? params.action : "";
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ctx.sessionKey;
const runId = typeof params.runId === "string" ? params.runId : "";
const workspaceDir = typeof params.workspaceDir === "string" ? params.workspaceDir : ctx.workspaceDir;
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
const baseParams = {
...params,
...operationParams,
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),

View File

@ -69,19 +69,20 @@ export async function exportXWorkmateArtifacts(input) {
});
const workspaceRoot = await fs.realpath(workspaceDir);
const warnings = [];
const artifactScope = optionalArtifactScope(params.artifactScope);
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
if (artifactScope && artifactScope !== expectedArtifactScope) {
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
throw new Error("artifactScope does not match sessionKey/runId");
}
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopedExport = artifactScope !== "";
let scopeKind = scopedExport ? "task" : "workspace";
const sessionScope = taskSessionScopeFor(sessionKey);
const artifactScope = requestedArtifactScope || expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
let scopeKind = "task";
let candidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs,
skipTaskScopeRoot: !scopedExport,
skipTaskScopeRoot: false,
warnings,
});
if (candidates.length === 0 && latestIfEmpty) {
@ -101,11 +102,11 @@ export async function exportXWorkmateArtifacts(input) {
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
if (scopedExport) {
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
if (latestTaskScopeIfEmpty) {
warnings.push("scoped artifact directory is empty; exported latest session task files instead");
}
else if (latestTaskScopeIfEmpty) {
warnings.push("workspace export is empty; exported latest session task files instead");
else {
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
@ -134,9 +135,11 @@ export async function exportXWorkmateArtifacts(input) {
sizeBytes: bytes.byteLength,
sha256,
artifactRef: signArtifactRef({
v: 1,
v: 2,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind: scopeKindForCandidate,
sessionScope,
runScope: expectedArtifactScope,
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
? { artifactScope: artifactScopeForCandidate }
: {}),
@ -163,7 +166,7 @@ export async function exportXWorkmateArtifacts(input) {
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
...(scopeKind === "task" ? { artifactScope } : {}),
scopeKind,
artifacts,
warnings,
@ -194,6 +197,7 @@ export async function readXWorkmateArtifact(input) {
const workspaceRoot = await fs.realpath(workspaceDir);
if (requestedArtifactRef) {
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
assertArtifactRefMatchesRequest(refPayload, expectedArtifactScope, expectedSessionScope);
relativePath = refPayload.relativePath;
if (refPayload.artifactScope) {
artifactScope = refPayload.artifactScope;
@ -245,9 +249,11 @@ export async function readXWorkmateArtifact(input) {
sha256,
artifactRef: requestedArtifactRef ||
signArtifactRef({
v: 1,
v: 2,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
sessionScope: expectedSessionScope,
runScope: expectedArtifactScope,
...(artifactScope ? { artifactScope } : {}),
relativePath: safeRelativePath(scopeRoot, realPath),
sizeBytes: bytes.byteLength,
@ -419,15 +425,18 @@ function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope,
}
throw new Error("artifactScope does not match sessionKey/runId");
}
function assertArtifactRefMatchesRequest(payload, expectedRunScope, expectedSessionScope) {
if (payload.sessionScope !== expectedSessionScope || payload.runScope !== expectedRunScope) {
throw new Error("artifactRef does not match sessionKey/runId");
}
}
function safeScopeSegment(value) {
const normalized = value
return value
.trim()
.replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 48);
const digest = createHash("sha256").update(value).digest("hex").slice(0, 12);
return `${normalized || "scope"}-${digest}`;
.slice(0, 96) || "scope";
}
function optionalArtifactScope(value) {
const scope = optionalString(value);
@ -446,6 +455,34 @@ function safeTaskArtifactScope(value) {
}
return scope;
}
function safeTaskSessionScope(value) {
const raw = optionalString(value);
if (!raw) {
throw new Error("invalid artifactRef");
}
let scope;
try {
scope = safeInputRelativePath(raw, "artifactRef sessionScope");
}
catch {
throw new Error("invalid artifactRef");
}
const parts = scope.split("/");
const rootParts = TASK_SCOPE_ROOT.split("/");
const scopeRoot = parts.slice(0, rootParts.length).join("/");
if (parts.length !== rootParts.length + 1 || scopeRoot !== TASK_SCOPE_ROOT) {
throw new Error("invalid artifactRef");
}
return scope;
}
function safeArtifactRefRunScope(value) {
try {
return safeTaskArtifactScope(value);
}
catch {
throw new Error("invalid artifactRef");
}
}
function safeInputRelativePath(value, label) {
const relativePath = optionalString(value);
if (!relativePath) {
@ -640,16 +677,23 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
}
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
const sha256 = optionalString(payload.sha256).toLowerCase();
if (payload.v !== 1 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
throw new Error("invalid artifactRef");
}
const sessionScope = safeTaskSessionScope(payload.sessionScope);
const runScope = safeArtifactRefRunScope(payload.runScope);
if (!runScope.startsWith(`${sessionScope}/`)) {
throw new Error("invalid artifactRef");
}
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
throw new Error("artifactRef does not match workspace");
}
return {
v: 1,
v: 2,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
sessionScope,
runScope,
...(artifactScope ? { artifactScope } : {}),
relativePath,
sizeBytes,

View File

@ -4,6 +4,7 @@ import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { prepareXWorkmateArtifacts } from "./src/exportArtifacts.js";
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
@ -63,13 +64,59 @@ describe("plugin registration", () => {
plugin.register(api);
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
parameters: { properties?: Record<string, unknown> };
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
};
const tool = factory({});
await expect(tool.execute("call-1", { action: "list", runId: "turn-1" })).rejects.toThrow("sessionKey required");
await expect(tool.execute("call-2", { action: "list", sessionKey: "thread-main" })).rejects.toThrow(
expect(tool.parameters.properties?.sessionKey).toBeUndefined();
expect(tool.parameters.properties?.runId).toBeUndefined();
expect(tool.parameters.properties?.workspaceDir).toBeUndefined();
await expect(tool.execute("call-1", { action: "list" })).rejects.toThrow("sessionKey required");
await expect(factory({ sessionKey: "thread-main" }).execute("call-2", { action: "list" })).rejects.toThrow(
"runId required",
);
});
it("uses host context scope for the optional agent tool", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-tool-"));
const current = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const other = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await fs.promises.writeFile(path.join(current.artifactDirectory, "current.txt"), "current");
await fs.promises.writeFile(path.join(other.artifactDirectory, "other.txt"), "other");
await fs.promises.writeFile(path.join(root, "global.txt"), "global");
const tools: Array<{ tool: unknown; options: unknown }> = [];
const api = {
config: {},
pluginConfig: {},
registerGatewayMethod: () => undefined,
registerTool: (tool: unknown, options: unknown) => {
tools.push({ tool, options });
},
} as unknown as OpenClawPluginApi;
plugin.register(api);
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>;
};
const tool = factory({ sessionKey: "thread-main", runId: "turn-1", workspaceDir: root });
const result = await tool.execute("call-1", {
action: "list",
sessionKey: "thread-other",
runId: "turn-2",
workspaceDir: "/",
});
expect(result.content[0]?.text).toContain("current.txt");
expect(result.content[0]?.text).not.toContain("other.txt");
expect(result.content[0]?.text).not.toContain("global.txt");
});
});

View File

@ -13,6 +13,12 @@ type XWorkmateToolContext = {
config?: unknown;
workspaceDir?: string;
sessionKey?: string;
runId?: string;
sessionScope?: {
sessionKey?: string;
runId?: string;
workspaceDir?: string;
};
};
const plugin = {
@ -121,18 +127,6 @@ function createXWorkmateArtifactsTool(
type: "string",
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
},
sessionKey: {
type: "string",
description: "OpenClaw session key supplied by the host or bridge runtime.",
},
runId: {
type: "string",
description: "OpenClaw run id supplied by the host or bridge runtime.",
},
workspaceDir: {
type: "string",
description: "OpenClaw workspace directory supplied by the host or bridge runtime.",
},
sinceUnixMs: {
type: "number",
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
@ -150,17 +144,23 @@ function createXWorkmateArtifactsTool(
},
async execute(_id: string, params: Record<string, unknown>) {
const action = typeof params.action === "string" ? params.action : "";
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ctx.sessionKey;
const runId = typeof params.runId === "string" ? params.runId : "";
const workspaceDir = typeof params.workspaceDir === "string" ? params.workspaceDir : ctx.workspaceDir;
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
const runId = ctx.sessionScope?.runId || ctx.runId || "";
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const {
sessionKey: _ignoredSessionKey,
runId: _ignoredRunId,
workspaceDir: _ignoredWorkspaceDir,
...operationParams
} = params;
const baseParams = {
...params,
...operationParams,
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),

View File

@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { createHash, createHmac } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@ -22,8 +22,8 @@ describe("exportXWorkmateArtifacts", () => {
pluginConfig: { workspaceDir: root },
});
expect(first.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-1-[a-f0-9]{12}$/);
expect(second.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-2-[a-f0-9]{12}$/);
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));
@ -32,8 +32,12 @@ describe("exportXWorkmateArtifacts", () => {
it("exports changed files with metadata and base64 content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await fs.mkdir(path.join(root, "reports"), { recursive: true });
const filePath = path.join(root, "reports", "final.md");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "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);
@ -48,6 +52,8 @@ describe("exportXWorkmateArtifacts", () => {
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",
@ -65,7 +71,11 @@ describe("exportXWorkmateArtifacts", () => {
it("filters old files by sinceUnixMs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const oldFile = path.join(root, "old.txt");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
const oldFile = path.join(prepared.artifactDirectory, "old.txt");
await fs.writeFile(oldFile, "old");
const stat = await fs.stat(oldFile);
@ -83,12 +93,16 @@ describe("exportXWorkmateArtifacts", () => {
it("skips excluded directories and symlinks", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await fs.mkdir(path.join(root, ".git"), { recursive: true });
await fs.mkdir(path.join(root, ".xworkmate", "artifacts"), { recursive: true });
await fs.writeFile(path.join(root, ".git", "secret.txt"), "secret");
await fs.writeFile(path.join(root, ".xworkmate", "artifacts", "index.json"), "{}");
await fs.writeFile(path.join(root, "real.txt"), "real");
await fs.symlink(path.join(root, "real.txt"), path.join(root, "linked.txt"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "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: {
@ -135,6 +149,53 @@ describe("exportXWorkmateArtifacts", () => {
});
});
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: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const other = await prepareXWorkmateArtifacts({
params: { sessionKey: "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: {
sessionKey: "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 when the current task scope is empty without latestIfEmpty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "global.txt"), "global");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
},
pluginConfig: { workspaceDir: root },
});
expect(result.scopeKind).toBe("task");
expect(result.artifacts).toEqual([]);
});
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({
@ -229,12 +290,43 @@ describe("exportXWorkmateArtifacts", () => {
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
]);
expect(result.warnings).toContain("workspace export is empty; exported latest session task files instead");
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest session task files instead");
});
it("does not include another session in latest session task fallback", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const sameSessionTask = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-previous" },
pluginConfig: { workspaceDir: root },
});
const otherSessionTask = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-other", runId: "turn-previous" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(sameSessionTask.artifactDirectory, "same.txt"), "same");
await fs.writeFile(path.join(otherSessionTask.artifactDirectory, "other.txt"), "other");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-follow-up",
latestIfEmpty: true,
latestTaskScopeIfEmpty: true,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["same.txt"]);
expect(result.artifacts[0]?.artifactScope).toBe(sameSessionTask.artifactScope);
});
it("leaves oversized artifacts out of inline content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await fs.writeFile(path.join(root, "large.pdf"), Buffer.from("large-content"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "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: {
@ -253,7 +345,11 @@ describe("exportXWorkmateArtifacts", () => {
it("can list artifacts without inline content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await fs.writeFile(path.join(root, "small.txt"), "small");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "small.txt"), "small");
const result = await exportXWorkmateArtifacts({
params: {
@ -272,8 +368,12 @@ describe("exportXWorkmateArtifacts", () => {
it("limits exported files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await fs.writeFile(path.join(root, "a.txt"), "a");
await fs.writeFile(path.join(root, "b.txt"), "b");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "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: {
@ -292,7 +392,11 @@ describe("exportXWorkmateArtifacts", () => {
const mainRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-main-"));
const agentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-agent-"));
await fs.writeFile(path.join(mainRoot, "main.txt"), "main");
await fs.writeFile(path.join(agentRoot, "agent.txt"), "agent");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "agent:research:thread-1", runId: "run-1" },
pluginConfig: { workspaceDir: agentRoot },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "agent.txt"), "agent");
const result = await exportXWorkmateArtifacts({
params: {
@ -404,7 +508,7 @@ describe("exportXWorkmateArtifacts", () => {
},
pluginConfig: { workspaceDir: root },
}),
).rejects.toThrow("artifactScope does not match sessionKey/runId");
).rejects.toThrow("artifactRef does not match sessionKey/runId");
});
it("reads a latest workspace artifact only through its artifactRef", async () => {
@ -445,9 +549,44 @@ describe("exportXWorkmateArtifacts", () => {
});
});
it("rejects latest workspace 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: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "existing.txt"), "existing");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
sinceUnixMs: Date.now() + 10_000,
latestIfEmpty: true,
},
pluginConfig: { workspaceDir: root },
});
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "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-"));
await fs.writeFile(path.join(root, "existing.txt"), "existing");
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "run-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
@ -470,6 +609,35 @@ describe("exportXWorkmateArtifacts", () => {
).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: "workspace-latest",
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: {
sessionKey: "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({

View File

@ -73,9 +73,11 @@ type ReadInput = {
};
type ArtifactRefPayload = {
v: 1;
v: 2;
workspaceRootHash: string;
scopeKind: XWorkmateArtifactScopeKind;
sessionScope: string;
runScope: string;
artifactScope?: string;
relativePath: string;
sizeBytes: number;
@ -148,19 +150,20 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
});
const workspaceRoot = await fs.realpath(workspaceDir);
const warnings: string[] = [];
const artifactScope = optionalArtifactScope(params.artifactScope);
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
if (artifactScope && artifactScope !== expectedArtifactScope) {
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
throw new Error("artifactScope does not match sessionKey/runId");
}
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopedExport = artifactScope !== "";
let scopeKind: XWorkmateArtifactScopeKind = scopedExport ? "task" : "workspace";
const sessionScope = taskSessionScopeFor(sessionKey);
const artifactScope = requestedArtifactScope || expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
let scopeKind: XWorkmateArtifactScopeKind = "task";
let candidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs,
skipTaskScopeRoot: !scopedExport,
skipTaskScopeRoot: false,
warnings,
});
@ -181,10 +184,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
if (scopedExport) {
if (latestTaskScopeIfEmpty) {
warnings.push("scoped artifact directory is empty; exported latest session task files instead");
} else {
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
} else if (latestTaskScopeIfEmpty) {
warnings.push("workspace export is empty; exported latest session task files instead");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
@ -217,9 +220,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
sha256,
artifactRef: signArtifactRef(
{
v: 1,
v: 2,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind: scopeKindForCandidate,
sessionScope,
runScope: expectedArtifactScope,
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
? { artifactScope: artifactScopeForCandidate }
: {}),
@ -248,7 +253,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath" as const,
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
...(scopeKind === "task" ? { artifactScope } : {}),
scopeKind,
artifacts,
warnings,
@ -284,6 +289,7 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
const workspaceRoot = await fs.realpath(workspaceDir);
if (requestedArtifactRef) {
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
assertArtifactRefMatchesRequest(refPayload, expectedArtifactScope, expectedSessionScope);
relativePath = refPayload.relativePath;
if (refPayload.artifactScope) {
artifactScope = refPayload.artifactScope;
@ -336,9 +342,11 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
requestedArtifactRef ||
signArtifactRef(
{
v: 1,
v: 2,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
sessionScope: expectedSessionScope,
runScope: expectedArtifactScope,
...(artifactScope ? { artifactScope } : {}),
relativePath: safeRelativePath(scopeRoot, realPath),
sizeBytes: bytes.byteLength,
@ -542,15 +550,23 @@ function assertArtifactScopeMatchesRequest(
throw new Error("artifactScope does not match sessionKey/runId");
}
function assertArtifactRefMatchesRequest(
payload: ArtifactRefPayload,
expectedRunScope: string,
expectedSessionScope: string,
): void {
if (payload.sessionScope !== expectedSessionScope || payload.runScope !== expectedRunScope) {
throw new Error("artifactRef does not match sessionKey/runId");
}
}
function safeScopeSegment(value: string): string {
const normalized = value
return value
.trim()
.replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 48);
const digest = createHash("sha256").update(value).digest("hex").slice(0, 12);
return `${normalized || "scope"}-${digest}`;
.slice(0, 96) || "scope";
}
function optionalArtifactScope(value: unknown): string {
@ -572,6 +588,34 @@ function safeTaskArtifactScope(value: unknown): string {
return scope;
}
function safeTaskSessionScope(value: unknown): string {
const raw = optionalString(value);
if (!raw) {
throw new Error("invalid artifactRef");
}
let scope: string;
try {
scope = safeInputRelativePath(raw, "artifactRef sessionScope");
} catch {
throw new Error("invalid artifactRef");
}
const parts = scope.split("/");
const rootParts = TASK_SCOPE_ROOT.split("/");
const scopeRoot = parts.slice(0, rootParts.length).join("/");
if (parts.length !== rootParts.length + 1 || scopeRoot !== TASK_SCOPE_ROOT) {
throw new Error("invalid artifactRef");
}
return scope;
}
function safeArtifactRefRunScope(value: unknown): string {
try {
return safeTaskArtifactScope(value);
} catch {
throw new Error("invalid artifactRef");
}
}
function safeInputRelativePath(value: unknown, label: string): string {
const relativePath = optionalString(value);
if (!relativePath) {
@ -791,16 +835,23 @@ function verifyArtifactRef(
}
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
const sha256 = optionalString(payload.sha256).toLowerCase();
if (payload.v !== 1 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
throw new Error("invalid artifactRef");
}
const sessionScope = safeTaskSessionScope(payload.sessionScope);
const runScope = safeArtifactRefRunScope(payload.runScope);
if (!runScope.startsWith(`${sessionScope}/`)) {
throw new Error("invalid artifactRef");
}
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
throw new Error("artifactRef does not match workspace");
}
return {
v: 1,
v: 2,
workspaceRootHash: workspaceRootHash(workspaceRoot),
scopeKind,
sessionScope,
runScope,
...(artifactScope ? { artifactScope } : {}),
relativePath,
sizeBytes,