diff --git a/README.md b/README.md index 5f35a30..2a2455f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ xworkmate.artifacts.list xworkmate.artifacts.read ``` -`prepare` creates a per-task artifact scope under the resolved OpenClaw workspace. `export` +`prepare` creates a per-task artifact scope under `tasks/` in the resolved OpenClaw workspace. `export` and `read` then return safe, relative artifact entries that XWorkmate Bridge can normalize into the APP `artifacts[]` contract. @@ -78,10 +78,10 @@ Prepare response payload: "sessionKey": "thread-main", "remoteWorkingDirectory": "/home/user/.openclaw/workspace", "remoteWorkspaceRefKind": "remotePath", - "artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", + "artifactScope": "tasks/thread-main-.../turn-1-...", "scopeKind": "task", - "artifactDirectory": "/home/user/.openclaw/workspace/.xworkmate/artifacts/tasks/thread-main-.../turn-1-...", - "relativeArtifactDirectory": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", + "artifactDirectory": "/home/user/.openclaw/workspace/tasks/thread-main-.../turn-1-...", + "relativeArtifactDirectory": "tasks/thread-main-.../turn-1-...", "warnings": [] } ``` @@ -92,7 +92,7 @@ Export request params: { "sessionKey": "thread-main", "runId": "turn-1", - "artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", + "artifactScope": "tasks/thread-main-.../turn-1-...", "sinceUnixMs": 1770000000000, "latestIfEmpty": true, "maxFiles": 64, @@ -108,7 +108,7 @@ Export response payload: "sessionKey": "thread-main", "remoteWorkingDirectory": "/home/user/.openclaw/workspace", "remoteWorkspaceRefKind": "remotePath", - "artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", + "artifactScope": "tasks/thread-main-.../turn-1-...", "scopeKind": "task", "artifacts": [ { @@ -117,7 +117,8 @@ Export response payload: "contentType": "text/markdown", "sizeBytes": 1234, "sha256": "...", - "artifactScope": ".xworkmate/artifacts/tasks/thread-main-.../turn-1-...", + "artifactRef": "...", + "artifactScope": "tasks/thread-main-.../turn-1-...", "scopeKind": "task" } ], @@ -130,7 +131,13 @@ 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. +runtime directories, including the plugin-owned top-level `tasks/` directory. + +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. ## View And Download @@ -161,7 +168,8 @@ Gateway clients can use: - `xworkmate.artifacts.prepare` before `chat.send` to allocate a task artifact directory. - `xworkmate.artifacts.list` for a metadata-only manifest and Markdown table. -- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one inline base64 file. +- `xworkmate.artifacts.read` with `artifactScope` and `relativePath` for one task file. +- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task or `workspace-latest` file. - `xworkmate.artifacts.export` with `artifactScope` after `agent.wait` for the XWorkmate APP sync path. Large files are metadata-only in the export payload, but XWorkmate Bridge can @@ -171,10 +179,11 @@ only remote file access path. ## Limits - 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`, top-level `tasks/`, build outputs, and dependency folders are skipped when scanning the workspace root. - Symlinks are skipped to avoid workspace escape. - Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined. -- `artifactScope` and `relativePath` must be workspace-relative paths; absolute paths, `..`, empty path segments, and symlink escapes are rejected. +- `artifactScope` must be `tasks//`. +- `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 5d83945..9f133ec 100644 --- a/dist/index.js +++ b/dist/index.js @@ -92,7 +92,15 @@ function createXWorkmateArtifactsTool(api, ctx) { }, relativePath: { type: "string", - description: "Artifact path relative to the workspace. Required for action=read.", + description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.", + }, + artifactScope: { + type: "string", + description: "Task artifact scope returned by prepare/export, for example tasks//.", + }, + artifactRef: { + type: "string", + description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.", }, sinceUnixMs: { type: "number", diff --git a/dist/src/exportArtifacts.d.ts b/dist/src/exportArtifacts.d.ts index 693c414..474386e 100644 --- a/dist/src/exportArtifacts.d.ts +++ b/dist/src/exportArtifacts.d.ts @@ -4,6 +4,7 @@ export type XWorkmateArtifact = { contentType: string; sizeBytes: number; sha256: string; + artifactRef: string; artifactScope?: string; scopeKind?: XWorkmateArtifactScopeKind; encoding?: "base64"; diff --git a/dist/src/exportArtifacts.js b/dist/src/exportArtifacts.js index 798b360..4f0f355 100644 --- a/dist/src/exportArtifacts.js +++ b/dist/src/exportArtifacts.js @@ -1,9 +1,11 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; +const TASK_SCOPE_ROOT = "tasks"; +const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex"); const SKIPPED_DIRS = new Set([ ".git", ".openclaw", @@ -69,6 +71,7 @@ export async function exportXWorkmateArtifacts(input) { scanRoot: scopeRoot, relativeRoot: scopeRoot, sinceUnixMs, + skipTaskScopeRoot: !scopedExport, warnings, }); if (candidates.length === 0 && latestIfEmpty) { @@ -77,6 +80,7 @@ export async function exportXWorkmateArtifacts(input) { scanRoot: workspaceRoot, relativeRoot: workspaceRoot, sinceUnixMs: 0, + skipTaskScopeRoot: true, warnings: latestWarnings, }); if (latestCandidates.length > 0) { @@ -101,12 +105,22 @@ export async function exportXWorkmateArtifacts(input) { break; } const bytes = await fs.readFile(candidate.absolutePath); + const sha256 = createHash("sha256").update(bytes).digest("hex"); const artifact = { relativePath: candidate.relativePath, label: path.posix.basename(candidate.relativePath), contentType: contentTypeForPath(candidate.relativePath), sizeBytes: bytes.byteLength, - sha256: createHash("sha256").update(bytes).digest("hex"), + sha256, + artifactRef: signArtifactRef({ + v: 1, + workspaceRootHash: workspaceRootHash(workspaceRoot), + scopeKind, + ...(scopeKind === "task" && artifactScope ? { artifactScope } : {}), + relativePath: candidate.relativePath, + sizeBytes: bytes.byteLength, + sha256, + }, pluginConfig), scopeKind, }; if (scopeKind === "task" && artifactScope) { @@ -141,8 +155,10 @@ export async function readXWorkmateArtifact(input) { const pluginConfig = input.pluginConfig ?? {}; const runId = optionalString(params.runId) || "read"; const sessionKey = requiredString(params.sessionKey, "sessionKey required"); - const relativePath = safeInputRelativePath(params.relativePath, "relativePath"); - const artifactScope = optionalArtifactScope(params.artifactScope); + const requestedArtifactRef = optionalString(params.artifactRef); + let relativePath = ""; + let artifactScope = optionalArtifactScope(params.artifactScope); + let refPayload; const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES); const workspaceDir = resolveWorkspaceDir({ config: input.config, @@ -151,8 +167,29 @@ export async function readXWorkmateArtifact(input) { sessionKey, }); const workspaceRoot = await fs.realpath(workspaceDir); + if (requestedArtifactRef) { + refPayload = verifyArtifactRef(requestedArtifactRef, workspaceRoot, pluginConfig); + relativePath = refPayload.relativePath; + if (refPayload.artifactScope) { + artifactScope = refPayload.artifactScope; + } + const requestedPath = optionalString(params.relativePath); + if (requestedPath && safeInputRelativePath(requestedPath, "relativePath") !== relativePath) { + throw new Error("artifactRef does not match relativePath"); + } + const requestedScope = optionalArtifactScope(params.artifactScope); + if (requestedScope && requestedScope !== artifactScope) { + throw new Error("artifactRef does not match artifactScope"); + } + } + else { + if (!artifactScope) { + throw new Error("artifactScope or artifactRef required"); + } + relativePath = safeInputRelativePath(params.relativePath, "relativePath"); + } const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot; - const scopeKind = artifactScope ? "task" : "workspace"; + const scopeKind = refPayload?.scopeKind ?? "task"; const absolutePath = path.join(scopeRoot, relativePath.split("/").join(path.sep)); const realPath = await fs.realpath(absolutePath); if (!isWithinRoot(scopeRoot, realPath)) { @@ -163,12 +200,26 @@ export async function readXWorkmateArtifact(input) { throw new Error("relativePath must point to a file"); } const bytes = await fs.readFile(realPath); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + if (refPayload && (refPayload.sizeBytes !== bytes.byteLength || refPayload.sha256 !== sha256)) { + throw new Error("artifactRef does not match file content"); + } const artifact = { relativePath: safeRelativePath(scopeRoot, realPath), label: path.posix.basename(relativePath), contentType: contentTypeForPath(relativePath), sizeBytes: bytes.byteLength, - sha256: createHash("sha256").update(bytes).digest("hex"), + sha256, + artifactRef: requestedArtifactRef || + signArtifactRef({ + v: 1, + workspaceRootHash: workspaceRootHash(workspaceRoot), + scopeKind, + ...(artifactScope ? { artifactScope } : {}), + relativePath: safeRelativePath(scopeRoot, realPath), + sizeBytes: bytes.byteLength, + sha256, + }, pluginConfig), scopeKind, }; if (artifactScope) { @@ -247,6 +298,9 @@ async function collectCandidates(input) { continue; } if (entry.isDirectory()) { + if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) { + continue; + } if (SKIPPED_DIRS.has(entry.name)) { continue; } @@ -281,9 +335,7 @@ async function collectCandidates(input) { } function artifactScopeFor(sessionKey, runId) { return [ - ".xworkmate", - "artifacts", - "tasks", + TASK_SCOPE_ROOT, safeScopeSegment(sessionKey), safeScopeSegment(runId), ].join("/"); @@ -303,7 +355,15 @@ function optionalArtifactScope(value) { if (!scope) { return ""; } - return safeInputRelativePath(scope, "artifactScope"); + return safeTaskArtifactScope(scope); +} +function safeTaskArtifactScope(value) { + const scope = safeInputRelativePath(value, "artifactScope"); + const parts = scope.split("/"); + if (parts.length !== 3 || parts[0] !== TASK_SCOPE_ROOT) { + throw new Error("artifactScope must be a task artifact scope"); + } + return scope; } function safeInputRelativePath(value, label) { const relativePath = optionalString(value); @@ -320,7 +380,7 @@ function safeInputRelativePath(value, label) { return normalized; } function resolveScopeRoot(workspaceRoot, artifactScope) { - const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope"); + const normalizedScope = safeTaskArtifactScope(artifactScope); const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep)); if (!isWithinRoot(workspaceRoot, scopeRoot)) { throw new Error("artifactScope must stay inside the workspace"); @@ -463,6 +523,75 @@ function nonNegativeNumber(value, fallback) { } return fallback; } +function signArtifactRef(payload, pluginConfig) { + const body = base64UrlEncode(JSON.stringify(payload)); + const signature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url"); + return `${body}.${signature}`; +} +function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) { + const [body, signature, ...extra] = artifactRef.split("."); + if (!body || !signature || extra.length > 0) { + throw new Error("invalid artifactRef"); + } + const expectedSignature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url"); + if (!constantTimeEqual(signature, expectedSignature)) { + throw new Error("invalid artifactRef"); + } + let parsed; + try { + parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); + } + catch { + throw new Error("invalid artifactRef"); + } + const payload = objectRecord(parsed); + const scopeKind = optionalString(payload.scopeKind); + if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) { + throw new Error("invalid artifactRef"); + } + const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath"); + const artifactScope = optionalArtifactScope(payload.artifactScope); + if (scopeKind === "task" && !artifactScope) { + throw new Error("invalid artifactRef"); + } + if (scopeKind !== "task" && artifactScope) { + throw new Error("invalid artifactRef"); + } + 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)) { + throw new Error("invalid artifactRef"); + } + if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) { + throw new Error("artifactRef does not match workspace"); + } + return { + v: 1, + workspaceRootHash: workspaceRootHash(workspaceRoot), + scopeKind, + ...(artifactScope ? { artifactScope } : {}), + relativePath, + sizeBytes, + sha256, + }; +} +function artifactRefSigningSecret(pluginConfig) { + return (optionalString(pluginConfig.artifactRefSigningSecret) || + optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) || + optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) || + GENERATED_ARTIFACT_REF_SECRET); +} +function workspaceRootHash(workspaceRoot) { + return createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex"); +} +function base64UrlEncode(value) { + return Buffer.from(value, "utf8").toString("base64url"); +} +function constantTimeEqual(left, right) { + const leftBytes = Buffer.from(left); + const rightBytes = Buffer.from(right); + return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes); +} function expandUserPath(value) { if (value === "~") { return os.homedir(); diff --git a/index.test.ts b/index.test.ts index 8eb3335..01e8c7e 100644 --- a/index.test.ts +++ b/index.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { describe, expect, it } from "vitest"; import plugin from "./index.js"; @@ -5,6 +6,16 @@ import plugin from "./index.js"; type GatewayMethodHandler = Parameters[1]; 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[] }; + configSchema?: { properties?: Record }; + }; + + expect(manifest.contracts?.tools).toContain("xworkmate_artifacts"); + expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy(); + }); + it("registers the xworkmate artifact export gateway method", () => { const methods: Array<{ method: string; handler: GatewayMethodHandler }> = []; const tools: unknown[] = []; diff --git a/index.ts b/index.ts index 5d1ef6c..dafd63d 100644 --- a/index.ts +++ b/index.ts @@ -111,7 +111,15 @@ function createXWorkmateArtifactsTool( }, relativePath: { type: "string", - description: "Artifact path relative to the workspace. Required for action=read.", + description: "Artifact path relative to artifactScope. Required for action=read without artifactRef.", + }, + artifactScope: { + type: "string", + description: "Task artifact scope returned by prepare/export, for example tasks//.", + }, + artifactRef: { + type: "string", + description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.", }, sinceUnixMs: { type: "number", diff --git a/openclaw.plugin.json b/openclaw.plugin.json index d235ef8..fe81cdb 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -5,6 +5,9 @@ "activation": { "onStartup": true }, + "contracts": { + "tools": ["xworkmate_artifacts"] + }, "configSchema": { "type": "object", "additionalProperties": false, @@ -20,6 +23,10 @@ "maxInlineBytes": { "type": "number", "description": "Default maximum file size to inline as base64." + }, + "artifactRefSigningSecret": { + "type": "string", + "description": "Optional stable secret used to sign artifactRef values. Defaults to an in-process secret." } } }, @@ -35,6 +42,11 @@ "maxInlineBytes": { "label": "Max Inline Bytes", "help": "Upper bound for base64 inline artifact content. Default: 10485760." + }, + "artifactRefSigningSecret": { + "label": "Artifact Ref Signing Secret", + "help": "Optional stable secret for plugin artifact references. Leave blank for process-local refs.", + "sensitive": true } } } diff --git a/src/exportArtifacts.test.ts b/src/exportArtifacts.test.ts index 16ee183..a52764b 100644 --- a/src/exportArtifacts.test.ts +++ b/src/exportArtifacts.test.ts @@ -22,8 +22,8 @@ describe("exportXWorkmateArtifacts", () => { pluginConfig: { workspaceDir: root }, }); - expect(first.artifactScope).toMatch(/^\.xworkmate\/artifacts\/tasks\/thread-main-[a-f0-9]{12}\/turn-1-[a-f0-9]{12}$/); - expect(second.artifactScope).toMatch(/^\.xworkmate\/artifacts\/tasks\/thread-main-[a-f0-9]{12}\/turn-2-[a-f0-9]{12}$/); + 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).not.toBe(second.artifactScope); expect((await fs.stat(first.artifactDirectory)).isDirectory()).toBe(true); expect(first.remoteWorkingDirectory).toBe(await fs.realpath(root)); @@ -58,6 +58,7 @@ describe("exportXWorkmateArtifacts", () => { encoding: "base64", content: Buffer.from("# Done\n").toString("base64"), }); + expect(result.artifacts[0]?.artifactRef).toContain("."); expect(result.manifestMarkdown).toContain("reports/final.md"); expect(result.manifestMarkdown).toContain("text/markdown"); }); @@ -140,7 +141,12 @@ describe("exportXWorkmateArtifacts", () => { params: { sessionKey: "thread-main", runId: "turn-1" }, pluginConfig: { workspaceDir: root }, }); + const otherTask = await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "turn-2" }, + pluginConfig: { workspaceDir: root }, + }); await fs.writeFile(path.join(root, "existing.pdf"), "pdf"); + await fs.writeFile(path.join(otherTask.artifactDirectory, "other-task.txt"), "other"); await fs.mkdir(path.join(root, ".xworkmate", "metadata"), { recursive: true }); await fs.writeFile(path.join(root, ".xworkmate", "metadata", "internal.json"), "{}"); const stat = await fs.stat(path.join(root, "existing.pdf")); @@ -161,6 +167,7 @@ describe("exportXWorkmateArtifacts", () => { expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["existing.pdf"]); expect(result.artifacts[0]?.artifactScope).toBeUndefined(); expect(result.artifacts[0]?.scopeKind).toBe("workspace-latest"); + expect(result.artifacts[0]?.artifactRef).toContain("."); expect(result.warnings).toContain("scoped artifact directory is empty; exported latest workspace files instead"); }); @@ -242,27 +249,21 @@ describe("exportXWorkmateArtifacts", () => { expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]); }); - it("reads one artifact by relative path", async () => { + it("rejects unscoped artifact reads by relative path", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); await fs.mkdir(path.join(root, "reports"), { recursive: true }); await fs.writeFile(path.join(root, "reports", "final.txt"), "final"); - const result = await readXWorkmateArtifact({ - params: { - sessionKey: "thread-main", - runId: "run-1", - relativePath: "reports/final.txt", - }, - pluginConfig: { workspaceDir: root }, - }); - - expect(result.artifacts).toHaveLength(1); - expect(result.artifacts[0]).toMatchObject({ - relativePath: "reports/final.txt", - contentType: "text/plain", - encoding: "base64", - content: Buffer.from("final").toString("base64"), - }); + await expect( + readXWorkmateArtifact({ + params: { + sessionKey: "thread-main", + runId: "run-1", + relativePath: "reports/final.txt", + }, + pluginConfig: { workspaceDir: root }, + }), + ).rejects.toThrow("artifactScope or artifactRef required"); }); it("reads one artifact inside a task artifact scope", async () => { @@ -293,16 +294,85 @@ describe("exportXWorkmateArtifacts", () => { encoding: "base64", content: Buffer.from("final").toString("base64"), }); + expect(result.artifacts[0]?.artifactRef).toContain("."); + }); + + it("reads a latest workspace artifact only through its artifactRef", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); + 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 }, + }); + + const result = await readXWorkmateArtifact({ + params: { + sessionKey: "thread-main", + runId: "turn-1", + artifactRef: exported.artifacts[0]?.artifactRef, + }, + pluginConfig: { workspaceDir: root }, + }); + + expect(result.scopeKind).toBe("workspace-latest"); + expect(result.artifactScope).toBeUndefined(); + expect(result.artifacts[0]).toMatchObject({ + relativePath: "existing.txt", + scopeKind: "workspace-latest", + encoding: "base64", + content: Buffer.from("existing").toString("base64"), + }); + }); + + it("rejects tampered artifact refs", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); + await fs.writeFile(path.join(root, "existing.txt"), "existing"); + const exported = await exportXWorkmateArtifacts({ + params: { + sessionKey: "thread-main", + runId: "run-1", + }, + pluginConfig: { workspaceDir: root }, + }); + const artifactRef = exported.artifacts[0]?.artifactRef ?? ""; + const tampered = `${artifactRef}x`; + + await expect( + readXWorkmateArtifact({ + params: { + sessionKey: "thread-main", + runId: "run-1", + artifactRef: tampered, + }, + pluginConfig: { workspaceDir: root }, + }), + ).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-xworkmate-artifacts-")); - await fs.writeFile(path.join(root, "large.bin"), Buffer.from("large-content")); + const prepared = await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "turn-1" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.writeFile(path.join(prepared.artifactDirectory, "large.bin"), Buffer.from("large-content")); const result = await readXWorkmateArtifact({ params: { sessionKey: "thread-main", runId: "run-1", + artifactScope: prepared.artifactScope, relativePath: "large.bin", maxInlineBytes: 2, }, @@ -323,12 +393,17 @@ describe("exportXWorkmateArtifacts", () => { it("rejects relative path traversal when reading artifacts", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-artifacts-")); + const prepared = await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "turn-1" }, + pluginConfig: { workspaceDir: root }, + }); await expect( readXWorkmateArtifact({ params: { sessionKey: "thread-main", runId: "run-1", + artifactScope: prepared.artifactScope, relativePath: "../outside.txt", }, pluginConfig: { workspaceDir: root }, @@ -357,13 +432,18 @@ describe("exportXWorkmateArtifacts", () => { const outsideRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-xworkmate-outside-")); const outsideFile = path.join(outsideRoot, "secret.txt"); await fs.writeFile(outsideFile, "secret"); - await fs.symlink(outsideFile, path.join(root, "linked-secret.txt")); + const prepared = await prepareXWorkmateArtifacts({ + params: { sessionKey: "thread-main", runId: "turn-1" }, + pluginConfig: { workspaceDir: root }, + }); + await fs.symlink(outsideFile, path.join(prepared.artifactDirectory, "linked-secret.txt")); await expect( readXWorkmateArtifact({ params: { sessionKey: "thread-main", runId: "run-1", + artifactScope: prepared.artifactScope, relativePath: "linked-secret.txt", }, pluginConfig: { workspaceDir: root }, diff --git a/src/exportArtifacts.ts b/src/exportArtifacts.ts index 4228330..53a7aac 100644 --- a/src/exportArtifacts.ts +++ b/src/exportArtifacts.ts @@ -1,10 +1,12 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; const DEFAULT_MAX_FILES = 64; const DEFAULT_MAX_INLINE_BYTES = 10 * 1024 * 1024; +const TASK_SCOPE_ROOT = "tasks"; +const GENERATED_ARTIFACT_REF_SECRET = randomBytes(32).toString("hex"); const SKIPPED_DIRS = new Set([ ".git", @@ -25,6 +27,7 @@ export type XWorkmateArtifact = { contentType: string; sizeBytes: number; sha256: string; + artifactRef: string; artifactScope?: string; scopeKind?: XWorkmateArtifactScopeKind; encoding?: "base64"; @@ -69,6 +72,16 @@ type ReadInput = { pluginConfig?: Record; }; +type ArtifactRefPayload = { + v: 1; + workspaceRootHash: string; + scopeKind: XWorkmateArtifactScopeKind; + artifactScope?: string; + relativePath: string; + sizeBytes: number; + sha256: string; +}; + type Candidate = { absolutePath: string; relativePath: string; @@ -135,6 +148,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise 0) { @@ -170,12 +185,25 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise { const candidates: Candidate[] = []; @@ -336,6 +405,9 @@ async function collectCandidates(input: { continue; } if (entry.isDirectory()) { + if (input.skipTaskScopeRoot && currentDir === input.relativeRoot && entry.name === TASK_SCOPE_ROOT) { + continue; + } if (SKIPPED_DIRS.has(entry.name)) { continue; } @@ -371,9 +443,7 @@ async function collectCandidates(input: { function artifactScopeFor(sessionKey: string, runId: string): string { return [ - ".xworkmate", - "artifacts", - "tasks", + TASK_SCOPE_ROOT, safeScopeSegment(sessionKey), safeScopeSegment(runId), ].join("/"); @@ -395,7 +465,16 @@ function optionalArtifactScope(value: unknown): string { if (!scope) { return ""; } - return safeInputRelativePath(scope, "artifactScope"); + return safeTaskArtifactScope(scope); +} + +function safeTaskArtifactScope(value: unknown): string { + const scope = safeInputRelativePath(value, "artifactScope"); + const parts = scope.split("/"); + if (parts.length !== 3 || parts[0] !== TASK_SCOPE_ROOT) { + throw new Error("artifactScope must be a task artifact scope"); + } + return scope; } function safeInputRelativePath(value: unknown, label: string): string { @@ -414,7 +493,7 @@ function safeInputRelativePath(value: unknown, label: string): string { } function resolveScopeRoot(workspaceRoot: string, artifactScope: string): string { - const normalizedScope = safeInputRelativePath(artifactScope, "artifactScope"); + const normalizedScope = safeTaskArtifactScope(artifactScope); const scopeRoot = path.join(workspaceRoot, normalizedScope.split("/").join(path.sep)); if (!isWithinRoot(workspaceRoot, scopeRoot)) { throw new Error("artifactScope must stay inside the workspace"); @@ -577,6 +656,86 @@ function nonNegativeNumber(value: unknown, fallback: number): number { return fallback; } +function signArtifactRef(payload: ArtifactRefPayload, pluginConfig: Record): string { + const body = base64UrlEncode(JSON.stringify(payload)); + const signature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url"); + return `${body}.${signature}`; +} + +function verifyArtifactRef( + artifactRef: string, + workspaceRoot: string, + pluginConfig: Record, +): ArtifactRefPayload { + const [body, signature, ...extra] = artifactRef.split("."); + if (!body || !signature || extra.length > 0) { + throw new Error("invalid artifactRef"); + } + const expectedSignature = createHmac("sha256", artifactRefSigningSecret(pluginConfig)).update(body).digest("base64url"); + if (!constantTimeEqual(signature, expectedSignature)) { + throw new Error("invalid artifactRef"); + } + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); + } catch { + throw new Error("invalid artifactRef"); + } + const payload = objectRecord(parsed); + const scopeKind = optionalString(payload.scopeKind) as XWorkmateArtifactScopeKind; + if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) { + throw new Error("invalid artifactRef"); + } + const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath"); + const artifactScope = optionalArtifactScope(payload.artifactScope); + if (scopeKind === "task" && !artifactScope) { + throw new Error("invalid artifactRef"); + } + if (scopeKind !== "task" && artifactScope) { + throw new Error("invalid artifactRef"); + } + 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)) { + throw new Error("invalid artifactRef"); + } + if (optionalString(payload.workspaceRootHash) !== workspaceRootHash(workspaceRoot)) { + throw new Error("artifactRef does not match workspace"); + } + return { + v: 1, + workspaceRootHash: workspaceRootHash(workspaceRoot), + scopeKind, + ...(artifactScope ? { artifactScope } : {}), + relativePath, + sizeBytes, + sha256, + }; +} + +function artifactRefSigningSecret(pluginConfig: Record): string { + return ( + optionalString(pluginConfig.artifactRefSigningSecret) || + optionalString(process.env.XWORKMATE_ARTIFACT_REF_SIGNING_SECRET) || + optionalString(process.env.XWORKMATE_ARTIFACT_DOWNLOAD_SIGNING_SECRET) || + GENERATED_ARTIFACT_REF_SECRET + ); +} + +function workspaceRootHash(workspaceRoot: string): string { + return createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex"); +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function constantTimeEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left); + const rightBytes = Buffer.from(right); + return leftBytes.byteLength === rightBytes.byteLength && timingSafeEqual(leftBytes, rightBytes); +} + function expandUserPath(value: string): string { if (value === "~") { return os.homedir();