diff --git a/README.md b/README.md index 6d3f04d..7069340 100644 --- a/README.md +++ b/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 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//`. +- 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 diff --git a/dist/index.js b/dist/index.js index 0e48a47..96431fc 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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({ diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index 4da686b..bec1c7f 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -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); diff --git a/index.test.ts b/index.test.ts index de5ac9e..b1285d7 100644 --- a/index.test.ts +++ b/index.test.ts @@ -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 { 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 }; }; 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) => { + execute: (id: string, params: Record) => Promise; + }; + 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", + ); }); }); diff --git a/index.ts b/index.ts index 332f29e..a2fdb86 100644 --- a/index.ts +++ b/index.ts @@ -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) { 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({ diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 9f725d4..a9aeec5 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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", diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index f724f40..a68b66a 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -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", }, diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 2df2bed..3a27bc1 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -96,6 +96,11 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise { 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 { - 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);