Initial OpenClaw XWorkmate artifacts plugin

This commit is contained in:
Haitao Pan 2026-05-05 11:08:01 +08:00
commit 52d8d07e66
12 changed files with 7043 additions and 0 deletions

39
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,39 @@
name: CI
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm test
- name: Typecheck
run: pnpm typecheck
- name: Verify npm package contents
run: pnpm pack:check

44
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Publish
on:
workflow_dispatch:
release:
types:
- published
jobs:
publish:
name: Publish to npm
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org/
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm test
- name: Typecheck
run: pnpm typecheck
- name: Publish
run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
coverage/
dist/
*.tsbuildinfo

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# openclaw-xworkmate-artifacts
OpenClaw Gateway plugin that exports structured workspace artifact manifests for XWorkmate.
It registers one Gateway method:
```text
xworkmate.artifacts.export
```
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.
## Install locally
Link this directory into OpenClaw:
```bash
openclaw plugins install --link /Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-xworkmate-artifacts
openclaw plugins enable openclaw-xworkmate-artifacts
```
Equivalent config shape:
```json
{
"plugins": {
"load": {
"paths": [
"/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-xworkmate-artifacts"
]
},
"entries": {
"openclaw-xworkmate-artifacts": {
"enabled": true
}
}
}
}
```
## Contract
Request params:
```json
{
"sessionKey": "thread-main",
"runId": "turn-1",
"sinceUnixMs": 1770000000000,
"maxFiles": 64,
"maxInlineBytes": 10485760
}
```
Response payload:
```json
{
"runId": "turn-1",
"sessionKey": "thread-main",
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
"remoteWorkspaceRefKind": "remotePath",
"artifacts": [
{
"relativePath": "reports/final.md",
"label": "final.md",
"contentType": "text/markdown",
"sizeBytes": 1234,
"sha256": "..."
}
],
"warnings": []
}
```
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.

22
index.test.ts Normal file
View File

@ -0,0 +1,22 @@
import type { GatewayRequestHandler, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import register from "./index.js";
describe("plugin registration", () => {
it("registers the xworkmate artifact export gateway method", () => {
const methods: Array<{ method: string; handler: GatewayRequestHandler }> = [];
const api = {
config: {},
pluginConfig: {},
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => {
methods.push({ method, handler });
},
} as unknown as OpenClawPluginApi;
register(api);
expect(methods).toHaveLength(1);
expect(methods[0]?.method).toBe("xworkmate.artifacts.export");
expect(typeof methods[0]?.handler).toBe("function");
});
});

20
index.ts Normal file
View File

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

37
openclaw.plugin.json Normal file
View File

@ -0,0 +1,37 @@
{
"id": "openclaw-xworkmate-artifacts",
"name": "XWorkmate Artifacts",
"description": "Exports structured artifact manifests from the OpenClaw workspace for XWorkmate.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"workspaceDir": {
"type": "string",
"description": "Optional workspace directory override. Defaults to the selected OpenClaw agent workspace."
},
"maxFiles": {
"type": "number",
"description": "Default maximum number of files to include."
},
"maxInlineBytes": {
"type": "number",
"description": "Default maximum file size to inline as base64."
}
}
},
"uiHints": {
"workspaceDir": {
"label": "Workspace Directory",
"help": "Leave blank to use the OpenClaw agent workspace."
},
"maxFiles": {
"label": "Max Files",
"help": "Upper bound for exported artifacts. Default: 64."
},
"maxInlineBytes": {
"label": "Max Inline Bytes",
"help": "Upper bound for base64 inline artifact content. Default: 10485760."
}
}
}

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "openclaw-xworkmate-artifacts",
"version": "0.1.0",
"description": "OpenClaw Gateway artifact export plugin for XWorkmate",
"type": "module",
"license": "MIT",
"keywords": [
"openclaw",
"xworkmate",
"artifacts",
"gateway",
"plugin"
],
"homepage": "https://github.com/x-evor/openclaw-xworkmate-artifacts#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/x-evor/openclaw-xworkmate-artifacts.git"
},
"bugs": {
"url": "https://github.com/x-evor/openclaw-xworkmate-artifacts/issues"
},
"engines": {
"node": ">=22"
},
"files": [
"README.md",
"index.ts",
"openclaw.plugin.json",
"src/exportArtifacts.ts"
],
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit",
"pack:check": "npm pack --dry-run"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^24.10.1",
"openclaw": "2026.5.3-1",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

