mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 14:21:37 +00:00
* refactor(extensions): remove dead QA and utility exports * refactor(extensions): trim Matrix QA internal exports * chore(deadcode): refresh extension export baseline * chore(ci): reconcile deadcode and LOC baselines * chore(deadcode): refresh baseline after main advance * chore(deadcode): refresh baseline after main advance * refactor(plugins): keep channel snapshot type private * fix(ci): align Linux deadcode baseline
158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
import path from "node:path";
|
|
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
|
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
import {
|
|
WorkspaceBindingResolutionError,
|
|
normalizeWorkspaceDataLogicalPath,
|
|
} from "./binding-contract.js";
|
|
import type { WorkspaceBinding, JsonValue } from "./schema.js";
|
|
|
|
export { DATA_READ_RPC_ALLOWLIST, WorkspaceBindingResolutionError } from "./binding-contract.js";
|
|
|
|
export type ResolveBindingOptions = {
|
|
stateDir?: string;
|
|
};
|
|
|
|
const MAX_FILE_BYTES = 1024 * 1024;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function readBinding(value: unknown): WorkspaceBinding {
|
|
if (!isRecord(value) || typeof value.source !== "string") {
|
|
throw new WorkspaceBindingResolutionError("binding_invalid", "binding source is required");
|
|
}
|
|
if (value.source === "static") {
|
|
return { source: "static", value: value.value as JsonValue };
|
|
}
|
|
if (value.source === "rpc") {
|
|
if (typeof value.method !== "string" || !value.method.trim()) {
|
|
throw new WorkspaceBindingResolutionError(
|
|
"binding_invalid",
|
|
"rpc binding method is required",
|
|
);
|
|
}
|
|
const params = isRecord(value.params) ? (value.params as Record<string, JsonValue>) : undefined;
|
|
return { source: "rpc", method: value.method, ...(params ? { params } : {}) };
|
|
}
|
|
if (value.source === "file") {
|
|
if (typeof value.path !== "string") {
|
|
throw new WorkspaceBindingResolutionError("binding_invalid", "file binding path is required");
|
|
}
|
|
if (value.pointer !== undefined && typeof value.pointer !== "string") {
|
|
throw new WorkspaceBindingResolutionError(
|
|
"binding_invalid",
|
|
"file binding pointer is invalid",
|
|
);
|
|
}
|
|
return {
|
|
source: "file",
|
|
path: value.path,
|
|
...(value.pointer !== undefined ? { pointer: value.pointer } : {}),
|
|
};
|
|
}
|
|
throw new WorkspaceBindingResolutionError("binding_invalid", "binding source is invalid");
|
|
}
|
|
|
|
function decodePointerSegment(value: string): string {
|
|
return value.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
}
|
|
|
|
function applyJsonPointer(value: unknown, pointer: string | undefined): unknown {
|
|
if (pointer === undefined || pointer === "") {
|
|
return value;
|
|
}
|
|
if (!pointer.startsWith("/")) {
|
|
throw new WorkspaceBindingResolutionError("binding_invalid", "JSON pointer is invalid");
|
|
}
|
|
let current = value;
|
|
for (const rawSegment of pointer.slice(1).split("/")) {
|
|
const segment = decodePointerSegment(rawSegment);
|
|
if (Array.isArray(current)) {
|
|
const index = Number(segment);
|
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
throw new WorkspaceBindingResolutionError("binding_not_found", "JSON pointer not found");
|
|
}
|
|
current = current[index];
|
|
continue;
|
|
}
|
|
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
|
|
throw new WorkspaceBindingResolutionError("binding_not_found", "JSON pointer not found");
|
|
}
|
|
current = current[segment];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
async function resolveFileBinding(
|
|
binding: Extract<WorkspaceBinding, { source: "file" }>,
|
|
options: ResolveBindingOptions,
|
|
): Promise<unknown> {
|
|
const logicalPath = normalizeWorkspaceDataLogicalPath(binding.path);
|
|
const stateDir = path.resolve(options.stateDir ?? resolveStateDir());
|
|
const dataRoot = path.join(stateDir, "workspaces", "data");
|
|
let content: string;
|
|
try {
|
|
const state = await fsRoot(stateDir);
|
|
const data = await fsRoot(dataRoot);
|
|
const expectedDataRoot = path.join(state.rootReal, "workspaces", "data");
|
|
// The data directory itself is part of the jail, not a trusted alias. Once
|
|
// this matches, fs-safe pins its canonical root and rejects later swaps.
|
|
if (data.rootReal !== expectedDataRoot) {
|
|
throw new WorkspaceBindingResolutionError("binding_invalid", "file binding path is invalid");
|
|
}
|
|
// The schema rejects leading `~/`, so this absolute path never invokes the
|
|
// shared root helper's home-expansion convenience.
|
|
const read = await data.readAbsolute(path.join(data.rootDir, logicalPath), {
|
|
hardlinks: "reject",
|
|
maxBytes: MAX_FILE_BYTES,
|
|
symlinks: "reject",
|
|
});
|
|
content = read.buffer.toString("utf8");
|
|
} catch (error) {
|
|
if (error instanceof FsSafeError) {
|
|
if (error.code === "too-large") {
|
|
throw new WorkspaceBindingResolutionError("binding_too_large", "file binding is too large");
|
|
}
|
|
if (error.code === "not-found" || error.code === "not-file") {
|
|
throw new WorkspaceBindingResolutionError("binding_not_found", "file binding not found");
|
|
}
|
|
throw new WorkspaceBindingResolutionError("binding_invalid", "file binding path is invalid");
|
|
}
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw new WorkspaceBindingResolutionError("binding_not_found", "file binding not found");
|
|
}
|
|
throw error;
|
|
}
|
|
const extension = path.extname(logicalPath).toLowerCase();
|
|
if (extension === ".md" || extension === ".csv") {
|
|
return content;
|
|
}
|
|
try {
|
|
return applyJsonPointer(JSON.parse(content), binding.pointer);
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceBindingResolutionError) {
|
|
throw error;
|
|
}
|
|
throw new WorkspaceBindingResolutionError("binding_invalid", "file binding JSON is invalid");
|
|
}
|
|
}
|
|
|
|
export async function resolveBinding(
|
|
bindingInput: unknown,
|
|
options: ResolveBindingOptions = {},
|
|
): Promise<unknown> {
|
|
const binding = readBinding(bindingInput);
|
|
if (binding.source === "static") {
|
|
return binding.value;
|
|
}
|
|
if (binding.source === "rpc") {
|
|
throw new WorkspaceBindingResolutionError(
|
|
"binding_client_resolved",
|
|
"rpc workspace bindings are resolved by the Control UI gateway client",
|
|
);
|
|
}
|
|
return await resolveFileBinding(binding, options);
|
|
}
|