fix: align openclaw artifact task scopes
This commit is contained in:
parent
bbc21098f7
commit
88b073d9d3
24
README.md
24
README.md
@ -70,6 +70,9 @@ Prepare request params are supplied by the OpenClaw host, bridge, or APP
|
||||
runtime. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
|
||||
trusted mapping into OpenClaw's built-in multi-session model; it does not parse
|
||||
paths from chat text and does not invent fallback session/run identities.
|
||||
Gateway methods accept these fields from bridge/app runtime params. The optional
|
||||
agent tool does not expose these fields to the model; it only uses host-injected
|
||||
tool context.
|
||||
|
||||
```json
|
||||
{
|
||||
@ -136,20 +139,21 @@ Export 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, including the top-level `tasks/` directory so other runs are
|
||||
not exported as workspace fallback files.
|
||||
When `artifactScope` is omitted, export/list defaults to the current task scope
|
||||
derived from `sessionKey/runId`. When that current task scope has no 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, including the top-level
|
||||
`tasks/` directory so other runs are not exported as workspace fallback files.
|
||||
|
||||
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||
the artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||
`artifactScope + relativePath` for the current `sessionKey/runId` task scope.
|
||||
Signed task `artifactRef` values are accepted for the current session, including
|
||||
same-session historical task fallback results returned by the plugin. Workspace
|
||||
fallback files must be read with `artifactRef`; there is no unscoped arbitrary
|
||||
workspace read API.
|
||||
fallback files must be read with a same-session and same-run `artifactRef`; there
|
||||
is no unscoped arbitrary workspace read API.
|
||||
|
||||
## View And Download
|
||||
|
||||
@ -196,7 +200,9 @@ only remote file access path.
|
||||
- Symlinks are skipped to avoid workspace escape.
|
||||
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
||||
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
||||
- `export` and `list` default to the current task scope when `artifactScope` is omitted.
|
||||
- Direct `artifactScope + relativePath` reads and scoped exports must match the supplied `sessionKey/runId`.
|
||||
- `artifactRef` is bound to the issued session/run and cannot be reused from another run.
|
||||
- `artifactScope`, `artifactRef`, and `relativePath` must stay inside the workspace; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
|
||||
|
||||
## Development
|
||||
|
||||
21
dist/index.js
vendored
21
dist/index.js
vendored
@ -102,18 +102,6 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
type: "string",
|
||||
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
|
||||
},
|
||||
sessionKey: {
|
||||
type: "string",
|
||||
description: "OpenClaw session key supplied by the host or bridge runtime.",
|
||||
},
|
||||
runId: {
|
||||
type: "string",
|
||||
description: "OpenClaw run id supplied by the host or bridge runtime.",
|
||||
},
|
||||
workspaceDir: {
|
||||
type: "string",
|
||||
description: "OpenClaw workspace directory supplied by the host or bridge runtime.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
|
||||
@ -131,17 +119,18 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const action = typeof params.action === "string" ? params.action : "";
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ctx.sessionKey;
|
||||
const runId = typeof params.runId === "string" ? params.runId : "";
|
||||
const workspaceDir = typeof params.workspaceDir === "string" ? params.workspaceDir : ctx.workspaceDir;
|
||||
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
|
||||
const runId = ctx.sessionScope?.runId || ctx.runId || "";
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const { sessionKey: _ignoredSessionKey, runId: _ignoredRunId, workspaceDir: _ignoredWorkspaceDir, ...operationParams } = params;
|
||||
const baseParams = {
|
||||
...params,
|
||||
...operationParams,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
|
||||
82
dist/src/exportArtifacts.js
vendored
82
dist/src/exportArtifacts.js
vendored
@ -69,19 +69,20 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const warnings = [];
|
||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
if (artifactScope && artifactScope !== expectedArtifactScope) {
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
const scopedExport = artifactScope !== "";
|
||||
let scopeKind = scopedExport ? "task" : "workspace";
|
||||
const sessionScope = taskSessionScopeFor(sessionKey);
|
||||
const artifactScope = requestedArtifactScope || expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
let scopeKind = "task";
|
||||
let candidates = await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
skipTaskScopeRoot: !scopedExport,
|
||||
skipTaskScopeRoot: false,
|
||||
warnings,
|
||||
});
|
||||
if (candidates.length === 0 && latestIfEmpty) {
|
||||
@ -101,11 +102,11 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
});
|
||||
if (latestCandidates.length > 0) {
|
||||
warnings.push(...latestWarnings);
|
||||
if (scopedExport) {
|
||||
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
|
||||
if (latestTaskScopeIfEmpty) {
|
||||
warnings.push("scoped artifact directory is empty; exported latest session task files instead");
|
||||
}
|
||||
else if (latestTaskScopeIfEmpty) {
|
||||
warnings.push("workspace export is empty; exported latest session task files instead");
|
||||
else {
|
||||
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
|
||||
}
|
||||
candidates = latestCandidates;
|
||||
scopeKind = "workspace-latest";
|
||||
@ -134,9 +135,11 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256,
|
||||
artifactRef: signArtifactRef({
|
||||
v: 1,
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind: scopeKindForCandidate,
|
||||
sessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
|
||||
? { artifactScope: artifactScopeForCandidate }
|
||||
: {}),
|
||||
@ -163,7 +166,7 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath",
|
||||
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
|
||||
...(scopeKind === "task" ? { artifactScope } : {}),
|
||||
scopeKind,
|
||||
artifacts,
|
||||
warnings,
|
||||
@ -194,6 +197,7 @@ export async function readXWorkmateArtifact(input) {
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
if (requestedArtifactRef) {
|
||||
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
|
||||
assertArtifactRefMatchesRequest(refPayload, expectedArtifactScope, expectedSessionScope);
|
||||
relativePath = refPayload.relativePath;
|
||||
if (refPayload.artifactScope) {
|
||||
artifactScope = refPayload.artifactScope;
|
||||
@ -245,9 +249,11 @@ export async function readXWorkmateArtifact(input) {
|
||||
sha256,
|
||||
artifactRef: requestedArtifactRef ||
|
||||
signArtifactRef({
|
||||
v: 1,
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind,
|
||||
sessionScope: expectedSessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(artifactScope ? { artifactScope } : {}),
|
||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||
sizeBytes: bytes.byteLength,
|
||||
@ -419,15 +425,18 @@ function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope,
|
||||
}
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
function assertArtifactRefMatchesRequest(payload, expectedRunScope, expectedSessionScope) {
|
||||
if (payload.sessionScope !== expectedSessionScope || payload.runScope !== expectedRunScope) {
|
||||
throw new Error("artifactRef does not match sessionKey/runId");
|
||||
}
|
||||
}
|
||||
function safeScopeSegment(value) {
|
||||
const normalized = value
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "_")
|
||||
.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}`;
|
||||
.slice(0, 96) || "scope";
|
||||
}
|
||||
function optionalArtifactScope(value) {
|
||||
const scope = optionalString(value);
|
||||
@ -446,6 +455,34 @@ function safeTaskArtifactScope(value) {
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
function safeTaskSessionScope(value) {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
let scope;
|
||||
try {
|
||||
scope = safeInputRelativePath(raw, "artifactRef sessionScope");
|
||||
}
|
||||
catch {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const parts = scope.split("/");
|
||||
const rootParts = TASK_SCOPE_ROOT.split("/");
|
||||
const scopeRoot = parts.slice(0, rootParts.length).join("/");
|
||||
if (parts.length !== rootParts.length + 1 || scopeRoot !== TASK_SCOPE_ROOT) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
function safeArtifactRefRunScope(value) {
|
||||
try {
|
||||
return safeTaskArtifactScope(value);
|
||||
}
|
||||
catch {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
}
|
||||
function safeInputRelativePath(value, label) {
|
||||
const relativePath = optionalString(value);
|
||||
if (!relativePath) {
|
||||
@ -640,16 +677,23 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
|
||||
}
|
||||
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)) {
|
||||
if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const sessionScope = safeTaskSessionScope(payload.sessionScope);
|
||||
const runScope = safeArtifactRefRunScope(payload.runScope);
|
||||
if (!runScope.startsWith(`${sessionScope}/`)) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
|
||||
throw new Error("artifactRef does not match workspace");
|
||||
}
|
||||
return {
|
||||
v: 1,
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind,
|
||||
sessionScope,
|
||||
runScope,
|
||||
...(artifactScope ? { artifactScope } : {}),
|
||||
relativePath,
|
||||
sizeBytes,
|
||||
|
||||
@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { prepareXWorkmateArtifacts } from "./src/exportArtifacts.js";
|
||||
|
||||
type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
|
||||
|
||||
@ -63,13 +64,59 @@ describe("plugin registration", () => {
|
||||
plugin.register(api);
|
||||
|
||||
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
|
||||
parameters: { properties?: Record<string, unknown> };
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
const tool = factory({});
|
||||
|
||||
await expect(tool.execute("call-1", { action: "list", runId: "turn-1" })).rejects.toThrow("sessionKey required");
|
||||
await expect(tool.execute("call-2", { action: "list", sessionKey: "thread-main" })).rejects.toThrow(
|
||||
expect(tool.parameters.properties?.sessionKey).toBeUndefined();
|
||||
expect(tool.parameters.properties?.runId).toBeUndefined();
|
||||
expect(tool.parameters.properties?.workspaceDir).toBeUndefined();
|
||||
await expect(tool.execute("call-1", { action: "list" })).rejects.toThrow("sessionKey required");
|
||||
await expect(factory({ sessionKey: "thread-main" }).execute("call-2", { action: "list" })).rejects.toThrow(
|
||||
"runId required",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses host context scope for the optional agent tool", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-tool-"));
|
||||
const current = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const other = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.promises.writeFile(path.join(current.artifactDirectory, "current.txt"), "current");
|
||||
await fs.promises.writeFile(path.join(other.artifactDirectory, "other.txt"), "other");
|
||||
await fs.promises.writeFile(path.join(root, "global.txt"), "global");
|
||||
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>;
|
||||
};
|
||||
const tool = factory({ sessionKey: "thread-main", runId: "turn-1", workspaceDir: root });
|
||||
const result = await tool.execute("call-1", {
|
||||
action: "list",
|
||||
sessionKey: "thread-other",
|
||||
runId: "turn-2",
|
||||
workspaceDir: "/",
|
||||
});
|
||||
|
||||
expect(result.content[0]?.text).toContain("current.txt");
|
||||
expect(result.content[0]?.text).not.toContain("other.txt");
|
||||
expect(result.content[0]?.text).not.toContain("global.txt");
|
||||
});
|
||||
});
|
||||
|
||||
32
index.ts
32
index.ts
@ -13,6 +13,12 @@ type XWorkmateToolContext = {
|
||||
config?: unknown;
|
||||
workspaceDir?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
sessionScope?: {
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
@ -121,18 +127,6 @@ function createXWorkmateArtifactsTool(
|
||||
type: "string",
|
||||
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
|
||||
},
|
||||
sessionKey: {
|
||||
type: "string",
|
||||
description: "OpenClaw session key supplied by the host or bridge runtime.",
|
||||
},
|
||||
runId: {
|
||||
type: "string",
|
||||
description: "OpenClaw run id supplied by the host or bridge runtime.",
|
||||
},
|
||||
workspaceDir: {
|
||||
type: "string",
|
||||
description: "OpenClaw workspace directory supplied by the host or bridge runtime.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
|
||||
@ -150,17 +144,23 @@ function createXWorkmateArtifactsTool(
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const action = typeof params.action === "string" ? params.action : "";
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ctx.sessionKey;
|
||||
const runId = typeof params.runId === "string" ? params.runId : "";
|
||||
const workspaceDir = typeof params.workspaceDir === "string" ? params.workspaceDir : ctx.workspaceDir;
|
||||
const sessionKey = ctx.sessionScope?.sessionKey || ctx.sessionKey;
|
||||
const runId = ctx.sessionScope?.runId || ctx.runId || "";
|
||||
const workspaceDir = ctx.sessionScope?.workspaceDir || ctx.workspaceDir;
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const {
|
||||
sessionKey: _ignoredSessionKey,
|
||||
runId: _ignoredRunId,
|
||||
workspaceDir: _ignoredWorkspaceDir,
|
||||
...operationParams
|
||||
} = params;
|
||||
const baseParams = {
|
||||
...params,
|
||||
...operationParams,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@ -22,8 +22,8 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(first.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-1-[a-f0-9]{12}$/);
|
||||
expect(second.artifactScope).toMatch(/^tasks\/thread-main-[a-f0-9]{12}\/turn-2-[a-f0-9]{12}$/);
|
||||
expect(first.artifactScope).toBe("tasks/thread-main/turn-1");
|
||||
expect(second.artifactScope).toBe("tasks/thread-main/turn-2");
|
||||
expect(first.artifactScope).not.toBe(second.artifactScope);
|
||||
expect((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true);
|
||||
expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
||||
@ -32,8 +32,12 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
it("exports changed files with metadata and base64 content", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
||||
const filePath = path.join(root, "reports", "final.md");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, "reports"), { recursive: true });
|
||||
const filePath = path.join(prepared.artifactDirectory, "reports", "final.md");
|
||||
await fs.writeFile(filePath, "# Done\n");
|
||||
const stat = await fs.stat(filePath);
|
||||
|
||||
@ -48,6 +52,8 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
expect(result.remoteWorkingDirectory).toBe(await fs.realpath(root));
|
||||
expect(result.remoteWorkspaceRefKind).toBe("remotePath");
|
||||
expect(result.scopeKind).toBe("task");
|
||||
expect(result.artifactScope).toBe(prepared.artifactScope);
|
||||
expect(result.artifacts).toHaveLength(1);
|
||||
expect(result.artifacts[0]).toMatchObject({
|
||||
relativePath: "reports/final.md",
|
||||
@ -65,7 +71,11 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
it("filters old files by sinceUnixMs", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const oldFile = path.join(root, "old.txt");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const oldFile = path.join(prepared.artifactDirectory, "old.txt");
|
||||
await fs.writeFile(oldFile, "old");
|
||||
const stat = await fs.stat(oldFile);
|
||||
|
||||
@ -83,12 +93,16 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
it("skips excluded directories and symlinks", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
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"));
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(prepared.artifactDirectory, ".xworkmate", "artifacts"), { recursive: true });
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, ".git", "secret.txt"), "secret");
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, ".xworkmate", "artifacts", "index.json"), "{}");
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "real.txt"), "real");
|
||||
await fs.symlink(path.join(prepared.artifactDirectory, "real.txt"), path.join(prepared.artifactDirectory, "linked.txt"));
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
@ -135,6 +149,53 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the current task scope when artifactScope is omitted", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const current = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const other = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(root, "global.txt"), "global");
|
||||
await fs.writeFile(path.join(current.artifactDirectory, "current.txt"), "current");
|
||||
await fs.writeFile(path.join(other.artifactDirectory, "other.txt"), "other");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.scopeKind).toBe("task");
|
||||
expect(result.artifactScope).toBe(current.artifactScope);
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["current.txt"]);
|
||||
});
|
||||
|
||||
it("does not scan the workspace root when the current task scope is empty without latestIfEmpty", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(root, "global.txt"), "global");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.scopeKind).toBe("task");
|
||||
expect(result.artifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects scoped exports that do not match the requested session/run", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const first = await prepareXWorkmateArtifacts({
|
||||
@ -229,12 +290,43 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
|
||||
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
|
||||
]);
|
||||
expect(result.warnings).toContain("workspace export is empty; exported latest session task files instead");
|
||||
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest session task files instead");
|
||||
});
|
||||
|
||||
it("does not include another session in latest session task fallback", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const sameSessionTask = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-previous" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const otherSessionTask = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-other", runId: "turn-previous" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(sameSessionTask.artifactDirectory, "same.txt"), "same");
|
||||
await fs.writeFile(path.join(otherSessionTask.artifactDirectory, "other.txt"), "other");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-follow-up",
|
||||
latestIfEmpty: true,
|
||||
latestTaskScopeIfEmpty: true,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["same.txt"]);
|
||||
expect(result.artifacts[0]?.artifactScope).toBe(sameSessionTask.artifactScope);
|
||||
});
|
||||
|
||||
it("leaves oversized artifacts out of inline content", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await fs.writeFile(path.join(root, "large.pdf"), Buffer.from("large-content"));
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "large.pdf"), Buffer.from("large-content"));
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
@ -253,7 +345,11 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
it("can list artifacts without inline content", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await fs.writeFile(path.join(root, "small.txt"), "small");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "small.txt"), "small");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
@ -272,8 +368,12 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
|
||||
it("limits exported files", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await fs.writeFile(path.join(root, "a.txt"), "a");
|
||||
await fs.writeFile(path.join(root, "b.txt"), "b");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "a.txt"), "a");
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "b.txt"), "b");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
@ -292,7 +392,11 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
const mainRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-main-"));
|
||||
const agentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-agent-"));
|
||||
await fs.writeFile(path.join(mainRoot, "main.txt"), "main");
|
||||
await fs.writeFile(path.join(agentRoot, "agent.txt"), "agent");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "agent:research:thread-1", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: agentRoot },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "agent.txt"), "agent");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
@ -404,7 +508,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
||||
).rejects.toThrow("artifactRef does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("reads a latest workspace artifact only through its artifactRef", async () => {
|
||||
@ -445,9 +549,44 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects latest workspace artifact refs from another run", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
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 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-2",
|
||||
artifactRef: exported.artifacts[0]?.artifactRef,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
).rejects.toThrow("artifactRef does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("rejects tampered artifact refs", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await fs.writeFile(path.join(root, "existing.txt"), "existing");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
@ -470,6 +609,35 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
).rejects.toThrow("invalid artifactRef");
|
||||
});
|
||||
|
||||
it("rejects legacy v1 artifact refs", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const secret = "test-secret";
|
||||
const legacyPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
workspaceRootHash: createHash("sha256").update(path.resolve(root)).digest("hex"),
|
||||
scopeKind: "workspace-latest",
|
||||
relativePath: "existing.txt",
|
||||
sizeBytes: 8,
|
||||
sha256: createHash("sha256").update("existing").digest("hex"),
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
const signature = createHmac("sha256", secret).update(legacyPayload).digest("base64url");
|
||||
const legacyRef = `${legacyPayload}.${signature}`;
|
||||
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
artifactRef: legacyRef,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root, artifactRefSigningSecret: secret },
|
||||
}),
|
||||
).rejects.toThrow("invalid artifactRef");
|
||||
});
|
||||
|
||||
it("reads artifact metadata without inline content when the file exceeds the limit", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
|
||||
@ -73,9 +73,11 @@ type ReadInput = {
|
||||
};
|
||||
|
||||
type ArtifactRefPayload = {
|
||||
v: 1;
|
||||
v: 2;
|
||||
workspaceRootHash: string;
|
||||
scopeKind: XWorkmateArtifactScopeKind;
|
||||
sessionScope: string;
|
||||
runScope: string;
|
||||
artifactScope?: string;
|
||||
relativePath: string;
|
||||
sizeBytes: number;
|
||||
@ -148,19 +150,20 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const warnings: string[] = [];
|
||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
if (artifactScope && artifactScope !== expectedArtifactScope) {
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
const scopedExport = artifactScope !== "";
|
||||
let scopeKind: XWorkmateArtifactScopeKind = scopedExport ? "task" : "workspace";
|
||||
const sessionScope = taskSessionScopeFor(sessionKey);
|
||||
const artifactScope = requestedArtifactScope || expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
let scopeKind: XWorkmateArtifactScopeKind = "task";
|
||||
let candidates = await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
skipTaskScopeRoot: !scopedExport,
|
||||
skipTaskScopeRoot: false,
|
||||
warnings,
|
||||
});
|
||||
|
||||
@ -181,10 +184,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
});
|
||||
if (latestCandidates.length > 0) {
|
||||
warnings.push(...latestWarnings);
|
||||
if (scopedExport) {
|
||||
if (latestTaskScopeIfEmpty) {
|
||||
warnings.push("scoped artifact directory is empty; exported latest session task files instead");
|
||||
} else {
|
||||
warnings.push("scoped artifact directory is empty; exported latest workspace files instead");
|
||||
} else if (latestTaskScopeIfEmpty) {
|
||||
warnings.push("workspace export is empty; exported latest session task files instead");
|
||||
}
|
||||
candidates = latestCandidates;
|
||||
scopeKind = "workspace-latest";
|
||||
@ -217,9 +220,11 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
sha256,
|
||||
artifactRef: signArtifactRef(
|
||||
{
|
||||
v: 1,
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind: scopeKindForCandidate,
|
||||
sessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
|
||||
? { artifactScope: artifactScopeForCandidate }
|
||||
: {}),
|
||||
@ -248,7 +253,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath" as const,
|
||||
...(scopeKind === "task" && artifactScope ? { artifactScope } : {}),
|
||||
...(scopeKind === "task" ? { artifactScope } : {}),
|
||||
scopeKind,
|
||||
artifacts,
|
||||
warnings,
|
||||
@ -284,6 +289,7 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
if (requestedArtifactRef) {
|
||||
refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig);
|
||||
assertArtifactRefMatchesRequest(refPayload, expectedArtifactScope, expectedSessionScope);
|
||||
relativePath = refPayload.relativePath;
|
||||
if (refPayload.artifactScope) {
|
||||
artifactScope = refPayload.artifactScope;
|
||||
@ -336,9 +342,11 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
requestedArtifactRef ||
|
||||
signArtifactRef(
|
||||
{
|
||||
v: 1,
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind,
|
||||
sessionScope: expectedSessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(artifactScope ? { artifactScope } : {}),
|
||||
relativePath: safeRelativePath(scopeRoot, realPath),
|
||||
sizeBytes: bytes.byteLength,
|
||||
@ -542,15 +550,23 @@ function assertArtifactScopeMatchesRequest(
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
|
||||
function assertArtifactRefMatchesRequest(
|
||||
payload: ArtifactRefPayload,
|
||||
expectedRunScope: string,
|
||||
expectedSessionScope: string,
|
||||
): void {
|
||||
if (payload.sessionScope !== expectedSessionScope || payload.runScope !== expectedRunScope) {
|
||||
throw new Error("artifactRef does not match sessionKey/runId");
|
||||
}
|
||||
}
|
||||
|
||||
function safeScopeSegment(value: string): string {
|
||||
const normalized = value
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[\\/]+/g, "_")
|
||||
.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}`;
|
||||
.slice(0, 96) || "scope";
|
||||
}
|
||||
|
||||
function optionalArtifactScope(value: unknown): string {
|
||||
@ -572,6 +588,34 @@ function safeTaskArtifactScope(value: unknown): string {
|
||||
return scope;
|
||||
}
|
||||
|
||||
function safeTaskSessionScope(value: unknown): string {
|
||||
const raw = optionalString(value);
|
||||
if (!raw) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
let scope: string;
|
||||
try {
|
||||
scope = safeInputRelativePath(raw, "artifactRef sessionScope");
|
||||
} catch {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const parts = scope.split("/");
|
||||
const rootParts = TASK_SCOPE_ROOT.split("/");
|
||||
const scopeRoot = parts.slice(0, rootParts.length).join("/");
|
||||
if (parts.length !== rootParts.length + 1 || scopeRoot !== TASK_SCOPE_ROOT) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function safeArtifactRefRunScope(value: unknown): string {
|
||||
try {
|
||||
return safeTaskArtifactScope(value);
|
||||
} catch {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
}
|
||||
|
||||
function safeInputRelativePath(value: unknown, label: string): string {
|
||||
const relativePath = optionalString(value);
|
||||
if (!relativePath) {
|
||||
@ -791,16 +835,23 @@ function verifyArtifactRef(
|
||||
}
|
||||
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)) {
|
||||
if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const sessionScope = safeTaskSessionScope(payload.sessionScope);
|
||||
const runScope = safeArtifactRefRunScope(payload.runScope);
|
||||
if (!runScope.startsWith(`${sessionScope}/`)) {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) {
|
||||
throw new Error("artifactRef does not match workspace");
|
||||
}
|
||||
return {
|
||||
v: 1,
|
||||
v: 2,
|
||||
workspaceRootHash: workspaceRootHash(workspaceRoot),
|
||||
scopeKind,
|
||||
sessionScope,
|
||||
runScope,
|
||||
...(artifactScope ? { artifactScope } : {}),
|
||||
relativePath,
|
||||
sizeBytes,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user