Keep OpenClaw artifact export task scoped
This commit is contained in:
parent
d887df0f64
commit
529965aa3b
36
README.md
36
README.md
@ -72,16 +72,18 @@ Equivalent config shape for a linked checkout:
|
||||
|
||||
Prepare request params are supplied by the OpenClaw host, bridge, or APP
|
||||
runtime. On OpenClaw runtimes that expose a trusted plugin `sessionScope`, the
|
||||
plugin uses that scope first. Otherwise it falls back to bridge/app runtime
|
||||
params. The plugin treats `sessionKey`, `runId`, and `workspaceDir` as the
|
||||
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. The optional
|
||||
agent tool does not expose these fields to the model; it only uses host-injected
|
||||
tool context.
|
||||
plugin uses that native scope first and maps native `sessionScope.sessionKey`
|
||||
to `openclawSessionKey` internally. External Gateway callers must use typed
|
||||
`appThreadKey`, `openclawSessionKey`, `runId`, and optional `workspaceDir`
|
||||
params. Legacy `sessionKey` is not accepted as a Gateway task or artifact lookup
|
||||
alias. The plugin does not parse paths from chat text and does not invent
|
||||
fallback session/run identities. The optional agent tool does not expose these
|
||||
fields to the model; it only uses host-injected tool context.
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionKey": "thread-main",
|
||||
"appThreadKey": "draft:thread-main",
|
||||
"openclawSessionKey": "agent:main:draft:thread-main",
|
||||
"runId": "turn-1",
|
||||
"workspaceDir": "/home/user/.openclaw/workspace"
|
||||
}
|
||||
@ -92,7 +94,7 @@ Prepare response payload:
|
||||
```json
|
||||
{
|
||||
"runId": "turn-1",
|
||||
"sessionKey": "thread-main",
|
||||
"sessionKey": "agent:main:draft:thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
@ -107,7 +109,7 @@ Export request params:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionKey": "thread-main",
|
||||
"openclawSessionKey": "agent:main:draft:thread-main",
|
||||
"runId": "turn-1",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
"sinceUnixMs": 1770000000000,
|
||||
@ -121,7 +123,7 @@ Export response payload:
|
||||
```json
|
||||
{
|
||||
"runId": "turn-1",
|
||||
"sessionKey": "thread-main",
|
||||
"sessionKey": "agent:main:draft:thread-main",
|
||||
"remoteWorkingDirectory": "/home/user/.openclaw/workspace",
|
||||
"remoteWorkspaceRefKind": "remotePath",
|
||||
"artifactScope": "tasks/thread-main-.../turn-1-...",
|
||||
@ -144,9 +146,10 @@ 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`. `sinceUnixMs` is only a filter inside that task
|
||||
scope. The prepared task scope remains authoritative: when it contains files,
|
||||
the plugin exports only that scope.
|
||||
derived from `openclawSessionKey/runId` for Gateway calls, or from native
|
||||
`sessionScope.sessionKey/runId` for host-injected tool calls. `sinceUnixMs` is
|
||||
only a filter inside that task scope. The prepared task scope remains
|
||||
authoritative: when it contains files, the plugin exports only that scope.
|
||||
|
||||
If the prepared task scope is empty, trusted Gateway callers may pass
|
||||
`expectedArtifactDirs` such as `["assets/images", "reports"]`. The plugin then
|
||||
@ -157,9 +160,10 @@ 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 only for the same `sessionKey/runId`
|
||||
that issued them. There is no unscoped arbitrary workspace read API.
|
||||
`artifactScope + relativePath` for the current `openclawSessionKey/runId` task
|
||||
scope. Signed task `artifactRef` values are accepted only for the same
|
||||
`openclawSessionKey/runId` that issued them. There is no unscoped arbitrary
|
||||
workspace read API.
|
||||
|
||||
## View And Download
|
||||
|
||||
|
||||
30
dist/src/exportArtifacts.js
vendored
30
dist/src/exportArtifacts.js
vendored
@ -161,29 +161,6 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
warnings.push(`Unable to read artifact scope timestamp: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
// Copy global media to task scope to fix path breakage
|
||||
if (scopePrepared || sinceUnixMs > 0) {
|
||||
for (const source of openClawSnapshotSources(params, pluginConfig)) {
|
||||
const globalCandidates = await collectSnapshotSourceCandidates({
|
||||
source,
|
||||
sinceUnixMs: effectiveSince,
|
||||
warnings,
|
||||
});
|
||||
for (const gc of globalCandidates) {
|
||||
const destRelPath = safeSnapshotDestinationRelativePath(source.label, gc.relativePath);
|
||||
const dest = path.join(scopeRoot, "artifacts", destRelPath.split("/").join(path.sep));
|
||||
if (isWithinRoot(scopeRoot, dest)) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.copyFile(gc.absolutePath, dest);
|
||||
}
|
||||
catch (error) {
|
||||
warnings.push(`Failed to copy media file ${gc.relativePath}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const scopedCandidates = (await directoryExists(scopeRoot))
|
||||
? await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
@ -777,13 +754,6 @@ function resolveWorkspaceDir(input) {
|
||||
}
|
||||
throw new Error("UnsupportedError: workspaceDir must be explicitly provided in params or pluginConfig");
|
||||
}
|
||||
function agentIdFromSessionKey(sessionKey) {
|
||||
const parts = sessionKey.split(":");
|
||||
if (parts.length >= 3 && parts[0] === "agent") {
|
||||
return parts[1]?.trim() ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function safeRelativePath(root, target) {
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
|
||||
@ -748,32 +748,6 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
expect(result.warnings).toContain("artifact limit reached; skipped remaining files after 1");
|
||||
});
|
||||
|
||||
it("selects an agent workspace from agent session keys", async () => {
|
||||
const mainRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-main-"));
|
||||
const agentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-agent-"));
|
||||
await fs.writeFile(path.join(mainRoot, "main.txt"), "main");
|
||||
const prepared = await prepareXWorkmateArtifacts({
|
||||
params: { openclawSessionKey: "agent:research:thread-1", runId: "run-1" },
|
||||
pluginConfig: { workspaceDir: agentRoot },
|
||||
});
|
||||
await fs.writeFile(path.join(prepared.artifactDirectory, "agent.txt"), "agent");
|
||||
|
||||
const result = await exportXWorkmateArtifacts({
|
||||
params: {
|
||||
openclawSessionKey: "agent:research:thread-1",
|
||||
runId: "run-1",
|
||||
},
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { workspace: mainRoot },
|
||||
list: [{ id: "research", workspace: agentRoot }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.artifacts.map((entry) => entry.relativePath)).toEqual(["agent.txt"]);
|
||||
});
|
||||
|
||||
it("rejects unscoped artifact reads by relative path", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await fs.mkdir(path.join(root, "reports"), { recursive: true });
|
||||
|
||||
@ -266,28 +266,6 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
}
|
||||
}
|
||||
|
||||
// Copy global media to task scope to fix path breakage
|
||||
if (scopePrepared || sinceUnixMs > 0) {
|
||||
for (const source of openClawSnapshotSources(params, pluginConfig)) {
|
||||
const globalCandidates = await collectSnapshotSourceCandidates({
|
||||
source,
|
||||
sinceUnixMs: effectiveSince,
|
||||
warnings,
|
||||
});
|
||||
for (const gc of globalCandidates) {
|
||||
const destRelPath = safeSnapshotDestinationRelativePath(source.label, gc.relativePath);
|
||||
const dest = path.join(scopeRoot, "artifacts", destRelPath.split("/").join(path.sep));
|
||||
if (isWithinRoot(scopeRoot, dest)) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.copyFile(gc.absolutePath, dest);
|
||||
} catch (error) {
|
||||
warnings.push(`Failed to copy media file ${gc.relativePath}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const scopedCandidates = (await directoryExists(scopeRoot))
|
||||
? await collectCandidates({
|
||||
scanRoot: scopeRoot,
|
||||
@ -960,14 +938,6 @@ function resolveWorkspaceDir(input: {
|
||||
throw new Error("UnsupportedError: workspaceDir must be explicitly provided in params or pluginConfig");
|
||||
}
|
||||
|
||||
function agentIdFromSessionKey(sessionKey: string): string {
|
||||
const parts = sessionKey.split(":");
|
||||
if (parts.length >= 3 && parts[0] === "agent") {
|
||||
return parts[1]?.trim() ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function safeRelativePath(root: string, target: string): string {
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
@ -1033,7 +1003,6 @@ function contentTypeForPath(relativePath: string): string {
|
||||
}
|
||||
|
||||
function openClawSnapshotSources(params: Record<string, unknown>, pluginConfig: Record<string, unknown>): SnapshotSource[] {
|
||||
|
||||
return [
|
||||
{
|
||||
label: "media",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user