fix: enforce openclaw artifact session scope

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

View File

@ -10,6 +10,11 @@ XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing
The APP can then sync generated files into its local thread workspace without 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
View File

@ -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({

View File

@ -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);

View File

@ -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",
);
}); });
}); });

View File

@ -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({

View File

@ -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",

View File

@ -135,6 +135,29 @@ describe("exportXWorkmateArtifacts", () => {
}); });
}); });
it("rejects scoped exports that do not match the requested session/run", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const first = await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-2" },
pluginConfig: { workspaceDir: root },
});
await expect(
exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-2",
artifactScope: first.artifactScope,
},
pluginConfig: { workspaceDir: root },
}),
).rejects.toThrow("artifactScope does not match sessionKey/runId");
});
it("falls back to latest workspace files when the scoped directory is empty", async () => { 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",
}, },

View File

@ -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);