Restrict artifacts to current task scope
This commit is contained in:
parent
097a62e2fa
commit
0ee969ea16
21
README.md
21
README.md
@ -106,7 +106,6 @@ Export request params:
|
||||
"runId": "turn-1",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"sinceUnixMs": 1770000000000,
|
||||
"latestIfEmpty": true,
|
||||
"maxFiles": 64,
|
||||
"maxInlineBytes": 10485760
|
||||
}
|
||||
@ -140,20 +139,15 @@ Export response payload:
|
||||
|
||||
Files at or below `maxInlineBytes` also include `encoding: "base64"` and `content`.
|
||||
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.
|
||||
derived from `sessionKey/runId`. If that scope has no files, export/list returns
|
||||
an empty artifact list. The plugin does not scan the workspace root and does not
|
||||
borrow artifacts from earlier task scopes.
|
||||
|
||||
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||
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 a same-session and same-run `artifactRef`; there
|
||||
is no unscoped arbitrary workspace read API.
|
||||
Signed task `artifactRef` values are accepted only for the same `sessionKey/runId`
|
||||
that issued them. There is no unscoped arbitrary workspace read API.
|
||||
|
||||
## View And Download
|
||||
|
||||
@ -185,7 +179,7 @@ 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 task file.
|
||||
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task or `workspace-latest` file.
|
||||
- `xworkmate.artifacts.read` with `artifactRef` for a plugin-returned task 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
|
||||
@ -195,8 +189,7 @@ 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.
|
||||
- Top-level `tasks/` is skipped during workspace fallback scanning.
|
||||
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are excluded from task artifact exports.
|
||||
- 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>`.
|
||||
|
||||
2
dist/index.js
vendored
2
dist/index.js
vendored
@ -100,7 +100,7 @@ function createXWorkmateArtifactsTool(api, ctx) {
|
||||
},
|
||||
artifactRef: {
|
||||
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. Bound to the issuing task scope.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
|
||||
2
dist/src/exportArtifacts.d.ts
vendored
2
dist/src/exportArtifacts.d.ts
vendored
@ -10,7 +10,7 @@ export type XWorkmateArtifact = {
|
||||
encoding?: "base64";
|
||||
content?: string;
|
||||
};
|
||||
export type XWorkmateArtifactScopeKind = "task" | "workspace" | "workspace-latest";
|
||||
export type XWorkmateArtifactScopeKind = "task";
|
||||
export type XWorkmateArtifactExport = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
|
||||
99
dist/src/exportArtifacts.js
vendored
99
dist/src/exportArtifacts.js
vendored
@ -59,8 +59,6 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
const maxInlineBytes = nonNegativeInteger(params.maxInlineBytes, pluginConfig.maxInlineBytes, DEFAULT_MAX_INLINE_BYTES);
|
||||
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const includeContent = optionalBoolean(params.includeContent, true);
|
||||
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
|
||||
const latestTaskScopeIfEmpty = optionalBoolean(params.latestTaskScopeIfEmpty, false);
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
@ -77,41 +75,14 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
const sessionScope = taskSessionScopeFor(sessionKey);
|
||||
const artifactScope = requestedArtifactScope || expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
let scopeKind = "task";
|
||||
let candidates = await collectCandidates({
|
||||
const scopeKind = "task";
|
||||
const candidates = await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
skipTaskScopeRoot: false,
|
||||
warnings,
|
||||
});
|
||||
if (candidates.length === 0 && latestIfEmpty) {
|
||||
const latestWarnings = [];
|
||||
const latestCandidates = latestTaskScopeIfEmpty
|
||||
? await collectLatestSessionTaskCandidates({
|
||||
workspaceRoot,
|
||||
sessionKey,
|
||||
warnings: latestWarnings,
|
||||
})
|
||||
: await collectCandidates({
|
||||
scanRoot: workspaceRoot,
|
||||
relativeRoot: workspaceRoot,
|
||||
sinceUnixMs: 0,
|
||||
skipTaskScopeRoot: true,
|
||||
warnings: latestWarnings,
|
||||
});
|
||||
if (latestCandidates.length > 0) {
|
||||
warnings.push(...latestWarnings);
|
||||
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");
|
||||
}
|
||||
candidates = latestCandidates;
|
||||
scopeKind = "workspace-latest";
|
||||
}
|
||||
}
|
||||
candidates.sort((left, right) => {
|
||||
if (right.mtimeMs !== left.mtimeMs) {
|
||||
return right.mtimeMs - left.mtimeMs;
|
||||
@ -140,16 +111,14 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
scopeKind: scopeKindForCandidate,
|
||||
sessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
|
||||
? { artifactScope: artifactScopeForCandidate }
|
||||
: {}),
|
||||
...(artifactScopeForCandidate ? { artifactScope: artifactScopeForCandidate } : {}),
|
||||
relativePath: candidate.relativePath,
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256,
|
||||
}, pluginConfig),
|
||||
scopeKind: scopeKindForCandidate,
|
||||
};
|
||||
if (scopeKindForCandidate === "task" && artifactScopeForCandidate) {
|
||||
if (artifactScopeForCandidate) {
|
||||
artifact.artifactScope = artifactScopeForCandidate;
|
||||
}
|
||||
if (includeContent && bytes.byteLength <= maxInlineBytes) {
|
||||
@ -166,7 +135,7 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath",
|
||||
...(scopeKind === "task" ? { artifactScope } : {}),
|
||||
artifactScope,
|
||||
scopeKind,
|
||||
artifacts,
|
||||
warnings,
|
||||
@ -210,19 +179,13 @@ 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,
|
||||
});
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
|
||||
}
|
||||
else {
|
||||
if (!artifactScope) {
|
||||
throw new Error("artifactScope or artifactRef required");
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
|
||||
allowSameSessionTaskHistory: false,
|
||||
});
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
|
||||
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
@ -372,57 +335,16 @@ async function collectCandidates(input) {
|
||||
}
|
||||
}
|
||||
}
|
||||
async function collectLatestSessionTaskCandidates(input) {
|
||||
const sessionScope = taskSessionScopeFor(input.sessionKey);
|
||||
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
|
||||
}
|
||||
catch {
|
||||
return [];
|
||||
}
|
||||
const candidates = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === "." || entry.name === "..") {
|
||||
continue;
|
||||
}
|
||||
const artifactScope = [sessionScope, entry.name].join("/");
|
||||
let scopeRoot;
|
||||
try {
|
||||
scopeRoot = resolveScopeRoot(input.workspaceRoot, artifactScope);
|
||||
}
|
||||
catch {
|
||||
continue;
|
||||
}
|
||||
const scopedCandidates = await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs: 0,
|
||||
skipTaskScopeRoot: false,
|
||||
warnings: input.warnings,
|
||||
});
|
||||
candidates.push(...scopedCandidates.map((candidate) => ({
|
||||
...candidate,
|
||||
artifactScope,
|
||||
scopeKind: "task",
|
||||
})));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
function artifactScopeFor(sessionKey, runId) {
|
||||
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||
}
|
||||
function taskSessionScopeFor(sessionKey) {
|
||||
return [TASK_SCOPE_ROOT, safeScopeSegment(sessionKey)].join("/");
|
||||
}
|
||||
function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, options) {
|
||||
function assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope) {
|
||||
if (artifactScope === expectedArtifactScope) {
|
||||
return;
|
||||
}
|
||||
if (options.allowSameSessionTaskHistory && artifactScope.startsWith(`${expectedSessionScope}/`)) {
|
||||
return;
|
||||
}
|
||||
throw new Error("artifactScope does not match sessionKey/runId");
|
||||
}
|
||||
function assertArtifactRefMatchesRequest(payload, expectedRunScope, expectedSessionScope) {
|
||||
@ -664,7 +586,7 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
|
||||
}
|
||||
const payload = objectRecord(parsed);
|
||||
const scopeKind = optionalString(payload.scopeKind);
|
||||
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) {
|
||||
if (scopeKind !== "task") {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
|
||||
@ -672,9 +594,6 @@ function verifyArtifactRef(artifactRef, workspaceRoot, pluginConfig) {
|
||||
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 !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
|
||||
2
index.ts
2
index.ts
@ -125,7 +125,7 @@ function createXWorkmateArtifactsTool(
|
||||
},
|
||||
artifactRef: {
|
||||
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. Bound to the issuing task scope.",
|
||||
},
|
||||
sinceUnixMs: {
|
||||
type: "number",
|
||||
|
||||
@ -176,7 +176,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
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 () => {
|
||||
it("does not scan the workspace root when the current task scope is empty", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
@ -219,7 +219,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
).rejects.toThrow("artifactScope does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("falls back to latest workspace files when the scoped directory is empty", async () => {
|
||||
it("does not fall back to 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({
|
||||
params: { sessionKey: "thread-main", runId: "turn-1" },
|
||||
@ -241,26 +241,26 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
runId: "turn-1",
|
||||
artifactScope: prepared.artifactScope,
|
||||
sinceUnixMs: stat.mtimeMs + 10_000,
|
||||
latestIfEmpty: true,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.scopeKind).toBe("workspace-latest");
|
||||
expect(result.artifactScope).toBeUndefined();
|
||||
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");
|
||||
expect(result.scopeKind).toBe("task");
|
||||
expect(result.artifactScope).toBe(prepared.artifactScope);
|
||||
expect(result.artifacts).toEqual([]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to latest session task files when requested", async () => {
|
||||
it("does not borrow previous session task files when current task scope is empty", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
const previousTask = await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-previous" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-main", runId: "turn-follow-up" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.pdf"), "pdf");
|
||||
await fs.writeFile(path.join(previousTask.artifactDirectory, "k8s-networking.docx"), "docx");
|
||||
|
||||
@ -269,55 +269,57 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-follow-up",
|
||||
sinceUnixMs: Date.now() + 10_000,
|
||||
latestIfEmpty: true,
|
||||
latestTaskScopeIfEmpty: true,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
|
||||
expect(result.scopeKind).toBe("workspace-latest");
|
||||
expect(result.artifactScope).toBeUndefined();
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual([
|
||||
"k8s-networking.docx",
|
||||
"k8s-networking.pdf",
|
||||
]);
|
||||
expect(
|
||||
result.artifacts.map((entry) => ({
|
||||
artifactScope: entry.artifactScope,
|
||||
scopeKind: entry.scopeKind,
|
||||
})),
|
||||
).toEqual([
|
||||
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
|
||||
{ artifactScope: previousTask.artifactScope, scopeKind: "task" },
|
||||
]);
|
||||
expect(result.warnings).toContain("scoped artifact directory is empty; exported latest session task files instead");
|
||||
expect(result.scopeKind).toBe("task");
|
||||
expect(result.artifactScope).toBe("tasks/thread-main/turn-follow-up");
|
||||
expect(result.artifacts).toEqual([]);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not include another session in latest session task fallback", async () => {
|
||||
it("exports concurrent task scopes independently", 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 prepared = await Promise.all([
|
||||
prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-a", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-b", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
prepareXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-a", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
]);
|
||||
await fs.writeFile(path.join(prepared[0].artifactDirectory, "a-1.txt"), "a1");
|
||||
await fs.writeFile(path.join(prepared[1].artifactDirectory, "b-1.txt"), "b1");
|
||||
await fs.writeFile(path.join(prepared[2].artifactDirectory, "a-2.txt"), "a2");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
sessionKey: "thread-main",
|
||||
runId: "turn-follow-up",
|
||||
latestIfEmpty: true,
|
||||
latestTaskScopeIfEmpty: true,
|
||||
},
|
||||
pluginConfig: { workspaceDir: root },
|
||||
});
|
||||
const results = await Promise.all([
|
||||
exportXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-a", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
exportXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-b", runId: "turn-1" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
exportXWorkmateArtifacts({
|
||||
params: { sessionKey: "thread-a", runId: "turn-2" },
|
||||
pluginConfig: { workspaceDir: root },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["same.txt"]);
|
||||
expect(result.artifacts[0]?.artifactScope).toBe(sameSessionTask.artifactScope);
|
||||
expect(results.map((result) => result.artifacts.map((entry) => entry.relativePath))).toEqual([
|
||||
["a-1.txt"],
|
||||
["b-1.txt"],
|
||||
["a-2.txt"],
|
||||
]);
|
||||
expect(results.map((result) => result.artifactScope)).toEqual(prepared.map((entry) => entry.artifactScope));
|
||||
});
|
||||
|
||||
it("leaves oversized artifacts out of inline content", async () => {
|
||||
@ -511,59 +513,19 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
).rejects.toThrow("artifactRef does not match sessionKey/runId");
|
||||
});
|
||||
|
||||
it("reads a latest workspace artifact only through its artifactRef", async () => {
|
||||
it("rejects signed task 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");
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "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 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 },
|
||||
});
|
||||
@ -616,7 +578,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
workspaceRootHash: createHash("sha256").update(path.resolve(root)).digest("hex"),
|
||||
scopeKind: "workspace-latest",
|
||||
scopeKind: "task",
|
||||
relativePath: "existing.txt",
|
||||
sizeBytes: 8,
|
||||
sha256: createHash("sha256").update("existing").digest("hex"),
|
||||
|
||||
@ -34,7 +34,7 @@ export type XWorkmateArtifact = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
export type XWorkmateArtifactScopeKind = "task" | "workspace" | "workspace-latest";
|
||||
export type XWorkmateArtifactScopeKind = "task";
|
||||
|
||||
export type XWorkmateArtifactExport = {
|
||||
runId: string;
|
||||
@ -140,8 +140,6 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
);
|
||||
const sinceUnixMs = nonNegativeNumber(params.sinceUnixMs, 0);
|
||||
const includeContent = optionalBoolean(params.includeContent, true);
|
||||
const latestIfEmpty = optionalBoolean(params.latestIfEmpty, false);
|
||||
const latestTaskScopeIfEmpty = optionalBoolean(params.latestTaskScopeIfEmpty, false);
|
||||
const workspaceDir = resolveWorkspaceDir({
|
||||
config: input.config,
|
||||
pluginConfig,
|
||||
@ -158,8 +156,8 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
const sessionScope = taskSessionScopeFor(sessionKey);
|
||||
const artifactScope = requestedArtifactScope || expectedArtifactScope;
|
||||
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
|
||||
let scopeKind: XWorkmateArtifactScopeKind = "task";
|
||||
let candidates = await collectCandidates({
|
||||
const scopeKind: XWorkmateArtifactScopeKind = "task";
|
||||
const candidates = await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs,
|
||||
@ -167,33 +165,6 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
warnings,
|
||||
});
|
||||
|
||||
if (candidates.length === 0 && latestIfEmpty) {
|
||||
const latestWarnings: string[] = [];
|
||||
const latestCandidates = latestTaskScopeIfEmpty
|
||||
? await collectLatestSessionTaskCandidates({
|
||||
workspaceRoot,
|
||||
sessionKey,
|
||||
warnings: latestWarnings,
|
||||
})
|
||||
: await collectCandidates({
|
||||
scanRoot: workspaceRoot,
|
||||
relativeRoot: workspaceRoot,
|
||||
sinceUnixMs: 0,
|
||||
skipTaskScopeRoot: true,
|
||||
warnings: latestWarnings,
|
||||
});
|
||||
if (latestCandidates.length > 0) {
|
||||
warnings.push(...latestWarnings);
|
||||
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");
|
||||
}
|
||||
candidates = latestCandidates;
|
||||
scopeKind = "workspace-latest";
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
if (right.mtimeMs !== left.mtimeMs) {
|
||||
return right.mtimeMs - left.mtimeMs;
|
||||
@ -225,9 +196,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
scopeKind: scopeKindForCandidate,
|
||||
sessionScope,
|
||||
runScope: expectedArtifactScope,
|
||||
...(scopeKindForCandidate === "task" && artifactScopeForCandidate
|
||||
? { artifactScope: artifactScopeForCandidate }
|
||||
: {}),
|
||||
...(artifactScopeForCandidate ? { artifactScope: artifactScopeForCandidate } : {}),
|
||||
relativePath: candidate.relativePath,
|
||||
sizeBytes: bytes.byteLength,
|
||||
sha256,
|
||||
@ -236,7 +205,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
),
|
||||
scopeKind: scopeKindForCandidate,
|
||||
};
|
||||
if (scopeKindForCandidate === "task" && artifactScopeForCandidate) {
|
||||
if (artifactScopeForCandidate) {
|
||||
artifact.artifactScope = artifactScopeForCandidate;
|
||||
}
|
||||
if (includeContent && bytes.byteLength <= maxInlineBytes) {
|
||||
@ -253,7 +222,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
sessionKey,
|
||||
remoteWorkingDirectory: workspaceRoot,
|
||||
remoteWorkspaceRefKind: "remotePath" as const,
|
||||
...(scopeKind === "task" ? { artifactScope } : {}),
|
||||
artifactScope,
|
||||
scopeKind,
|
||||
artifacts,
|
||||
warnings,
|
||||
@ -302,18 +271,12 @@ 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,
|
||||
});
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
|
||||
} else {
|
||||
if (!artifactScope) {
|
||||
throw new Error("artifactScope or artifactRef required");
|
||||
}
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope, expectedSessionScope, {
|
||||
allowSameSessionTaskHistory: false,
|
||||
});
|
||||
assertArtifactScopeMatchesRequest(artifactScope, expectedArtifactScope);
|
||||
relativePath = safeInputRelativePath(params.relativePath, "relativePath");
|
||||
}
|
||||
const scopeRoot = artifactScope ? resolveScopeRoot(workspaceRoot, artifactScope) : workspaceRoot;
|
||||
@ -484,49 +447,6 @@ async function collectCandidates(input: {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectLatestSessionTaskCandidates(input: {
|
||||
workspaceRoot: string;
|
||||
sessionKey: string;
|
||||
warnings: string[];
|
||||
}): Promise<Candidate[]> {
|
||||
const sessionScope = taskSessionScopeFor(input.sessionKey);
|
||||
const sessionRoot = path.join(input.workspaceRoot, sessionScope.split("/").join(path.sep));
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const candidates: Candidate[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === "." || entry.name === "..") {
|
||||
continue;
|
||||
}
|
||||
const artifactScope = [sessionScope, entry.name].join("/");
|
||||
let scopeRoot: string;
|
||||
try {
|
||||
scopeRoot = resolveScopeRoot(input.workspaceRoot, artifactScope);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const scopedCandidates = await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
relativeRoot: scopeRoot,
|
||||
sinceUnixMs: 0,
|
||||
skipTaskScopeRoot: false,
|
||||
warnings: input.warnings,
|
||||
});
|
||||
candidates.push(
|
||||
...scopedCandidates.map((candidate) => ({
|
||||
...candidate,
|
||||
artifactScope,
|
||||
scopeKind: "task" as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function artifactScopeFor(sessionKey: string, runId: string): string {
|
||||
return [taskSessionScopeFor(sessionKey), safeScopeSegment(runId)].join("/");
|
||||
}
|
||||
@ -538,15 +458,10 @@ function taskSessionScopeFor(sessionKey: string): string {
|
||||
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");
|
||||
}
|
||||
|
||||
@ -822,7 +737,7 @@ function verifyArtifactRef(
|
||||
}
|
||||
const payload = objectRecord(parsed);
|
||||
const scopeKind = optionalString(payload.scopeKind) as XWorkmateArtifactScopeKind;
|
||||
if (!["task", "workspace", "workspace-latest"].includes(scopeKind)) {
|
||||
if (scopeKind !== "task") {
|
||||
throw new Error("invalid artifactRef");
|
||||
}
|
||||
const relativePath = safeInputRelativePath(payload.relativePath, "artifactRef relativePath");
|
||||
@ -830,9 +745,6 @@ function verifyArtifactRef(
|
||||
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 !== 2 || sizeBytes < 0 || !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user