fix: enforce openclaw artifact session scope
This commit is contained in:
parent
b9c05e3657
commit
bbc21098f7
26
README.md
26
README.md
@ -10,6 +10,11 @@ XWorkmate talks to OpenClaw through `xworkmate-bridge` using the existing
|
||||
The APP can then sync generated files into its local thread workspace without
|
||||
changing the UI or adding provider-specific routes.
|
||||
|
||||
This plugin is not a scheduler. OpenClaw core owns sub-agents, multi-agent
|
||||
routing, queues, cron, and cross-session execution. This package only adapts
|
||||
those existing OpenClaw multi-task/session identities into isolated artifact
|
||||
directories and signed artifact reads.
|
||||
|
||||
It registers four Gateway methods:
|
||||
|
||||
```text
|
||||
@ -61,12 +66,16 @@ Equivalent config shape for a linked checkout:
|
||||
|
||||
## Contract
|
||||
|
||||
Prepare request params:
|
||||
Prepare request params are supplied by the OpenClaw host, bridge, or APP
|
||||
runtime. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
|
||||
trusted mapping into OpenClaw's built-in multi-session model; it does not parse
|
||||
paths from chat text and does not invent fallback session/run identities.
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionKey": "thread-main",
|
||||
"runId": "turn-1"
|
||||
"runId": "turn-1",
|
||||
"workspaceDir": "/home/user/.openclaw/workspace"
|
||||
}
|
||||
```
|
||||
|
||||
@ -131,13 +140,16 @@ When scoped export finds no task files and `latestIfEmpty` is true, the plugin s
|
||||
the workspace root for the latest real files and returns them with `scopeKind:
|
||||
"workspace-latest"`. This is a controlled recovery path for existing files already
|
||||
present in `/home/ubuntu/.openclaw/workspace`; it still skips plugin metadata and
|
||||
runtime directories, including the plugin-owned `.xworkmate/` directory.
|
||||
runtime directories, including the top-level `tasks/` directory so other runs are
|
||||
not exported as workspace fallback files.
|
||||
|
||||
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||
the artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||
`artifactScope + relativePath` for task-scope files. Workspace fallback files
|
||||
must be read with `artifactRef`; there is no unscoped arbitrary workspace read
|
||||
API.
|
||||
`artifactScope + relativePath` for the current `sessionKey/runId` task scope.
|
||||
Signed task `artifactRef` values are accepted for the current session, including
|
||||
same-session historical task fallback results returned by the plugin. Workspace
|
||||
fallback files must be read with `artifactRef`; there is no unscoped arbitrary
|
||||
workspace read API.
|
||||
|
||||
## View And Download
|
||||
|
||||
@ -180,9 +192,11 @@ only remote file access path.
|
||||
|
||||
- Only files inside the resolved OpenClaw workspace are exported.
|
||||
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are skipped when scanning the workspace root.
|
||||
- Top-level `tasks/` is skipped during workspace fallback scanning.
|
||||
- Symlinks are skipped to avoid workspace escape.
|
||||
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
||||
- `artifactScope` must be `tasks/<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.
|
||||
|
||||
## Development
|
||||
|
||||
27
dist/index.js
vendored
27
dist/index.js
vendored
@ -102,6 +102,18 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
type: "string",
|
||||
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
|
||||
},
|
||||
sessionKey: {
|
||||
type: "string",
|
||||
description: "OpenClaw session key supplied by the host or bridge runtime.",
|
||||
},
|
||||
runId: {
|
||||
type: "string",
|
||||
description: "OpenClaw run id supplied by the host or bridge runtime.",
|
||||
},
|
||||
workspaceDir: {
|
||||
type: "string",
|
||||
description: "OpenClaw workspace directory supplied by the host or bridge runtime.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
|
||||
@ -119,11 +131,20 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const action = typeof params.action === "string" ? params.action : "";
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ctx.sessionKey;
|
||||
const runId = typeof params.runId === "string" ? params.runId : "";
|
||||
const workspaceDir = typeof params.workspaceDir === "string" ? params.workspaceDir : ctx.workspaceDir;
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const baseParams = {
|
||||
...params,
|
||||
sessionKey: ctx.sessionKey || "agent:main:main",
|
||||
runId: typeof params.runId === "string" ? params.runId : "tool",
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
};
|
||||
if (action === "list") {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
|
||||
45
dist/src/exportArtifacts.js
vendored
45
dist/src/exportArtifacts.js
vendored
@ -23,6 +23,11 @@ export async function prepareXWorkmateArtifacts(input) {
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
@ -30,7 +35,7 @@ export async function prepareXWorkmateArtifacts(input) {
|
||||
sessionKey,
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const artifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const artifactScope = expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
return {
|
||||
@ -65,6 +70,10 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const warnings = [];
|
||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
if (artifactScope && artifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
const scopedExport = artifactScope !== "";
|
||||
let scopeKind = scopedExport ? "task" : "workspace";
|
||||
@ -167,8 +176,10 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
export async function readXWorkmateArtifact(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = optionalString(params.runId) || "read";
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
let relativePath = "";
|
||||
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
@ -195,11 +206,19 @@ export async function readXWorkmateArtifact(input) {
|
||||
if (requestedScope && requestedScope !== artifactScope) {
|
||||
throw new Error("artifactRef does not match artifactScope");
|
||||
}
|
||||
if (refPayload.scopeKind === "task") {
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
|
||||
allowSameSessionTaskHistory: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!artifactScope) {
|
||||
throw new Error("artifactScope or artifactRef required");
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
|
||||
allowSameSessionTaskHistory: false,
|
||||
});
|
||||
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
@ -348,7 +367,7 @@ async function collectCandidates(input) {
|
||||
}
|
||||
}
|
||||
async function collectLatestSessionTaskCandidates(input) {
|
||||
const sessionScope = [TASK_SCOPE_ROOT, safeScopeSegment(input.sessionKey)].join("/");
|
||||
const sessionScope = taskSessionScopeFor(input.sessionKey);
|
||||
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
|
||||
let entries;
|
||||
try {
|
||||
@ -386,16 +405,24 @@ async function collectLatestSessionTaskCandidates(input) {
|
||||
return candidates;
|
||||
}
|
||||
function artifactScopeFor(sessionKey, runId) {
|
||||
return [
|
||||
TASK_SCOPE_ROOT,
|
||||
safeScopeSegment(sessionKey),
|
||||
safeScopeSegment(runId),
|
||||
].join("/");
|
||||
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||
}
|
||||
function taskSessionScopeFor(sessionKey) {
|
||||
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
|
||||
}
|
||||
function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, options) {
|
||||
if (artifactScope === expectedArtifactScope) {
|
||||
return;
|
||||
}
|
||||
if (options.allowSameSessionTaskHistory && artifactScope.startsWith(`${expectedSessionScope}/`)) {
|
||||
return;
|
||||
}
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
function safeScopeSegment(value) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replaceAll(path.sep, "_")
|
||||
.replace(/[\\/]+/g, "_")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||
.replace(/^[._-]+|[._-]+$/g, "")
|
||||
.slice(0, 48);
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
@ -8,25 +10,26 @@ type GatewayMethodHandler = Parameters<OpenClawPluginApi["registerGatewayMethod"
|
||||
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[] };
|
||||
contracts?: { tools?: string[]; sessionScopedTools?: string[] };
|
||||
configSchema?: { properties?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(manifest.contracts?.tools).toContain("openclaw_multi_session_artifacts");
|
||||
expect(manifest.contracts?.sessionScopedTools).toContain("openclaw_multi_session_artifacts");
|
||||
expect(manifest.configSchema?.properties?.artifactRefSigningSecret).toBeTruthy();
|
||||
});
|
||||
|
||||
it("registers the xworkmate artifact export gateway method", () => {
|
||||
it("registers the xworkmate artifact gateway methods and optional tool", () => {
|
||||
const methods: Array<{ method: string; handler: GatewayMethodHandler }> = [];
|
||||
const tools: unknown[] = [];
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
registerGatewayMethod: (method: string, handler: GatewayMethodHandler) => {
|
||||
methods.push({ method, handler });
|
||||
},
|
||||
registerTool: (tool: unknown) => {
|
||||
tools.push(tool);
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
@ -40,5 +43,33 @@ describe("plugin registration", () => {
|
||||
]);
|
||||
expect(methods.every((entry) => typeof entry.handler === "function")).toBe(true);
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0]?.options).toMatchObject({
|
||||
names: ["openclaw_multi_session_artifacts"],
|
||||
optional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invent default session or run ids for the optional agent tool", async () => {
|
||||
const tools: Array<{ tool: unknown; options: unknown }> = [];
|
||||
const api = {
|
||||
config: {},
|
||||
pluginConfig: { workspaceDir: path.join(os.tmpdir(), "openclaw-multi-session-tool-test") },
|
||||
registerGatewayMethod: () => undefined,
|
||||
registerTool: (tool: unknown, options: unknown) => {
|
||||
tools.push({ tool, options });
|
||||
},
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
const factory = tools[0]?.tool as (ctx: Record<string, unknown>) => {
|
||||
execute: (id: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
const tool = factory({});
|
||||
|
||||
await expect(tool.execute("call-1", { action: "list", runId: "turn-1" })).rejects.toThrow("sessionKey required");
|
||||
await expect(tool.execute("call-2", { action: "list", sessionKey: "thread-main" })).rejects.toThrow(
|
||||
"runId required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
27
index.ts
27
index.ts
@ -121,6 +121,18 @@ function createXWorkmateArtifactsTool(
|
||||
type: "string",
|
||||
description: "Plugin-signed artifact reference returned by export/list. Required for workspace-latest reads.",
|
||||
},
|
||||
sessionKey: {
|
||||
type: "string",
|
||||
description: "OpenClaw session key supplied by the host or bridge runtime.",
|
||||
},
|
||||
runId: {
|
||||
type: "string",
|
||||
description: "OpenClaw run id supplied by the host or bridge runtime.",
|
||||
},
|
||||
workspaceDir: {
|
||||
type: "string",
|
||||
description: "OpenClaw workspace directory supplied by the host or bridge runtime.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
description: "Only list files changed at or after this Unix timestamp in milliseconds.",
|
||||
@ -138,11 +150,20 @@ function createXWorkmateArtifactsTool(
|
||||
},
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const action = typeof params.action === "string" ? params.action : "";
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : ctx.sessionKey;
|
||||
const runId = typeof params.runId === "string" ? params.runId : "";
|
||||
const workspaceDir = typeof params.workspaceDir === "string" ? params.workspaceDir : ctx.workspaceDir;
|
||||
if (!sessionKey) {
|
||||
throw new Error("sessionKey required");
|
||||
}
|
||||
if (!runId) {
|
||||
throw new Error("runId required");
|
||||
}
|
||||
const baseParams = {
|
||||
...params,
|
||||
sessionKey: ctx.sessionKey || "agent:main:main",
|
||||
runId: typeof params.runId === "string" ? params.runId : "tool",
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
sessionKey,
|
||||
runId,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
};
|
||||
if (action === "list") {
|
||||
const payload = await exportXWorkmateArtifacts({
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"onStartup": true
|
||||
},
|
||||
"contracts": {
|
||||
"tools": ["openclaw_multi_session_artifacts"]
|
||||
"tools": ["openclaw_multi_session_artifacts"],
|
||||
"sessionScopedTools": ["openclaw_multi_session_artifacts"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
|
||||
@ -135,6 +135,29 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects scoped exports that do not match the requested session/run", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const first = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
await expect(
|
||||
exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-2",
|
||||
artifactScope: first.artifactScope,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("falls back to latest workspace files when the scoped directory is empty", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
@ -335,6 +358,55 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
expect(result.artifacts[0]?.artifactRef).toContain(".");
|
||||
});
|
||||
|
||||
it("rejects direct reads from another run artifact scope", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const first = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(first.artifactDirectory, "first.txt"), "first");
|
||||
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-2",
|
||||
artifactScope: first.artifactScope,
|
||||
relativePath: "first.txt",
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("rejects signed task artifact refs from another session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "first.txt"), "first");
|
||||
const exported = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
await expect(
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-other",
|
||||
runId: "turn-1",
|
||||
artifactRef: exported.artifacts[0]?.artifactRef,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("reads a latest workspace artifact only through its artifactRef", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
@ -409,7 +481,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
const result = await readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "large.bin",
|
||||
maxInlineBytes: 2,
|
||||
@ -440,7 +512,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "../outside.txt",
|
||||
},
|
||||
@ -480,7 +552,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
readXWorkmateArtifact({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "run-1",
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
relativePath: "linked-secret.txt",
|
||||
},
|
||||
|
||||
@ -96,6 +96,11 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const requestedArtifactScope = optionalArtifactScope(params.artifactScope);
|
||||
if (requestedArtifactScope && requestedArtifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
@ -103,7 +108,7 @@ export async function prepareXWorkmateArtifacts(input: ExportInput): Promise<XWo
|
||||
sessionKey,
|
||||
});
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const artifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const artifactScope = expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
await fs.mkdir(scopeRoot, { recursive: true });
|
||||
return {
|
||||
@ -144,6 +149,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
const workspaceRoot = await fs.realpath(workspaceDir);
|
||||
const warnings: string[] = [];
|
||||
const artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
if (artifactScope && artifactScope !== expectedArtifactScope) {
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
const scopedExport = artifactScope !== "";
|
||||
let scopeKind: XWorkmateArtifactScopeKind = scopedExport ? "task" : "workspace";
|
||||
@ -253,8 +262,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmateArtifactExport> {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
const runId = optionalString(params.runId) || "read";
|
||||
const runId = requiredString(params.runId, "runId required");
|
||||
const sessionKey = requiredString(params.sessionKey, "sessionKey required");
|
||||
const expectedArtifactScope = artifactScopeFor(sessionKey, runId);
|
||||
const expectedSessionScope = taskSessionScopeFor(sessionKey);
|
||||
const requestedArtifactRef = optionalString(params.artifactRef);
|
||||
let relativePath = "";
|
||||
let artifactScope = optionalArtifactScope(params.artifactScope);
|
||||
@ -285,10 +296,18 @@ export async function readXWorkmateArtifact(input: ReadInput): Promise<XWorkmate
|
||||
if (requestedScope && requestedScope !== artifactScope) {
|
||||
throw new Error("artifactRef does not match artifactScope");
|
||||
}
|
||||
if (refPayload.scopeKind === "task") {
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
|
||||
allowSameSessionTaskHistory: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!artifactScope) {
|
||||
throw new Error("artifactScope or artifactRef required");
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
|
||||
allowSameSessionTaskHistory: false,
|
||||
});
|
||||
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
@ -462,7 +481,7 @@ async function collectLatestSessionTaskCandidates(input: {
|
||||
sessionKey: string;
|
||||
warnings: string[];
|
||||
}): 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));
|
||||
let entries;
|
||||
try {
|
||||
@ -501,17 +520,32 @@ async function collectLatestSessionTaskCandidates(input: {
|
||||
}
|
||||
|
||||
function artifactScopeFor(sessionKey: string, runId: string): string {
|
||||
return [
|
||||
TASK_SCOPE_ROOT,
|
||||
safeScopeSegment(sessionKey),
|
||||
safeScopeSegment(runId),
|
||||
].join("/");
|
||||
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||
}
|
||||
|
||||
function taskSessionScopeFor(sessionKey: string): string {
|
||||
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
|
||||
}
|
||||
|
||||
function assertArtifactScopeMatchesRequest(
|
||||
artifactScope: string,
|
||||
expectedArtifactScope: string,
|
||||
expectedSessionScope: string,
|
||||
options: { allowSameSessionTaskHistory: boolean },
|
||||
): void {
|
||||
if (artifactScope === expectedArtifactScope) {
|
||||
return;
|
||||
}
|
||||
if (options.allowSameSessionTaskHistory && artifactScope.startsWith(`${expectedSessionScope}/`)) {
|
||||
return;
|
||||
}
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
|
||||
function safeScopeSegment(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replaceAll(path.sep, "_")
|
||||
.replace(/[\\/]+/g, "_")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||
.replace(/^[._-]+|[._-]+$/g, "")
|
||||
.slice(0, 48);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user