openclaw/src/infra/secret-file.ts
lishuaigit 76500c7a78
fix: detect Ollama "prompt too long" as context overflow error (#34019)
Merged via squash.

Prepared head SHA: 825a402f0fe7d521902291e7dd6dbb288699224e
Co-authored-by: lishuaigit <7495165+lishuaigit@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-16 18:57:33 -07:00

141 lines
3.5 KiB
TypeScript

import fs from "node:fs";
import { resolveUserPath } from "../utils.js";
import { openVerifiedFileSync } from "./safe-open-sync.js";
export const DEFAULT_SECRET_FILE_MAX_BYTES = 16 * 1024;
export type SecretFileReadOptions = {
maxBytes?: number;
rejectSymlink?: boolean;
};
export type SecretFileReadResult =
| {
ok: true;
secret: string;
resolvedPath: string;
}
| {
ok: false;
message: string;
resolvedPath?: string;
error?: unknown;
};
function normalizeSecretReadError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function loadSecretFileSync(
filePath: string,
label: string,
options: SecretFileReadOptions = {},
): SecretFileReadResult {
const trimmedPath = filePath.trim();
const resolvedPath = resolveUserPath(trimmedPath);
if (!resolvedPath) {
return { ok: false, message: `${label} file path is empty.` };
}
const maxBytes = options.maxBytes ?? DEFAULT_SECRET_FILE_MAX_BYTES;
let previewStat: fs.Stats;
try {
previewStat = fs.lstatSync(resolvedPath);
} catch (error) {
const normalized = normalizeSecretReadError(error);
return {
ok: false,
resolvedPath,
error: normalized,
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
};
}
if (options.rejectSymlink && previewStat.isSymbolicLink()) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} must not be a symlink.`,
};
}
if (!previewStat.isFile()) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} must be a regular file.`,
};
}
if (previewStat.size > maxBytes) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} exceeds ${maxBytes} bytes.`,
};
}
const opened = openVerifiedFileSync({
filePath: resolvedPath,
rejectPathSymlink: options.rejectSymlink,
maxBytes,
});
if (!opened.ok) {
const error = normalizeSecretReadError(
opened.reason === "validation" ? new Error("security validation failed") : opened.error,
);
return {
ok: false,
resolvedPath,
error,
message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`,
};
}
try {
const raw = fs.readFileSync(opened.fd, "utf8");
const secret = raw.trim();
if (!secret) {
return {
ok: false,
resolvedPath,
message: `${label} file at ${resolvedPath} is empty.`,
};
}
return { ok: true, secret, resolvedPath };
} catch (error) {
const normalized = normalizeSecretReadError(error);
return {
ok: false,
resolvedPath,
error: normalized,
message: `Failed to read ${label} file at ${resolvedPath}: ${String(normalized)}`,
};
} finally {
fs.closeSync(opened.fd);
}
}
export function readSecretFileSync(
filePath: string,
label: string,
options: SecretFileReadOptions = {},
): string {
const result = loadSecretFileSync(filePath, label, options);
if (result.ok) {
return result.secret;
}
throw new Error(result.message, result.error ? { cause: result.error } : undefined);
}
export function tryReadSecretFileSync(
filePath: string | undefined,
label: string,
options: SecretFileReadOptions = {},
): string | undefined {
if (!filePath?.trim()) {
return undefined;
}
const result = loadSecretFileSync(filePath, label, options);
return result.ok ? result.secret : undefined;
}