Claude 5362b4a796
fix(media): stream large media between the store and local computers
Attachments copied into a local computer's workspace were read whole from
the artifact store first, so a 500 MiB video sat in memory before it was
written to disk. attach_file did the same in the other direction: a large
file on a local computer was read whole before it was stored.

SandboxProvider gains optional readFileStream/writeFileStream. The desktop
(host workspace) provider implements them with the same path confinement
as its byte methods, one chunk in memory at a time, and removes a file
whose write fails or is aborted, including one that replaced an earlier
copy. The host-aware wrapper routes them, and providers without them
(remote computers, still capped at 12 MiB) receive the collected bytes.
Current-turn staging and attach_file now go through these helpers, and
unread streams are cancelled so the stored file is released. Media job
inputs stop copying when the job is cancelled.

The stream helpers move from the local artifact store into a shared
module so both use one implementation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:17:54 +00:00

173 lines
5.2 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { lstat, mkdir, open, rm } from "node:fs/promises";
import path from "node:path";
import type {
AdapterContext,
ArtifactByteRange,
ArtifactPut,
ArtifactStore,
ArtifactStreamPut,
NotificationMessage,
NotificationProvider,
StoredArtifactStream,
} from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { byteChunks, fileByteStream, writeFully } from "./byte-streams.js";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
/** Space ids and artifact keys are generated ids; anything else could address another path. */
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
export class LocalArtifactStore implements ArtifactStore {
constructor(private readonly root: string) {}
describe() {
return {
id: "local-artifacts",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { stream: true },
};
}
private spaceDir(context: AdapterContext) {
if (!SAFE_SEGMENT.test(context.spaceId)) throw new Error("Invalid artifact space");
return path.join(this.root, "artifacts", context.spaceId);
}
private file(key: string, context: AdapterContext) {
if (!SAFE_SEGMENT.test(key)) throw new Error("Invalid artifact key");
return path.join(this.spaceDir(context), key);
}
/** Creates a new owner-only file under a fresh key; never follows or replaces an existing path. */
private async create(context: AdapterContext) {
const dir = this.spaceDir(context);
await mkdir(dir, { recursive: true });
const key = randomUUID();
const file = path.join(dir, key);
const handle = await open(
file,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW,
0o600,
);
return { key, file, handle };
}
private openForRead(key: string, context: AdapterContext) {
return open(this.file(key, context), constants.O_RDONLY | O_NOFOLLOW);
}
async put(artifact: ArtifactPut, context: AdapterContext) {
const { key, handle } = await this.create(context);
try {
await handle.writeFile(artifact.bytes);
} finally {
await handle.close();
}
return { id: key, hash: String(artifact.bytes.byteLength) };
}
async putStream(
artifact: ArtifactStreamPut,
context: AdapterContext,
): Promise<StoredArtifactStream> {
if (!Number.isSafeInteger(artifact.maxBytes) || artifact.maxBytes < 0) {
throw new Error("maxBytes must be a non-negative integer");
}
const { key, file, handle } = await this.create(context);
const hash = createHash("sha256");
let size = 0;
try {
try {
for await (const chunk of byteChunks(artifact.stream)) {
context.signal.throwIfAborted();
size += chunk.byteLength;
if (size > artifact.maxBytes) throw new ArtifactTooLargeError(artifact.maxBytes);
hash.update(chunk);
await writeFully(handle, chunk);
}
} finally {
await handle.close();
}
} catch (error) {
await rm(file, { force: true }).catch(() => undefined);
throw error;
}
return { key, size, sha256: hash.digest("hex") };
}
async get(id: string, context: AdapterContext) {
const handle = await this.openForRead(id, context);
try {
return new Uint8Array(await handle.readFile());
} finally {
await handle.close();
}
}
async stat(key: string, context: AdapterContext) {
const handle = await this.openForRead(key, context);
try {
const info = await handle.stat();
if (!info.isFile()) throw new Error("Artifact is not a file");
return { size: info.size };
} finally {
await handle.close();
}
}
async createReadStream(key: string, context: AdapterContext, range?: ArtifactByteRange) {
const handle = await this.openForRead(key, context);
try {
const info = await handle.stat();
if (!info.isFile()) throw new Error("Artifact is not a file");
const start = range?.start ?? 0;
const end = range?.end ?? info.size - 1;
if (
range &&
(!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
end < start ||
end >= info.size)
) {
throw new RangeError("Range does not fit the artifact");
}
return fileByteStream(handle, start, end);
} catch (error) {
await handle.close();
throw error;
}
}
async localPath(key: string, context: AdapterContext) {
const file = this.file(key, context);
const info = await lstat(file);
if (!info.isFile()) throw new Error("Artifact is not a file");
return file;
}
async remove(id: string, context: AdapterContext) {
await rm(this.file(id, context), { force: true });
}
}
export class CapturingNotificationProvider implements NotificationProvider {
readonly sent: NotificationMessage[] = [];
describe() {
return {
id: "capturing",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { push: false, email: false },
};
}
async send(message: NotificationMessage, _context: AdapterContext): Promise<void> {
this.sent.push(message);
}
}