fix: enforce openclaw artifact session scope
This commit is contained in:
parent
b9c05e3657
commit
bbc21098f7
26
README.md
26
README.md
@ -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
|
The APP can then sync generated files into its local thread workspace without
|
||||||
changing the UI or adding provider-specific routes.
|
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:
|
It registers four Gateway methods:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -61,12 +66,16 @@ Equivalent config shape for a linked checkout:
|
|||||||
|
|
||||||
## Contract
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
"sessionKey": "thread-main",
|
"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:
|
the workspace root for the latest real files and returns them with `scopeKind:
|
||||||
"workspace-latest"`. This is a controlled recovery path for existing files already
|
"workspace-latest"`. This is a controlled recovery path for existing files already
|
||||||
present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and
|
present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and
|
||||||
runtime directories, 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
|
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||||
the artifact scope, path, size, and SHA-256 digest. `read` accepts
|
the artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||||
`artifactScope + relativePath` for task-scope files. Workspace fallback files
|
`artifactScope + relativePath` for the current `sessionKey/runId` task scope.
|
||||||
must be read with `artifactRef`; there is no unscoped arbitrary workspace read
|
Signed task `artifactRef` values are accepted for the current session, including
|
||||||
API.
|
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
|
## View And Download
|
||||||
|
|
||||||
@ -180,9 +192,11 @@ only remote file access path.
|
|||||||
|
|
||||||
- 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 skipped when scanning the workspace root.
|
||||||
|
- 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>`.
|
||||||
|
- 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.
|
- `artifactScope`, `artifactRef`, and `relativePath` must stay inside the workspace; absolute paths, `..`, empty path segments, and symlink escapes are rejected.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
27
dist/index.js
vendored
27
dist/index.js
vendored
@ -102,6 +102,18 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
|||||||
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. 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: {
|
sinceUnixMs: {
|
||||||
type: "number",
|
type: "number",
|
||||||
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
|
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) {
|
async execute(_id, params) {
|
||||||
const action = typeof params.action === "string" ? params.action : "";
|
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 = {
|
const baseParams = {
|
||||||
...params,
|
...params,
|
||||||
sessionKey: ctx.sessionKey || "agent:main:main",
|
sessionKey,
|
||||||
runId: typeof params.runId === "string" ? params.runId : "tool",
|
runId,
|
||||||
workspaceDir: ctx.workspaceDir,
|
...(workspaceDir ? { workspaceDir } : {}),
|
||||||
};
|
};
|
||||||
if (action === "list") {
|
if (action === "list") {
|
||||||
const payload = await exportXWorkmateArtifacts({
|
const payload = await exportXWorkmateArtifacts({
|
||||||
|
|||||||
45
dist/src/exportArtifacts.js
vendored
45
dist/src/exportArtifacts.js
vendored
@ -23,6 +23,11 @@ export async function prepareXWorkmateArtifacts(input) {
|
|||||||
const pluginConfig = input.pluginConfig ?? {};
|
const pluginConfig = input.pluginConfig ?? {};
|
||||||
const runId = requiredString(params.runId, "runId required");
|
const runId = requiredString(params.runId, "runId required");
|
||||||
const sessionKey = requiredString(params.sessionKey, "sessionKey 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({
|
const workspaceDir = resolveWorkspaceDir({
|
||||||
config: input.config,
|
config: input.config,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
@ -30,7 +35,7 @@ export async function prepareXWorkmateArtifacts(input) {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
});
|
});
|
||||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||||
const artifactScope = artifactScopeFor(sessionKey, runId);
|
const artifactScope = expectedArtifactScope;
|
||||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||||
await fs.mkdir(scopeRoot, { recursive: true });
|
await fs.mkdir(scopeRoot, { recursive: true });
|
||||||
return {
|
return {
|
||||||
@ -65,6 +70,10 @@ export async function exportXWorkmateArtifacts(input) {
|
|||||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
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 scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||||
const scopedExport = artifactScope !== "";
|
const scopedExport = artifactScope !== "";
|
||||||
let scopeKind = scopedExport ? "task" : "workspace";
|
let scopeKind = scopedExport ? "task" : "workspace";
|
||||||
@ -167,8 +176,10 @@ export async function exportXWorkmateArtifacts(input) {
|
|||||||
export async function readXWorkmateArtifact(input) {
|
export async function readXWorkmateArtifact(input) {
|
||||||
const params = input.params ?? {};
|
const params = input.params ?? {};
|
||||||
const pluginConfig = input.pluginConfig ?? {};
|
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 sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||||
|
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||||
|
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||||
let relativePath = "";
|
let relativePath = "";
|
||||||
let artifactScope = optionalArtifactScope(params.artifactScope);
|
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||||
@ -195,11 +206,19 @@ 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, 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, {
|
||||||
|
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;
|
||||||
@ -348,7 +367,7 @@ async function collectCandidates(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function collectLatestSessionTaskCandidates(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));
|
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
@ -386,16 +405,24 @@ async function collectLatestSessionTaskCandidates(input) {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
function artifactScopeFor(sessionKey, runId) {
|
function artifactScopeFor(sessionKey, runId) {
|
||||||
return [
|
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||||
TASK_SCOPE_ROOT,
|
}
|
||||||
safeScopeSegment(sessionKey),
|
function taskSessionScopeFor(sessionKey) {
|
||||||
safeScopeSegment(runId),
|
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
|
||||||
].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) {
|
function safeScopeSegment(value) {
|
||||||
const normalized = value
|
const normalized = value
|
||||||
.trim()
|
.trim()
|
||||||
.replaceAll(path.sep, "_")
|
.replace(/[\\/]+/g, "_")
|
||||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||||
.replace(/^[._-]+|[._-]+$/g, "")
|
.replace(/^[._-]+|[._-]+$/g, "")
|
||||||
.slice(0, 48);
|
.slice(0, 48);
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import plugin from "./index.js";
|
import plugin from "./index.js";
|
||||||
@ -8,25 +10,26 @@ type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"
|
|||||||
describe("plugin registration", () => {
|
describe("plugin registration", () => {
|
||||||
it("declares registered agent tools in the manifest contract", () => {
|
it("declares registered agent tools in the manifest contract", () => {
|
||||||
const manifest = JSON.parse(fs.readFileSync("openclaw.plugin.json", "utf8")) as {
|
const manifest = JSON.parse(fs.readFileSync("openclaw.plugin.json", "utf8")) as {
|
||||||
contracts?: { tools?: string[] };
|
contracts?: { tools?: string[]; sessionScopedTools?: string[] };
|
||||||
configSchema?: { properties?: Record<string, unknown> };
|
configSchema?: { properties?: Record<string, unknown> };
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts");
|
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts");
|
||||||
|
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts");
|
||||||
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
|
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 methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
||||||
const tools: unknown[] = [];
|
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||||
const api = {
|
const api = {
|
||||||
config: {},
|
config: {},
|
||||||
pluginConfig: {},
|
pluginConfig: {},
|
||||||
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||||
methods.push({ method, handler });
|
methods.push({ method, handler });
|
||||||
},
|
},
|
||||||
registerTool: (tool: unknown) => {
|
registerTool: (tool: unknown, options: unknown) => {
|
||||||
tools.push(tool);
|
tools.push({ tool, options });
|
||||||
},
|
},
|
||||||
} as unknown as OpenClawPluginApi;
|
} as unknown as OpenClawPluginApi;
|
||||||
|
|
||||||
@ -40,5 +43,33 @@ describe("plugin registration", () => {
|
|||||||
]);
|
]);
|
||||||
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
|
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
|
||||||
expect(tools).toHaveLength(1);
|
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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
27
index.ts
27
index.ts
@ -121,6 +121,18 @@ function createXWorkmateArtifactsTool(
|
|||||||
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. 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: {
|
sinceUnixMs: {
|
||||||
type: "number",
|
type: "number",
|
||||||
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
|
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>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const action = typeof params.action === "string" ? params.action : "";
|
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 = {
|
const baseParams = {
|
||||||
...params,
|
...params,
|
||||||
sessionKey: ctx.sessionKey || "agent:main:main",
|
sessionKey,
|
||||||
runId: typeof params.runId === "string" ? params.runId : "tool",
|
runId,
|
||||||
workspaceDir: ctx.workspaceDir,
|
...(workspaceDir ? { workspaceDir } : {}),
|
||||||
};
|
};
|
||||||
if (action === "list") {
|
if (action === "list") {
|
||||||
const payload = await exportXWorkmateArtifacts({
|
const payload = await exportXWorkmateArtifacts({
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
"onStartup": true
|
"onStartup": true
|
||||||
},
|
},
|
||||||
"contracts": {
|
"contracts": {
|
||||||
"tools": ["openclaw_multi_session_artifacts"]
|
"tools": ["openclaw_multi_session_artifacts"],
|
||||||
|
"sessionScopedTools": ["openclaw_multi_session_artifacts"]
|
||||||
},
|
},
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@ -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 () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||||
const prepared = await prepareXWorkmateArtifacts({
|
const prepared = await prepareXWorkmateArtifacts({
|
||||||
@ -335,6 +358,55 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
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 () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||||
const prepared = await prepareXWorkmateArtifacts({
|
const prepared = await prepareXWorkmateArtifacts({
|
||||||
@ -409,7 +481,7 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
const result = await readXWorkmateArtifact({
|
const result = await readXWorkmateArtifact({
|
||||||
params: {
|
params: {
|
||||||
sessionKey: "thread-main",
|
sessionKey: "thread-main",
|
||||||
runId: "run-1",
|
runId: "turn-1",
|
||||||
artifactScope: prepared.artifactScope,
|
artifactScope: prepared.artifactScope,
|
||||||
relativePath: "large.bin",
|
relativePath: "large.bin",
|
||||||
maxInlineBytes: 2,
|
maxInlineBytes: 2,
|
||||||
@ -440,7 +512,7 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
readXWorkmateArtifact({
|
readXWorkmateArtifact({
|
||||||
params: {
|
params: {
|
||||||
sessionKey: "thread-main",
|
sessionKey: "thread-main",
|
||||||
runId: "run-1",
|
runId: "turn-1",
|
||||||
artifactScope: prepared.artifactScope,
|
artifactScope: prepared.artifactScope,
|
||||||
relativePath: "../outside.txt",
|
relativePath: "../outside.txt",
|
||||||
},
|
},
|
||||||
@ -480,7 +552,7 @@ describe("exportXWorkmateArtifacts", () => {
|
|||||||
readXWorkmateArtifact({
|
readXWorkmateArtifact({
|
||||||
params: {
|
params: {
|
||||||
sessionKey: "thread-main",
|
sessionKey: "thread-main",
|
||||||
runId: "run-1",
|
runId: "turn-1",
|
||||||
artifactScope: prepared.artifactScope,
|
artifactScope: prepared.artifactScope,
|
||||||
relativePath: "linked-secret.txt",
|
relativePath: "linked-secret.txt",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -96,6 +96,11 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
|
|||||||
const pluginConfig = input.pluginConfig ?? {};
|
const pluginConfig = input.pluginConfig ?? {};
|
||||||
const runId = requiredString(params.runId, "runId required");
|
const runId = requiredString(params.runId, "runId required");
|
||||||
const sessionKey = requiredString(params.sessionKey, "sessionKey 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({
|
const workspaceDir = resolveWorkspaceDir({
|
||||||
config: input.config,
|
config: input.config,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
@ -103,7 +108,7 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
});
|
});
|
||||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||||
const artifactScope = artifactScopeFor(sessionKey, runId);
|
const artifactScope = expectedArtifactScope;
|
||||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||||
await fs.mkdir(scopeRoot, { recursive: true });
|
await fs.mkdir(scopeRoot, { recursive: true });
|
||||||
return {
|
return {
|
||||||
@ -144,6 +149,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
|||||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
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 scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||||
const scopedExport = artifactScope !== "";
|
const scopedExport = artifactScope !== "";
|
||||||
let scopeKind: XWorkmateArtifactScopeKind = scopedExport ? "task" : "workspace";
|
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> {
|
export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport> {
|
||||||
const params = input.params ?? {};
|
const params = input.params ?? {};
|
||||||
const pluginConfig = input.pluginConfig ?? {};
|
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 sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||||
|
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||||
|
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||||
let relativePath = "";
|
let relativePath = "";
|
||||||
let artifactScope = optionalArtifactScope(params.artifactScope);
|
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||||
@ -285,10 +296,18 @@ 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, 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, {
|
||||||
|
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;
|
||||||
@ -462,7 +481,7 @@ async function collectLatestSessionTaskCandidates(input: {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}): Promise<Candidate[]> {
|
}): 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));
|
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
@ -501,17 +520,32 @@ async function collectLatestSessionTaskCandidates(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function artifactScopeFor(sessionKey: string, runId: string): string {
|
function artifactScopeFor(sessionKey: string, runId: string): string {
|
||||||
return [
|
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||||
TASK_SCOPE_ROOT,
|
}
|
||||||
safeScopeSegment(sessionKey),
|
|
||||||
safeScopeSegment(runId),
|
function taskSessionScopeFor(sessionKey: string): string {
|
||||||
].join("/");
|
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 {
|
function safeScopeSegment(value: string): string {
|
||||||
const normalized = value
|
const normalized = value
|
||||||
.trim()
|
.trim()
|
||||||
.replaceAll(path.sep, "_")
|
.replace(/[\\/]+/g, "_")
|
||||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||||
.replace(/^[._-]+|[._-]+$/g, "")
|
.replace(/^[._-]+|[._-]+$/g, "")
|
||||||
.slice(0, 48);
|
.slice(0, 48);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user