6278
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

134
src/exportArtifacts.test.ts Normal file
View File

@ -0,0 +1,134 @@
import { createHash } 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 { exportXWorkmateArtifacts } from "./exportArtifacts.js";
describe("exportXWorkmateArtifacts", () => {
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 });
const filePath = path.join(root, "reports", "final.md");
await fs.writeFile(filePath, "# Done\n");
const stat = await fs.stat(filePath);
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "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.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"),
});
});
it("filters old files by sinceUnixMs", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
const oldFile = path.join(root, "old.txt");
await fs.writeFile(oldFile, "old");
const stat = await fs.stat(oldFile);
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "run-1",
sinceUnixMs: stat.mtimeMs + 10_000,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts).toEqual([]);
});
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.writeFile(path.join(root, ".git", "secret.txt"), "secret");
await fs.writeFile(path.join(root, "real.txt"), "real");
await fs.symlink(path.join(root, "real.txt"), path.join(root, "linked.txt"));
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "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("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"));
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "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("limits exported files", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
await fs.writeFile(path.join(root, "a.txt"), "a");
await fs.writeFile(path.join(root, "b.txt"), "b");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "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("selects an agent workspace from agent session keys", async () => {
const mainRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-main-"));
const agentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-agent-"));
await fs.writeFile(path.join(mainRoot, "main.txt"), "main");
await fs.writeFile(path.join(agentRoot, "agent.txt"), "agent");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "agent:research:thread-1",
runId: "run-1",
},
config: {
agents: {
defaults: { workspace: mainRoot },
list: [{ id: "research", workspace: agentRoot }],
},
},
});
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]);
});
});

325
src/exportArtifacts.ts Normal file
View File

