fix: enforce openclaw artifact session scope

This commit is contained in:
Haitao Pan 2026-05-07 17:02:55 +08:00
parent b9c05e3657
commit bbc21098f7
8 changed files with 260 additions and 39 deletions

View File

@ -10,6 +10,11 @@ XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing
The APP can then sync generated files into its local thread workspace without
changing the UI or adding provider-specific routes.
This plugin is not a scheduler. OpenClaw core owns sub-agents, multi-agent
routing, queues, cron, and cross-session execution. This package only adapts
those existing OpenClaw multi-task/session identities into isolated artifact
directories and signed artifact reads.
It registers four Gateway methods:
```text
@ -61,12 +66,16 @@ Equivalent config shape for a linked checkout:
## Contract
Prepare request params:
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.
```json
{
"sessionKey": "thread-main",
"runId": "turn-1"
"runId": "turn-1",
"workspaceDir": "/home/user/.openclaw/workspace"
}
```
@ -131,13 +140,16 @@ 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:
"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 plugin-owned `.xworkmate/` directory.
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
`artifactScope + relativePath` for task-scope files. Workspace fallback files
must be read with `artifactRef`; there is no unscoped arbitrary workspace read
API.
`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.
## View And Download
@ -180,9 +192,11 @@ only remote file access path.
- 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.
- Top-level `tasks/` is skipped during workspace fallback scanning.
- 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>`.
- Direct `artifactScope + relativePath` reads and scoped exports must match the supplied `sessionKey/runId`.
- `artifactScope`, `artifactRef`, and `relativePath` must stay inside the workspace; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
## Development

27
dist/index.js vendored
View File

@ -102,6 +102,18 @@ 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.",
@ -119,11 +131,20 @@ 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;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const baseParams = {
...params,
sessionKey: ctx.sessionKey || "agent:main:main",
runId: typeof params.runId === "string" ? params.runId : "tool",
workspaceDir: ctx.workspaceDir,
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
};
if (action === "list") {
const payload = await exportXWorkmateArtifacts({

View File

@ -23,6 +23,11 @@ export async function prepareXWorkmateArtifacts(input) {
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
throw new Error("artifactScope does not match sessionKey/runId");
}
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
@ -30,7 +35,7 @@ export async function prepareXWorkmateArtifacts(input) {
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const artifactScope = artifactScopeFor(sessionKey, runId);
const artifactScope = expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
await fs.mkdir(scopeRoot, { recursive: true });
return {
@ -65,6 +70,10 @@ 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) {
throw new Error("artifactScope does not match sessionKey/runId");
}
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
const scopedExport = artifactScope !== "";
let scopeKind = scopedExport ? "task" : "workspace";
@ -167,8 +176,10 @@ export async function exportXWorkmateArtifacts(input) {
export async function readXWorkmateArtifact(input) {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId) || "read";
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const expectedSessionScope = taskSessionScopeFor(sessionKey);
const requestedArtifactRef = optionalString(params.artifactRef);
let relativePath = "";
let artifactScope = optionalArtifactScope(params.artifactScope);
@ -195,11 +206,19 @@ export async function readXWorkmateArtifact(input) {
if (requestedScope && requestedScope !== artifactScope) {
throw new Error("artifactRef does not match artifactScope");
}
if (refPayload.scopeKind === "task") {
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
allowSameSessionTaskHistory: true,
});
}
}
else {
if (!artifactScope) {
throw new Error("artifactScope or artifactRef required");
}
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
allowSameSessionTaskHistory: false,
});
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
}
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
@ -348,7 +367,7 @@ async function collectCandidates(input) {
}
}
async function collectLatestSessionTaskCandidates(input) {
const sessionScope = [TASK_SCOPE_ROOT, safeScopeSegment(input.sessionKey)].join("/");
const sessionScope = taskSessionScopeFor(input.sessionKey);
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
let entries;
try {
@ -386,16 +405,24 @@ async function collectLatestSessionTaskCandidates(input) {
return candidates;
}
function artifactScopeFor(sessionKey, runId) {
return [
TASK_SCOPE_ROOT,
safeScopeSegment(sessionKey),
safeScopeSegment(runId),
].join("/");
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
}
function taskSessionScopeFor(sessionKey) {
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
}
function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, options) {
if (artifactScope === expectedArtifactScope) {
return;
}
if (options.allowSameSessionTaskHistory && artifactScope.startsWith(`${expectedSessionScope}/`)) {
return;
}
throw new Error("artifactScope does not match sessionKey/runId");
}
function safeScopeSegment(value) {
const normalized = value
.trim()
.replaceAll(path.sep, "_")
.replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 48);

View File

@ -1,4 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
@ -8,25 +10,26 @@ type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"
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[] };
contracts?: { tools?: string[]; sessionScopedTools?: string[] };
configSchema?: { properties?: Record<string, unknown> };
};
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts");
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts");
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
});
it("registers the xworkmate artifact export gateway method", () => {
it("registers the xworkmate artifact gateway methods and optional tool", () => {
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
const tools: unknown[] = [];
const tools: Array<{ tool: unknown; options: unknown }> = [];
const api = {
config: {},
pluginConfig: {},
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
methods.push({ method, handler });
},
registerTool: (tool: unknown) => {
tools.push(tool);
registerTool: (tool: unknown, options: unknown) => {
tools.push({ tool, options });
},
} as unknown as OpenClawPluginApi;
@ -40,5 +43,33 @@ describe("plugin registration", () => {
]);
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
expect(tools).toHaveLength(1);
expect(tools[0]?.options).toMatchObject({
names: ["openclaw_multi_session_artifacts"],
optional: true,
});
});
it("does not invent default session or run ids for the optional agent tool", async () => {
const tools: Array<{ tool: unknown; options: unknown }> = [];
const api = {
config: {},
pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") },
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<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(
"runId required",
);
});
});

View File

@ -121,6 +121,18 @@ 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.",
@ -138,11 +150,20 @@ 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;
if (!sessionKey) {
throw new Error("sessionKey required");
}
if (!runId) {
throw new Error("runId required");
}
const baseParams = {
...params,
sessionKey: ctx.sessionKey || "agent:main:main",
runId: typeof params.runId === "string" ? params.runId : "tool",
workspaceDir: ctx.workspaceDir,
sessionKey,
runId,
...(workspaceDir ? { workspaceDir } : {}),
};
if (action === "list") {
const payload = await exportXWorkmateArtifacts({

View File

@ -6,7 +6,8 @@
"onStartup": true
},
"contracts": {
"tools": ["openclaw_multi_session_artifacts"]
"tools": ["openclaw_multi_session_artifacts"],
"sessionScopedTools": ["openclaw_multi_session_artifacts"]
},
"configSchema": {
"type": "object",

View File

@ -135,6 +135,29 @@ describe("exportXWorkmateArtifacts", () => {
});
});
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({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await expect(
exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-2",
artifactScope: first.artifactScope,
},
pluginConfig: { workspaceDir: root },
}),
).rejects.toThrow("artifactScope does not match sessionKey/runId");
});
it("falls back to latest workspace files when the scoped directory is empty", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
@ -335,6 +358,55 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.artifacts[0]?.artifactRef).toContain(".");
});
it("rejects direct reads from another run artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(first.artifactDirectory, "first.txt"), "first");
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
runId: "turn-2",
artifactScope: first.artifactScope,
relativePath: "first.txt",
},
pluginConfig: { workspaceDir: root },
}),
).rejects.toThrow("artifactScope does not match sessionKey/runId");
});
it("rejects signed task artifact refs from another session", 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(prepared.artifactDirectory, "first.txt"), "first");
const exported = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
artifactScope: prepared.artifactScope,
},
pluginConfig: { workspaceDir: root },
});
await expect(
readXWorkmateArtifact({
params: {
sessionKey: "thread-other",
runId: "turn-1",
artifactRef: exported.artifacts[0]?.artifactRef,
},
pluginConfig: { workspaceDir: root },
}),
).rejects.toThrow("artifactScope does not match sessionKey/runId");
});
it("reads a latest workspace artifact only through its artifactRef", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const prepared = await prepareXWorkmateArtifacts({
@ -409,7 +481,7 @@ describe("exportXWorkmateArtifacts", () => {
const result = await readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
runId: "run-1",
runId: "turn-1",
artifactScope: prepared.artifactScope,
relativePath: "large.bin",
maxInlineBytes: 2,
@ -440,7 +512,7 @@ describe("exportXWorkmateArtifacts", () => {
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
runId: "run-1",
runId: "turn-1",
artifactScope: prepared.artifactScope,
relativePath: "../outside.txt",
},
@ -480,7 +552,7 @@ describe("exportXWorkmateArtifacts", () => {
readXWorkmateArtifact({
params: {
sessionKey: "thread-main",
runId: "run-1",
runId: "turn-1",
artifactScope: prepared.artifactScope,
relativePath: "linked-secret.txt",
},

View File

@ -96,6 +96,11 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
const pluginConfig = input.pluginConfig ?? {};
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
throw new Error("artifactScope does not match sessionKey/runId");
}
const workspaceDir = resolveWorkspaceDir({
config: input.config,
pluginConfig,
@ -103,7 +108,7 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
sessionKey,
});
const workspaceRoot = await fs.realpath(workspaceDir);
const artifactScope = artifactScopeFor(sessionKey, runId);
const artifactScope = expectedArtifactScope;
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
await fs.mkdir(scopeRoot, { recursive: true });
return {
@ -144,6 +149,10 @@ 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) {
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";
@ -253,8 +262,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport> {
const params = input.params ?? {};
const pluginConfig = input.pluginConfig ?? {};
const runId = optionalString(params.runId) || "read";
const runId = requiredString(params.runId, "runId required");
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
const expectedSessionScope = taskSessionScopeFor(sessionKey);
const requestedArtifactRef = optionalString(params.artifactRef);
let relativePath = "";
let artifactScope = optionalArtifactScope(params.artifactScope);
@ -285,10 +296,18 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
if (requestedScope && requestedScope !== artifactScope) {
throw new Error("artifactRef does not match artifactScope");
}
if (refPayload.scopeKind === "task") {
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
allowSameSessionTaskHistory: true,
});
}
} else {
if (!artifactScope) {
throw new Error("artifactScope or artifactRef required");
}
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
allowSameSessionTaskHistory: false,
});
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
}
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
@ -462,7 +481,7 @@ async function collectLatestSessionTaskCandidates(input: {
sessionKey: string;
warnings: string[];
}): Promise<Candidate[]> {
const sessionScope = [TASK_SCOPE_ROOT, safeScopeSegment(input.sessionKey)].join("/");
const sessionScope = taskSessionScopeFor(input.sessionKey);
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
let entries;
try {
@ -501,17 +520,32 @@ async function collectLatestSessionTaskCandidates(input: {
}
function artifactScopeFor(sessionKey: string, runId: string): string {
return [
TASK_SCOPE_ROOT,
safeScopeSegment(sessionKey),
safeScopeSegment(runId),
].join("/");
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
}
function taskSessionScopeFor(sessionKey: string): string {
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
}
function assertArtifactScopeMatchesRequest(
artifactScope: string,
expectedArtifactScope: string,
expectedSessionScope: string,
options: { allowSameSessionTaskHistory: boolean },
): void {
if (artifactScope === expectedArtifactScope) {
return;
}
if (options.allowSameSessionTaskHistory && artifactScope.startsWith(`${expectedSessionScope}/`)) {
return;
}
throw new Error("artifactScope does not match sessionKey/runId");
}
function safeScopeSegment(value: string): string {
const normalized = value
.trim()
.replaceAll(path.sep, "_")
.replace(/[\\/]+/g, "_")
.replace(/[^A-Za-z0-9._-]+/g, "_")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 48);