From ac3a285dc2e4d947d318ebe78cd54fe81a57d7d3 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 6 May 2026 09:33:54 +0800 Subject: [PATCH] feat: add scoped OpenClaw artifact exports --- README.md | 71 +++++++++--- dist/index.js | 18 +++- dist/src/exportArtifacts.d.ts | 19 ++++ dist/src/exportArtifacts.js | 165 +++++++++++++++++++++++----- index.test.ts | 1 + index.ts | 16 +++ package.json | 2 +- src/exportArtifacts.test.ts | 137 +++++++++++++++++++++++- src/exportArtifacts.ts | 196 ++++++++++++++++++++++++++++------ 9 files changed, 548 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 9dd838b..5f35a30 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dist/index.js b/dist/index.js index e4645b1..5d83945 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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({ diff --git a/dist/src/exportArtifacts.d.ts b/dist/src/exportArtifacts.d.ts index 8c4fd88..693c414 100644 --- a/dist/src/exportArtifacts.d.ts +++ b/dist/src/exportArtifacts.d.ts @@ -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; config?: unknown; @@ -26,10 +42,13 @@ type ReadInput = { config?: unknown; pluginConfig?: Record; }; +export declare function prepareXWorkmateArtifacts(input: ExportInput): Promise; export declare function exportXWorkmateArtifacts(input: ExportInput): Promise; export declare function readXWorkmateArtifact(input: ReadInput): Promise; export declare function formatArtifactManifestMarkdown(input: { remoteWorkingDirectory: string; + artifactScope?: string; + scopeKind?: XWorkmateArtifactScopeKind; artifacts: XWorkmateArtifact[]; warnings: string[]; }): string; diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index b71c45d..798b360 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -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; diff --git a/index.test.ts b/index.test.ts index 526d782..8eb3335 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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", diff --git a/index.ts b/index.ts index ec5fcb7..5d1ef6c 100644 --- a/index.ts +++ b/index.ts @@ -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({ diff --git a/package.json b/package.json index 5405592..e94b712 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index 2d4fedb..16ee183 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -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-")); diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index f55b53b..4228330 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -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; config?: unknown; @@ -57,17 +76,39 @@ type Candidate = { mtimeMs: number; }; +export async function prepareXWorkmateArtifacts(input: ExportInput): Promise { + const params = input.params ?? {}; + const pluginConfig = input.pluginConfig ?? {}; + const runId = requiredString(params.runId, "runId required"); + const sessionKey = requiredString(params.sessionKey, "sessionKey required"); + const 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 { 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 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 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 { const candidates: Candidate[] = []; - await walk(input.workspaceRoot); + await walk(input.scanRoot); return candidates; async function walk(currentDir: string): Promise { @@ -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; @@ -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;