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