Files
openclaw/src/gateway/server-methods/artifacts.ts
T
Peter Steinberger 132299bcfa fix(gateway): allow downloading zero-byte artifacts (#122928)
* fix(gateway): preserve zero-byte artifacts

* fix(gateway): keep non-string data out of artifact lists

* test(gateway): compact zero-byte artifact coverage
2026-08-12 20:09:32 -07:00

737 lines
23 KiB
TypeScript

// Artifact gateway methods collect generated artifacts from session transcripts
// and expose list/get/download RPCs scoped by session, run, task, or agent.
import { createHash } from "node:crypto";
import { isHttpUrl } from "@openclaw/net-policy/url-protocol";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeOptionalString as asNonEmptyString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
type ArtifactSummary,
type ArtifactsGetParams,
validateArtifactsDownloadParams,
validateArtifactsGetParams,
validateArtifactsListParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolvePersistedSessionStoreOwnerForKey } from "../../config/sessions/session-store-owner.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
toAgentStoreSessionKey,
} from "../../routing/session-key.js";
import { getTaskSessionLookupByIdForStatus } from "../../tasks/task-status-access.js";
import {
parseManagedOutgoingArtifactId,
resolveManagedOutgoingMediaArtifactDownload,
resolveManagedOutgoingMediaUrlDownload,
} from "../managed-image-attachments.js";
import { resolveSessionKeyForRun } from "../server-session-key.js";
import {
resolveRequestedSessionAgentId,
tryResolveSessionCompatibilityOwnerAgentId,
} from "../session-request-agent.js";
import {
resolveSessionStoreAgentId,
resolveStoredSessionKeyForAgentStore,
} from "../session-store-key.js";
import { visitSessionMessagesAsync } from "../session-transcript-readers.js";
import { loadGatewaySessionEntryReadOnly } from "../session-utils.js";
import {
type ArtifactBase64Payload,
base64FromDataUrl,
mimeFromDataUrl,
readArtifactBase64Payload,
} from "./artifacts-base64.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
type ArtifactDownloadMode = ArtifactSummary["download"]["mode"];
type ArtifactRecord = ArtifactSummary & {
data?: string;
url?: string;
};
type ArtifactQuery = {
sessionKey?: string;
runId?: string;
taskId?: string;
agentId?: string;
};
type ArtifactCollectionOptions = {
includeDownloadData?: boolean;
downloadArtifactId?: string;
};
type ResolvedArtifactSession = {
sessionKey: string;
agentId?: string;
};
function admitArtifactQuery<T extends ArtifactQuery>(
query: T,
cfg: OpenClawConfig | undefined,
respond: RespondFn,
): T | undefined {
const sessionKey = asNonEmptyString(query.sessionKey);
if (!sessionKey || !cfg) {
return query;
}
const owner = resolveRequestedSessionAgentId(cfg, sessionKey, query.agentId);
if (!owner.ok) {
respond(false, undefined, owner.error);
return undefined;
}
return { ...query, agentId: owner.agentId };
}
function artifactError(type: string, message: string, details?: Record<string, unknown>) {
return errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
type,
...details,
},
});
}
function resolveArtifactSessionAgentId(
sessionKey: string | undefined,
cfg?: OpenClawConfig,
): string | undefined {
const key = asNonEmptyString(sessionKey);
if (!key) {
return undefined;
}
const parsed = parseAgentSessionKey(key);
if (!parsed && key.toLowerCase().startsWith("agent:")) {
return undefined;
}
if (cfg) {
const owner = resolveRequestedSessionAgentId(cfg, key);
if (!owner.ok) {
throw new ArtifactSessionResolutionError(owner.error);
}
return owner.agentId;
}
if (parsed) {
return parsed.agentId;
}
return resolveAgentIdFromSessionKey(key);
}
/** Applies an optional agent scope to a transcript session key without crossing stores. */
function resolveScopedArtifactSessionKey(
sessionKey: string | undefined,
agentId: string | undefined,
cfg?: OpenClawConfig,
): string | undefined {
const key = asNonEmptyString(sessionKey);
if (!key) {
return undefined;
}
const scopedAgentId = asNonEmptyString(agentId);
if (!scopedAgentId) {
return key;
}
const parsed = parseAgentSessionKey(key);
if (!parsed && key.toLowerCase().startsWith("agent:")) {
return undefined;
}
if (cfg) {
const scopedKey = resolveStoredSessionKeyForAgentStore({
cfg,
agentId: scopedAgentId,
sessionKey: key,
});
if (
scopedKey !== "global" &&
scopedKey !== "unknown" &&
resolveSessionStoreAgentId(cfg, scopedKey) !== normalizeAgentId(scopedAgentId)
) {
return undefined;
}
return scopedKey;
}
if (parsed && parsed.agentId !== normalizeAgentId(scopedAgentId)) {
return undefined;
}
return toAgentStoreSessionKey({ agentId: scopedAgentId, requestKey: key });
}
function normalizeArtifactType(value: string): string {
const normalized = value.trim().toLowerCase();
if (normalized === "image" || normalized === "input_image" || normalized === "image_url") {
return "image";
}
if (normalized === "audio" || normalized === "input_audio") {
return "audio";
}
if (normalized === "video" || normalized === "input_video") {
return "video";
}
if (normalized === "file" || normalized === "input_file") {
return "file";
}
return "file";
}
function mediaUrlValue(value: unknown): string | undefined {
if (typeof value === "string") {
return asNonEmptyString(value);
}
const record = asOptionalRecord(value);
return asNonEmptyString(record?.url);
}
function isSafeDownloadUrl(value: string): boolean {
const trimmed = value.trim();
if (!trimmed || /^data:/i.test(trimmed)) {
return false;
}
if (trimmed.startsWith("/")) {
return !trimmed.startsWith("//") && trimmed.startsWith("/api/");
}
return isHttpUrl(trimmed);
}
/** Generates a stable id from transcript position plus display metadata. */
function artifactId(parts: {
sessionKey: string;
messageSeq: number;
contentIndex: number;
title: string;
type: string;
}): string {
const hash = createHash("sha256")
.update(
`${parts.sessionKey}\0${parts.messageSeq}\0${parts.contentIndex}\0${parts.type}\0${parts.title}`,
)
.digest("base64url")
.slice(0, 18);
return `artifact_${hash}`;
}
function resolveMessageSeq(message: Record<string, unknown>, fallback: number): number {
const meta = asOptionalRecord(message["__openclaw"]);
const seq = meta?.seq;
return typeof seq === "number" && Number.isInteger(seq) && seq > 0 ? seq : fallback;
}
function resolveMessageRunId(message: Record<string, unknown>): string | undefined {
const meta = asOptionalRecord(message["__openclaw"]);
return asNonEmptyString(meta?.runId) ?? asNonEmptyString(message.runId);
}
function resolveMessageTaskId(message: Record<string, unknown>): string | undefined {
const meta = asOptionalRecord(message["__openclaw"]);
return (
asNonEmptyString(meta?.messageTaskId) ??
asNonEmptyString(meta?.taskId) ??
asNonEmptyString(message.messageTaskId) ??
asNonEmptyString(message.taskId)
);
}
function resolveBlockDownload(
block: Record<string, unknown>,
opts: { includeData: boolean },
): {
mode: ArtifactDownloadMode;
data?: string;
url?: string;
mimeType?: string;
sizeBytes?: number;
} {
const data = readStringValue(block.data)?.trim();
const content = readStringValue(block.content)?.trim();
const url = asNonEmptyString(block.url) ?? asNonEmptyString(block.openUrl);
const imageUrl = mediaUrlValue(block.image_url);
const audioUrl = asNonEmptyString(block.audio_url);
const source = asOptionalRecord(block.source);
const sourceData = readStringValue(source?.data)?.trim();
const sourceUrl = asNonEmptyString(source?.url);
const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, content, sourceData].find(
(value) => typeof value === "string" && /^data:/i.test(value),
);
const base64FromDetectedDataUrl = readArtifactBase64Payload(
dataUrl ? base64FromDataUrl(dataUrl) : undefined,
opts,
);
const directBase64 = [data, sourceData, content]
.filter((value): value is string => typeof value === "string" && !/^data:/i.test(value))
.map((value) => readArtifactBase64Payload(value, opts))
.find((value): value is ArtifactBase64Payload => value !== undefined);
const base64 = base64FromDetectedDataUrl ?? directBase64;
const remoteUrl = [url, sourceUrl, imageUrl, audioUrl].find(
(value) => typeof value === "string" && isSafeDownloadUrl(value),
);
const mimeType =
asNonEmptyString(block.mimeType) ??
asNonEmptyString(block.media_type) ??
asNonEmptyString(source?.media_type) ??
asNonEmptyString(source?.mimeType) ??
(dataUrl ? mimeFromDataUrl(dataUrl) : undefined);
const explicitSize = block.sizeBytes ?? source?.sizeBytes;
const sizeBytes =
typeof explicitSize === "number" && Number.isFinite(explicitSize) && explicitSize >= 0
? Math.floor(explicitSize)
: base64?.sizeBytes;
if (base64) {
return { mode: "bytes", data: base64.data, mimeType, sizeBytes };
}
if (remoteUrl) {
return { mode: "url", url: remoteUrl, mimeType, sizeBytes };
}
return { mode: "unsupported", mimeType, sizeBytes };
}
function isArtifactBlock(block: Record<string, unknown>): boolean {
const type = asNonEmptyString(block.type)?.toLowerCase();
if (
type === "image" ||
type === "audio" ||
type === "video" ||
type === "file" ||
type === "input_image" ||
type === "input_audio" ||
type === "input_video" ||
type === "input_file" ||
type === "image_url"
) {
return true;
}
return (
typeof block.data === "string" ||
Boolean(block.url || block.openUrl || block.source || block.image_url || block.audio_url)
);
}
function collectArtifactsFromMessage(params: {
message: unknown;
messageFallbackSeq: number;
artifacts: ArtifactRecord[];
sessionKey: string;
runId?: string;
taskId?: string;
includeDownloadData?: boolean;
downloadArtifactId?: string;
}): void {
const msg = asOptionalRecord(params.message);
if (!msg) {
return;
}
const messageSeq = resolveMessageSeq(msg, params.messageFallbackSeq);
const messageRunId = resolveMessageRunId(msg);
const messageTaskId = resolveMessageTaskId(msg);
if (params.runId && messageRunId !== params.runId) {
return;
}
if (params.taskId && messageTaskId !== params.taskId) {
return;
}
const content = Array.isArray(msg.content) ? msg.content : [];
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
const block = asOptionalRecord(content[contentIndex]);
if (!block || !isArtifactBlock(block)) {
continue;
}
const type = normalizeArtifactType(asNonEmptyString(block.type) ?? "file");
const title =
asNonEmptyString(block.title) ??
asNonEmptyString(block.fileName) ??
asNonEmptyString(block.filename) ??
asNonEmptyString(block.alt) ??
`${type} ${params.artifacts.length + 1}`;
const declaredArtifactId = asNonEmptyString(block.artifactId);
const id =
declaredArtifactId && parseManagedOutgoingArtifactId(declaredArtifactId)
? declaredArtifactId
: artifactId({
sessionKey: params.sessionKey,
messageSeq,
contentIndex,
title,
type,
});
const includeData = params.downloadArtifactId
? params.downloadArtifactId === id
: params.includeDownloadData !== false;
const download = resolveBlockDownload(block, { includeData });
const summary: ArtifactRecord = {
id,
type,
title,
...(download.mimeType ? { mimeType: download.mimeType } : {}),
...(download.sizeBytes !== undefined ? { sizeBytes: download.sizeBytes } : {}),
sessionKey: params.sessionKey,
...(messageRunId ? { runId: messageRunId } : {}),
...(messageTaskId ? { taskId: messageTaskId } : {}),
messageSeq,
source: "session-transcript",
download: { mode: download.mode },
...(download.data !== undefined ? { data: download.data } : {}),
...(download.url ? { url: download.url } : {}),
};
params.artifacts.push(summary);
}
}
function resolveQuerySession(
query: ArtifactQuery,
cfg?: OpenClawConfig,
): ResolvedArtifactSession | undefined {
if (query.sessionKey) {
const sessionKey = resolveScopedArtifactSessionKey(query.sessionKey, query.agentId, cfg);
if (!sessionKey) {
return undefined;
}
return { sessionKey, ...(query.agentId ? { agentId: query.agentId } : {}) };
}
if (query.runId) {
// A live run context can resolve its own agent-scoped key. Do not force an
// unrelated default-agent selection before consulting that authoritative row.
const sessionKey = resolveSessionKeyForRun(
query.runId,
query.agentId ? { agentId: query.agentId } : {},
);
const agentId =
query.agentId ??
resolveArtifactSessionAgentId(sessionKey, cfg) ??
resolveSessionAgentId({ config: cfg });
const scopedSessionKey = resolveScopedArtifactSessionKey(sessionKey, agentId, cfg);
return scopedSessionKey ? { sessionKey: scopedSessionKey, agentId } : undefined;
}
if (query.taskId) {
const task = getTaskSessionLookupByIdForStatus(query.taskId);
const requesterSessionKey = asNonEmptyString(task?.requesterSessionKey);
const ownerAgentId = parseAgentSessionKey(task?.ownerKey)?.agentId;
const persistedRequesterOwner = requesterSessionKey
? resolvePersistedSessionStoreOwnerForKey(cfg ?? {}, requesterSessionKey)
: { kind: "none" as const };
const requesterAgentId =
asNonEmptyString(task?.requesterAgentId) ??
ownerAgentId ??
(persistedRequesterOwner.kind === "configured"
? persistedRequesterOwner.agentId
: resolveArtifactSessionAgentId(requesterSessionKey, cfg));
const taskAgentId = asNonEmptyString(task?.agentId) ?? requesterAgentId;
if (
query.agentId &&
taskAgentId &&
normalizeAgentId(query.agentId) !== normalizeAgentId(taskAgentId)
) {
return undefined;
}
if (requesterSessionKey) {
// task.agentId identifies the executor. requesterAgentId keeps global
// requester transcripts in the correct agent store across restarts.
const sessionAgentId =
requesterAgentId ?? resolveArtifactSessionAgentId(requesterSessionKey, cfg);
if (!sessionAgentId) {
return undefined;
}
const scopedSessionKey = resolveScopedArtifactSessionKey(
requesterSessionKey,
sessionAgentId,
cfg,
);
return scopedSessionKey
? { sessionKey: scopedSessionKey, agentId: sessionAgentId }
: undefined;
}
const agentId = query.agentId ?? taskAgentId ?? resolveSessionAgentId({ config: cfg });
const runId = asNonEmptyString(task?.runId);
const sessionKey = runId ? resolveSessionKeyForRun(runId, { agentId }) : undefined;
const scopedSessionKey = resolveScopedArtifactSessionKey(sessionKey, agentId, cfg);
return scopedSessionKey ? { sessionKey: scopedSessionKey, agentId } : undefined;
}
return undefined;
}
class ArtifactSessionResolutionError extends Error {
constructor(readonly shape: ReturnType<typeof errorShape>) {
super(shape.message);
}
}
/** Loads artifacts from the transcript selected by sessionKey, runId, or taskId. */
async function loadArtifacts(
query: ArtifactQuery,
cfg?: OpenClawConfig,
opts: ArtifactCollectionOptions = {},
): Promise<{ artifacts: ArtifactRecord[]; sessionKey?: string }> {
const resolved = resolveQuerySession(query, cfg);
if (!resolved) {
return { artifacts: [] };
}
const { sessionKey } = resolved;
const unscopedAgentId = parseAgentSessionKey(sessionKey) ? undefined : resolved.agentId;
const { storePath, entry } = unscopedAgentId
? loadGatewaySessionEntryReadOnly(sessionKey, { agentId: unscopedAgentId })
: loadGatewaySessionEntryReadOnly(sessionKey);
const sessionId = entry?.sessionId;
if (!sessionId || !storePath) {
return { sessionKey, artifacts: [] };
}
const artifacts: ArtifactRecord[] = [];
await visitSessionMessagesAsync(
{
agentId: resolved.agentId ?? resolveAgentIdFromSessionKey(sessionKey),
sessionEntry: entry,
sessionId,
sessionKey,
storePath,
},
(message, seq) => {
collectArtifactsFromMessage({
message,
messageFallbackSeq: seq,
artifacts,
sessionKey,
runId: query.runId,
taskId: query.taskId,
includeDownloadData: opts.includeDownloadData,
downloadArtifactId: opts.downloadArtifactId,
});
},
{
mode: "full",
reason: "artifact query transcript scan",
cache: "skip",
},
);
return {
sessionKey,
artifacts,
};
}
function requireQueryable(params: ArtifactQuery, respond: RespondFn): boolean {
if (params.sessionKey || params.runId || params.taskId) {
return true;
}
respond(
false,
undefined,
artifactError(
"artifact_query_unsupported",
"artifacts require one of sessionKey, runId, or taskId",
),
);
return false;
}
async function runArtifactSessionOperation<T>(
respond: RespondFn,
operation: () => Promise<T> | T,
): Promise<{ ok: true; value: T } | { ok: false }> {
try {
return { ok: true, value: await operation() };
} catch (error) {
if (error instanceof ArtifactSessionResolutionError) {
respond(false, undefined, error.shape);
return { ok: false };
}
if (error instanceof AgentSelectionRequiredError) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message));
return { ok: false };
}
throw error;
}
}
async function findArtifact(
params: ArtifactsGetParams,
cfg?: OpenClawConfig,
opts: ArtifactCollectionOptions = {},
): Promise<{
artifact?: ArtifactRecord;
sessionKey?: string;
}> {
const loaded = await loadArtifacts(params, cfg, opts);
return {
sessionKey: loaded.sessionKey,
artifact: loaded.artifacts.find((artifact) => artifact.id === params.artifactId),
};
}
function toSummary(artifact: ArtifactRecord): ArtifactSummary {
const { data: _dataValue, url: _url, ...summary } = artifact;
return summary;
}
/** Gateway handlers for listing, summarizing, and downloading transcript artifacts. */
export const artifactsHandlers: GatewayRequestHandlers = {
"artifacts.list": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateArtifactsListParams, "artifacts.list", respond)) {
return;
}
if (!requireQueryable(params, respond)) {
return;
}
const cfg = context.getRuntimeConfig?.();
const admittedQuery = admitArtifactQuery(params, cfg, respond);
if (!admittedQuery) {
return;
}
const loaded = await runArtifactSessionOperation(respond, () =>
loadArtifacts(admittedQuery, cfg, { includeDownloadData: false }),
);
if (!loaded.ok) {
return;
}
const { artifacts, sessionKey } = loaded.value;
if (!sessionKey && (params.runId || params.taskId)) {
respond(
false,
undefined,
artifactError("artifact_scope_not_found", "no session found for artifact query"),
);
return;
}
respond(true, { artifacts: artifacts.map(toSummary) });
},
"artifacts.get": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateArtifactsGetParams, "artifacts.get", respond)) {
return;
}
if (!requireQueryable(params, respond)) {
return;
}
const cfg = context.getRuntimeConfig?.();
const admittedQuery = admitArtifactQuery(params, cfg, respond);
if (!admittedQuery) {
return;
}
const found = await runArtifactSessionOperation(respond, () =>
findArtifact(admittedQuery, cfg, { includeDownloadData: false }),
);
if (!found.ok) {
return;
}
const { artifact } = found.value;
if (!artifact) {
respond(
false,
undefined,
artifactError("artifact_not_found", "artifact not found", {
artifactId: params.artifactId,
}),
);
return;
}
respond(true, { artifact: toSummary(artifact) });
},
"artifacts.download": async ({ params, respond, context }) => {
if (
!assertValidParams(params, validateArtifactsDownloadParams, "artifacts.download", respond)
) {
return;
}
if (!requireQueryable(params, respond)) {
return;
}
const cfg = context.getRuntimeConfig?.();
const admittedQuery = admitArtifactQuery(params, cfg, respond);
if (!admittedQuery) {
return;
}
if (
admittedQuery.sessionKey &&
!admittedQuery.runId &&
!admittedQuery.taskId &&
parseManagedOutgoingArtifactId(params.artifactId)
) {
const resolvedResult = await runArtifactSessionOperation(respond, () =>
resolveQuerySession(admittedQuery, cfg),
);
if (!resolvedResult.ok) {
return;
}
const resolved = resolvedResult.value;
const defaultAgentId = resolved
? tryResolveSessionCompatibilityOwnerAgentId(cfg ?? {}, resolved.sessionKey)
: undefined;
const managed = resolved
? await resolveManagedOutgoingMediaArtifactDownload({
sessionKey: resolved.sessionKey,
...(resolved.agentId ? { agentId: resolved.agentId } : {}),
...(defaultAgentId ? { defaultAgentId } : {}),
artifactId: params.artifactId,
})
: null;
if (managed) {
respond(true, {
artifact: {
id: managed.artifactId,
type: managed.type,
title: managed.title,
...(managed.mimeType ? { mimeType: managed.mimeType } : {}),
...(managed.sizeBytes !== undefined ? { sizeBytes: managed.sizeBytes } : {}),
sessionKey: managed.sessionKey,
source: "session-transcript",
download: { mode: "url" as const },
},
url: managed.url,
expiresAt: managed.expiresAt,
});
return;
}
}
const found = await runArtifactSessionOperation(respond, () =>
findArtifact(admittedQuery, cfg, { downloadArtifactId: params.artifactId }),
);
if (!found.ok) {
return;
}
const { artifact } = found.value;
if (!artifact) {
respond(
false,
undefined,
artifactError("artifact_not_found", "artifact not found", {
artifactId: params.artifactId,
}),
);
return;
}
if (artifact.download.mode === "unsupported") {
respond(
false,
undefined,
artifactError("artifact_download_unsupported", "artifact download is unsupported", {
artifactId: artifact.id,
}),
);
return;
}
const managedUrl =
artifact.download.mode === "url" && artifact.url && artifact.sessionKey
? await resolveManagedOutgoingMediaUrlDownload({
sessionKey: artifact.sessionKey,
url: artifact.url,
})
: null;
respond(true, {
artifact: toSummary(artifact),
...(artifact.download.mode === "bytes"
? { encoding: "base64" as const, data: artifact.data }
: {}),
...(artifact.download.mode === "url"
? {
url: managedUrl?.url ?? artifact.url,
...(managedUrl ? { expiresAt: managedUrl.expiresAt } : {}),
}
: {}),
});
},
};