import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils/fs-error"; import { ToolError } from "@oh-my-pi/pi-tui/tools/tool-errors"; import type { JsStatusEvent } from "./types"; export interface HelperOptions { limit?: number; offset?: number; } /** * Inputs the helper factory needs from its host runtime. `cwd` is a getter so the runtime * can update it between cells (e.g. when the agent's session cwd changes) without * recreating helpers. */ export interface HelperContext { cwd(): string; env: Map; /** * On-disk roots for internal-URL schemes the helpers accept, keyed by scheme * (see `buildEvalUrlRoots`). A path like `://x.md` is rewritten to * `/x.md` before any filesystem op; unknown schemes are rejected. */ localRoots(): Record; emitStatus(event: JsStatusEvent): void; } /** * The set of functions exposed to user code via `globalThis.__omp_helpers__`. The JS * prelude reads from this bag and attaches short aliases (`read`, `write`, `env`, ...) * onto the global scope. */ export interface HelperBundle { /** Resolves to `undefined` when `artifact://` is not in this session's artifacts dir; the prelude then asks the read tool. */ read(rawPath: string, options?: HelperOptions): Promise; writeFile(rawPath: string, data: unknown): Promise; env(key?: string, value?: string): string | Record | undefined; /** Whether `rawPath` is an internal URL whose scheme has an injected root (served by these helpers, not delegated to the read tool). */ hasRoot(rawPath: string): boolean; } const utf8Encoder = new TextEncoder(); export function createHelpers(ctx: HelperContext): HelperBundle { return { read: async (rawPath, options = {}) => { const resolved = await resolveRegularFile(ctx, rawPath); if (!resolved) return undefined; const { filePath, file, size } = resolved; let text = await file.text(); const offset = typeof options.offset === "number" ? options.offset : 1; const limit = typeof options.limit === "number" ? options.limit : undefined; if (offset > 1 || limit !== undefined) { const lines = text.split(/\r?\n/); const start = Math.max(0, offset - 1); const end = limit !== undefined ? start + limit : lines.length; text = lines.slice(start, end).join("\n"); } ctx.emitStatus({ op: "read", path: filePath, bytes: size, chars: text.length }); return text; }, writeFile: async (rawPath, data) => { if (!isWriteData(data)) { throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data"); } const filePath = resolveHelperPath(ctx, rawPath, "write"); if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) { await Bun.write(filePath, data); } else { await Bun.write(filePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); } ctx.emitStatus({ op: "write", path: filePath, bytes: getDataSize(data) }); return filePath; }, env: (key, value) => { if (!key) { const merged = Object.fromEntries(Object.entries(getMergedEnv(ctx)).sort(([a], [b]) => a.localeCompare(b))); ctx.emitStatus({ op: "env", count: Object.keys(merged).length, keys: Object.keys(merged).slice(0, 20) }); return merged; } if (value !== undefined) { ctx.env.set(key, value); ctx.emitStatus({ op: "env", key, value, action: "set" }); return value; } const result = ctx.env.get(key) ?? Bun.env[key]; ctx.emitStatus({ op: "env", key, value: result, action: "get" }); return result; }, hasRoot: rawPath => { const match = INTERNAL_URL_RE.exec(rawPath); if (match === null) return false; const scheme = match[1].toLowerCase(); if (!Object.hasOwn(ctx.localRoots(), scheme)) return false; // `artifact://` reads its file here; selector forms (`artifact://3:raw:5-9`) stay with the read tool. return scheme !== "artifact" || ARTIFACT_ID_RE.test(match[2]); }, }; } function getMergedEnv(ctx: HelperContext): Record { const merged: Record = {}; for (const [key, value] of Object.entries(Bun.env)) { if (typeof value === "string") merged[key] = value; } for (const [key, value] of ctx.env) merged[key] = value; return merged; } const INTERNAL_URL_RE = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i; const ARTIFACT_ID_RE = /^\d+$/; function resolvePath(ctx: HelperContext, value: string): string { if (path.isAbsolute(value)) return path.normalize(value); return path.resolve(ctx.cwd(), value); } /** * Map a raw helper path to an absolute filesystem path. Plain paths resolve * against the cwd; an internal-URL whose scheme has an injected root is * rewritten under that root; any other `scheme://` is rejected * so we never silently create a literal `scheme:/` directory. */ function resolveHelperPath(ctx: HelperContext, rawPath: string, op: "read" | "write"): string { const match = INTERNAL_URL_RE.exec(rawPath); if (!match) return resolvePath(ctx, rawPath); const scheme = match[1].toLowerCase(); const root = ctx.localRoots()[scheme]; // Artifacts are immutable, and their root is a lookup dir rather than a path prefix. if (!root || scheme === "artifact") { throw new ToolError(`Protocol paths are not supported by ${op}(): ${rawPath}`); } return resolveUnderRoot(scheme, root, match[2], rawPath); } /** * The file backing `artifact://` in the artifacts dir `root` (`..log`), or `undefined` when absent. * Mirrors `ArtifactManager.getPath`, which lives on the host (the worker cannot import it without loading the * native addon), as `resolveUnderRoot` mirrors the local:// handler. A miss, including a dir still being seeded, * falls back to the read tool, which resolves through the manager. */ async function resolveArtifactPath(root: string, id: string): Promise { let files: string[]; try { files = await fs.readdir(root); } catch (error) { if (!isEnoent(error)) throw error; return undefined; } const name = files.find(file => file.startsWith(`${id}.`)); return name === undefined ? undefined : path.join(root, name); } /** Resolve an internal-URL relative path under its root, mirroring the host * protocol handler: decode, reject absolute/traversal, confine to root. */ function resolveUnderRoot(scheme: string, root: string, rawRelative: string, rawPath: string): string { let relative: string; try { relative = decodeURIComponent(rawRelative.replaceAll("\\", "/")); } catch { throw new ToolError(`Invalid URL encoding in ${scheme}:// path: ${rawPath}`); } const rootPath = path.resolve(root); if (relative === "") return rootPath; if (path.isAbsolute(relative)) { throw new ToolError(`Absolute paths are not allowed in ${scheme}:// URLs: ${rawPath}`); } const normalized = path.normalize(relative); if (normalized.startsWith("..") || normalized.includes("/../") || normalized.includes("/..")) { throw new ToolError(`Path traversal (..) is not allowed in ${scheme}:// URLs: ${rawPath}`); } const resolved = path.resolve(rootPath, normalized); if (resolved !== rootPath && !resolved.startsWith(`${rootPath}${path.sep}`)) { throw new ToolError(`${scheme}:// path escapes its root: ${rawPath}`); } return resolved; } async function resolveRegularFile( ctx: HelperContext, rawPath: string, ): Promise<{ filePath: string; file: Bun.BunFile; size: number } | undefined> { const match = INTERNAL_URL_RE.exec(rawPath); const artifactRoot = match?.[1].toLowerCase() === "artifact" ? ctx.localRoots().artifact : undefined; const filePath = artifactRoot && match && ARTIFACT_ID_RE.test(match[2]) ? await resolveArtifactPath(artifactRoot, match[2]) : resolveHelperPath(ctx, rawPath, "read"); if (filePath === undefined) return undefined; const file = Bun.file(filePath); const stat = await file.stat(); if (stat.isDirectory()) { throw new ToolError(`Directory paths are not supported by read(): ${filePath}`); } return { filePath, file, size: stat.size }; } function getDataSize(data: string | Blob | ArrayBuffer | ArrayBufferView): number { if (typeof data === "string") return utf8Encoder.encode(data).byteLength; if (data instanceof Blob) return data.size; if (data instanceof ArrayBuffer) return data.byteLength; return data.byteLength; } function isWriteData(value: unknown): value is string | Blob | ArrayBuffer | ArrayBufferView { return ( typeof value === "string" || value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value) ); }