feat: add scoped OpenClaw artifact exports
This commit is contained in:
parent
a89d8c3759
commit
ac3a285dc2
71
README.md
71
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
|
||||
|
||||
|
||||
18
dist/index.js
vendored
18
dist/index.js
vendored
@ -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({
|
||||
|
||||
19
dist/src/exportArtifacts.d.ts
vendored
19
dist/src/exportArtifacts.d.ts
vendored
@ -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;
|
||||
|
||||
165
dist/src/exportArtifacts.js
vendored
165
dist/src/exportArtifacts.js
vendored
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
16
index.ts
16
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({
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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-"));
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user