Restrict artifacts to current task scope

This commit is contained in:
Haitao Pan 2026-05-11 11:45:32 +08:00
parent 097a62e2fa
commit 0ee969ea16
7 changed files with 84 additions and 298 deletions

View File

@ -106,7 +106,6 @@ Export request params:
"runId": "turn-1", "runId": "turn-1",
"artifactScope": "tasks/thread-main-.../turn-1-...", "artifactScope": "tasks/thread-main-.../turn-1-...",
"sinceUnixMs": 1770000000000, "sinceUnixMs": 1770000000000,
"latestIfEmpty": true,
"maxFiles": 64, "maxFiles": 64,
"maxInlineBytes": 10485760 "maxInlineBytes": 10485760
} }
@ -140,20 +139,15 @@ Export response payload:
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`. Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
When `artifactScope` is omitted, export/list defaults to the current task scope 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 derived from `sessionKey/runId`. If that scope has no files, export/list returns
`latestIfEmpty` is true, the plugin scans the workspace root for the latest real an empty artifact list. The plugin does not scan the workspace root and does not
files and returns them with `scopeKind: "workspace-latest"`. This is a controlled borrow artifacts from earlier task scopes.
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 Each exported artifact includes `artifactRef`, a plugin-signed reference over
the issued session/run scope, 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. `artifactScope + relativePath` for the current `sessionKey/runId` task scope.
Signed task `artifactRef` values are accepted for the current session, including Signed task `artifactRef` values are accepted only for the same `sessionKey/runId`
same-session historical task fallback results returned by the plugin. Workspace that issued them. 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 ## View And Download
@ -185,7 +179,7 @@ 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 task 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.read` with `artifactRef` for a plugin-returned task 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
@ -195,8 +189,7 @@ 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`, build outputs, and dependency folders are excluded from task artifact exports.
- Top-level `tasks/` is skipped during workspace fallback scanning.
- 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` must be `tasks/<safe-session-key>/<safe-run-id>`. - `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.

2
dist/index.js vendored
View File

@ -100,7 +100,7 @@ function createXWorkmateArtifactsTool(api, ctx) {
}, },
artifactRef: { artifactRef: {
type: "string", type: "string",
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.", description: "Plugin-signed artifact reference returned by export/list. Bound to the issuing task scope.",
}, },
sinceUnixMs: { sinceUnixMs: {
type: "number", type: "number",

View File

@ -10,7 +10,7 @@ export type XWorkmateArtifact = {
encoding?: "base64"; encoding?: "base64";
content?: string; content?: string;
}; };
export type XWorkmateArtifactScopeKind = "task" | "workspace" | "workspace-latest"; export type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = { export type XWorkmateArtifactExport = {
runId: string; runId: string;
sessionKey: string; sessionKey: string;

View File

@ -59,8 +59,6 @@ export async function exportXWorkmateArtifacts(input) {
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true); const includeContent = optionalBoolean(params.includeContent, true);
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
const latestTaskScopeIfEmpty = optionalBoolean(params.latestTaskScopeIfEmpty, false);
const workspaceDir = resolveWorkspaceDir({ const workspaceDir = resolveWorkspaceDir({
config: input.config, config: input.config,
pluginConfig, pluginConfig,
@ -77,41 +75,14 @@ export async function exportXWorkmateArtifacts(input) {
const sessionScope = taskSessionScopeFor(sessionKey); const sessionScope = taskSessionScopeFor(sessionKey);
const artifactScope = requestedArtifactScope || expectedArtifactScope; const artifactScope = requestedArtifactScope || expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope); const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
let scopeKind = "task"; const scopeKind = "task";
let candidates = await collectCandidates({ const candidates = await collectCandidates({
scanRoot: scopeRoot, scanRoot: scopeRoot,
relativeRoot: scopeRoot, relativeRoot: scopeRoot,
sinceUnixMs, sinceUnixMs,
skipTaskScopeRoot: false, skipTaskScopeRoot: false,
warnings, warnings,
}); });
if (candidates.length === 0 && latestIfEmpty) {
const latestWarnings = [];
const latestCandidates = latestTaskScopeIfEmpty
? await collectLatestSessionTaskCandidates({
workspaceRoot,
sessionKey,
warnings: latestWarnings,
})
: await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings,
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
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");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
}
}
candidates.sort((left, right) => { candidates.sort((left, right) => {
if (right.mtimeMs !== left.mtimeMs) { if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs; return right.mtimeMs - left.mtimeMs;
@ -140,16 +111,14 @@ export async function exportXWorkmateArtifacts(input) {
scopeKind: scopeKindForCandidate, scopeKind: scopeKindForCandidate,
sessionScope, sessionScope,
runScope: expectedArtifactScope, runScope: expectedArtifactScope,
...(scopeKindForCandidate === "task" && artifactScopeForCandidate ...(artifactScopeForCandidate ? { artifactScope: artifactScopeForCandidate } : {}),
? { artifactScope: artifactScopeForCandidate }
: {}),
relativePath: candidate.relativePath, relativePath: candidate.relativePath,
sizeBytes: bytes.byteLength, sizeBytes: bytes.byteLength,
sha256, sha256,
}, pluginConfig), }, pluginConfig),
scopeKind: scopeKindForCandidate, scopeKind: scopeKindForCandidate,
}; };
if (scopeKindForCandidate === "task" && artifactScopeForCandidate) { if (artifactScopeForCandidate) {
artifact.artifactScope = artifactScopeForCandidate; artifact.artifactScope = artifactScopeForCandidate;
} }
if (includeContent && bytes.byteLength <= maxInlineBytes) { if (includeContent && bytes.byteLength <= maxInlineBytes) {
@ -166,7 +135,7 @@ export async function exportXWorkmateArtifacts(input) {
sessionKey, sessionKey,
remoteWorkingDirectory: workspaceRoot, remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath", remoteWorkspaceRefKind: "remotePath",
...(scopeKind === "task" ? { artifactScope } : {}), artifactScope,
scopeKind, scopeKind,
artifacts, artifacts,
warnings, warnings,
@ -210,19 +179,13 @@ export async function readXWorkmateArtifact(input) {
if (requestedScope && requestedScope !== artifactScope) { if (requestedScope && requestedScope !== artifactScope) {
throw new Error("artifactRef does not match artifactScope"); throw new Error("artifactRef does not match artifactScope");
} }
if (refPayload.scopeKind === "task") { assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
allowSameSessionTaskHistory: true,
});
}
} }
else { else {
if (!artifactScope) { if (!artifactScope) {
throw new Error("artifactScope or artifactRef required"); throw new Error("artifactScope or artifactRef required");
} }
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, { assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
allowSameSessionTaskHistory: false,
});
relativePath = safeInputRelativePath(params.relativePath, "relativePath"); relativePath = safeInputRelativePath(params.relativePath, "relativePath");
} }
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot; const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
@ -372,57 +335,16 @@ async function collectCandidates(input) {
} }
} }
} }
async function collectLatestSessionTaskCandidates(input) {
const sessionScope = taskSessionScopeFor(input.sessionKey);
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
let entries;
try {
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
}
catch {
return [];
}
const candidates = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "." || entry.name === "..") {
continue;
}
const artifactScope = [sessionScope, entry.name].join("/");
let scopeRoot;
try {
scopeRoot = resolveScopeRoot(input.workspaceRoot, artifactScope);
}
catch {
continue;
}
const scopedCandidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: false,
warnings: input.warnings,
});
candidates.push(...scopedCandidates.map((candidate) => ({
...candidate,
artifactScope,
scopeKind: "task",
})));
}
return candidates;
}
function artifactScopeFor(sessionKey, runId) { function artifactScopeFor(sessionKey, runId) {
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/"); return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
} }
function taskSessionScopeFor(sessionKey) { function taskSessionScopeFor(sessionKey) {
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/"); return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
} }
function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, options) { function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope) {
if (artifactScope === expectedArtifactScope) { if (artifactScope === expectedArtifactScope) {
return; return;
} }
if (options.allowSameSessionTaskHistory && artifactScope.startsWith(`${expectedSessionScope}/`)) {
return;
}
throw new Error("artifactScope does not match sessionKey/runId"); throw new Error("artifactScope does not match sessionKey/runId");
} }
function assertArtifactRefMatchesRequest(payload, expectedRunScope, expectedSessionScope) { function assertArtifactRefMatchesRequest(payload, expectedRunScope, expectedSessionScope) {
@ -664,7 +586,7 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
} }
const payload = objectRecord(parsed); const payload = objectRecord(parsed);
const scopeKind = optionalString(payload.scopeKind); const scopeKind = optionalString(payload.scopeKind);
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) { if (scopeKind !== "task") {
throw new Error("invalid artifactRef"); throw new Error("invalid artifactRef");
} }
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath"); const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
@ -672,9 +594,6 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
if (scopeKind === "task" && !artifactScope) { if (scopeKind === "task" && !artifactScope) {
throw new Error("invalid artifactRef"); throw new Error("invalid artifactRef");
} }
if (scopeKind !== "task" && artifactScope) {
throw new Error("invalid artifactRef");
}
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1); const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
const sha256 = optionalString(payload.sha256).toLowerCase(); const sha256 = optionalString(payload.sha256).toLowerCase();
if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) { if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {

View File

@ -125,7 +125,7 @@ function createXWorkmateArtifactsTool(
}, },
artifactRef: { artifactRef: {
type: "string", type: "string",
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.", description: "Plugin-signed artifact reference returned by export/list. Bound to the issuing task scope.",
}, },
sinceUnixMs: { sinceUnixMs: {
type: "number", type: "number",

View File

@ -176,7 +176,7 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["current.txt"]); 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 () => { it("does not scan the workspace root when the current task scope is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({ await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" }, params: { sessionKey: "thread-main", runId: "turn-1" },
@ -219,7 +219,7 @@ describe("exportXWorkmateArtifacts", () => {
).rejects.toThrow("artifactScope does not match sessionKey/runId"); ).rejects.toThrow("artifactScope does not match sessionKey/runId");
}); });
it("falls back to latest workspace files when the scoped directory is empty", async () => { it("does not fall back to workspace files when the scoped directory is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({ const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" }, params: { sessionKey: "thread-main", runId: "turn-1" },
@ -241,26 +241,26 @@ describe("exportXWorkmateArtifacts", () => {
runId: "turn-1", runId: "turn-1",
artifactScope: prepared.artifactScope, artifactScope: prepared.artifactScope,
sinceUnixMs: stat.mtimeMs + 10_000, sinceUnixMs: stat.mtimeMs + 10_000,
latestIfEmpty: true,
}, },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
}); });
expect(result.scopeKind).toBe("workspace-latest"); expect(result.scopeKind).toBe("task");
expect(result.artifactScope).toBeUndefined(); expect(result.artifactScope).toBe(prepared.artifactScope);
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["existing.pdf"]); expect(result.artifacts).toEqual([]);
expect(result.artifacts[0]?.artifactScope).toBeUndefined(); expect(result.warnings).toEqual([]);
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");
}); });
it("falls back to latest session task files when requested", async () => { it("does not borrow previous session task files when current task scope is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const previousTask = await prepareXWorkmateArtifacts({ const previousTask = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-previous" }, params: { sessionKey: "thread-main", runId: "turn-previous" },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
}); });
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-follow-up" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.pdf"), "pdf"); await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.pdf"), "pdf");
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.docx"), "docx"); await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.docx"), "docx");
@ -269,55 +269,57 @@ describe("exportXWorkmateArtifacts", () => {
sessionKey: "thread-main", sessionKey: "thread-main",
runId: "turn-follow-up", runId: "turn-follow-up",
sinceUnixMs: Date.now() + 10_000, sinceUnixMs: Date.now() + 10_000,
latestIfEmpty: true,
latestTaskScopeIfEmpty: true,
}, },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
}); });
expect(result.scopeKind).toBe("workspace-latest"); expect(result.scopeKind).toBe("task");
expect(result.artifactScope).toBeUndefined(); expect(result.artifactScope).toBe("tasks/thread-main/turn-follow-up");
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([ expect(result.artifacts).toEqual([]);
"k8s-networking.docx", expect(result.warnings).toEqual([]);
"k8s-networking.pdf",
]);
expect(
result.artifacts.map((entry) => ({
artifactScope: entry.artifactScope,
scopeKind: entry.scopeKind,
})),
).toEqual([
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
]);
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 () => { it("exports concurrent task scopes independently", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const sameSessionTask = await prepareXWorkmateArtifacts({ const prepared = await Promise.all([
params: { sessionKey: "thread-main", runId: "turn-previous" }, prepareXWorkmateArtifacts({
pluginConfig: { workspaceDir: root }, params: { sessionKey: "thread-a", runId: "turn-1" },
}); pluginConfig: { workspaceDir: root },
const otherSessionTask = await prepareXWorkmateArtifacts({ }),
params: { sessionKey: "thread-other", runId: "turn-previous" }, prepareXWorkmateArtifacts({
pluginConfig: { workspaceDir: root }, params: { sessionKey: "thread-b", runId: "turn-1" },
}); pluginConfig: { workspaceDir: root },
await fs.writeFile(path.join(sameSessionTask.artifactDirectory, "same.txt"), "same"); }),
await fs.writeFile(path.join(otherSessionTask.artifactDirectory, "other.txt"), "other"); prepareXWorkmateArtifacts({
params: { sessionKey: "thread-a", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
}),
]);
await fs.writeFile(path.join(prepared[0].artifactDirectory, "a-1.txt"), "a1");
await fs.writeFile(path.join(prepared[1].artifactDirectory, "b-1.txt"), "b1");
await fs.writeFile(path.join(prepared[2].artifactDirectory, "a-2.txt"), "a2");
const result = await exportXWorkmateArtifacts({ const results = await Promise.all([
params: { exportXWorkmateArtifacts({
sessionKey: "thread-main", params: { sessionKey: "thread-a", runId: "turn-1" },
runId: "turn-follow-up", pluginConfig: { workspaceDir: root },
latestIfEmpty: true, }),
latestTaskScopeIfEmpty: true, exportXWorkmateArtifacts({
}, params: { sessionKey: "thread-b", runId: "turn-1" },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
}); }),
exportXWorkmateArtifacts({
params: { sessionKey: "thread-a", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
}),
]);
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["same.txt"]); expect(results.map((result) => result.artifacts.map((entry) => entry.relativePath))).toEqual([
expect(result.artifacts[0]?.artifactScope).toBe(sameSessionTask.artifactScope); ["a-1.txt"],
["b-1.txt"],
["a-2.txt"],
]);
expect(results.map((result) => result.artifactScope)).toEqual(prepared.map((entry) => entry.artifactScope));
}); });
it("leaves oversized artifacts out of inline content", async () => { it("leaves oversized artifacts out of inline content", async () => {
@ -511,59 +513,19 @@ describe("exportXWorkmateArtifacts", () => {
).rejects.toThrow("artifactRef does not match sessionKey/runId"); ).rejects.toThrow("artifactRef does not match sessionKey/runId");
}); });
it("reads a latest workspace artifact only through its artifactRef", async () => { it("rejects signed task artifact refs from another run", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({ const prepared = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" }, params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root }, pluginConfig: { workspaceDir: root },
}); });
await fs.writeFile(path.join(root, "existing.txt"), "existing"); await fs.writeFile(path.join(prepared.artifactDirectory, "existing.txt"), "existing");
const exported = await exportXWorkmateArtifacts({ const exported = await exportXWorkmateArtifacts({
params: { params: {
sessionKey: "thread-main", sessionKey: "thread-main",
runId: "turn-1", runId: "turn-1",
artifactScope: prepared.artifactScope, 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 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 }, pluginConfig: { workspaceDir: root },
}); });
@ -616,7 +578,7 @@ describe("exportXWorkmateArtifacts", () => {
JSON.stringify({ JSON.stringify({
v: 1, v: 1,
workspaceRootHash: createHash("sha256").update(path.resolve(root)).digest("hex"), workspaceRootHash: createHash("sha256").update(path.resolve(root)).digest("hex"),
scopeKind: "workspace-latest", scopeKind: "task",
relativePath: "existing.txt", relativePath: "existing.txt",
sizeBytes: 8, sizeBytes: 8,
sha256: createHash("sha256").update("existing").digest("hex"), sha256: createHash("sha256").update("existing").digest("hex"),

View File

@ -34,7 +34,7 @@ export type XWorkmateArtifact = {
content?: string; content?: string;
}; };
export type XWorkmateArtifactScopeKind = "task" | "workspace" | "workspace-latest"; export type XWorkmateArtifactScopeKind = "task";
export type XWorkmateArtifactExport = { export type XWorkmateArtifactExport = {
runId: string; runId: string;
@ -140,8 +140,6 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
); );
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0); const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
const includeContent = optionalBoolean(params.includeContent, true); const includeContent = optionalBoolean(params.includeContent, true);
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
const latestTaskScopeIfEmpty = optionalBoolean(params.latestTaskScopeIfEmpty, false);
const workspaceDir = resolveWorkspaceDir({ const workspaceDir = resolveWorkspaceDir({
config: input.config, config: input.config,
pluginConfig, pluginConfig,
@ -158,8 +156,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
const sessionScope = taskSessionScopeFor(sessionKey); const sessionScope = taskSessionScopeFor(sessionKey);
const artifactScope = requestedArtifactScope || expectedArtifactScope; const artifactScope = requestedArtifactScope || expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope); const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
let scopeKind: XWorkmateArtifactScopeKind = "task"; const scopeKind: XWorkmateArtifactScopeKind = "task";
let candidates = await collectCandidates({ const candidates = await collectCandidates({
scanRoot: scopeRoot, scanRoot: scopeRoot,
relativeRoot: scopeRoot, relativeRoot: scopeRoot,
sinceUnixMs, sinceUnixMs,
@ -167,33 +165,6 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
warnings, warnings,
}); });
if (candidates.length === 0 && latestIfEmpty) {
const latestWarnings: string[] = [];
const latestCandidates = latestTaskScopeIfEmpty
? await collectLatestSessionTaskCandidates({
workspaceRoot,
sessionKey,
warnings: latestWarnings,
})
: await collectCandidates({
scanRoot: workspaceRoot,
relativeRoot: workspaceRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: true,
warnings: latestWarnings,
});
if (latestCandidates.length > 0) {
warnings.push(...latestWarnings);
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");
}
candidates = latestCandidates;
scopeKind = "workspace-latest";
}
}
candidates.sort((left, right) => { candidates.sort((left, right) => {
if (right.mtimeMs !== left.mtimeMs) { if (right.mtimeMs !== left.mtimeMs) {
return right.mtimeMs - left.mtimeMs; return right.mtimeMs - left.mtimeMs;
@ -225,9 +196,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
scopeKind: scopeKindForCandidate, scopeKind: scopeKindForCandidate,
sessionScope, sessionScope,
runScope: expectedArtifactScope, runScope: expectedArtifactScope,
...(scopeKindForCandidate === "task" && artifactScopeForCandidate ...(artifactScopeForCandidate ? { artifactScope: artifactScopeForCandidate } : {}),
? { artifactScope: artifactScopeForCandidate }
: {}),
relativePath: candidate.relativePath, relativePath: candidate.relativePath,
sizeBytes: bytes.byteLength, sizeBytes: bytes.byteLength,
sha256, sha256,
@ -236,7 +205,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
), ),
scopeKind: scopeKindForCandidate, scopeKind: scopeKindForCandidate,
}; };
if (scopeKindForCandidate === "task" && artifactScopeForCandidate) { if (artifactScopeForCandidate) {
artifact.artifactScope = artifactScopeForCandidate; artifact.artifactScope = artifactScopeForCandidate;
} }
if (includeContent && bytes.byteLength <= maxInlineBytes) { if (includeContent && bytes.byteLength <= maxInlineBytes) {
@ -253,7 +222,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
sessionKey, sessionKey,
remoteWorkingDirectory: workspaceRoot, remoteWorkingDirectory: workspaceRoot,
remoteWorkspaceRefKind: "remotePath" as const, remoteWorkspaceRefKind: "remotePath" as const,
...(scopeKind === "task" ? { artifactScope } : {}), artifactScope,
scopeKind, scopeKind,
artifacts, artifacts,
warnings, warnings,
@ -302,18 +271,12 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
if (requestedScope && requestedScope !== artifactScope) { if (requestedScope && requestedScope !== artifactScope) {
throw new Error("artifactRef does not match artifactScope"); throw new Error("artifactRef does not match artifactScope");
} }
if (refPayload.scopeKind === "task") { assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
allowSameSessionTaskHistory: true,
});
}
} else { } else {
if (!artifactScope) { if (!artifactScope) {
throw new Error("artifactScope or artifactRef required"); throw new Error("artifactScope or artifactRef required");
} }
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, { assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
allowSameSessionTaskHistory: false,
});
relativePath = safeInputRelativePath(params.relativePath, "relativePath"); relativePath = safeInputRelativePath(params.relativePath, "relativePath");
} }
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot; const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
@ -484,49 +447,6 @@ async function collectCandidates(input: {
} }
} }
async function collectLatestSessionTaskCandidates(input: {
workspaceRoot: string;
sessionKey: string;
warnings: string[];
}): Promise<Candidate[]> {
const sessionScope = taskSessionScopeFor(input.sessionKey);
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
let entries;
try {
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
} catch {
return [];
}
const candidates: Candidate[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === "." || entry.name === "..") {
continue;
}
const artifactScope = [sessionScope, entry.name].join("/");
let scopeRoot: string;
try {
scopeRoot = resolveScopeRoot(input.workspaceRoot, artifactScope);
} catch {
continue;
}
const scopedCandidates = await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
sinceUnixMs: 0,
skipTaskScopeRoot: false,
warnings: input.warnings,
});
candidates.push(
...scopedCandidates.map((candidate) => ({
...candidate,
artifactScope,
scopeKind: "task" as const,
})),
);
}
return candidates;
}
function artifactScopeFor(sessionKey: string, runId: string): string { function artifactScopeFor(sessionKey: string, runId: string): string {
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/"); return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
} }
@ -538,15 +458,10 @@ function taskSessionScopeFor(sessionKey: string): string {
function assertArtifactScopeMatchesRequest( function assertArtifactScopeMatchesRequest(
artifactScope: string, artifactScope: string,
expectedArtifactScope: string, expectedArtifactScope: string,
expectedSessionScope: string,
options: { allowSameSessionTaskHistory: boolean },
): void { ): void {
if (artifactScope === expectedArtifactScope) { if (artifactScope === expectedArtifactScope) {
return; return;
} }
if (options.allowSameSessionTaskHistory && artifactScope.startsWith(`${expectedSessionScope}/`)) {
return;
}
throw new Error("artifactScope does not match sessionKey/runId"); throw new Error("artifactScope does not match sessionKey/runId");
} }
@ -822,7 +737,7 @@ function verifyArtifactRef(
} }
const payload = objectRecord(parsed); const payload = objectRecord(parsed);
const scopeKind = optionalString(payload.scopeKind) as XWorkmateArtifactScopeKind; const scopeKind = optionalString(payload.scopeKind) as XWorkmateArtifactScopeKind;
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) { if (scopeKind !== "task") {
throw new Error("invalid artifactRef"); throw new Error("invalid artifactRef");
} }
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath"); const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
@ -830,9 +745,6 @@ function verifyArtifactRef(
if (scopeKind === "task" && !artifactScope) { if (scopeKind === "task" && !artifactScope) {
throw new Error("invalid artifactRef"); throw new Error("invalid artifactRef");
} }
if (scopeKind !== "task" && artifactScope) {
throw new Error("invalid artifactRef");
}
const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1); const sizeBytes = nonNegativeInteger(payload.sizeBytes, undefined, -1);
const sha256 = optionalString(payload.sha256).toLowerCase(); const sha256 = optionalString(payload.sha256).toLowerCase();
if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) { if (payload.v !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {