fix: declare xworkmate artifact tool contract
This commit is contained in:
parent
ac3a285dc2
commit
74ea384374
31
README.md
31
README.md
@ -19,7 +19,7 @@ xworkmate.artifacts.list
|
|||||||
xworkmate.artifacts.read
|
xworkmate.artifacts.read
|
||||||
```
|
```
|
||||||
|
|
||||||
`prepare` creates a per-task artifact scope under the resolved OpenClaw workspace. `export`
|
`prepare` creates a per-task artifact scope under `tasks/` in the resolved OpenClaw workspace. `export`
|
||||||
and `read` then return safe, relative artifact entries that XWorkmate Bridge can normalize
|
and `read` then return safe, relative artifact entries that XWorkmate Bridge can normalize
|
||||||
into the APP `artifacts[]` contract.
|
into the APP `artifacts[]` contract.
|
||||||
|
|
||||||
@ -78,10 +78,10 @@ Prepare response payload:
|
|||||||
"sessionKey": "thread-main",
|
"sessionKey": "thread-main",
|
||||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||||
"remoteWorkspaceRefKind": "remotePath",
|
"remoteWorkspaceRefKind": "remotePath",
|
||||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||||
"scopeKind": "task",
|
"scopeKind": "task",
|
||||||
"artifactDirectory": "/home/user/.openclaw/workspace/.xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
"artifactDirectory": "/home/user/.openclaw/workspace/tasks/thread-main-.../turn-1-...",
|
||||||
"relativeArtifactDirectory": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
"relativeArtifactDirectory": "tasks/thread-main-.../turn-1-...",
|
||||||
"warnings": []
|
"warnings": []
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -92,7 +92,7 @@ Export request params:
|
|||||||
{
|
{
|
||||||
"sessionKey": "thread-main",
|
"sessionKey": "thread-main",
|
||||||
"runId": "turn-1",
|
"runId": "turn-1",
|
||||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||||
"sinceUnixMs": 1770000000000,
|
"sinceUnixMs": 1770000000000,
|
||||||
"latestIfEmpty": true,
|
"latestIfEmpty": true,
|
||||||
"maxFiles": 64,
|
"maxFiles": 64,
|
||||||
@ -108,7 +108,7 @@ Export response payload:
|
|||||||
"sessionKey": "thread-main",
|
"sessionKey": "thread-main",
|
||||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||||
"remoteWorkspaceRefKind": "remotePath",
|
"remoteWorkspaceRefKind": "remotePath",
|
||||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||||
"scopeKind": "task",
|
"scopeKind": "task",
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
@ -117,7 +117,8 @@ Export response payload:
|
|||||||
"contentType": "text/markdown",
|
"contentType": "text/markdown",
|
||||||
"sizeBytes": 1234,
|
"sizeBytes": 1234,
|
||||||
"sha256": "...",
|
"sha256": "...",
|
||||||
"artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...",
|
"artifactRef": "...",
|
||||||
|
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||||
"scopeKind": "task"
|
"scopeKind": "task"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -130,7 +131,13 @@ When scoped export finds no task files and `latestIfEmpty` is true, the plugin s
|
|||||||
the workspace root for the latest real files and returns them with `scopeKind:
|
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
|
"workspace-latest"`. This is a controlled recovery path for existing files already
|
||||||
present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and
|
present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and
|
||||||
runtime directories.
|
runtime directories, including the plugin-owned top-level `tasks/` directory.
|
||||||
|
|
||||||
|
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||||
|
the artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||||
|
`artifactScope + relativePath` for task-scope files. Workspace fallback files
|
||||||
|
must be read with `artifactRef`; there is no unscoped arbitrary workspace read
|
||||||
|
API.
|
||||||
|
|
||||||
## View And Download
|
## View And Download
|
||||||
|
|
||||||
@ -161,7 +168,8 @@ Gateway clients can use:
|
|||||||
|
|
||||||
- `xworkmate.artifacts.prepare` before `chat.send` to allocate a task artifact directory.
|
- `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.list` for a metadata-only manifest and Markdown table.
|
||||||
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one inline base64 file.
|
- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file.
|
||||||
|
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task or `workspace-latest` file.
|
||||||
- `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path.
|
- `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path.
|
||||||
|
|
||||||
Large files are metadata-only in the export payload, but XWorkmate Bridge can
|
Large files are metadata-only in the export payload, but XWorkmate Bridge can
|
||||||
@ -171,10 +179,11 @@ only remote file access path.
|
|||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
- Only files inside the resolved OpenClaw workspace are exported.
|
- Only files inside the resolved OpenClaw workspace are exported.
|
||||||
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are skipped when scanning the workspace root.
|
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, top-level `tasks/`, build outputs, and dependency folders are skipped when scanning the workspace root.
|
||||||
- Symlinks are skipped to avoid workspace escape.
|
- Symlinks are skipped to avoid workspace escape.
|
||||||
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
- 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.
|
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
||||||
|
- `artifactScope`, `artifactRef`, and `relativePath` must stay inside the workspace; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
10
dist/index.js
vendored
10
dist/index.js
vendored
@ -92,7 +92,15 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
|||||||
},
|
},
|
||||||
relativePath: {
|
relativePath: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Artifact path relative to the workspace. Required for action=read.",
|
description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.",
|
||||||
|
},
|
||||||
|
artifactScope: {
|
||||||
|
type: "string",
|
||||||
|
description: "Task artifact scope returned by prepare/export, for example tasks/<session>/<run>.",
|
||||||
|
},
|
||||||
|
artifactRef: {
|
||||||
|
type: "string",
|
||||||
|
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
|
||||||
},
|
},
|
||||||
sinceUnixMs: {
|
sinceUnixMs: {
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|||||||
1
dist/src/exportArtifacts.d.ts
vendored
1
dist/src/exportArtifacts.d.ts
vendored
@ -4,6 +4,7 @@ export type XWorkmateArtifact = {
|
|||||||
contentType: string;
|
contentType: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
|
artifactRef: string;
|
||||||
artifactScope?: string;
|
artifactScope?: string;
|
||||||
scopeKind?: XWorkmateArtifactScopeKind;
|
scopeKind?: XWorkmateArtifactScopeKind;
|
||||||
encoding?: "base64";
|
encoding?: "base64";
|
||||||
|
|||||||
151
dist/src/exportArtifacts.js
vendored
151
dist/src/exportArtifacts.js
vendored
@ -1,9 +1,11 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
const DEFAULT_MAX_FILES = 64;
|
const DEFAULT_MAX_FILES = 64;
|
||||||
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const TASK_SCOPE_ROOT = "tasks";
|
||||||
|
const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex");
|
||||||
const SKIPPED_DIRS = new Set([
|
const SKIPPED_DIRS = new Set([
|
||||||
".git",
|
".git",
|
||||||
".openclaw",
|
".openclaw",
|
||||||
@ -69,6 +71,7 @@ export async function exportXWorkmateArtifacts(input) {
|
|||||||
scanRoot: scopeRoot,
|
scanRoot: scopeRoot,
|
||||||
relativeRoot: scopeRoot,
|
relativeRoot: scopeRoot,
|
||||||
sinceUnixMs,
|
sinceUnixMs,
|
||||||
|
skipTaskScopeRoot: !scopedExport,
|
||||||
warnings,
|
warnings,
|
||||||
});
|
});
|
||||||
if (candidates.length === 0 && latestIfEmpty) {
|
if (candidates.length === 0 && latestIfEmpty) {
|
||||||
@ -77,6 +80,7 @@ export async function exportXWorkmateArtifacts(input) {
|
|||||||
scanRoot: workspaceRoot,
|
scanRoot: workspaceRoot,
|
||||||
relativeRoot: workspaceRoot,
|
relativeRoot: workspaceRoot,
|
||||||
sinceUnixMs: 0,
|
sinceUnixMs: 0,
|
||||||
|
skipTaskScopeRoot: true,
|
||||||
warnings: latestWarnings,
|
warnings: latestWarnings,
|
||||||
});
|
});
|
||||||
if (latestCandidates.length > 0) {
|
if (latestCandidates.length > 0) {
|
||||||
@ -101,12 +105,22 @@ export async function exportXWorkmateArtifacts(input) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const bytes = await fs.readFile(candidate.absolutePath);
|
const bytes = await fs.readFile(candidate.absolutePath);
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
const artifact = {
|
const artifact = {
|
||||||
relativePath: candidate.relativePath,
|
relativePath: candidate.relativePath,
|
||||||
label: path.posix.basename(candidate.relativePath),
|
label: path.posix.basename(candidate.relativePath),
|
||||||
contentType: contentTypeForPath(candidate.relativePath),
|
contentType: contentTypeForPath(candidate.relativePath),
|
||||||
sizeBytes: bytes.byteLength,
|
sizeBytes: bytes.byteLength,
|
||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256,
|
||||||
|
artifactRef: signArtifactRef({
|
||||||
|
v: 1,
|
||||||
|
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||||
|
scopeKind,
|
||||||
|
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
|
||||||
|
relativePath: candidate.relativePath,
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
sha256,
|
||||||
|
}, pluginConfig),
|
||||||
scopeKind,
|
scopeKind,
|
||||||
};
|
};
|
||||||
if (scopeKind === "task" && artifactScope) {
|
if (scopeKind === "task" && artifactScope) {
|
||||||
@ -141,8 +155,10 @@ export async function readXWorkmateArtifact(input) {
|
|||||||
const pluginConfig = input.pluginConfig ?? {};
|
const pluginConfig = input.pluginConfig ?? {};
|
||||||
const runId = optionalString(params.runId) || "read";
|
const runId = optionalString(params.runId) || "read";
|
||||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||||
const relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
let relativePath = "";
|
||||||
|
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||||
|
let refPayload;
|
||||||
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
|
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
|
||||||
const workspaceDir = resolveWorkspaceDir({
|
const workspaceDir = resolveWorkspaceDir({
|
||||||
config: input.config,
|
config: input.config,
|
||||||
@ -151,8 +167,29 @@ export async function readXWorkmateArtifact(input) {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
});
|
});
|
||||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||||
|
if (requestedArtifactRef) {
|
||||||
|
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
|
||||||
|
relativePath = refPayload.relativePath;
|
||||||
|
if (refPayload.artifactScope) {
|
||||||
|
artifactScope = refPayload.artifactScope;
|
||||||
|
}
|
||||||
|
const requestedPath = optionalString(params.relativePath);
|
||||||
|
if (requestedPath && safeInputRelativePath(requestedPath, "relativePath") !== relativePath) {
|
||||||
|
throw new Error("artifactRef does not match relativePath");
|
||||||
|
}
|
||||||
|
const requestedScope = optionalArtifactScope(params.artifactScope);
|
||||||
|
if (requestedScope && requestedScope !== artifactScope) {
|
||||||
|
throw new Error("artifactRef does not match artifactScope");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!artifactScope) {
|
||||||
|
throw new Error("artifactScope or artifactRef required");
|
||||||
|
}
|
||||||
|
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||||
|
}
|
||||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||||
const scopeKind = artifactScope ? "task" : "workspace";
|
const scopeKind = refPayload?.scopeKind ?? "task";
|
||||||
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
|
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
|
||||||
const realPath = await fs.realpath(absolutePath);
|
const realPath = await fs.realpath(absolutePath);
|
||||||
if (!isWithinRoot(scopeRoot, realPath)) {
|
if (!isWithinRoot(scopeRoot, realPath)) {
|
||||||
@ -163,12 +200,26 @@ export async function readXWorkmateArtifact(input) {
|
|||||||
throw new Error("relativePath must point to a file");
|
throw new Error("relativePath must point to a file");
|
||||||
}
|
}
|
||||||
const bytes = await fs.readFile(realPath);
|
const bytes = await fs.readFile(realPath);
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
if (refPayload && (refPayload.sizeBytes !== bytes.byteLength || refPayload.sha256 !== sha256)) {
|
||||||
|
throw new Error("artifactRef does not match file content");
|
||||||
|
}
|
||||||
const artifact = {
|
const artifact = {
|
||||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||||
label: path.posix.basename(relativePath),
|
label: path.posix.basename(relativePath),
|
||||||
contentType: contentTypeForPath(relativePath),
|
contentType: contentTypeForPath(relativePath),
|
||||||
sizeBytes: bytes.byteLength,
|
sizeBytes: bytes.byteLength,
|
||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256,
|
||||||
|
artifactRef: requestedArtifactRef ||
|
||||||
|
signArtifactRef({
|
||||||
|
v: 1,
|
||||||
|
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||||
|
scopeKind,
|
||||||
|
...(artifactScope ? { artifactScope } : {}),
|
||||||
|
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
sha256,
|
||||||
|
}, pluginConfig),
|
||||||
scopeKind,
|
scopeKind,
|
||||||
};
|
};
|
||||||
if (artifactScope) {
|
if (artifactScope) {
|
||||||
@ -247,6 +298,9 @@ async function collectCandidates(input) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (SKIPPED_DIRS.has(entry.name)) {
|
if (SKIPPED_DIRS.has(entry.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -281,9 +335,7 @@ async function collectCandidates(input) {
|
|||||||
}
|
}
|
||||||
function artifactScopeFor(sessionKey, runId) {
|
function artifactScopeFor(sessionKey, runId) {
|
||||||
return [
|
return [
|
||||||
".xworkmate",
|
TASK_SCOPE_ROOT,
|
||||||
"artifacts",
|
|
||||||
"tasks",
|
|
||||||
safeScopeSegment(sessionKey),
|
safeScopeSegment(sessionKey),
|
||||||
safeScopeSegment(runId),
|
safeScopeSegment(runId),
|
||||||
].join("/");
|
].join("/");
|
||||||
@ -303,7 +355,15 @@ function optionalArtifactScope(value) {
|
|||||||
if (!scope) {
|
if (!scope) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return safeInputRelativePath(scope, "artifactScope");
|
return safeTaskArtifactScope(scope);
|
||||||
|
}
|
||||||
|
function safeTaskArtifactScope(value) {
|
||||||
|
const scope = safeInputRelativePath(value, "artifactScope");
|
||||||
|
const parts = scope.split("/");
|
||||||
|
if (parts.length !== 3 || parts[0] !== TASK_SCOPE_ROOT) {
|
||||||
|
throw new Error("artifactScope must be a task artifact scope");
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
}
|
}
|
||||||
function safeInputRelativePath(value, label) {
|
function safeInputRelativePath(value, label) {
|
||||||
const relativePath = optionalString(value);
|
const relativePath = optionalString(value);
|
||||||
@ -320,7 +380,7 @@ function safeInputRelativePath(value, label) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
function resolveScopeRoot(workspaceRoot, artifactScope) {
|
function resolveScopeRoot(workspaceRoot, artifactScope) {
|
||||||
const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope");
|
const normalizedScope = safeTaskArtifactScope(artifactScope);
|
||||||
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
|
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
|
||||||
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
|
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
|
||||||
throw new Error("artifactScope must stay inside the workspace");
|
throw new Error("artifactScope must stay inside the workspace");
|
||||||
@ -463,6 +523,75 @@ function nonNegativeNumber(value, fallback) {
|
|||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
function signArtifactRef(payload, pluginConfig) {
|
||||||
|
const body = base64UrlEncode(JSON.stringify(payload));
|
||||||
|
const signature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
|
||||||
|
return `${body}.${signature}`;
|
||||||
|
}
|
||||||
|
function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
|
||||||
|
const [body, signature, ...extra] = artifactRef.split(".");
|
||||||
|
if (!body || !signature || extra.length > 0) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const expectedSignature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
|
||||||
|
if (!constantTimeEqual(signature, expectedSignature)) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const payload = objectRecord(parsed);
|
||||||
|
const scopeKind = optionalString(payload.scopeKind);
|
||||||
|
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
|
||||||
|
const artifactScope = optionalArtifactScope(payload.artifactScope);
|
||||||
|
if (scopeKind === "task" && !artifactScope) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
if (scopeKind !== "task" && artifactScope) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
|
||||||
|
const sha256 = optionalString(payload.sha256).toLowerCase();
|
||||||
|
if (payload.v !== 1 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
|
||||||
|
throw new Error("artifactRef does not match workspace");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||||
|
scopeKind,
|
||||||
|
...(artifactScope ? { artifactScope } : {}),
|
||||||
|
relativePath,
|
||||||
|
sizeBytes,
|
||||||
|
sha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function artifactRefSigningSecret(pluginConfig) {
|
||||||
|
return (optionalString(pluginConfig.artifactRefSigningSecret) ||
|
||||||
|
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
|
||||||
|
optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) ||
|
||||||
|
GENERATED_ARTIFACT_REF_SECRET);
|
||||||
|
}
|
||||||
|
function workspaceRootHash(workspaceRoot) {
|
||||||
|
return createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex");
|
||||||
|
}
|
||||||
|
function base64UrlEncode(value) {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
function constantTimeEqual(left, right) {
|
||||||
|
const leftBytes = Buffer.from(left);
|
||||||
|
const rightBytes = Buffer.from(right);
|
||||||
|
return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes);
|
||||||
|
}
|
||||||
function expandUserPath(value) {
|
function expandUserPath(value) {
|
||||||
if (value === "~") {
|
if (value === "~") {
|
||||||
return os.homedir();
|
return os.homedir();
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import plugin from "./index.js";
|
import plugin from "./index.js";
|
||||||
@ -5,6 +6,16 @@ import plugin from "./index.js";
|
|||||||
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||||
|
|
||||||
describe("plugin registration", () => {
|
describe("plugin registration", () => {
|
||||||
|
it("declares registered agent tools in the manifest contract", () => {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync("openclaw.plugin.json", "utf8")) as {
|
||||||
|
contracts?: { tools?: string[] };
|
||||||
|
configSchema?: { properties?: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(manifest.contracts?.tools).toContain("xworkmate_artifacts");
|
||||||
|
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("registers the xworkmate artifact export gateway method", () => {
|
it("registers the xworkmate artifact export gateway method", () => {
|
||||||
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
||||||
const tools: unknown[] = [];
|
const tools: unknown[] = [];
|
||||||
|
|||||||
10
index.ts
10
index.ts
@ -111,7 +111,15 @@ function createXWorkmateArtifactsTool(
|
|||||||
},
|
},
|
||||||
relativePath: {
|
relativePath: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Artifact path relative to the workspace. Required for action=read.",
|
description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.",
|
||||||
|
},
|
||||||
|
artifactScope: {
|
||||||
|
type: "string",
|
||||||
|
description: "Task artifact scope returned by prepare/export, for example tasks/<session>/<run>.",
|
||||||
|
},
|
||||||
|
artifactRef: {
|
||||||
|
type: "string",
|
||||||
|
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
|
||||||
},
|
},
|
||||||
sinceUnixMs: {
|
sinceUnixMs: {
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|||||||
@ -5,6 +5,9 @@
|
|||||||
"activation": {
|
"activation": {
|
||||||
"onStartup": true
|
"onStartup": true
|
||||||
},
|
},
|
||||||
|
"contracts": {
|
||||||
|
"tools": ["xworkmate_artifacts"]
|
||||||
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@ -20,6 +23,10 @@
|
|||||||
"maxInlineBytes": {
|
"maxInlineBytes": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Default maximum file size to inline as base64."
|
"description": "Default maximum file size to inline as base64."
|
||||||
|
},
|
||||||
|
"artifactRefSigningSecret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -35,6 +42,11 @@
|
|||||||
"maxInlineBytes": {
|
"maxInlineBytes": {
|
||||||
"label": "Max Inline Bytes",
|
"label": "Max Inline Bytes",
|
||||||
"help": "Upper bound for base64 inline artifact content. Default: 10485760."
|
"help": "Upper bound for base64 inline artifact content. Default: 10485760."
|
||||||
|
},
|
||||||
|
"artifactRefSigningSecret": {
|
||||||
|
"label": "Artifact Ref Signing Secret",
|
||||||
|
"help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.",
|
||||||
|
"sensitive": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,8 +22,8 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
pluginConfig: { workspaceDir: root },
|
pluginConfig: { workspaceDir: root },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(first.artifactScope).toMatch(/^\.xworkmate\/artifacts\/tasks\/thread-main-[a-f0-9]{12}\/turn-1-[a-f0-9]{12}$/);
|
expect(first.artifactScope).toMatch(/^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(second.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-2-[a-f0-9]{12}$/);
|
||||||
expect(first.artifactScope).not.toBe(second.artifactScope);
|
expect(first.artifactScope).not.toBe(second.artifactScope);
|
||||||
expect((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true);
|
expect((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true);
|
||||||
expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
||||||
@ -58,6 +58,7 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
encoding: "base64",
|
encoding: "base64",
|
||||||
content: Buffer.from("# Done\n").toString("base64"),
|
content: Buffer.from("# Done\n").toString("base64"),
|
||||||
});
|
});
|
||||||
|
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
||||||
expect(result.manifestMarkdown).toContain("reports/final.md");
|
expect(result.manifestMarkdown).toContain("reports/final.md");
|
||||||
expect(result.manifestMarkdown).toContain("text/markdown");
|
expect(result.manifestMarkdown).toContain("text/markdown");
|
||||||
});
|
});
|
||||||
@ -140,7 +141,12 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||||
pluginConfig: { workspaceDir: root },
|
pluginConfig: { workspaceDir: root },
|
||||||
});
|
});
|
||||||
|
const otherTask = await prepareXWorkmateArtifacts({
|
||||||
|
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
await fs.writeFile(path.join(root, "existing.pdf"), "pdf");
|
await fs.writeFile(path.join(root, "existing.pdf"), "pdf");
|
||||||
|
await fs.writeFile(path.join(otherTask.artifactDirectory, "other-task.txt"), "other");
|
||||||
await fs.mkdir(path.join(root, ".xworkmate", "metadata"), { recursive: true });
|
await fs.mkdir(path.join(root, ".xworkmate", "metadata"), { recursive: true });
|
||||||
await fs.writeFile(path.join(root, ".xworkmate", "metadata", "internal.json"), "{}");
|
await fs.writeFile(path.join(root, ".xworkmate", "metadata", "internal.json"), "{}");
|
||||||
const stat = await fs.stat(path.join(root, "existing.pdf"));
|
const stat = await fs.stat(path.join(root, "existing.pdf"));
|
||||||
@ -161,6 +167,7 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["existing.pdf"]);
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["existing.pdf"]);
|
||||||
expect(result.artifacts[0]?.artifactScope).toBeUndefined();
|
expect(result.artifacts[0]?.artifactScope).toBeUndefined();
|
||||||
expect(result.artifacts[0]?.scopeKind).toBe("workspace-latest");
|
expect(result.artifacts[0]?.scopeKind).toBe("workspace-latest");
|
||||||
|
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
||||||
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest workspace files instead");
|
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest workspace files instead");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -242,27 +249,21 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]);
|
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads one artifact by relative path", async () => {
|
it("rejects unscoped artifact reads by relative path", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
||||||
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
||||||
await fs.writeFile(path.join(root, "reports", "final.txt"), "final");
|
await fs.writeFile(path.join(root, "reports", "final.txt"), "final");
|
||||||
|
|
||||||
const result = await readXWorkmateArtifact({
|
await expect(
|
||||||
params: {
|
readXWorkmateArtifact({
|
||||||
sessionKey: "thread-main",
|
params: {
|
||||||
runId: "run-1",
|
sessionKey: "thread-main",
|
||||||
relativePath: "reports/final.txt",
|
runId: "run-1",
|
||||||
},
|
relativePath: "reports/final.txt",
|
||||||
pluginConfig: { workspaceDir: root },
|
},
|
||||||
});
|
pluginConfig: { workspaceDir: root },
|
||||||
|
}),
|
||||||
expect(result.artifacts).toHaveLength(1);
|
).rejects.toThrow("artifactScope or artifactRef required");
|
||||||
expect(result.artifacts[0]).toMatchObject({
|
|
||||||
relativePath: "reports/final.txt",
|
|
||||||
contentType: "text/plain",
|
|
||||||
encoding: "base64",
|
|
||||||
content: Buffer.from("final").toString("base64"),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads one artifact inside a task artifact scope", async () => {
|
it("reads one artifact inside a task artifact scope", async () => {
|
||||||
@ -293,16 +294,85 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
encoding: "base64",
|
encoding: "base64",
|
||||||
content: Buffer.from("final").toString("base64"),
|
content: Buffer.from("final").toString("base64"),
|
||||||
});
|
});
|
||||||
|
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a latest workspace artifact only through its artifactRef", 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.txt"), "existing");
|
||||||
|
|
||||||
|
const exported = await exportXWorkmateArtifacts({
|
||||||
|
params: {
|
||||||
|
sessionKey: "thread-main",
|
||||||
|
runId: "turn-1",
|
||||||
|
artifactScope: prepared.artifactScope,
|
||||||
|
sinceUnixMs: Date.now() + 10_000,
|
||||||
|
latestIfEmpty: true,
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await readXWorkmateArtifact({
|
||||||
|
params: {
|
||||||
|
sessionKey: "thread-main",
|
||||||
|
runId: "turn-1",
|
||||||
|
artifactRef: exported.artifacts[0]?.artifactRef,
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.scopeKind).toBe("workspace-latest");
|
||||||
|
expect(result.artifactScope).toBeUndefined();
|
||||||
|
expect(result.artifacts[0]).toMatchObject({
|
||||||
|
relativePath: "existing.txt",
|
||||||
|
scopeKind: "workspace-latest",
|
||||||
|
encoding: "base64",
|
||||||
|
content: Buffer.from("existing").toString("base64"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tampered artifact refs", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
||||||
|
await fs.writeFile(path.join(root, "existing.txt"), "existing");
|
||||||
|
const exported = await exportXWorkmateArtifacts({
|
||||||
|
params: {
|
||||||
|
sessionKey: "thread-main",
|
||||||
|
runId: "run-1",
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
const artifactRef = exported.artifacts[0]?.artifactRef ?? "";
|
||||||
|
const tampered = `${artifactRef}x`;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
readXWorkmateArtifact({
|
||||||
|
params: {
|
||||||
|
sessionKey: "thread-main",
|
||||||
|
runId: "run-1",
|
||||||
|
artifactRef: tampered,
|
||||||
|
},
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("invalid artifactRef");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads artifact metadata without inline content when the file exceeds the limit", async () => {
|
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-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
||||||
await fs.writeFile(path.join(root, "large.bin"), Buffer.from("large-content"));
|
const prepared = await prepareXWorkmateArtifacts({
|
||||||
|
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(prepared.artifactDirectory, "large.bin"), Buffer.from("large-content"));
|
||||||
|
|
||||||
const result = await readXWorkmateArtifact({
|
const result = await readXWorkmateArtifact({
|
||||||
params: {
|
params: {
|
||||||
sessionKey: "thread-main",
|
sessionKey: "thread-main",
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
|
artifactScope: prepared.artifactScope,
|
||||||
relativePath: "large.bin",
|
relativePath: "large.bin",
|
||||||
maxInlineBytes: 2,
|
maxInlineBytes: 2,
|
||||||
},
|
},
|
||||||
@ -323,12 +393,17 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
|
|
||||||
it("rejects relative path traversal when reading artifacts", async () => {
|
it("rejects relative path traversal when reading artifacts", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-"));
|
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 expect(
|
await expect(
|
||||||
readXWorkmateArtifact({
|
readXWorkmateArtifact({
|
||||||
params: {
|
params: {
|
||||||
sessionKey: "thread-main",
|
sessionKey: "thread-main",
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
|
artifactScope: prepared.artifactScope,
|
||||||
relativePath: "../outside.txt",
|
relativePath: "../outside.txt",
|
||||||
},
|
},
|
||||||
pluginConfig: { workspaceDir: root },
|
pluginConfig: { workspaceDir: root },
|
||||||
@ -357,13 +432,18 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-outside-"));
|
const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-outside-"));
|
||||||
const outsideFile = path.join(outsideRoot, "secret.txt");
|
const outsideFile = path.join(outsideRoot, "secret.txt");
|
||||||
await fs.writeFile(outsideFile, "secret");
|
await fs.writeFile(outsideFile, "secret");
|
||||||
await fs.symlink(outsideFile, path.join(root, "linked-secret.txt"));
|
const prepared = await prepareXWorkmateArtifacts({
|
||||||
|
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||||
|
pluginConfig: { workspaceDir: root },
|
||||||
|
});
|
||||||
|
await fs.symlink(outsideFile, path.join(prepared.artifactDirectory, "linked-secret.txt"));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
readXWorkmateArtifact({
|
readXWorkmateArtifact({
|
||||||
params: {
|
params: {
|
||||||
sessionKey: "thread-main",
|
sessionKey: "thread-main",
|
||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
|
artifactScope: prepared.artifactScope,
|
||||||
relativePath: "linked-secret.txt",
|
relativePath: "linked-secret.txt",
|
||||||
},
|
},
|
||||||
pluginConfig: { workspaceDir: root },
|
pluginConfig: { workspaceDir: root },
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const DEFAULT_MAX_FILES = 64;
|
const DEFAULT_MAX_FILES = 64;
|
||||||
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const TASK_SCOPE_ROOT = "tasks";
|
||||||
|
const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex");
|
||||||
|
|
||||||
const SKIPPED_DIRS = new Set([
|
const SKIPPED_DIRS = new Set([
|
||||||
".git",
|
".git",
|
||||||
@ -25,6 +27,7 @@ export type XWorkmateArtifact = {
|
|||||||
contentType: string;
|
contentType: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
|
artifactRef: string;
|
||||||
artifactScope?: string;
|
artifactScope?: string;
|
||||||
scopeKind?: XWorkmateArtifactScopeKind;
|
scopeKind?: XWorkmateArtifactScopeKind;
|
||||||
encoding?: "base64";
|
encoding?: "base64";
|
||||||
@ -69,6 +72,16 @@ type ReadInput = {
|
|||||||
pluginConfig?: Record<string, unknown>;
|
pluginConfig?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ArtifactRefPayload = {
|
||||||
|
v: 1;
|
||||||
|
workspaceRootHash: string;
|
||||||
|
scopeKind: XWorkmateArtifactScopeKind;
|
||||||
|
artifactScope?: string;
|
||||||
|
relativePath: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
sha256: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Candidate = {
|
type Candidate = {
|
||||||
absolutePath: string;
|
absolutePath: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
@ -135,6 +148,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
|||||||
scanRoot: scopeRoot,
|
scanRoot: scopeRoot,
|
||||||
relativeRoot: scopeRoot,
|
relativeRoot: scopeRoot,
|
||||||
sinceUnixMs,
|
sinceUnixMs,
|
||||||
|
skipTaskScopeRoot: !scopedExport,
|
||||||
warnings,
|
warnings,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -144,6 +158,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
|||||||
scanRoot: workspaceRoot,
|
scanRoot: workspaceRoot,
|
||||||
relativeRoot: workspaceRoot,
|
relativeRoot: workspaceRoot,
|
||||||
sinceUnixMs: 0,
|
sinceUnixMs: 0,
|
||||||
|
skipTaskScopeRoot: true,
|
||||||
warnings: latestWarnings,
|
warnings: latestWarnings,
|
||||||
});
|
});
|
||||||
if (latestCandidates.length > 0) {
|
if (latestCandidates.length > 0) {
|
||||||
@ -170,12 +185,25 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const bytes = await fs.readFile(candidate.absolutePath);
|
const bytes = await fs.readFile(candidate.absolutePath);
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
const artifact: XWorkmateArtifact = {
|
const artifact: XWorkmateArtifact = {
|
||||||
relativePath: candidate.relativePath,
|
relativePath: candidate.relativePath,
|
||||||
label: path.posix.basename(candidate.relativePath),
|
label: path.posix.basename(candidate.relativePath),
|
||||||
contentType: contentTypeForPath(candidate.relativePath),
|
contentType: contentTypeForPath(candidate.relativePath),
|
||||||
sizeBytes: bytes.byteLength,
|
sizeBytes: bytes.byteLength,
|
||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256,
|
||||||
|
artifactRef: signArtifactRef(
|
||||||
|
{
|
||||||
|
v: 1,
|
||||||
|
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||||
|
scopeKind,
|
||||||
|
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
|
||||||
|
relativePath: candidate.relativePath,
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
sha256,
|
||||||
|
},
|
||||||
|
pluginConfig,
|
||||||
|
),
|
||||||
scopeKind,
|
scopeKind,
|
||||||
};
|
};
|
||||||
if (scopeKind === "task" && artifactScope) {
|
if (scopeKind === "task" && artifactScope) {
|
||||||
@ -211,8 +239,10 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
|||||||
const pluginConfig = input.pluginConfig ?? {};
|
const pluginConfig = input.pluginConfig ?? {};
|
||||||
const runId = optionalString(params.runId) || "read";
|
const runId = optionalString(params.runId) || "read";
|
||||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||||
const relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
let relativePath = "";
|
||||||
|
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||||
|
let refPayload: ArtifactRefPayload | undefined;
|
||||||
const maxInlineBytes = nonNegativeInteger(
|
const maxInlineBytes = nonNegativeInteger(
|
||||||
params.maxInlineBytes,
|
params.maxInlineBytes,
|
||||||
pluginConfig.maxInlineBytes,
|
pluginConfig.maxInlineBytes,
|
||||||
@ -225,8 +255,28 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
});
|
});
|
||||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||||
|
if (requestedArtifactRef) {
|
||||||
|
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
|
||||||
|
relativePath = refPayload.relativePath;
|
||||||
|
if (refPayload.artifactScope) {
|
||||||
|
artifactScope = refPayload.artifactScope;
|
||||||
|
}
|
||||||
|
const requestedPath = optionalString(params.relativePath);
|
||||||
|
if (requestedPath && safeInputRelativePath(requestedPath, "relativePath") !== relativePath) {
|
||||||
|
throw new Error("artifactRef does not match relativePath");
|
||||||
|
}
|
||||||
|
const requestedScope = optionalArtifactScope(params.artifactScope);
|
||||||
|
if (requestedScope && requestedScope !== artifactScope) {
|
||||||
|
throw new Error("artifactRef does not match artifactScope");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!artifactScope) {
|
||||||
|
throw new Error("artifactScope or artifactRef required");
|
||||||
|
}
|
||||||
|
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||||
|
}
|
||||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||||
const scopeKind: XWorkmateArtifactScopeKind = artifactScope ? "task" : "workspace";
|
const scopeKind: XWorkmateArtifactScopeKind = refPayload?.scopeKind ?? "task";
|
||||||
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
|
const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep));
|
||||||
const realPath = await fs.realpath(absolutePath);
|
const realPath = await fs.realpath(absolutePath);
|
||||||
if (!isWithinRoot(scopeRoot, realPath)) {
|
if (!isWithinRoot(scopeRoot, realPath)) {
|
||||||
@ -237,12 +287,30 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
|||||||
throw new Error("relativePath must point to a file");
|
throw new Error("relativePath must point to a file");
|
||||||
}
|
}
|
||||||
const bytes = await fs.readFile(realPath);
|
const bytes = await fs.readFile(realPath);
|
||||||
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
if (refPayload && (refPayload.sizeBytes !== bytes.byteLength || refPayload.sha256 !== sha256)) {
|
||||||
|
throw new Error("artifactRef does not match file content");
|
||||||
|
}
|
||||||
const artifact: XWorkmateArtifact = {
|
const artifact: XWorkmateArtifact = {
|
||||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||||
label: path.posix.basename(relativePath),
|
label: path.posix.basename(relativePath),
|
||||||
contentType: contentTypeForPath(relativePath),
|
contentType: contentTypeForPath(relativePath),
|
||||||
sizeBytes: bytes.byteLength,
|
sizeBytes: bytes.byteLength,
|
||||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
sha256,
|
||||||
|
artifactRef:
|
||||||
|
requestedArtifactRef ||
|
||||||
|
signArtifactRef(
|
||||||
|
{
|
||||||
|
v: 1,
|
||||||
|
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||||
|
scopeKind,
|
||||||
|
...(artifactScope ? { artifactScope } : {}),
|
||||||
|
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
sha256,
|
||||||
|
},
|
||||||
|
pluginConfig,
|
||||||
|
),
|
||||||
scopeKind,
|
scopeKind,
|
||||||
};
|
};
|
||||||
if (artifactScope) {
|
if (artifactScope) {
|
||||||
@ -311,6 +379,7 @@ async function collectCandidates(input: {
|
|||||||
scanRoot: string;
|
scanRoot: string;
|
||||||
relativeRoot: string;
|
relativeRoot: string;
|
||||||
sinceUnixMs: number;
|
sinceUnixMs: number;
|
||||||
|
skipTaskScopeRoot: boolean;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}): Promise<Candidate[]> {
|
}): Promise<Candidate[]> {
|
||||||
const candidates: Candidate[] = [];
|
const candidates: Candidate[] = [];
|
||||||
@ -336,6 +405,9 @@ async function collectCandidates(input: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (SKIPPED_DIRS.has(entry.name)) {
|
if (SKIPPED_DIRS.has(entry.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -371,9 +443,7 @@ async function collectCandidates(input: {
|
|||||||
|
|
||||||
function artifactScopeFor(sessionKey: string, runId: string): string {
|
function artifactScopeFor(sessionKey: string, runId: string): string {
|
||||||
return [
|
return [
|
||||||
".xworkmate",
|
TASK_SCOPE_ROOT,
|
||||||
"artifacts",
|
|
||||||
"tasks",
|
|
||||||
safeScopeSegment(sessionKey),
|
safeScopeSegment(sessionKey),
|
||||||
safeScopeSegment(runId),
|
safeScopeSegment(runId),
|
||||||
].join("/");
|
].join("/");
|
||||||
@ -395,7 +465,16 @@ function optionalArtifactScope(value: unknown): string {
|
|||||||
if (!scope) {
|
if (!scope) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return safeInputRelativePath(scope, "artifactScope");
|
return safeTaskArtifactScope(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeTaskArtifactScope(value: unknown): string {
|
||||||
|
const scope = safeInputRelativePath(value, "artifactScope");
|
||||||
|
const parts = scope.split("/");
|
||||||
|
if (parts.length !== 3 || parts[0] !== TASK_SCOPE_ROOT) {
|
||||||
|
throw new Error("artifactScope must be a task artifact scope");
|
||||||
|
}
|
||||||
|
return scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeInputRelativePath(value: unknown, label: string): string {
|
function safeInputRelativePath(value: unknown, label: string): string {
|
||||||
@ -414,7 +493,7 @@ function safeInputRelativePath(value: unknown, label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveScopeRoot(workspaceRoot: string, artifactScope: string): string {
|
function resolveScopeRoot(workspaceRoot: string, artifactScope: string): string {
|
||||||
const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope");
|
const normalizedScope = safeTaskArtifactScope(artifactScope);
|
||||||
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
|
const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep));
|
||||||
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
|
if (!isWithinRoot(workspaceRoot, scopeRoot)) {
|
||||||
throw new Error("artifactScope must stay inside the workspace");
|
throw new Error("artifactScope must stay inside the workspace");
|
||||||
@ -577,6 +656,86 @@ function nonNegativeNumber(value: unknown, fallback: number): number {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function signArtifactRef(payload: ArtifactRefPayload, pluginConfig: Record<string, unknown>): string {
|
||||||
|
const body = base64UrlEncode(JSON.stringify(payload));
|
||||||
|
const signature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
|
||||||
|
return `${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyArtifactRef(
|
||||||
|
artifactRef: string,
|
||||||
|
workspaceRoot: string,
|
||||||
|
pluginConfig: Record<string, unknown>,
|
||||||
|
): ArtifactRefPayload {
|
||||||
|
const [body, signature, ...extra] = artifactRef.split(".");
|
||||||
|
if (!body || !signature || extra.length > 0) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const expectedSignature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url");
|
||||||
|
if (!constantTimeEqual(signature, expectedSignature)) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const payload = objectRecord(parsed);
|
||||||
|
const scopeKind = optionalString(payload.scopeKind) as XWorkmateArtifactScopeKind;
|
||||||
|
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
|
||||||
|
const artifactScope = optionalArtifactScope(payload.artifactScope);
|
||||||
|
if (scopeKind === "task" && !artifactScope) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
if (scopeKind !== "task" && artifactScope) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
|
||||||
|
const sha256 = optionalString(payload.sha256).toLowerCase();
|
||||||
|
if (payload.v !== 1 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||||
|
throw new Error("invalid artifactRef");
|
||||||
|
}
|
||||||
|
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
|
||||||
|
throw new Error("artifactRef does not match workspace");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||||
|
scopeKind,
|
||||||
|
...(artifactScope ? { artifactScope } : {}),
|
||||||
|
relativePath,
|
||||||
|
sizeBytes,
|
||||||
|
sha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactRefSigningSecret(pluginConfig: Record<string, unknown>): string {
|
||||||
|
return (
|
||||||
|
optionalString(pluginConfig.artifactRefSigningSecret) ||
|
||||||
|
optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) ||
|
||||||
|
optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) ||
|
||||||
|
GENERATED_ARTIFACT_REF_SECRET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceRootHash(workspaceRoot: string): string {
|
||||||
|
return createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(value: string): string {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function constantTimeEqual(left: string, right: string): boolean {
|
||||||
|
const leftBytes = Buffer.from(left);
|
||||||
|
const rightBytes = Buffer.from(right);
|
||||||
|
return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes);
|
||||||
|
}
|
||||||
|
|
||||||
function expandUserPath(value: string): string {
|
function expandUserPath(value: string): string {
|
||||||
if (value === "~") {
|
if (value === "~") {
|
||||||
return os.homedir();
|
return os.homedir();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user