fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { lstat, link, mkdir, open, realpath, unlink } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { FAST_BROWSER_ARTIFACT_LIMITS } from "./contracts.ts";
|
||||
import type {
|
||||
PrivateArtifactKind,
|
||||
PrivateArtifactMetadata,
|
||||
PrivateArtifactOwner,
|
||||
PrivateArtifactStore,
|
||||
PrivateArtifactWriter,
|
||||
} from "./contracts.ts";
|
||||
|
||||
const MAX_OWNER_FIELD = 200;
|
||||
const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const ROOT_MODE = 0o700;
|
||||
const FILE_MODE = 0o600;
|
||||
|
||||
export interface PrivateArtifactStoreOptions {
|
||||
/** A dedicated local directory. It is never selected by a caller per artifact. */
|
||||
root: string;
|
||||
/** Maximum bytes per artifact; defaults to eight MiB. */
|
||||
maxArtifactBytes?: number;
|
||||
}
|
||||
|
||||
function safeOwnerField(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > MAX_OWNER_FIELD || value.includes("\0")) {
|
||||
throw new Error(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateOwner(owner: PrivateArtifactOwner): PrivateArtifactOwner {
|
||||
if (!owner || typeof owner !== "object") throw new Error("artifact owner is invalid");
|
||||
return Object.freeze({
|
||||
sessionId: safeOwnerField(owner.sessionId, "artifact sessionId"),
|
||||
runId: safeOwnerField(owner.runId, "artifact runId"),
|
||||
operationId: safeOwnerField(owner.operationId, "artifact operationId"),
|
||||
});
|
||||
}
|
||||
|
||||
function validateKind(kind: PrivateArtifactKind): PrivateArtifactKind {
|
||||
if (kind !== "screenshot" && kind !== "diagnostics") throw new Error("artifact kind is invalid");
|
||||
return kind;
|
||||
}
|
||||
|
||||
function encodedComponent(value: string): string {
|
||||
// Owner identifiers are deliberately not included verbatim in filesystem
|
||||
// paths. This both prevents traversal and keeps credentials/tokens out of
|
||||
// local artifact metadata and directory names.
|
||||
return Buffer.from(value, "utf8").toString("base64url").slice(0, 96);
|
||||
}
|
||||
|
||||
function artifactId(): string {
|
||||
return `a-${randomBytes(18).toString("hex")}`;
|
||||
}
|
||||
|
||||
async function assertRegularDirectory(path: string): Promise<void> {
|
||||
const stat = await lstat(path);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("artifact path is not a private directory");
|
||||
if (stat.mode & 0o077) throw new Error("artifact directory permissions are too broad");
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
||||
if (uid !== undefined && stat.uid !== uid) throw new Error("artifact directory is not owned by this process");
|
||||
}
|
||||
|
||||
async function assertNoSymlinkComponents(path: string): Promise<void> {
|
||||
const absolute = resolve(path);
|
||||
const parts = absolute.split(sep);
|
||||
let current = parts[0] || sep;
|
||||
for (const part of parts.slice(1)) {
|
||||
if (!part) continue;
|
||||
current = current === sep ? join(current, part) : join(current, part);
|
||||
const stat = await lstat(current).catch(() => undefined);
|
||||
if (stat?.isSymbolicLink()) throw new Error("artifact path may not contain symlinks");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePrivateDirectory(path: string, create = true, tighten = true): Promise<void> {
|
||||
await assertNoSymlinkComponents(path);
|
||||
if (create) {
|
||||
try {
|
||||
await mkdir(path, { recursive: true, mode: ROOT_MODE });
|
||||
} catch (error) {
|
||||
throw new Error("could not create private artifact directory", { cause: error });
|
||||
}
|
||||
}
|
||||
const stat = await lstat(path).catch(() => undefined);
|
||||
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error("artifact path is not a private directory");
|
||||
}
|
||||
// Never chmod a caller-owned parent or weaken a pre-existing directory's
|
||||
// permissions. Store roots must already be private; only mkdir-created
|
||||
// children are accepted with the requested mode.
|
||||
if (tighten) await assertRegularDirectory(path);
|
||||
}
|
||||
|
||||
function contentType(kind: PrivateArtifactKind): PrivateArtifactMetadata["contentType"] {
|
||||
return kind === "screenshot" ? "image/png" : "application/json";
|
||||
}
|
||||
|
||||
function isWithin(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel !== "" && rel !== ".." && !rel.startsWith(`..${resolve("/")}`) && !rel.startsWith("../") && !rel.startsWith("..\\") && !rel.includes("\0");
|
||||
}
|
||||
|
||||
class FileArtifactWriter implements PrivateArtifactWriter {
|
||||
readonly owner: PrivateArtifactOwner;
|
||||
readonly kind: PrivateArtifactKind;
|
||||
private handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
private bytesWritten = 0;
|
||||
private state: "open" | "committed" | "aborted" = "open";
|
||||
private readonly tempPath: string;
|
||||
private readonly finalPath: string;
|
||||
private readonly id: string;
|
||||
private readonly maxBytes: number;
|
||||
private readonly onDone: () => void;
|
||||
|
||||
constructor(
|
||||
owner: PrivateArtifactOwner,
|
||||
kind: PrivateArtifactKind,
|
||||
handle: Awaited<ReturnType<typeof open>>,
|
||||
tempPath: string,
|
||||
finalPath: string,
|
||||
id: string,
|
||||
maxBytes: number,
|
||||
onDone: () => void,
|
||||
) {
|
||||
this.owner = owner;
|
||||
this.kind = kind;
|
||||
this.handle = handle;
|
||||
this.tempPath = tempPath;
|
||||
this.finalPath = finalPath;
|
||||
this.id = id;
|
||||
this.maxBytes = maxBytes;
|
||||
this.onDone = onDone;
|
||||
}
|
||||
|
||||
async write(chunk: Uint8Array): Promise<void> {
|
||||
if (this.state !== "open" || !this.handle) throw new Error("artifact writer is closed");
|
||||
if (!(chunk instanceof Uint8Array)) throw new Error("artifact chunk is invalid");
|
||||
if (chunk.byteLength > this.maxBytes - this.bytesWritten) {
|
||||
await this.abort();
|
||||
throw new Error("artifact exceeds the private size limit");
|
||||
}
|
||||
if (chunk.byteLength === 0) return;
|
||||
try {
|
||||
await this.handle.write(chunk);
|
||||
this.bytesWritten += chunk.byteLength;
|
||||
} catch (error) {
|
||||
await this.abort();
|
||||
throw new Error("could not write private artifact", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async commit(): Promise<PrivateArtifactMetadata> {
|
||||
if (this.state !== "open" || !this.handle) throw new Error("artifact writer is closed");
|
||||
try {
|
||||
await this.handle.sync();
|
||||
const stat = await this.handle.stat();
|
||||
if (!stat.isFile() || stat.size !== this.bytesWritten || stat.mode & 0o077) {
|
||||
throw new Error("private artifact validation failed");
|
||||
}
|
||||
await this.handle.chmod(FILE_MODE);
|
||||
await this.handle.close();
|
||||
this.handle = undefined;
|
||||
// link() makes the completed file visible atomically without replacing a
|
||||
// pre-existing symlink or foreign artifact. The temporary inode is then
|
||||
// removed; no caller-selected path is ever opened for writing.
|
||||
await link(this.tempPath, this.finalPath);
|
||||
await unlink(this.tempPath);
|
||||
this.state = "committed";
|
||||
this.onDone();
|
||||
return Object.freeze({
|
||||
artifactId: this.id,
|
||||
kind: this.kind,
|
||||
contentType: contentType(this.kind),
|
||||
path: this.finalPath,
|
||||
bytes: this.bytesWritten,
|
||||
owner: this.owner,
|
||||
permissions: "0600",
|
||||
localOnly: true,
|
||||
modelForwarded: false,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.abort();
|
||||
throw error instanceof Error && error.message.startsWith("private artifact")
|
||||
? error
|
||||
: new Error("could not commit private artifact", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
if (this.state !== "open") return;
|
||||
this.state = "aborted";
|
||||
const handle = this.handle;
|
||||
this.handle = undefined;
|
||||
try { await handle?.close(); } catch { /* best effort cleanup */ }
|
||||
try { await unlink(this.tempPath); } catch { /* already absent */ }
|
||||
this.onDone();
|
||||
}
|
||||
}
|
||||
|
||||
/** Filesystem-backed implementation of the frozen PrivateArtifactStore API. */
|
||||
export class FilePrivateArtifactStore implements PrivateArtifactStore {
|
||||
readonly root: string;
|
||||
private readonly maxBytes: number;
|
||||
private readonly active = new Set<FileArtifactWriter>();
|
||||
private closed = false;
|
||||
private ready: Promise<void>;
|
||||
|
||||
constructor(options: PrivateArtifactStoreOptions | string) {
|
||||
const root = typeof options === "string" ? options : options.root;
|
||||
const maxBytes = typeof options === "string" ? DEFAULT_MAX_BYTES : options.maxArtifactBytes ?? DEFAULT_MAX_BYTES;
|
||||
if (typeof root !== "string" || root.length === 0 || root.includes("\0")) throw new Error("artifact root is invalid");
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > DEFAULT_MAX_BYTES) throw new Error("artifact size limit is invalid");
|
||||
this.root = resolve(root);
|
||||
this.maxBytes = maxBytes;
|
||||
this.ready = this.initialize();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
const parent = dirname(this.root);
|
||||
await ensurePrivateDirectory(parent, true, false);
|
||||
await assertNoSymlinkComponents(this.root);
|
||||
const existing = await lstat(this.root).catch(() => undefined);
|
||||
if (existing?.isSymbolicLink()) throw new Error("artifact root may not be a symlink");
|
||||
await ensurePrivateDirectory(this.root);
|
||||
await assertNoSymlinkComponents(this.root);
|
||||
const canonical = await realpath(this.root);
|
||||
if (canonical !== this.root) throw new Error("artifact root may not resolve through a symlink");
|
||||
}
|
||||
|
||||
async open(owner: PrivateArtifactOwner, kind: PrivateArtifactKind): Promise<PrivateArtifactWriter> {
|
||||
if (this.closed) throw new Error("artifact store is closed");
|
||||
const checkedOwner = validateOwner(owner);
|
||||
const checkedKind = validateKind(kind);
|
||||
await this.ready;
|
||||
if (this.closed) throw new Error("artifact store is closed");
|
||||
const sessionDir = join(this.root, encodedComponent(checkedOwner.sessionId));
|
||||
const runDir = join(sessionDir, encodedComponent(checkedOwner.runId));
|
||||
await ensurePrivateDirectory(sessionDir);
|
||||
await ensurePrivateDirectory(runDir);
|
||||
const id = artifactId();
|
||||
const extension = checkedKind === "screenshot" ? "png" : "json";
|
||||
const finalPath = join(runDir, `${id}.${extension}`);
|
||||
const tempPath = join(runDir, `.${id}.tmp`);
|
||||
if (!isWithin(this.root, finalPath) || !isWithin(this.root, tempPath)) throw new Error("artifact path escaped private root");
|
||||
// wx + O_NOFOLLOW ensures a malicious replacement cannot redirect writes.
|
||||
const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0);
|
||||
const handle = await open(tempPath, flags, FILE_MODE).catch((error) => {
|
||||
throw new Error("could not create private artifact", { cause: error });
|
||||
});
|
||||
await handle.chmod(FILE_MODE);
|
||||
const kindLimit = checkedKind === "screenshot" ? this.maxBytes : Math.min(this.maxBytes, FAST_BROWSER_ARTIFACT_LIMITS.diagnosticsBytes);
|
||||
const writer = new FileArtifactWriter(checkedOwner, checkedKind, handle, tempPath, finalPath, id, kindLimit, () => this.active.delete(writer));
|
||||
this.active.add(writer);
|
||||
return writer;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
await this.ready.catch(() => undefined);
|
||||
await Promise.all([...this.active].map((writer) => writer.abort()));
|
||||
}
|
||||
}
|
||||
|
||||
export function createPrivateArtifactStore(options: PrivateArtifactStoreOptions | string): PrivateArtifactStore {
|
||||
return new FilePrivateArtifactStore(options);
|
||||
}
|
||||
|
||||
// A descriptive alias keeps the concrete implementation discoverable without
|
||||
// widening the frozen contract surface.
|
||||
export const createArtifactStore = createPrivateArtifactStore;
|
||||
Reference in New Issue
Block a user