feat: add scoped OpenClaw artifact exports

This commit is contained in:
Haitao Pan 2026-05-06 09:33:54 +08:00
parent a89d8c3759
commit ac3a285dc2
9 changed files with 548 additions and 77 deletions

View File

@ -10,15 +10,18 @@ XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing
can then sync generated files into its local thread workspace without changing
the UI or adding provider-specific routes.
It registers three Gateway methods:
It registers four Gateway methods:
```text
xworkmate.artifacts.prepare
xworkmate.artifacts.export
xworkmate.artifacts.list
xworkmate.artifacts.read
```
The method scans the resolved OpenClaw workspace after a run finishes and returns safe, relative artifact entries that XWorkmate Bridge can normalize into the APP `artifacts[]` contract.
`prepare` creates a per-task artifact scope under the resolved OpenClaw workspace. `export`
and `read` then return safe, relative artifact entries that XWorkmate Bridge can normalize
into the APP `artifacts[]` contract.
## Install
@ -58,19 +61,16 @@ Equivalent config shape for a linked checkout:
## Contract
Request params:
Prepare request params:
```json
{
"sessionKey": "thread-main",
"runId": "turn-1",
"sinceUnixMs": 1770000000000,
"maxFiles": 64,
"maxInlineBytes": 10485760
"runId": "turn-1"
}
```
Response payload:
Prepare response payload:
```json
{
@ -78,13 +78,47 @@ Response payload:
"sessionKey": "thread-main",
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
"scopeKind": "task",
"artifactDirectory": "/home/user/.openclaw/workspace/.xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
"relativeArtifactDirectory": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
"warnings": []
}
```
Export request params:
```json
{
"sessionKey": "thread-main",
"runId": "turn-1",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
"sinceUnixMs": 1770000000000,
"latestIfEmpty": true,
"maxFiles": 64,
"maxInlineBytes": 10485760
}
```
Export response payload:
```json
{
"runId": "turn-1",
"sessionKey": "thread-main",
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
"scopeKind": "task",
"artifacts": [
{
"relativePath": "reports/final.md",
"label": "final.md",
"contentType": "text/markdown",
"sizeBytes": 1234,
"sha256": "..."
"sha256": "...",
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
"scopeKind": "task"
}
],
"warnings": []
@ -92,6 +126,11 @@ 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.
## View And Download
@ -120,20 +159,22 @@ local users can open or download them directly from that workspace path.
Gateway clients can use:
- `xworkmate.artifacts.prepare` before `chat.send` to allocate a task artifact directory.
- `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table.
- `xworkmate.artifacts.read` with `relativePath` for one inline base64 file.
- `xworkmate.artifacts.export` after `agent.wait` for the XWorkmate APP sync path.
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one inline base64 file.
- `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path.
Large files are intentionally metadata-only in v1. XWorkmate Bridge can add a
hosted artifact cache/download endpoint later if remote APP clients need direct
links for large PPT/PDF/DOCX files.
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
only remote file access path.
## Limits
- Only files inside the resolved OpenClaw workspace are exported.
- `.git`, `.openclaw`, `.pi`, build outputs, and dependency folders are skipped.
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are skipped when scanning the workspace root.
- Symlinks are skipped to avoid workspace escape.
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
- `artifactScope` and `relativePath` must be workspace-relative paths; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
## Development

18
dist/index.js vendored
View File

@ -1,4 +1,4 @@
import { exportXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
import { exportXWorkmateArtifacts, prepareXWorkmateArtifacts, readXWorkmateArtifact, } from "./src/exportArtifacts.js";
const plugin = {
id: "xworkmate-artifacts",
name: "XWorkmate Artifacts",
@ -7,6 +7,22 @@ const plugin = {
};
export default plugin;
function register(api) {
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts) => {
try {
const payload = await prepareXWorkmateArtifacts({
params: 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.export", async (opts) => {
try {
const payload = await exportXWorkmateArtifacts({

View File

@ -4,18 +4,34 @@ export type XWorkmateArtifact = {
contentType: string;
sizeBytes: number;
sha256: string;
artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind;
encoding?: "base64";
content?: string;
};
export type XWorkmateArtifactScopeKind = "task" | "workspace" | "workspace-latest";
export type XWorkmateArtifactExport = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
remoteWorkspaceRefKind: "remotePath";
artifactScope?: string;
scopeKind: XWorkmateArtifactScopeKind;
artifacts: XWorkmateArtifact[];
warnings: string[];
manifestMarkdown: string;
};
export type XWorkmateArtifactPrepare = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
remoteWorkspaceRefKind: "remotePath";
artifactScope: string;
scopeKind: "task";
artifactDirectory: string;
relativeArtifactDirectory: string;
warnings: string[];
};
type ExportInput = {
params: Record<string, unknown>;
config?: unknown;
@ -26,10 +42,13 @@ type ReadInput = {
config?: unknown;
pluginConfig?: Record<string, unknown>;
};
export declare function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactPrepare>;
export declare function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport>;
export declare function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport>;
export declare function formatArtifactManifestMarkdown(input: {
remoteWorkingDirectory: string;
artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind;
artifacts: XWorkmateArtifact[];
warnings: string[];
}): string;

View File

@ -7,6 +7,7 @@ const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
const SKIPPED_DIRS = new Set([
".git",
".openclaw",
".xworkmate",
".pi",
".dart_tool",
".next",
@ -15,21 +16,43 @@ const SKIPPED_DIRS = new Set([
"dist",
"node_modules",
]);
export async function prepareXWorkmateArtifacts(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
params,
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const artifactScope = artifactScopeFor(sessionKey, runId);
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
await fs.mkdir(scopeRoot, { recursive: true });
return {
runId,
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
artifactScope,
scopeKind: "task",
artifactDirectory: scopeRoot,
relativeArtifactDirectory: artifactScope,
warnings: [],
};
}
export async function exportXWorkmateArtifacts(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId);
if (!runId) {
throw new Error("runId required");
}
const sessionKey = optionalString(params.sessionKey);
if (!sessionKey) {
throw new Error("sessionKey required");
}
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true);
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
@ -38,11 +61,33 @@ export async function exportXWorkmateArtifacts(input) {
});
const workspaceRoot = await fs.realpath(workspaceDir);
const warnings = [];
const candidates = await collectCandidates({
workspaceRoot,
const artifactScope = optionalArtifactScope(params.artifactScope);
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopedExport = artifactScope !== "";
let scopeKind = scopedExport ? "task" : "workspace";
let candidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs,
warnings,
});
if (candidates.length === 0 && latestIfEmpty) {
const latestWarnings = [];
const latestCandidates = await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
warnings: latestWarnings,
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
if (scopedExport) {
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
}
}
candidates.sort((left, right) => {
if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs;
@ -62,7 +107,11 @@ export async function exportXWorkmateArtifacts(input) {
contentType: contentTypeForPath(candidate.relativePath),
sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
scopeKind,
};
if (scopeKind === "task" && artifactScope) {
artifact.artifactScope = artifactScope;
}
if (includeContent && bytes.byteLength <= maxInlineBytes) {
artifact.encoding = "base64";
artifact.content = bytes.toString("base64");
@ -77,6 +126,8 @@ export async function exportXWorkmateArtifacts(input) {
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
scopeKind,
artifacts,
warnings,
};
@ -89,17 +140,9 @@ export async function readXWorkmateArtifact(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId) || "read";
const sessionKey = optionalString(params.sessionKey);
if (!sessionKey) {
throw new Error("sessionKey required");
}
const relativePath = optionalString(params.relativePath);
if (!relativePath) {
throw new Error("relativePath required");
}
if (relativePath.split(/[\\/]/).some((part) => part === ".." || part === "")) {
throw new Error("relativePath must stay inside the workspace");
}
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const relativePath = safeInputRelativePath(params.relativePath, "relativePath");
const artifactScope = optionalArtifactScope(params.artifactScope);
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
const workspaceDir = resolveWorkspaceDir({
config: input.config,
@ -108,9 +151,11 @@ export async function readXWorkmateArtifact(input) {
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const absolutePath = path.join(workspaceRoot, relativePath.split("/").join(path.sep));
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopeKind = artifactScope ? "task" : "workspace";
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(workspaceRoot, realPath)) {
if (!isWithinRoot(scopeRoot, realPath)) {
throw new Error("relativePath must stay inside the workspace");
}
const stat = await fs.stat(realPath);
@ -119,12 +164,16 @@ export async function readXWorkmateArtifact(input) {
}
const bytes = await fs.readFile(realPath);
const artifact = {
relativePath: safeRelativePath(workspaceRoot, realPath),
relativePath: safeRelativePath(scopeRoot, realPath),
label: path.posix.basename(relativePath),
contentType: contentTypeForPath(relativePath),
sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
scopeKind,
};
if (artifactScope) {
artifact.artifactScope = artifactScope;
}
const warnings = [];
if (bytes.byteLength <= maxInlineBytes) {
artifact.encoding = "base64";
@ -138,6 +187,8 @@ export async function readXWorkmateArtifact(input) {
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
...(artifactScope ? { artifactScope } : {}),
scopeKind,
artifacts: [artifact],
warnings,
};
@ -151,6 +202,7 @@ export function formatArtifactManifestMarkdown(input) {
"## XWorkmate artifacts",
"",
`Workspace: \`${input.remoteWorkingDirectory}\``,
input.artifactScope ? `Artifact scope: \`${input.artifactScope}\`` : `Artifact scope: \`${input.scopeKind ?? "workspace"}\``,
"",
];
if (input.artifacts.length === 0) {
@ -173,7 +225,7 @@ export function formatArtifactManifestMarkdown(input) {
}
async function collectCandidates(input) {
const candidates = [];
await walk(input.workspaceRoot);
await walk(input.scanRoot);
return candidates;
async function walk(currentDir) {
let entries;
@ -181,7 +233,7 @@ async function collectCandidates(input) {
entries = await fs.readdir(currentDir, { withFileTypes: true });
}
catch (error) {
input.warnings.push(`cannot read ${safeDisplayPath(input.workspaceRoot, currentDir)}: ${String(error)}`);
input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`);
return;
}
entries.sort((left, right) => left.name.localeCompare(right.name));
@ -191,7 +243,7 @@ async function collectCandidates(input) {
}
const absolutePath = path.join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
input.warnings.push(`skipped symlink ${safeDisplayPath(input.workspaceRoot, absolutePath)}`);
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
continue;
}
if (entry.isDirectory()) {
@ -210,11 +262,11 @@ async function collectCandidates(input) {
continue;
}
const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(input.workspaceRoot, realPath)) {
if (!isWithinRoot(input.relativeRoot, realPath)) {
input.warnings.push(`skipped path outside workspace ${entry.name}`);
continue;
}
const relativePath = safeRelativePath(input.workspaceRoot, realPath);
const relativePath = safeRelativePath(input.relativeRoot, realPath);
if (!relativePath) {
continue;
}
@ -227,6 +279,54 @@ async function collectCandidates(input) {
}
}
}
function artifactScopeFor(sessionKey, runId) {
return [
".xworkmate",
"artifacts",
"tasks",
safeScopeSegment(sessionKey),
safeScopeSegment(runId),
].join("/");
}
function safeScopeSegment(value) {
const normalized = value
.trim()
.replaceAll(path.sep, "_")
.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}`;
}
function optionalArtifactScope(value) {
const scope = optionalString(value);
if (!scope) {
return "";
}
return safeInputRelativePath(scope, "artifactScope");
}
function safeInputRelativePath(value, label) {
const relativePath = optionalString(value);
if (!relativePath) {
throw new Error(`${label} required`);
}
if (path.isAbsolute(relativePath) || relativePath.includes("\0")) {
throw new Error(`${label} must stay inside the workspace`);
}
const normalized = relativePath.split(/[\\/]/).filter(Boolean).join("/");
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
throw new Error(`${label} must stay inside the workspace`);
}
return normalized;
}
function resolveScopeRoot(workspaceRoot, artifactScope) {
const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope");
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
throw new Error("artifactScope must stay inside the workspace");
}
return scopeRoot;
}
function resolveWorkspaceDir(input) {
const explicit = optionalString(input.params.workspaceDir) || optionalString(input.pluginConfig.workspaceDir);
if (explicit) {
@ -325,6 +425,13 @@ function objectRecord(value) {
function optionalString(value) {
return typeof value === "string" ? value.trim() : "";
}
function requiredString(value, message) {
const resolved = optionalString(value);
if (!resolved) {
throw new Error(message);
}
return resolved;
}
function optionalBoolean(value, fallback) {
if (typeof value === "boolean") {
return value;

View File

@ -22,6 +22,7 @@ describe("plugin registration", () => {
plugin.register(api);
expect(methods.map((entry) => entry.method)).toEqual([
"xworkmate.artifacts.prepare",
"xworkmate.artifacts.export",
"xworkmate.artifacts.list",
"xworkmate.artifacts.read",

View File

@ -5,6 +5,7 @@ import type {
} from "openclaw/plugin-sdk/core";
import {
exportXWorkmateArtifacts,
prepareXWorkmateArtifacts,
readXWorkmateArtifact,
} from "./src/exportArtifacts.js";
@ -24,6 +25,21 @@ const plugin = {
export default plugin;
function register(api: OpenClawPluginApi) {
api.registerGatewayMethod("xworkmate.artifacts.prepare", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await prepareXWorkmateArtifacts({
params: 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.export", async (opts: GatewayRequestHandlerOptions) => {
try {
const payload = await exportXWorkmateArtifacts({

View File

@ -1,6 +1,6 @@
{
"name": "xworkmate-artifacts",
"version": "0.1.3",
"version": "0.1.4",
"description": "XWorkmate artifact export plugin for OpenClaw Gateway",
"type": "module",
"license": "MIT",

View File

@ -3,9 +3,33 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { exportXWorkmateArtifacts, readXWorkmateArtifact } from "./exportArtifacts.js";
import {
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-xworkmate-artifacts-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const second = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
expect(first.artifactScope).toMatch(/^\.xworkmate\/artifacts\/tasks\/thread-main-[a-f0-9]{12}\/turn-1-[a-f0-9]{12}$/);
expect(second.artifactScope).toMatch(/^\.xworkmate\/artifacts\/tasks\/thread-main-[a-f0-9]{12}\/turn-2-[a-f0-9]{12}$/);
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("exports changed files with metadata and base64 content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.mkdir(path.join(root, "reports"), { recursive: true });
@ -59,7 +83,9 @@ describe("exportXWorkmateArtifacts", () => {
it("skips excluded directories and symlinks", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
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"));
@ -75,6 +101,69 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.warnings.some((entry) => entry.includes("linked.txt"))).toBe(true);
});
it("exports only files inside a task artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const second = await prepareXWorkmateArtifacts({
params: { sessionKey: "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: {
sessionKey: "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("falls back to latest workspace files when the scoped directory is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "existing.pdf"), "pdf");
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: {
sessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
sinceUnixMs: stat.mtimeMs + 10_000,
latestIfEmpty: true,
},
pluginConfig: { workspaceDir: root },
});
expect(result.scopeKind).toBe("workspace-latest");
expect(result.artifactScope).toBeUndefined();
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["existing.pdf"]);
expect(result.artifacts[0]?.artifactScope).toBeUndefined();
expect(result.artifacts[0]?.scopeKind).toBe("workspace-latest");
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest workspace files instead");
});
it("leaves oversized artifacts out of inline content", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.writeFile(path.join(root, "large.pdf"), Buffer.from("large-content"));
@ -176,6 +265,36 @@ describe("exportXWorkmateArtifacts", () => {
});
});
it("reads one artifact inside a task artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "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: {
sessionKey: "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"),
});
});
it("reads artifact metadata without inline content when the file exceeds the limit", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.writeFile(path.join(root, "large.bin"), Buffer.from("large-content"));
@ -217,6 +336,22 @@ describe("exportXWorkmateArtifacts", () => {
).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-xworkmate-artifacts-"));
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "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-xworkmate-artifacts-"));
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-outside-"));

View File

@ -9,6 +9,7 @@ const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
const SKIPPED_DIRS = new Set([
".git",
".openclaw",
".xworkmate",
".pi",
".dart_tool",
".next",
@ -24,20 +25,38 @@ export type XWorkmateArtifact = {
contentType: string;
sizeBytes: number;
sha256: string;
artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind;
encoding?: "base64";
content?: string;
};
export type XWorkmateArtifactScopeKind = "task" | "workspace" | "workspace-latest";
export type XWorkmateArtifactExport = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
remoteWorkspaceRefKind: "remotePath";
artifactScope?: string;
scopeKind: XWorkmateArtifactScopeKind;
artifacts: XWorkmateArtifact[];
warnings: string[];
manifestMarkdown: string;
};
export type XWorkmateArtifactPrepare = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
remoteWorkspaceRefKind: "remotePath";
artifactScope: string;
scopeKind: "task";
artifactDirectory: string;
relativeArtifactDirectory: string;
warnings: string[];
};
type ExportInput = {
params: Record<string, unknown>;
config?: unknown;
@ -57,17 +76,39 @@ type Candidate = {
mtimeMs: number;
};
export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactPrepare> {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
params,
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const artifactScope = artifactScopeFor(sessionKey, runId);
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
await fs.mkdir(scopeRoot, { recursive: true });
return {
runId,
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
artifactScope,
scopeKind: "task",
artifactDirectory: scopeRoot,
relativeArtifactDirectory: artifactScope,
warnings: [],
};
}
export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWorkmateArtifactExport> {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId);
if (!runId) {
throw new Error("runId required");
}
const sessionKey = optionalString(params.sessionKey);
if (!sessionKey) {
throw new Error("sessionKey required");
}
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
const maxInlineBytes = nonNegativeInteger(
@ -77,6 +118,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
);
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true);
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
@ -85,12 +127,35 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
});
const workspaceRoot = await fs.realpath(workspaceDir);
const warnings: string[] = [];
const candidates = await collectCandidates({
workspaceRoot,
const artifactScope = optionalArtifactScope(params.artifactScope);
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopedExport = artifactScope !== "";
let scopeKind: XWorkmateArtifactScopeKind = scopedExport ? "task" : "workspace";
let candidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs,
warnings,
});
if (candidates.length === 0 && latestIfEmpty) {
const latestWarnings: string[] = [];
const latestCandidates = await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
warnings: latestWarnings,
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
if (scopedExport) {
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
}
}
candidates.sort((left, right) => {
if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs;
@ -111,7 +176,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
contentType: contentTypeForPath(candidate.relativePath),
sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
scopeKind,
};
if (scopeKind === "task" && artifactScope) {
artifact.artifactScope = artifactScope;
}
if (includeContent && bytes.byteLength <= maxInlineBytes) {
artifact.encoding = "base64";
artifact.content = bytes.toString("base64");
@ -126,6 +195,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath" as const,
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
scopeKind,
artifacts,
warnings,
};
@ -139,17 +210,9 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId) || "read";
const sessionKey = optionalString(params.sessionKey);
if (!sessionKey) {
throw new Error("sessionKey required");
}
const relativePath = optionalString(params.relativePath);
if (!relativePath) {
throw new Error("relativePath required");
}
if (relativePath.split(/[\\/]/).some((part) => part === ".." || part === "")) {
throw new Error("relativePath must stay inside the workspace");
}
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const relativePath = safeInputRelativePath(params.relativePath, "relativePath");
const artifactScope = optionalArtifactScope(params.artifactScope);
const maxInlineBytes = nonNegativeInteger(
params.maxInlineBytes,
pluginConfig.maxInlineBytes,
@ -162,9 +225,11 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const absolutePath = path.join(workspaceRoot, relativePath.split("/").join(path.sep));
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopeKind: XWorkmateArtifactScopeKind = artifactScope ? "task" : "workspace";
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(workspaceRoot, realPath)) {
if (!isWithinRoot(scopeRoot, realPath)) {
throw new Error("relativePath must stay inside the workspace");
}
const stat = await fs.stat(realPath);
@ -173,12 +238,16 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
}
const bytes = await fs.readFile(realPath);
const artifact: XWorkmateArtifact = {
relativePath: safeRelativePath(workspaceRoot, realPath),
relativePath: safeRelativePath(scopeRoot, realPath),
label: path.posix.basename(relativePath),
contentType: contentTypeForPath(relativePath),
sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
scopeKind,
};
if (artifactScope) {
artifact.artifactScope = artifactScope;
}
const warnings: string[] = [];
if (bytes.byteLength <= maxInlineBytes) {
artifact.encoding = "base64";
@ -191,6 +260,8 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath" as const,
...(artifactScope ? { artifactScope } : {}),
scopeKind,
artifacts: [artifact],
warnings,
};
@ -202,6 +273,8 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
export function formatArtifactManifestMarkdown(input: {
remoteWorkingDirectory: string;
artifactScope?: string;
scopeKind?: XWorkmateArtifactScopeKind;
artifacts: XWorkmateArtifact[];
warnings: string[];
}): string {
@ -209,6 +282,7 @@ export function formatArtifactManifestMarkdown(input: {
"## XWorkmate artifacts",
"",
`Workspace: \`${input.remoteWorkingDirectory}\``,
input.artifactScope ? `Artifact scope: \`${input.artifactScope}\`` : `Artifact scope: \`${input.scopeKind ?? "workspace"}\``,
"",
];
if (input.artifacts.length === 0) {
@ -234,12 +308,13 @@ export function formatArtifactManifestMarkdown(input: {
}
async function collectCandidates(input: {
workspaceRoot: string;
scanRoot: string;
relativeRoot: string;
sinceUnixMs: number;
warnings: string[];
}): Promise<Candidate[]> {
const candidates: Candidate[] = [];
await walk(input.workspaceRoot);
await walk(input.scanRoot);
return candidates;
async function walk(currentDir: string): Promise<void> {
@ -247,7 +322,7 @@ async function collectCandidates(input: {
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch (error) {
input.warnings.push(`cannot read ${safeDisplayPath(input.workspaceRoot, currentDir)}: ${String(error)}`);
input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`);
return;
}
entries.sort((left, right) => left.name.localeCompare(right.name));
@ -257,7 +332,7 @@ async function collectCandidates(input: {
}
const absolutePath = path.join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
input.warnings.push(`skipped symlink ${safeDisplayPath(input.workspaceRoot, absolutePath)}`);
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
continue;
}
if (entry.isDirectory()) {
@ -276,11 +351,11 @@ async function collectCandidates(input: {
continue;
}
const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(input.workspaceRoot, realPath)) {
if (!isWithinRoot(input.relativeRoot, realPath)) {
input.warnings.push(`skipped path outside workspace ${entry.name}`);
continue;
}
const relativePath = safeRelativePath(input.workspaceRoot, realPath);
const relativePath = safeRelativePath(input.relativeRoot, realPath);
if (!relativePath) {
continue;
}
@ -294,6 +369,59 @@ async function collectCandidates(input: {
}
}
function artifactScopeFor(sessionKey: string, runId: string): string {
return [
".xworkmate",
"artifacts",
"tasks",
safeScopeSegment(sessionKey),
safeScopeSegment(runId),
].join("/");
}
function safeScopeSegment(value: string): string {
const normalized = value
.trim()
.replaceAll(path.sep, "_")
.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}`;
}
function optionalArtifactScope(value: unknown): string {
const scope = optionalString(value);
if (!scope) {
return "";
}
return safeInputRelativePath(scope, "artifactScope");
}
function safeInputRelativePath(value: unknown, label: string): string {
const relativePath = optionalString(value);
if (!relativePath) {
throw new Error(`${label} required`);
}
if (path.isAbsolute(relativePath) || relativePath.includes("\0")) {
throw new Error(`${label} must stay inside the workspace`);
}
const normalized = relativePath.split(/[\\/]/).filter(Boolean).join("/");
if (!normalized || normalized.split("/").some((part) => part === ".." || part === ".")) {
throw new Error(`${label} must stay inside the workspace`);
}
return normalized;
}
function resolveScopeRoot(workspaceRoot: string, artifactScope: string): string {
const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope");
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
throw new Error("artifactScope must stay inside the workspace");
}
return scopeRoot;
}
function resolveWorkspaceDir(input: {
config?: unknown;
pluginConfig: Record<string, unknown>;
@ -406,6 +534,14 @@ function optionalString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function requiredString(value: unknown, message: string): string {
const resolved = optionalString(value);
if (!resolved) {
throw new Error(message);
}
return resolved;
}
function optionalBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") {
return value;