@ -0,0 +1,325 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const DEFAULT_MAX_FILES = 64;
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
const SKIPPED_DIRS = new Set([
".git",
".openclaw",
".pi",
".dart_tool",
".next",
".turbo",
"build",
"dist",
"node_modules",
]);
export type XWorkmateArtifact = {
relativePath: string;
label: string;
contentType: string;
sizeBytes: number;
sha256: string;
encoding?: "base64";
content?: string;
};
export type XWorkmateArtifactExport = {
runId: string;
sessionKey: string;
remoteWorkingDirectory: string;
remoteWorkspaceRefKind: "remotePath";
artifacts: XWorkmateArtifact[];
warnings: string[];
};
type ExportInput = {
params: Record<string, unknown>;
config?: unknown;
pluginConfig?: Record<string, unknown>;
};
type Candidate = {
absolutePath: string;
relativePath: string;
sizeBytes: number;
mtimeMs: number;
};
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 maxFiles = positiveInteger(params.maxFiles, pluginConfig.maxFiles, DEFAULT_MAX_FILES);
const maxInlineBytes = positiveInteger(
params.maxInlineBytes,
pluginConfig.maxInlineBytes,
DEFAULT_MAX_INLINE_BYTES,
);
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
params,
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const warnings: string[] = [];
const candidates = await collectCandidates({
workspaceRoot,
sinceUnixMs,
warnings,
});
candidates.sort((left, right) => {
if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs;
}
return left.relativePath.localeCompare(right.relativePath);
});
const artifacts: XWorkmateArtifact[] = [];
for (const candidate of candidates) {
if (artifacts.length >= maxFiles) {
warnings.push(`artifact limit reached; skipped remaining files after ${maxFiles}`);
break;
}
const bytes = await fs.readFile(candidate.absolutePath);
const artifact: XWorkmateArtifact = {
relativePath: candidate.relativePath,
label: path.posix.basename(candidate.relativePath),
contentType: contentTypeForPath(candidate.relativePath),
sizeBytes: bytes.byteLength,
sha256: createHash("sha256").update(bytes).digest("hex"),
};
if (bytes.byteLength <= maxInlineBytes) {
artifact.encoding = "base64";
artifact.content = bytes.toString("base64");
} else {
warnings.push(`${candidate.relativePath} exceeds maxInlineBytes and was not inlined`);
}
artifacts.push(artifact);
}
return {
runId,
sessionKey,
remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath",
artifacts,
warnings,
};
}
async function collectCandidates(input: {
workspaceRoot: string;
sinceUnixMs: number;
warnings: string[];
}): Promise<Candidate[]> {
const candidates: Candidate[] = [];
await walk(input.workspaceRoot);
return candidates;
async function walk(currentDir: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch (error) {
input.warnings.push(`cannot read ${safeDisplayPath(input.workspaceRoot, currentDir)}: ${String(error)}`);
return;
}
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
if (entry.name === "." || entry.name === "..") {
continue;
}
const absolutePath = path.join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
input.warnings.push(`skipped symlink ${safeDisplayPath(input.workspaceRoot, absolutePath)}`);
continue;
}
if (entry.isDirectory()) {
if (SKIPPED_DIRS.has(entry.name)) {
continue;
}
await walk(absolutePath);
continue;
}
if (!entry.isFile()) {
continue;
}
const stat = await fs.stat(absolutePath);
const changedAtMs = Math.max(stat.mtimeMs, stat.ctimeMs);
if (changedAtMs < input.sinceUnixMs) {
continue;
}
const realPath = await fs.realpath(absolutePath);
if (!isWithinRoot(input.workspaceRoot, realPath)) {
input.warnings.push(`skipped path outside workspace ${entry.name}`);
continue;
}
const relativePath = safeRelativePath(input.workspaceRoot, realPath);
if (!relativePath) {
continue;
}
candidates.push({
absolutePath: realPath,
relativePath,
sizeBytes: stat.size,
mtimeMs: changedAtMs,
});
}
}
}
function resolveWorkspaceDir(input: {
config?: unknown;
pluginConfig: Record<string, unknown>;
params: Record<string, unknown>;
sessionKey: string;
}): string {
const explicit = optionalString(input.params.workspaceDir) || optionalString(input.pluginConfig.workspaceDir);
if (explicit) {
return expandUserPath(explicit);
}
const config = objectRecord(input.config);
const agents = objectRecord(config.agents);
const agentList = Array.isArray(agents.list)
? agents.list.map(objectRecord).filter((entry) => Object.keys(entry).length > 0)
: [];
const agentId = agentIdFromSessionKey(input.sessionKey);
const selected =
(agentId ? agentList.find((entry) => optionalString(entry.id) === agentId) : undefined) ??
agentList.find((entry) => entry.default === true) ??
agentList[0];
const selectedWorkspace = selected ? optionalString(selected.workspace) : "";
if (selectedWorkspace) {
return expandUserPath(selectedWorkspace);
}
const defaults = objectRecord(agents.defaults);
const defaultWorkspace = optionalString(defaults.workspace);
if (defaultWorkspace) {
return expandUserPath(defaultWorkspace);
}
const profile = process.env.OPENCLAW_PROFILE?.trim();
if (profile && profile.toLowerCase() !== "default") {
return path.join(os.homedir(), ".openclaw", `workspace-${profile}`);
}
return path.join(os.homedir(), ".openclaw", "workspace");
}
function agentIdFromSessionKey(sessionKey: string): string {
const parts = sessionKey.split(":");
if (parts.length >= 3 && parts[0] === "agent") {
return parts[1]?.trim() ?? "";
}
return "";
}
function safeRelativePath(root: string, target: string): string {
const relative = path.relative(root, target);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
return "";
}
const normalized = relative.split(path.sep).join(path.posix.sep);
if (normalized.split("/").some((part) => part === ".." || part === "")) {
return "";
}
return normalized;
}
function safeDisplayPath(root: string, target: string): string {
return safeRelativePath(root, target) || path.basename(target);
}
function isWithinRoot(root: string, target: string): boolean {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function contentTypeForPath(relativePath: string): string {
switch (path.extname(relativePath).toLowerCase()) {
case ".md":
case ".markdown":
return "text/markdown";
case ".txt":
case ".log":
return "text/plain";
case ".json":
return "application/json";
case ".csv":
return "text/csv";
case ".html":
case ".htm":
return "text/html";
case ".pdf":
return "application/pdf";
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
function objectRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function optionalString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function positiveInteger(primary: unknown, secondary: unknown, fallback: number): number {
for (const value of [primary, secondary]) {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return Math.floor(numeric);
}
}
return fallback;
}
function nonNegativeNumber(value: unknown, fallback: number): number {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 0) {
return numeric;
}
return fallback;
}
function expandUserPath(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return path.resolve(value);
}

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vitest"]
},
"include": ["*.ts", "src/**/*.ts"]
}