forked from EduCraft/curriculum-project-hub
8a81c60ea5
pbank materialize previously shelled out to `unzip` and soft-failed when the binary was missing, so agents only saw titles. Read zip entries with Node zlib (store/deflate) and write under workspace .pbank-sources.
665 lines
22 KiB
TypeScript
665 lines
22 KiB
TypeScript
/**
|
|
* ADR-0027: pbank capability — Paradigm 题库 search/fetch tools for Agent runs.
|
|
*
|
|
* Invariants:
|
|
* 1. Credential isolation — org ACTIVE connection is resolved in Hub; credentials
|
|
* never reach the Agent process (ADR-0024/0027).
|
|
* 2. Workspace containment — materialize path is always under the run workspace
|
|
* (ADR-0018 AgentSurface).
|
|
* 3. Mandatory fact — each successful tool call writes ≥1 UsageFact with
|
|
* kind=external_capability and unit=requests (cost unknown unless reported).
|
|
*/
|
|
import { inflateRawSync } from "node:zlib";
|
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import type { PrismaClient } from "@prisma/client";
|
|
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
|
import { resolveCapabilityCredential } from "./capabilityConnections.js";
|
|
import {
|
|
extractProblemId,
|
|
HttpPbankClient,
|
|
pbankRightsFromCredential,
|
|
type PbankClient,
|
|
} from "./pbankClient.js";
|
|
import {
|
|
CAPABILITIES,
|
|
asPbankSecret,
|
|
type PbankCapabilitySecretPayload,
|
|
} from "./types.js";
|
|
|
|
export const PBANK_CAPABILITY_ID = "pbank" as const;
|
|
const PROVIDER_ID = "paradigm_pbank";
|
|
const MAX_BATCH_SIZE = 20;
|
|
const MAX_PROJECT_BYTES = 80 * 1024 * 1024;
|
|
const MAX_PROJECT_TEXT_BYTES = 120 * 1024;
|
|
const MAX_EXTRACTED_PROJECT_BYTES = 80 * 1024 * 1024;
|
|
const MAX_INLINE_ASSET_BYTES = 1024 * 1024;
|
|
const MAX_INLINE_ASSETS = 4;
|
|
const CACHE_DIR_NAME = ".pbank-sources";
|
|
|
|
export class CapabilityPathEscape extends Error {
|
|
constructor(readonly requested: string, readonly workspaceDir: string) {
|
|
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
|
|
this.name = "CapabilityPathEscape";
|
|
}
|
|
}
|
|
|
|
export interface PbankServiceDeps {
|
|
readonly prisma: PrismaClient;
|
|
readonly secrets: LocalSecretEnvelope;
|
|
readonly client?: PbankClient;
|
|
}
|
|
|
|
export interface PbankToolContext {
|
|
readonly organizationId: string;
|
|
readonly runId: string;
|
|
readonly workspaceDir: string;
|
|
}
|
|
|
|
export interface PbankSearchArgs {
|
|
readonly q?: string | undefined;
|
|
readonly keywords?: readonly string[] | undefined;
|
|
readonly pageNum?: number | undefined;
|
|
readonly pageSize?: number | undefined;
|
|
}
|
|
|
|
export interface PbankGetProblemArgs {
|
|
readonly urlOrId: string;
|
|
readonly includeProjects?: boolean | undefined;
|
|
readonly materializeProjects?: boolean | undefined;
|
|
readonly includeAssetImages?: boolean | undefined;
|
|
readonly includeOccurrences?: boolean | undefined;
|
|
}
|
|
|
|
export interface PbankGetManyArgs {
|
|
readonly urlsOrIds: readonly string[];
|
|
readonly includeProjects?: boolean | undefined;
|
|
readonly materializeProjects?: boolean | undefined;
|
|
readonly includeAssetImages?: boolean | undefined;
|
|
readonly includeOccurrences?: boolean | undefined;
|
|
}
|
|
|
|
export interface PbankToolResult {
|
|
readonly data: unknown;
|
|
readonly inlineImages: readonly { readonly data: string; readonly mimeType: string }[];
|
|
}
|
|
|
|
interface TokenCacheEntry {
|
|
readonly token: string;
|
|
readonly expiresAt: number;
|
|
readonly password: string;
|
|
readonly username: string;
|
|
readonly baseUrl: string;
|
|
}
|
|
export interface PbankService {
|
|
searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult>;
|
|
getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult>;
|
|
getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult>;
|
|
}
|
|
|
|
export function createPbankService(deps: PbankServiceDeps): PbankService {
|
|
const client = deps.client ?? new HttpPbankClient();
|
|
const tokenCache = new Map<string, TokenCacheEntry>();
|
|
|
|
return {
|
|
async searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult> {
|
|
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
|
const token = await loginCached(client, tokenCache, credential);
|
|
const pageNum = clampInt(args.pageNum ?? 1, 1, 10_000);
|
|
const pageSize = clampInt(args.pageSize ?? 10, 1, 50);
|
|
const result = await client.searchProblems(credential, token, {
|
|
q: args.q,
|
|
keywords: args.keywords,
|
|
pageNum,
|
|
pageSize,
|
|
});
|
|
const data = {
|
|
rights: pbankRightsFromCredential(credential),
|
|
...(typeof result === "object" && result !== null ? result : { result }),
|
|
};
|
|
await writeUsageFact(deps.prisma, ctx.runId, "search", 1);
|
|
return { data, inlineImages: [] };
|
|
},
|
|
|
|
async getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult> {
|
|
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
|
const bundle = await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, args);
|
|
await writeUsageFact(deps.prisma, ctx.runId, extractProblemId(args.urlOrId), 1);
|
|
return toToolResult(bundle);
|
|
},
|
|
|
|
async getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult> {
|
|
if (args.urlsOrIds.length === 0) {
|
|
throw new Error("urlsOrIds must not be empty");
|
|
}
|
|
if (args.urlsOrIds.length > MAX_BATCH_SIZE) {
|
|
throw new Error(`urlsOrIds exceeds max batch size ${MAX_BATCH_SIZE}`);
|
|
}
|
|
const credential = await resolvePbankCredential(deps, ctx.organizationId);
|
|
const problems = [];
|
|
for (const urlOrId of args.urlsOrIds) {
|
|
problems.push(
|
|
await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, {
|
|
urlOrId,
|
|
includeProjects: args.includeProjects,
|
|
materializeProjects: args.materializeProjects,
|
|
includeAssetImages: args.includeAssetImages,
|
|
includeOccurrences: args.includeOccurrences,
|
|
}),
|
|
);
|
|
}
|
|
await writeUsageFact(deps.prisma, ctx.runId, "batch", problems.length);
|
|
return toToolResult({
|
|
count: problems.length,
|
|
rights: pbankRightsFromCredential(credential),
|
|
problems,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
async function resolvePbankCredential(
|
|
deps: PbankServiceDeps,
|
|
organizationId: string,
|
|
): Promise<PbankCapabilitySecretPayload & { connectionId: string }> {
|
|
const resolved = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
|
|
organizationId,
|
|
capabilityId: PBANK_CAPABILITY_ID,
|
|
});
|
|
const secret = asPbankSecret(resolved);
|
|
return { ...secret, connectionId: resolved.connectionId };
|
|
}
|
|
|
|
async function loginCached(
|
|
client: PbankClient,
|
|
cache: Map<string, TokenCacheEntry>,
|
|
credential: PbankCapabilitySecretPayload & { connectionId: string },
|
|
): Promise<string> {
|
|
const now = Date.now();
|
|
const cached = cache.get(credential.connectionId);
|
|
if (
|
|
cached !== undefined &&
|
|
cached.expiresAt - 60_000 > now &&
|
|
cached.username === credential.username &&
|
|
cached.password === credential.password &&
|
|
cached.baseUrl === credential.baseUrl
|
|
) {
|
|
return cached.token;
|
|
}
|
|
const login = await client.login(credential);
|
|
cache.set(credential.connectionId, {
|
|
token: login.token,
|
|
expiresAt: login.expiresAt,
|
|
username: credential.username,
|
|
password: credential.password,
|
|
baseUrl: credential.baseUrl,
|
|
});
|
|
return login.token;
|
|
}
|
|
|
|
async function getProblemBundle(
|
|
client: PbankClient,
|
|
cache: Map<string, TokenCacheEntry>,
|
|
credential: PbankCapabilitySecretPayload & { connectionId: string },
|
|
workspaceDir: string,
|
|
args: PbankGetProblemArgs,
|
|
): Promise<Record<string, unknown>> {
|
|
const id = extractProblemId(args.urlOrId);
|
|
const token = await loginCached(client, cache, credential);
|
|
const problem = await client.getProblem(credential, token, id);
|
|
const result: Record<string, unknown> = {
|
|
id,
|
|
source: `${credential.baseUrl.replace(/\/+$/, "")}/problem/${id}`,
|
|
rights: pbankRightsFromCredential(credential),
|
|
problem,
|
|
};
|
|
|
|
const includeProjects = args.includeProjects !== false;
|
|
const materializeProjects = args.materializeProjects !== false;
|
|
const includeAssetImages = args.includeAssetImages !== false;
|
|
if (includeProjects) {
|
|
const projects: Record<string, unknown> = {};
|
|
for (const target of ["problem", "answer"] as const) {
|
|
try {
|
|
projects[target] = await downloadAndMaterializeProject({
|
|
client,
|
|
credential,
|
|
token,
|
|
id,
|
|
target,
|
|
workspaceDir,
|
|
materialize: materializeProjects,
|
|
includeAssetImages,
|
|
});
|
|
} catch (error) {
|
|
projects[target] = {
|
|
target,
|
|
status: "error",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
result.projects = projects;
|
|
}
|
|
|
|
if (args.includeOccurrences === true) {
|
|
try {
|
|
result.occurrences = await client.getOccurrences(credential, token, id);
|
|
} catch (error) {
|
|
result.occurrences = {
|
|
status: "error",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function downloadAndMaterializeProject(input: {
|
|
readonly client: PbankClient;
|
|
readonly credential: PbankCapabilitySecretPayload;
|
|
readonly token: string;
|
|
readonly id: string;
|
|
readonly target: "problem" | "answer";
|
|
readonly workspaceDir: string;
|
|
readonly materialize: boolean;
|
|
readonly includeAssetImages: boolean;
|
|
}): Promise<Record<string, unknown>> {
|
|
const downloaded = await input.client.downloadProject(
|
|
input.credential,
|
|
input.token,
|
|
input.id,
|
|
input.target,
|
|
);
|
|
if (downloaded.buffer.byteLength > MAX_PROJECT_BYTES) {
|
|
throw new Error(`project archive exceeds ${MAX_PROJECT_BYTES} bytes`);
|
|
}
|
|
|
|
if (!isZipBuffer(downloaded.buffer, downloaded.contentType)) {
|
|
const text = decodeUtf8IfText(downloaded.buffer);
|
|
if (text !== null) {
|
|
return {
|
|
target: input.target,
|
|
status: "text",
|
|
bytes: downloaded.buffer.byteLength,
|
|
content: text.slice(0, MAX_PROJECT_TEXT_BYTES),
|
|
omitted: text.length > MAX_PROJECT_TEXT_BYTES ? [{ reason: "text truncated" }] : [],
|
|
};
|
|
}
|
|
return {
|
|
target: input.target,
|
|
status: "binary",
|
|
bytes: downloaded.buffer.byteLength,
|
|
contentType: downloaded.contentType,
|
|
};
|
|
}
|
|
|
|
return readProjectZip(downloaded.buffer, {
|
|
id: input.id,
|
|
target: input.target,
|
|
workspaceDir: input.workspaceDir,
|
|
materialize: input.materialize,
|
|
includeAssetImages: input.includeAssetImages,
|
|
});
|
|
}
|
|
|
|
async function readProjectZip(
|
|
buffer: Buffer,
|
|
options: {
|
|
readonly id: string;
|
|
readonly target: string;
|
|
readonly workspaceDir: string;
|
|
readonly materialize: boolean;
|
|
readonly includeAssetImages: boolean;
|
|
},
|
|
): Promise<Record<string, unknown>> {
|
|
// Pure Node unzip (store/deflate). Do not shell out to host `unzip` —
|
|
// silo service PATH/tooling must not gate 题库 materialize.
|
|
const entries = listZipEntries(buffer);
|
|
const cacheRoot = confineToWorkspace(CACHE_DIR_NAME, options.workspaceDir);
|
|
const cacheDir = join(cacheRoot, safePathSegment(options.id), safePathSegment(options.target));
|
|
const extractDir = options.materialize ? join(cacheDir, "source") : null;
|
|
const zipPath = options.materialize ? join(cacheDir, `${options.target}.zip`) : null;
|
|
|
|
if (options.materialize) {
|
|
await mkdir(cacheDir, { recursive: true });
|
|
if (extractDir !== null) {
|
|
await rm(extractDir, { recursive: true, force: true });
|
|
await mkdir(extractDir, { recursive: true });
|
|
}
|
|
if (zipPath !== null) await writeFile(zipPath, buffer);
|
|
}
|
|
|
|
const files: Array<Record<string, unknown>> = [];
|
|
const assets: Array<{
|
|
path: string;
|
|
localPath: string | null;
|
|
bytes: number;
|
|
mimeType: string;
|
|
inlineData?: string;
|
|
}> = [];
|
|
const extractedFiles: Array<Record<string, unknown>> = [];
|
|
const omitted: Array<Record<string, unknown>> = [];
|
|
let usedTextBytes = 0;
|
|
let usedExtractedBytes = 0;
|
|
let inlineAssetCount = 0;
|
|
|
|
for (const entry of entries) {
|
|
if (!safeZipPath(entry.name)) {
|
|
omitted.push({ path: entry.name, reason: "unsafe path" });
|
|
continue;
|
|
}
|
|
if (entry.name.endsWith("/") || entry.isDirectory) continue;
|
|
if (usedExtractedBytes >= MAX_EXTRACTED_PROJECT_BYTES) {
|
|
omitted.push({ path: entry.name, reason: "extracted byte limit reached" });
|
|
continue;
|
|
}
|
|
const maxEntryBytes = Math.max(
|
|
1,
|
|
Math.min(MAX_PROJECT_BYTES, MAX_EXTRACTED_PROJECT_BYTES - usedExtractedBytes),
|
|
);
|
|
try {
|
|
const entryBuffer = inflateZipEntry(buffer, entry, maxEntryBytes);
|
|
usedExtractedBytes += entryBuffer.length;
|
|
let localPath: string | null = null;
|
|
if (extractDir !== null) {
|
|
localPath = join(extractDir, ...entry.name.split(/[\\/]+/));
|
|
await mkdir(dirname(localPath), { recursive: true });
|
|
await writeFile(localPath, entryBuffer);
|
|
extractedFiles.push({
|
|
path: entry.name,
|
|
localPath: toWorkspaceRelative(options.workspaceDir, localPath),
|
|
bytes: entryBuffer.length,
|
|
});
|
|
}
|
|
|
|
const mimeType = assetMimeType(entry.name);
|
|
if (mimeType !== null) {
|
|
const asset: {
|
|
path: string;
|
|
localPath: string | null;
|
|
bytes: number;
|
|
mimeType: string;
|
|
inlineData?: string;
|
|
} = {
|
|
path: entry.name,
|
|
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
|
|
bytes: entryBuffer.length,
|
|
mimeType,
|
|
};
|
|
if (
|
|
options.includeAssetImages &&
|
|
isInlineImageMime(mimeType) &&
|
|
entryBuffer.length <= MAX_INLINE_ASSET_BYTES &&
|
|
inlineAssetCount < MAX_INLINE_ASSETS
|
|
) {
|
|
asset.inlineData = entryBuffer.toString("base64");
|
|
inlineAssetCount += 1;
|
|
}
|
|
assets.push(asset);
|
|
}
|
|
|
|
if (isTextLikePath(entry.name)) {
|
|
if (usedTextBytes >= MAX_PROJECT_TEXT_BYTES) {
|
|
omitted.push({ path: entry.name, reason: "text byte limit reached" });
|
|
continue;
|
|
}
|
|
const content = decodeUtf8IfText(entryBuffer);
|
|
if (content === null) {
|
|
omitted.push({ path: entry.name, reason: "text decode failed" });
|
|
continue;
|
|
}
|
|
usedTextBytes += Buffer.byteLength(content, "utf8");
|
|
files.push({
|
|
path: entry.name,
|
|
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
|
|
content,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
omitted.push({
|
|
path: entry.name,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
target: options.target,
|
|
status: "downloaded",
|
|
bytes: buffer.length,
|
|
zipPath: zipPath === null ? null : toWorkspaceRelative(options.workspaceDir, zipPath),
|
|
extractDir: extractDir === null ? null : toWorkspaceRelative(options.workspaceDir, extractDir),
|
|
extractedFiles,
|
|
files,
|
|
assets: assets.map(({ inlineData: _inlineData, ...asset }) => asset),
|
|
inlineAssets: assets
|
|
.filter((asset) => asset.inlineData !== undefined)
|
|
.map((asset) => ({
|
|
path: asset.path,
|
|
localPath: asset.localPath,
|
|
bytes: asset.bytes,
|
|
mimeType: asset.mimeType,
|
|
data: asset.inlineData,
|
|
})),
|
|
omitted,
|
|
};
|
|
}
|
|
|
|
interface ZipEntryMeta {
|
|
readonly name: string;
|
|
readonly method: number;
|
|
readonly compressedSize: number;
|
|
readonly uncompressedSize: number;
|
|
readonly localHeaderOffset: number;
|
|
readonly isDirectory: boolean;
|
|
}
|
|
|
|
/** Minimal ZIP central-directory reader (store + deflate). No external unzip binary. */
|
|
function listZipEntries(buffer: Buffer): ZipEntryMeta[] {
|
|
let eocd = -1;
|
|
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
|
|
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
|
|
if (buffer.readUInt32LE(i) === 0x06054b50) {
|
|
eocd = i;
|
|
break;
|
|
}
|
|
}
|
|
if (eocd < 0) throw new Error("invalid zip: missing end of central directory");
|
|
|
|
const totalEntries = buffer.readUInt16LE(eocd + 10);
|
|
const centralSize = buffer.readUInt32LE(eocd + 12);
|
|
const centralOffset = buffer.readUInt32LE(eocd + 16);
|
|
if (centralOffset + centralSize > buffer.length) {
|
|
throw new Error("invalid zip: central directory out of range");
|
|
}
|
|
|
|
const entries: ZipEntryMeta[] = [];
|
|
let offset = centralOffset;
|
|
for (let i = 0; i < totalEntries; i += 1) {
|
|
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
|
|
throw new Error("invalid zip: bad central directory entry");
|
|
}
|
|
const method = buffer.readUInt16LE(offset + 10);
|
|
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
const nameLen = buffer.readUInt16LE(offset + 28);
|
|
const extraLen = buffer.readUInt16LE(offset + 30);
|
|
const commentLen = buffer.readUInt16LE(offset + 32);
|
|
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
|
|
const nameStart = offset + 46;
|
|
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
|
|
entries.push({
|
|
name,
|
|
method,
|
|
compressedSize,
|
|
uncompressedSize,
|
|
localHeaderOffset,
|
|
isDirectory: name.endsWith("/"),
|
|
});
|
|
offset = nameStart + nameLen + extraLen + commentLen;
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function inflateZipEntry(buffer: Buffer, entry: ZipEntryMeta, maxBytes: number): Buffer {
|
|
if (entry.uncompressedSize > maxBytes) {
|
|
throw new Error(`zip entry exceeds ${maxBytes} bytes`);
|
|
}
|
|
const local = entry.localHeaderOffset;
|
|
if (local + 30 > buffer.length || buffer.readUInt32LE(local) !== 0x04034b50) {
|
|
throw new Error("invalid zip: bad local header");
|
|
}
|
|
const nameLen = buffer.readUInt16LE(local + 26);
|
|
const extraLen = buffer.readUInt16LE(local + 28);
|
|
const dataStart = local + 30 + nameLen + extraLen;
|
|
const dataEnd = dataStart + entry.compressedSize;
|
|
if (dataEnd > buffer.length) throw new Error("invalid zip: compressed data out of range");
|
|
const compressed = buffer.subarray(dataStart, dataEnd);
|
|
|
|
if (entry.method === 0) {
|
|
if (compressed.length > maxBytes) throw new Error(`zip entry exceeds ${maxBytes} bytes`);
|
|
return Buffer.from(compressed);
|
|
}
|
|
if (entry.method === 8) {
|
|
return Buffer.from(inflateRawSync(compressed, { maxOutputLength: maxBytes }));
|
|
}
|
|
throw new Error(`unsupported zip compression method ${entry.method}`);
|
|
}
|
|
|
|
|
|
async function writeUsageFact(
|
|
prisma: PrismaClient,
|
|
runId: string,
|
|
correlationId: string,
|
|
quantity: number,
|
|
): Promise<void> {
|
|
const descriptor = CAPABILITIES[PBANK_CAPABILITY_ID];
|
|
await prisma.usageFact.create({
|
|
data: {
|
|
runId,
|
|
occurredAt: new Date(),
|
|
kind: "external_capability",
|
|
provider: PROVIDER_ID,
|
|
model: null,
|
|
inputTokens: null,
|
|
outputTokens: null,
|
|
quantity,
|
|
unit: descriptor.meteringUnit,
|
|
costUsd: null,
|
|
costSource: "unknown",
|
|
capabilityId: PBANK_CAPABILITY_ID,
|
|
correlationId,
|
|
metadata: {},
|
|
},
|
|
});
|
|
}
|
|
|
|
function toToolResult(data: unknown): PbankToolResult {
|
|
const inlineImages: Array<{ data: string; mimeType: string }> = [];
|
|
collectInlineAssets(data, inlineImages);
|
|
return { data, inlineImages };
|
|
}
|
|
|
|
function collectInlineAssets(
|
|
value: unknown,
|
|
out: Array<{ data: string; mimeType: string }>,
|
|
): void {
|
|
if (typeof value !== "object" || value === null) return;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectInlineAssets(item, out);
|
|
return;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
if (Array.isArray(record.inlineAssets)) {
|
|
for (const asset of record.inlineAssets) {
|
|
if (typeof asset !== "object" || asset === null) continue;
|
|
const item = asset as Record<string, unknown>;
|
|
if (typeof item.data === "string" && typeof item.mimeType === "string") {
|
|
out.push({ data: item.data, mimeType: item.mimeType });
|
|
}
|
|
}
|
|
delete record.inlineAssets;
|
|
}
|
|
if (record.projects !== undefined) collectInlineAssets(record.projects, out);
|
|
if (Array.isArray(record.problems)) {
|
|
for (const problem of record.problems) collectInlineAssets(problem, out);
|
|
}
|
|
}
|
|
|
|
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
|
|
const resolved = resolve(workspaceDir, requestedPath);
|
|
const rel = relative(workspaceDir, resolved);
|
|
if (rel.startsWith("..") || rel === "") {
|
|
throw new CapabilityPathEscape(requestedPath, workspaceDir);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function toWorkspaceRelative(workspaceDir: string, absolutePath: string): string {
|
|
const rel = relative(workspaceDir, absolutePath);
|
|
if (rel.startsWith("..")) {
|
|
throw new CapabilityPathEscape(absolutePath, workspaceDir);
|
|
}
|
|
return rel;
|
|
}
|
|
|
|
function decodeUtf8IfText(buffer: Buffer): string | null {
|
|
const text = buffer.toString("utf8");
|
|
const replacementRatio = (text.match(/\uFFFD/g) ?? []).length / Math.max(text.length, 1);
|
|
if (replacementRatio > 0.02) return null;
|
|
return text;
|
|
}
|
|
|
|
function isZipBuffer(buffer: Buffer, contentType: string): boolean {
|
|
if (contentType.includes("zip")) return true;
|
|
return buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b;
|
|
}
|
|
|
|
function isTextLikePath(filePath: string): boolean {
|
|
const lower = filePath.toLowerCase();
|
|
return [".typ", ".md", ".txt", ".tex", ".json", ".yaml", ".yml", ".toml", ".csv"].some((ext) =>
|
|
lower.endsWith(ext),
|
|
);
|
|
}
|
|
|
|
function assetMimeType(filePath: string): string | null {
|
|
const lower = filePath.toLowerCase();
|
|
if (lower.endsWith(".png")) return "image/png";
|
|
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
if (lower.endsWith(".gif")) return "image/gif";
|
|
if (lower.endsWith(".webp")) return "image/webp";
|
|
if (lower.endsWith(".pdf")) return "application/pdf";
|
|
return null;
|
|
}
|
|
|
|
function isInlineImageMime(mimeType: string): boolean {
|
|
return mimeType.startsWith("image/") && mimeType !== "image/svg+xml";
|
|
}
|
|
|
|
function safeZipPath(entry: string): boolean {
|
|
if (entry.includes("\0")) return false;
|
|
const normalized = entry.replace(/\\/g, "/");
|
|
if (normalized.startsWith("/") || normalized.includes("://")) return false;
|
|
for (const part of normalized.split("/")) {
|
|
if (part === ".." || part === "") return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function safePathSegment(value: string): string {
|
|
const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
if (cleaned === "" || cleaned === "." || cleaned === "..") return "item";
|
|
return cleaned.slice(0, 80);
|
|
}
|
|
|
|
function clampInt(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) return min;
|
|
const n = Math.trunc(value);
|
|
if (n < min) return min;
|
|
if (n > max) return max;
|
|
return n;
|
|
}
|