Adopt OpenClaw root artifacts into task scope

This commit is contained in:
Haitao Pan 2026-05-18 06:26:53 +08:00
parent 29e09701b8
commit b68fc811c9
4 changed files with 223 additions and 10 deletions

View File

@ -139,10 +139,13 @@ 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`. If that scope has no files, export/list returns
an empty artifact list with the manifest text `No artifacts found for this task
run.` The plugin does not scan the workspace root and does not borrow artifacts
from earlier task scopes.
derived from `sessionKey/runId`. If `sinceUnixMs` is provided, export also
adopts files created or changed in the workspace root during the current run by
copying them into that task scope before returning the manifest. This covers
agents that save output as `./file.md` while still keeping XWorkmate sync scoped
to `tasks/<session>/<run>`. Without `sinceUnixMs`, export/list only reads the
current task scope. The plugin never scans `tasks/` as a fallback 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
@ -191,6 +194,7 @@ only remote file access path.
- Only files inside the resolved OpenClaw workspace are exported.
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are excluded from task artifact exports.
- Workspace-root files are adopted only when `sinceUnixMs` is provided; adopted files are copied into the current `tasks/<safe-session-key>/<safe-run-id>` scope before listing or reading.
- 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>`.

View File

@ -77,7 +77,10 @@ export async function exportXWorkmateArtifacts(input) {
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
const scopeKind = "task";
const scopePrepared = await directoryExists(scopeRoot);
const candidates = scopePrepared
if (!scopePrepared && sinceUnixMs > 0) {
await fs.mkdir(scopeRoot, { recursive: true });
}
const scopedCandidates = (await directoryExists(scopeRoot))
? await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
@ -86,7 +89,18 @@ export async function exportXWorkmateArtifacts(input) {
warnings,
})
: [];
if (!scopePrepared) {
const adoptedCandidates = sinceUnixMs > 0
? await adoptWorkspaceRootCandidatesIntoScope({
workspaceRoot,
scopeRoot,
artifactScope,
sinceUnixMs,
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
warnings,
})
: [];
const candidates = [...scopedCandidates, ...adoptedCandidates];
if (!scopePrepared && candidates.length === 0) {
warnings.push("artifact scope is not prepared for this task run");
}
candidates.sort((left, right) => {
@ -282,6 +296,44 @@ export function formatArtifactManifestMarkdown(input) {
}
return lines.join("\n");
}
async function adoptWorkspaceRootCandidatesIntoScope(input) {
const rootCandidates = await collectCandidates({
scanRoot: input.workspaceRoot,
relativeRoot: input.workspaceRoot,
sinceUnixMs: input.sinceUnixMs,
skipTaskScopeRoot: true,
warnings: input.warnings,
});
const adopted = [];
for (const candidate of rootCandidates) {
if (input.existingRelativePaths.has(candidate.relativePath)) {
continue;
}
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
if (!isWithinRoot(input.scopeRoot, targetPath)) {
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
continue;
}
if (await fileExists(targetPath)) {
input.existingRelativePaths.add(candidate.relativePath);
continue;
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(candidate.absolutePath, targetPath);
const stat = await fs.stat(targetPath);
const realPath = await fs.realpath(targetPath);
adopted.push({
absolutePath: realPath,
relativePath: candidate.relativePath,
sizeBytes: stat.size,
mtimeMs: candidate.mtimeMs,
artifactScope: input.artifactScope,
scopeKind: "task",
});
input.existingRelativePaths.add(candidate.relativePath);
}
return adopted;
}
async function collectCandidates(input) {
const candidates = [];
await walk(input.scanRoot);
@ -412,6 +464,15 @@ async function directoryExists(absolutePath) {
return false;
}
}
async function fileExists(absolutePath) {
try {
const stat = await fs.stat(absolutePath);
return stat.isFile();
}
catch {
return false;
}
}
function safeArtifactRefRunScope(value) {
try {
return safeTaskArtifactScope(value);

View File

@ -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", async () => {
it("does not scan the workspace root without a current-run timestamp", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
@ -198,6 +198,84 @@ describe("exportXWorkmateArtifacts", () => {
expect(result.manifestMarkdown).toContain("Artifact scope: `tasks/thread-main/turn-1`");
});
it("adopts current-run workspace root files into the task artifact scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
const sinceUnixMs = Date.now() - 1_000;
await fs.writeFile(path.join(root, "xhs_account_security.md"), "# Account security\n");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
sinceUnixMs,
},
pluginConfig: { workspaceDir: root },
});
expect(result.scopeKind).toBe("task");
expect(result.artifactScope).toBe("tasks/thread-main/turn-1");
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["xhs_account_security.md"]);
expect(result.artifacts[0]).toMatchObject({
artifactScope: "tasks/thread-main/turn-1",
scopeKind: "task",
contentType: "text/markdown",
encoding: "base64",
content: Buffer.from("# Account security\n").toString("base64"),
});
expect(await fs.readFile(path.join(root, "tasks", "thread-main", "turn-1", "xhs_account_security.md"), "utf8")).toBe(
"# Account security\n",
);
});
it("creates the current task scope when adopting root files after bridge export", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
const sinceUnixMs = Date.now() - 1_000;
await fs.mkdir(path.join(root, "reports"), { recursive: true });
await fs.writeFile(path.join(root, "reports", "final.md"), "final");
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
sinceUnixMs,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifactScope).toBe("tasks/thread-main/turn-1");
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["reports/final.md"]);
expect(result.warnings).toEqual([]);
expect(await fs.readFile(path.join(root, "tasks", "thread-main", "turn-1", "reports", "final.md"), "utf8")).toBe(
"final",
);
});
it("does not adopt old workspace root files into a later task scope", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
await prepareXWorkmateArtifacts({
params: { sessionKey: "thread-main", runId: "turn-1" },
pluginConfig: { workspaceDir: root },
});
await fs.writeFile(path.join(root, "old-root.md"), "old");
const stat = await fs.stat(path.join(root, "old-root.md"));
const result = await exportXWorkmateArtifacts({
params: {
sessionKey: "thread-main",
runId: "turn-1",
sinceUnixMs: stat.mtimeMs + 10_000,
},
pluginConfig: { workspaceDir: root },
});
expect(result.artifacts).toEqual([]);
await expect(fs.stat(path.join(root, "tasks", "thread-main", "turn-1", "old-root.md"))).rejects.toThrow();
});
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({
@ -221,7 +299,7 @@ describe("exportXWorkmateArtifacts", () => {
).rejects.toThrow("artifactScope does not match sessionKey/runId");
});
it("does not fall back to workspace files when the scoped directory is empty", async () => {
it("does not adopt old 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" },

View File

@ -158,7 +158,10 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
const scopeRoot = resolveScopeRoot(workspaceRoot, artifactScope);
const scopeKind: XWorkmateArtifactScopeKind = "task";
const scopePrepared = await directoryExists(scopeRoot);
const candidates = scopePrepared
if (!scopePrepared && sinceUnixMs > 0) {
await fs.mkdir(scopeRoot, { recursive: true });
}
const scopedCandidates = (await directoryExists(scopeRoot))
? await collectCandidates({
scanRoot: scopeRoot,
relativeRoot: scopeRoot,
@ -167,7 +170,19 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
warnings,
})
: [];
if (!scopePrepared) {
const adoptedCandidates =
sinceUnixMs > 0
? await adoptWorkspaceRootCandidatesIntoScope({
workspaceRoot,
scopeRoot,
artifactScope,
sinceUnixMs,
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
warnings,
})
: [];
const candidates = [...scopedCandidates, ...adoptedCandidates];
if (!scopePrepared && candidates.length === 0) {
warnings.push("artifact scope is not prepared for this task run");
}
@ -387,6 +402,52 @@ export function formatArtifactManifestMarkdown(input: {
return lines.join("\n");
}
async function adoptWorkspaceRootCandidatesIntoScope(input: {
workspaceRoot: string;
scopeRoot: string;
artifactScope: string;
sinceUnixMs: number;
existingRelativePaths: Set<string>;
warnings: string[];
}): Promise<Candidate[]> {
const rootCandidates = await collectCandidates({
scanRoot: input.workspaceRoot,
relativeRoot: input.workspaceRoot,
sinceUnixMs: input.sinceUnixMs,
skipTaskScopeRoot: true,
warnings: input.warnings,
});
const adopted: Candidate[] = [];
for (const candidate of rootCandidates) {
if (input.existingRelativePaths.has(candidate.relativePath)) {
continue;
}
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
if (!isWithinRoot(input.scopeRoot, targetPath)) {
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
continue;
}
if (await fileExists(targetPath)) {
input.existingRelativePaths.add(candidate.relativePath);
continue;
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(candidate.absolutePath, targetPath);
const stat = await fs.stat(targetPath);
const realPath = await fs.realpath(targetPath);
adopted.push({
absolutePath: realPath,
relativePath: candidate.relativePath,
sizeBytes: stat.size,
mtimeMs: candidate.mtimeMs,
artifactScope: input.artifactScope,
scopeKind: "task",
});
input.existingRelativePaths.add(candidate.relativePath);
}
return adopted;
}
async function collectCandidates(input: {
scanRoot: string;
relativeRoot: string;
@ -538,6 +599,15 @@ async function directoryExists(absolutePath: string): Promise<boolean> {
}
}
async function fileExists(absolutePath: string): Promise<boolean> {
try {
const stat = await fs.stat(absolutePath);
return stat.isFile();
} catch {
return false;
}
}
function safeArtifactRefRunScope(value: unknown): string {
try {
return safeTaskArtifactScope(value);