openclaw / src /gateway /server-methods /agents-workspace.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
78d0c37 verified
Raw History Blame Contribute Delete
8.38 kB
// Read-only agent workspace browsing for operator clients (mobile apps, UI).
// Deliberately no write/delete/upload surface: mutations need their own
// reviewed contract; see the allowlisted agents.files.* API for edits.
import path from "node:path";
import { detectMime } from "@openclaw/media-core/mime";
import {
ErrorCodes,
errorShape,
type AgentsWorkspaceEntry,
validateAgentsWorkspaceGetParams,
validateAgentsWorkspaceListParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeAgentIdStrict } from "../../routing/session-key.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
import {
decodeUtf8Strict,
listWorkspacePath,
normalizeRelativePath,
readWorkspaceFile,
resolveWorkspacePath,
sortWorkspaceEntries,
statWorkspacePath,
toUpdatedAtMs,
WORKSPACE_PREVIEW_MAX_BYTES,
workspaceStatKind,
} from "./workspace-fs.js";
// Images bypass the text preview cap but stay far below the 25MB WS payload
// limit even after base64 expansion (see server-constants MAX_PAYLOAD_BYTES).
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const DEFAULT_LIST_LIMIT = 250;
const MAX_LIST_LIMIT = 500;
const IMAGE_EXTENSIONS = new Set([
".avif",
".bmp",
".gif",
".heic",
".heif",
".jpeg",
".jpg",
".png",
".webp",
]);
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
"image/avif",
"image/bmp",
"image/gif",
"image/heic",
"image/heif",
"image/jpeg",
"image/png",
"image/webp",
]);
function workspaceError(type: string, message: string, details?: Record<string, unknown>) {
return errorShape(ErrorCodes.INVALID_REQUEST, message, {
details: {
type,
...details,
},
});
}
function resolveWorkspaceScopeOrRespond(
params: { agentId: string; path?: string },
cfg: OpenClawConfig,
respond: RespondFn,
): { agentId: string; workspaceDir: string; browserPath: string } | null {
const normalized = normalizeAgentIdStrict(params.agentId);
if (!normalized.ok || !new Set(listAgentIds(cfg)).has(normalized.value)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `agent "${params.agentId}" not found`),
);
return null;
}
const agentId = normalized.value;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const rawPath = params.path ?? "";
const portablePath = rawPath.replaceAll("\\", "/");
if (path.posix.isAbsolute(portablePath) || path.win32.isAbsolute(rawPath)) {
respond(
false,
undefined,
workspaceError("workspace_path_invalid", "path must be workspace-relative", {
path: rawPath,
}),
);
return null;
}
const browserPath = normalizeRelativePath(params.path);
if (!resolveWorkspacePath(workspaceDir, browserPath || ".")) {
respond(
false,
undefined,
workspaceError("workspace_path_invalid", "path escapes the agent workspace", {
path: params.path ?? "",
}),
);
return null;
}
return { agentId, workspaceDir, browserPath };
}
/** Gateway handlers for read-only agent workspace browsing. */
export const agentsWorkspaceHandlers: GatewayRequestHandlers = {
"agents.workspace.list": async ({ params, respond, context }) => {
if (
!assertValidParams(
params,
validateAgentsWorkspaceListParams,
"agents.workspace.list",
respond,
)
) {
return;
}
const scope = resolveWorkspaceScopeOrRespond(params, context.getRuntimeConfig(), respond);
if (!scope) {
return;
}
const { agentId, workspaceDir, browserPath } = scope;
const stat = await statWorkspacePath(workspaceDir, browserPath);
const dirents =
stat && workspaceStatKind(stat) === "directory"
? await listWorkspacePath(workspaceDir, browserPath)
: undefined;
if (!dirents) {
respond(
false,
undefined,
workspaceError("workspace_path_not_found", "workspace directory not found", {
path: browserPath,
}),
);
return;
}
const entries = sortWorkspaceEntries(
dirents.flatMap((dirent): AgentsWorkspaceEntry[] => {
const statKind = workspaceStatKind(dirent);
const kind = statKind === "directory" ? "directory" : statKind === "file" ? "file" : null;
if (!kind) {
return [];
}
return [
{
path: browserPath ? `${browserPath}/${dirent.name}` : dirent.name,
name: dirent.name,
kind,
...(kind === "file" ? { size: dirent.size } : {}),
updatedAtMs: toUpdatedAtMs(dirent.mtimeMs),
},
];
}),
);
const offset = Math.min(params.offset ?? 0, entries.length);
const limit = Math.min(params.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
const parent = path.dirname(browserPath);
respond(true, {
agentId,
path: browserPath,
...(browserPath ? { parentPath: parent === "." ? "" : parent } : {}),
entries: entries.slice(offset, offset + limit),
totalEntries: entries.length,
offset,
});
},
"agents.workspace.get": async ({ params, respond, context }) => {
if (
!assertValidParams(params, validateAgentsWorkspaceGetParams, "agents.workspace.get", respond)
) {
return;
}
const scope = resolveWorkspaceScopeOrRespond(params, context.getRuntimeConfig(), respond);
if (!scope) {
return;
}
const { agentId, workspaceDir, browserPath } = scope;
const respondNotFound = () => {
respond(
false,
undefined,
workspaceError("workspace_file_not_found", "workspace file not found", {
path: browserPath,
}),
);
};
if (!browserPath) {
respondNotFound();
return;
}
const stat = await statWorkspacePath(workspaceDir, browserPath);
if (!stat || workspaceStatKind(stat) !== "file") {
respondNotFound();
return;
}
const expectsImage = IMAGE_EXTENSIONS.has(path.extname(browserPath).toLowerCase());
const maxBytes = expectsImage ? MAX_IMAGE_BYTES : WORKSPACE_PREVIEW_MAX_BYTES;
const read =
stat.size > maxBytes
? "too-large"
: await readWorkspaceFile(workspaceDir, browserPath, { maxBytes });
if (read === "too-large") {
respond(
false,
undefined,
workspaceError("workspace_file_too_large", "workspace file is too large to preview", {
maxBytes,
path: browserPath,
size: stat.size,
}),
);
return;
}
if (!read) {
respondNotFound();
return;
}
const respondUnsupported = () => {
respond(
false,
undefined,
workspaceError(
"workspace_file_unsupported",
"workspace file is not UTF-8 text or a supported image",
{ path: browserPath },
),
);
};
// The extension only picks the byte cap; content decides what leaves the
// gateway. Magic-byte sniffing (no filename hints) keeps renamed binaries
// from riding the image path past the UTF-8 text gate.
if (expectsImage) {
const sniffedMime = await detectMime({ buffer: read.buffer });
if (!sniffedMime || !SUPPORTED_IMAGE_MIME_TYPES.has(sniffedMime)) {
respondUnsupported();
return;
}
respond(true, {
agentId,
file: {
path: browserPath,
name: path.basename(browserPath),
size: read.stat.size,
updatedAtMs: toUpdatedAtMs(read.stat.mtimeMs),
mimeType: sniffedMime,
encoding: "base64" as const,
content: read.buffer.toString("base64"),
},
});
return;
}
const text = decodeUtf8Strict(read.buffer);
if (text === undefined) {
respondUnsupported();
return;
}
respond(true, {
agentId,
file: {
path: browserPath,
name: path.basename(browserPath),
size: read.stat.size,
updatedAtMs: toUpdatedAtMs(read.stat.mtimeMs),
mimeType: "text/plain",
encoding: "utf8" as const,
content: text,
},
});
},
};