From 1e3e5c9557ca4e07d8058410e57eddcea6b0339c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:10:23 +0000 Subject: [PATCH 01/17] workspace: add Crockford base32 helper for asset key ids The assets module needs a short, URL-safe token to make every share unique. A hyphenated UUID is 36 characters; Crockford base32 over the same 16 random bytes is 26 lowercase characters with no hyphens and no visually ambiguous letters. Add encodeBase32 / decodeBase32 and a randomId helper that pulls 16 bytes from crypto.getRandomValues. The random source is injectable so tests can pin the output. Decoding folds the ambiguous letters back so a transcribed token still resolves. --- packages/workspace/src/assets/base32.test.ts | 72 ++++++++++++++++ packages/workspace/src/assets/base32.ts | 87 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 packages/workspace/src/assets/base32.test.ts create mode 100644 packages/workspace/src/assets/base32.ts diff --git a/packages/workspace/src/assets/base32.test.ts b/packages/workspace/src/assets/base32.test.ts new file mode 100644 index 00000000..0954f1f8 --- /dev/null +++ b/packages/workspace/src/assets/base32.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { decodeBase32, encodeBase32, randomId } from "./base32.js"; + +describe("encodeBase32", () => { + it("encodes the empty input to the empty string", () => { + expect(encodeBase32(new Uint8Array([]))).toBe(""); + }); + + it("encodes one byte to two lowercase characters", () => { + // 0x00 → 00000 000(00) → "00" + expect(encodeBase32(new Uint8Array([0x00]))).toBe("00"); + // 0xff → 11111 111(00) → 0x1f, 0x1c → "zw" + expect(encodeBase32(new Uint8Array([0xff]))).toBe("zw"); + }); + + it("uses only lowercase Crockford alphabet characters", () => { + const bytes = new Uint8Array(16); + for (let i = 0; i < bytes.length; i++) bytes[i] = i * 17; + const encoded = encodeBase32(bytes); + expect(encoded).toMatch(/^[0-9abcdefghjkmnpqrstvwxyz]+$/); + expect(encoded).not.toMatch(/[ilou]/); + }); + + it("encodes 16 bytes to 26 characters", () => { + expect(encodeBase32(new Uint8Array(16)).length).toBe(26); + }); +}); + +describe("decodeBase32", () => { + it("round-trips arbitrary byte vectors", () => { + for (const len of [1, 5, 10, 16, 20]) { + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = (i * 31 + 7) & 0xff; + expect(decodeBase32(encodeBase32(bytes))).toEqual(bytes); + } + }); + + it("folds ambiguous letters: I/L → 1, O → 0", () => { + expect(decodeBase32("O0")).toEqual(decodeBase32("00")); + expect(decodeBase32("I1")).toEqual(decodeBase32("11")); + expect(decodeBase32("L1")).toEqual(decodeBase32("11")); + }); + + it("accepts uppercase input", () => { + const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const lower = encodeBase32(bytes); + expect(decodeBase32(lower.toUpperCase())).toEqual(bytes); + }); + + it("throws on a character outside the alphabet", () => { + expect(() => decodeBase32("!!")).toThrow(/invalid character/); + }); +}); + +describe("randomId", () => { + it("produces a 26-character lowercase token", () => { + const id = randomId(); + expect(id).toMatch(/^[0-9abcdefghjkmnpqrstvwxyz]{26}$/); + }); + + it("is unique across calls", () => { + const ids = new Set(); + for (let i = 0; i < 1000; i++) ids.add(randomId()); + expect(ids.size).toBe(1000); + }); + + it("encodes exactly the bytes from the injected source", () => { + const fixed = new Uint8Array(16).fill(0); + expect(randomId(() => fixed)).toBe("00000000000000000000000000"); + }); +}); diff --git a/packages/workspace/src/assets/base32.ts b/packages/workspace/src/assets/base32.ts new file mode 100644 index 00000000..892dd51f --- /dev/null +++ b/packages/workspace/src/assets/base32.ts @@ -0,0 +1,87 @@ +// Crockford base32 encoding. +// +// Used to turn the 16 random bytes behind a UUID into a short, +// URL-safe token for asset keys: ~26 lowercase characters with no +// hyphens, versus the 36-char hyphenated UUID string. +// +// The alphabet is Crockford's: digits 0-9 then the consonants and +// vowels of the Latin alphabet with I, L, O, and U removed to avoid +// visual ambiguity. We emit lowercase. Decoding accepts either +// case and maps the ambiguous letters back (I/L → 1, O → 0) so a +// round trip survives a human transcribing the token. +// +// This is a plain big-endian base32: bytes are treated as one +// continuous bit string, most significant bit first, sliced into +// 5-bit groups. No checksum, no padding. + +const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; + +// Reverse lookup for decode. Built once. Includes the lowercase +// alphabet, its uppercase form, and the ambiguous-letter aliases. +const DECODE = new Map(); +for (let i = 0; i < ALPHABET.length; i++) { + DECODE.set(ALPHABET[i], i); + DECODE.set(ALPHABET[i].toUpperCase(), i); +} +DECODE.set("o", 0); +DECODE.set("O", 0); +DECODE.set("i", 1); +DECODE.set("I", 1); +DECODE.set("l", 1); +DECODE.set("L", 1); + +// Encode bytes as Crockford base32. The output length is +// ceil(bytes.length * 8 / 5) characters. +export function encodeBase32(bytes: Uint8Array): string { + let out = ""; + let bits = 0; + let value = 0; + for (let i = 0; i < bytes.length; i++) { + value = (value << 8) | bytes[i]; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += ALPHABET[(value >>> bits) & 0x1f]; + } + } + if (bits > 0) { + out += ALPHABET[(value << (5 - bits)) & 0x1f]; + } + return out; +} + +// Decode a Crockford base32 string back to bytes. Throws on any +// character outside the alphabet (after alias folding). The number +// of bytes recovered is floor(input.length * 5 / 8); trailing bits +// that don't fill a byte are discarded, matching the encoder's +// zero-padding of the final group. +export function decodeBase32(text: string): Uint8Array { + const out: number[] = []; + let bits = 0; + let value = 0; + for (const char of text) { + const digit = DECODE.get(char); + if (digit === undefined) { + throw new Error(`decodeBase32: invalid character ${JSON.stringify(char)}`); + } + value = (value << 5) | digit; + bits += 5; + if (bits >= 8) { + bits -= 8; + out.push((value >>> bits) & 0xff); + } + } + return new Uint8Array(out); +} + +// Generate a short random id: 16 random bytes (a UUID's worth of +// entropy) Crockford base32-encoded into 26 lowercase characters. +export function randomId(randomBytes: (n: number) => Uint8Array = defaultRandomBytes): string { + return encodeBase32(randomBytes(16)); +} + +function defaultRandomBytes(n: number): Uint8Array { + const bytes = new Uint8Array(n); + crypto.getRandomValues(bytes); + return bytes; +} From b3551d41751cc81a29f81f028976b3f0fcc5b43e Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:11:16 +0000 Subject: [PATCH 02/17] workspace: add extension to content-type lookup for assets Shared assets need a Content-Type so a browser opening the presigned URL renders them correctly. A full mime database is overkill for the file kinds an agent shares, so ship a small table covering images, text, common documents, archives, and media, with an application/octet-stream fallback. The lookup keys off the basename's extension and is case-insensitive. Dotfiles and extensionless paths take the fallback. --- packages/workspace/src/assets/mime.test.ts | 38 ++++++++++++++ packages/workspace/src/assets/mime.ts | 60 ++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 packages/workspace/src/assets/mime.test.ts create mode 100644 packages/workspace/src/assets/mime.ts diff --git a/packages/workspace/src/assets/mime.test.ts b/packages/workspace/src/assets/mime.test.ts new file mode 100644 index 00000000..641c2f81 --- /dev/null +++ b/packages/workspace/src/assets/mime.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { contentTypeForPath, DEFAULT_CONTENT_TYPE } from "./mime.js"; + +describe("contentTypeForPath", () => { + it("maps known image extensions", () => { + expect(contentTypeForPath("/workspace/a/image.png")).toBe("image/png"); + expect(contentTypeForPath("photo.JPG")).toBe("image/jpeg"); + expect(contentTypeForPath("diagram.svg")).toBe("image/svg+xml"); + }); + + it("maps known text and data extensions", () => { + expect(contentTypeForPath("notes.txt")).toBe("text/plain"); + expect(contentTypeForPath("README.md")).toBe("text/markdown"); + expect(contentTypeForPath("data.json")).toBe("application/json"); + }); + + it("is case-insensitive on the extension", () => { + expect(contentTypeForPath("ARCHIVE.ZIP")).toBe("application/zip"); + }); + + it("falls back for an unknown extension", () => { + expect(contentTypeForPath("mystery.qqq")).toBe(DEFAULT_CONTENT_TYPE); + }); + + it("falls back when there is no extension", () => { + expect(contentTypeForPath("/workspace/Makefile")).toBe(DEFAULT_CONTENT_TYPE); + expect(contentTypeForPath("noext")).toBe(DEFAULT_CONTENT_TYPE); + }); + + it("treats a leading-dot dotfile as having no extension", () => { + expect(contentTypeForPath("/workspace/.gitignore")).toBe(DEFAULT_CONTENT_TYPE); + }); + + it("uses the basename, ignoring dots in parent directories", () => { + expect(contentTypeForPath("/workspace/v1.2/file.png")).toBe("image/png"); + }); +}); diff --git a/packages/workspace/src/assets/mime.ts b/packages/workspace/src/assets/mime.ts new file mode 100644 index 00000000..ef2983f5 --- /dev/null +++ b/packages/workspace/src/assets/mime.ts @@ -0,0 +1,60 @@ +// Minimal extension → content-type lookup. +// +// Just enough to set a sensible Content-Type on shared assets +// without pulling a multi-hundred-entry mime database into the +// bundle. Covers the file kinds an agent is likely to share — +// images, text, common documents, archives — and falls back to +// application/octet-stream for anything unrecognised. +// +// Callers that know better pass ShareOptions.contentType, which +// always wins over this table. + +const TYPES: Record = { + // images + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + avif: "image/avif", + bmp: "image/bmp", + ico: "image/x-icon", + // text & code + txt: "text/plain", + md: "text/markdown", + csv: "text/csv", + html: "text/html", + htm: "text/html", + css: "text/css", + js: "text/javascript", + mjs: "text/javascript", + json: "application/json", + xml: "application/xml", + yaml: "application/yaml", + yml: "application/yaml", + // documents + pdf: "application/pdf", + // archives + zip: "application/zip", + gz: "application/gzip", + tar: "application/x-tar", + // media + mp4: "video/mp4", + webm: "video/webm", + mp3: "audio/mpeg", + wav: "audio/wav", +}; + +export const DEFAULT_CONTENT_TYPE = "application/octet-stream"; + +// Infer a content type from a path or filename's extension. The +// match is case-insensitive. Returns DEFAULT_CONTENT_TYPE when the +// path has no extension or the extension isn't in the table. +export function contentTypeForPath(path: string): string { + const base = path.slice(path.lastIndexOf("/") + 1); + const dot = base.lastIndexOf("."); + if (dot <= 0) return DEFAULT_CONTENT_TYPE; + const ext = base.slice(dot + 1).toLowerCase(); + return TYPES[ext] ?? DEFAULT_CONTENT_TYPE; +} From 70b91a81495cea4a15eb7258efe7ca1e0ca6e86e Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:14:24 +0000 Subject: [PATCH 03/17] workspace: add a streaming SigV4 query-string signer Presigning an R2 GET URL is a handful of HMAC-SHA256 steps over deterministic strings, so implement SigV4 in house rather than pull a dependency. The presigned-GET path uses UNSIGNED-PAYLOAD, so the URL is a pure function of the inputs and the clock and no request body is read. Keep a sha256HexStream helper that hashes a ReadableStream incrementally in constant space, for the future signed-upload path a buffering library can't serve. Tests validate the signing-key derivation and empty-string digest against AWS's published vectors. --- packages/workspace/src/assets/sigv4.test.ts | 123 ++++++++++++++ packages/workspace/src/assets/sigv4.ts | 171 ++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 packages/workspace/src/assets/sigv4.test.ts create mode 100644 packages/workspace/src/assets/sigv4.ts diff --git a/packages/workspace/src/assets/sigv4.test.ts b/packages/workspace/src/assets/sigv4.test.ts new file mode 100644 index 00000000..4e11b090 --- /dev/null +++ b/packages/workspace/src/assets/sigv4.test.ts @@ -0,0 +1,123 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { presignUrl, sha256Hex, sha256HexStream, signingKey } from "./sigv4.js"; + +function toHex(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +} + +describe("signingKey", () => { + // AWS-published derivation vector: + // https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + // secret "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", date + // 20150830, region us-east-1, service iam. + it("matches the AWS documentation derivation vector", () => { + const key = signingKey( + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "20150830", + "us-east-1", + "iam", + ); + expect(toHex(key)).toBe("c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9"); + }); +}); + +describe("sha256Hex", () => { + it("hashes the empty string to the known SHA-256 constant", () => { + expect(sha256Hex("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + }); +}); + +describe("presignUrl", () => { + const base = { + endpoint: "https://acct123.r2.cloudflarestorage.com", + bucket: "assets", + key: "agent-x/abc/image.png", + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + expiresIn: 30, + now: () => Date.UTC(2026, 0, 2, 3, 4, 5), + }; + + it("targets the right host, bucket, and key path", () => { + const url = new URL(presignUrl(base)); + expect(url.host).toBe("acct123.r2.cloudflarestorage.com"); + expect(url.pathname).toBe("/assets/agent-x/abc/image.png"); + }); + + it("includes the required SigV4 query parameters", () => { + const url = new URL(presignUrl(base)); + expect(url.searchParams.get("X-Amz-Algorithm")).toBe("AWS4-HMAC-SHA256"); + expect(url.searchParams.get("X-Amz-Expires")).toBe("30"); + expect(url.searchParams.get("X-Amz-SignedHeaders")).toBe("host"); + expect(url.searchParams.get("X-Amz-Date")).toBe("20260102T030405Z"); + expect(url.searchParams.get("X-Amz-Signature")).toMatch(/^[0-9a-f]{64}$/); + const cred = url.searchParams.get("X-Amz-Credential"); + expect(cred).toBe("AKIDEXAMPLE/20260102/auto/s3/aws4_request"); + }); + + it("is deterministic for a fixed clock and inputs", () => { + expect(presignUrl(base)).toBe(presignUrl(base)); + }); + + it("changes the signature when the clock advances", () => { + const later = { ...base, now: () => base.now() + 86_400_000 }; + const sigA = new URL(presignUrl(base)).searchParams.get("X-Amz-Signature"); + const sigB = new URL(presignUrl(later)).searchParams.get("X-Amz-Signature"); + expect(sigA).not.toBe(sigB); + }); + + it("changes the signature when the key changes", () => { + const other = { ...base, key: "agent-x/def/image.png" }; + const sigA = new URL(presignUrl(base)).searchParams.get("X-Amz-Signature"); + const sigB = new URL(presignUrl(other)).searchParams.get("X-Amz-Signature"); + expect(sigA).not.toBe(sigB); + }); + + it("percent-encodes reserved characters in the key but keeps slashes", () => { + // The slashes between key segments stay literal; the space and + // plus inside segments are percent-encoded. + expect(presignUrl({ ...base, key: "a b/c+d/e.png" })).toContain("/assets/a%20b/c%2Bd/e.png"); + }); +}); + +describe("sha256HexStream", () => { + function streamOf(...chunks: Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(c) { + for (const chunk of chunks) c.enqueue(chunk); + c.close(); + }, + }); + } + + it("matches a one-shot hash of the concatenated chunks", async () => { + const a = new Uint8Array([1, 2, 3, 4, 5]); + const b = new Uint8Array([6, 7, 8]); + const c = new Uint8Array([9]); + const joined = new Uint8Array([...a, ...b, ...c]); + const oneShot = createHash("sha256").update(joined).digest("hex"); + expect(await sha256HexStream(streamOf(a, b, c))).toBe(oneShot); + }); + + it("hashes an empty stream to the SHA-256 of empty input", async () => { + expect(await sha256HexStream(streamOf())).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); + + it("handles a large multi-chunk stream without buffering", async () => { + const chunks: Uint8Array[] = []; + const hash = createHash("sha256"); + for (let i = 0; i < 256; i++) { + const chunk = new Uint8Array(64 * 1024).fill(i & 0xff); + chunks.push(chunk); + hash.update(chunk); + } + expect(await sha256HexStream(streamOf(...chunks))).toBe(hash.digest("hex")); + }); +}); diff --git a/packages/workspace/src/assets/sigv4.ts b/packages/workspace/src/assets/sigv4.ts new file mode 100644 index 00000000..f9591325 --- /dev/null +++ b/packages/workspace/src/assets/sigv4.ts @@ -0,0 +1,171 @@ +// Minimal AWS Signature Version 4, query-string ("presigned URL") +// flavour, scoped to what R2's S3-compatible endpoint needs. +// +// We don't pull a dependency for this. The whole algorithm is a +// handful of HMAC-SHA256 steps over deterministic strings, and the +// R2 surface only needs the presigned-GET path. Rolling it here +// also lets us keep a streaming payload-hash helper that hashes a +// ReadableStream incrementally — the piece a buffering library +// like aws4fetch can't give us — for the day we sign upload +// payloads directly. +// +// Reference: +// https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + +import { createHash, createHmac } from "node:crypto"; + +const ALGORITHM = "AWS4-HMAC-SHA256"; + +// RFC 3986 unreserved set; everything else is percent-encoded. +// encodeURIComponent leaves !*'()~ unescaped and escapes ~, so we +// fix up both directions by hand to match AWS's expectations. +function uriEncode(input: string, encodeSlash: boolean): string { + let out = ""; + for (const ch of input) { + if (/[A-Za-z0-9\-._~]/.test(ch)) { + out += ch; + } else if (ch === "/" && !encodeSlash) { + out += ch; + } else { + const bytes = new TextEncoder().encode(ch); + for (const b of bytes) out += `%${b.toString(16).toUpperCase().padStart(2, "0")}`; + } + } + return out; +} + +// Exported for tests, which validate it against AWS's published +// SigV4 vectors. Not part of the package's public surface. +export function sha256Hex(data: string | Uint8Array): string { + return createHash("sha256").update(data).digest("hex"); +} + +function hmac(key: Uint8Array | string, data: string): Uint8Array { + return new Uint8Array(createHmac("sha256", key).update(data).digest()); +} + +// yyyymmddThhmmssZ (basic ISO 8601, no separators) and the yyyymmdd +// date stamp used in the credential scope. +function formatAmzDate(epochMs: number): { amzDate: string; dateStamp: string } { + const d = new Date(epochMs); + const pad = (n: number) => String(n).padStart(2, "0"); + const yyyy = d.getUTCFullYear(); + const mm = pad(d.getUTCMonth() + 1); + const dd = pad(d.getUTCDate()); + const hh = pad(d.getUTCHours()); + const mi = pad(d.getUTCMinutes()); + const ss = pad(d.getUTCSeconds()); + return { + amzDate: `${yyyy}${mm}${dd}T${hh}${mi}${ss}Z`, + dateStamp: `${yyyy}${mm}${dd}`, + }; +} + +// Exported for tests against AWS's published signing-key vector. +// Not part of the package's public surface. +export function signingKey( + secretAccessKey: string, + dateStamp: string, + region: string, + service: string, +): Uint8Array { + const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, service); + return hmac(kService, "aws4_request"); +} + +export interface PresignOptions { + // Bucket endpoint origin, e.g. + // https://.r2.cloudflarestorage.com — no trailing + // slash, no bucket. + endpoint: string; + bucket: string; + // Object key. May contain slashes; each segment is encoded but + // the slashes are preserved in the path. + key: string; + accessKeyId: string; + secretAccessKey: string; + // Lifetime of the URL in seconds. AWS caps presigned URLs at + // 7 days (604800). + expiresIn: number; + method?: string; // default GET + region?: string; // default "auto" (R2) + service?: string; // default "s3" + now?: () => number; // injectable clock; default Date.now +} + +// Build a presigned URL. For GET the payload is unsigned +// (UNSIGNED-PAYLOAD), so no request body is read or hashed: the URL +// is a pure function of the inputs and the clock. +export function presignUrl(options: PresignOptions): string { + const method = options.method ?? "GET"; + const region = options.region ?? "auto"; + const service = options.service ?? "s3"; + const now = options.now ?? Date.now; + + const url = new URL(options.endpoint); + const host = url.host; + const canonicalUri = `/${uriEncode(options.bucket, true)}/${uriEncode(options.key, false)}`; + + const { amzDate, dateStamp } = formatAmzDate(now()); + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + const credential = `${options.accessKeyId}/${credentialScope}`; + + // Query parameters that take part in the signature, sorted by + // key. Values are URI-encoded; the credential's slashes are + // encoded too (encodeSlash=true) because it lives in a query + // value. + const params: Array<[string, string]> = [ + ["X-Amz-Algorithm", ALGORITHM], + ["X-Amz-Credential", credential], + ["X-Amz-Date", amzDate], + ["X-Amz-Expires", String(options.expiresIn)], + ["X-Amz-SignedHeaders", "host"], + ]; + params.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + const canonicalQuery = params + .map(([k, v]) => `${uriEncode(k, true)}=${uriEncode(v, true)}`) + .join("&"); + + const canonicalHeaders = `host:${host}\n`; + const signedHeaders = "host"; + const payloadHash = "UNSIGNED-PAYLOAD"; + + const canonicalRequest = [ + method, + canonicalUri, + canonicalQuery, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join("\n"); + + const stringToSign = [ALGORITHM, amzDate, credentialScope, sha256Hex(canonicalRequest)].join( + "\n", + ); + + const key = signingKey(options.secretAccessKey, dateStamp, region, service); + const signature = createHmac("sha256", key).update(stringToSign).digest("hex"); + + return `${url.origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`; +} + +// Incremental SHA-256 over a ReadableStream. Never holds more than +// one chunk in memory, so a multi-gigabyte body hashes in constant +// space. Returned lowercase hex. Kept for the future signed-upload +// path; the presigned GET above does not call it. +export async function sha256HexStream(stream: ReadableStream): Promise { + const hash = createHash("sha256"); + const reader = stream.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) hash.update(value); + } + } finally { + reader.releaseLock(); + } + return hash.digest("hex"); +} From f4dda88ac017afe54482ab9ecf41879a8ad3e13f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:23:48 +0000 Subject: [PATCH 04/17] workspace: expose sessionId on the Workspace facade The constructor already accepts sessionId and forwards it to mount factories, but never surfaced it. The assets module tags each shared object with the originating session, so expose a read-only getter that returns the configured id (empty string when unset). --- packages/workspace/src/workspace.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/workspace/src/workspace.ts b/packages/workspace/src/workspace.ts index 3ee2aaf1..bdabb067 100644 --- a/packages/workspace/src/workspace.ts +++ b/packages/workspace/src/workspace.ts @@ -86,6 +86,7 @@ export class Workspace { readonly #defaultBackendId: string | undefined; readonly #observer: WorkspaceObserver; readonly #now: () => number; + readonly #sessionId: string; readonly #defaultGitIdentity: GitIdentity | undefined; // Lazily-constructed git client, cached so the dynamic // imports of isomorphic-git / diff land once per Workspace. @@ -114,6 +115,7 @@ export class Workspace { constructor(options: WorkspaceOptions) { this.#now = options.now ?? Date.now; + this.#sessionId = options.sessionId ?? ""; this.#defaultGitIdentity = options.defaultGitIdentity; this.#db = new Database(options.storage); initializeSchema(this.#db, this.#now); @@ -190,6 +192,14 @@ export class Workspace { return this.#fs; } + // Identifier for this workspace / session, as passed to the + // constructor. Empty string when the caller did not supply one. + // Forwarded to mount factories and used by the assets module to + // tag shared objects with their originating session. + get sessionId(): string { + return this.#sessionId; + } + // Git facade. Available immediately and does not require a // backend — every supported subcommand reads and writes through // the local SQLite-backed VFS. The dynamic imports for From a583c139efbe77e067c6ccb43bb7447e7ee36ef1 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:24:04 +0000 Subject: [PATCH 05/17] workspace: add assets module with createAssets and share createAssets({ ws, bucket, s3 }) binds a workspace and an R2 bucket and returns a client whose share(path, opts) uploads a VFS file to R2 and returns a presigned GET URL. The object key is prefix/id/basename: a fresh base32 id per share, so the same file shared twice yields two distinct keys, and only the basename lands in the key so the full VFS path is never exposed. The body streams straight from ws.fs.readFile into bucket.put without buffering. Content-Type is inferred from the extension, the Content-Disposition carries the filename, and custom metadata records the source path, session id, and expiry. Credentials and the account-derived endpoint resolve from the standard R2 / Cloudflare env vars when not passed explicitly. The tsconfig gains the node types entry dofs already carries, matching the node:crypto already externalised in the rolldown config. --- packages/workspace/src/assets/index.test.ts | 226 +++++++++++++++++++ packages/workspace/src/assets/index.ts | 208 +++++++++++++++++ packages/workspace/src/assets/upload.test.ts | 72 ++++++ packages/workspace/src/assets/upload.ts | 88 ++++++++ packages/workspace/tsconfig.json | 2 +- 5 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 packages/workspace/src/assets/index.test.ts create mode 100644 packages/workspace/src/assets/index.ts create mode 100644 packages/workspace/src/assets/upload.test.ts create mode 100644 packages/workspace/src/assets/upload.ts diff --git a/packages/workspace/src/assets/index.test.ts b/packages/workspace/src/assets/index.test.ts new file mode 100644 index 00000000..fdb77e39 --- /dev/null +++ b/packages/workspace/src/assets/index.test.ts @@ -0,0 +1,226 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; + +import { Workspace } from "../workspace.js"; +import { createAssets, resolveS3 } from "./index.js"; + +// Captures every put() so tests can assert on the key, the bytes +// streamed in, and the metadata — without a real R2. +interface CapturedPut { + key: string; + bytes: Uint8Array; + isStream: boolean; + httpMetadata?: { contentType?: string; contentDisposition?: string }; + customMetadata?: Record; +} + +function fakeBucket(): { bucket: { put: unknown }; puts: CapturedPut[] } { + const puts: CapturedPut[] = []; + const bucket = { + async put( + key: string, + value: ReadableStream, + options?: { + httpMetadata?: { contentType?: string; contentDisposition?: string }; + customMetadata?: Record; + }, + ) { + const isStream = value instanceof ReadableStream; + const reader = value.getReader(); + const parts: Uint8Array[] = []; + while (true) { + const { value: chunk, done } = await reader.read(); + if (done) break; + if (chunk) parts.push(chunk); + } + reader.releaseLock(); + let len = 0; + for (const p of parts) len += p.byteLength; + const bytes = new Uint8Array(len); + let off = 0; + for (const p of parts) { + bytes.set(p, off); + off += p.byteLength; + } + puts.push({ + key, + bytes, + isStream, + httpMetadata: options?.httpMetadata, + customMetadata: options?.customMetadata, + }); + return {}; + }, + }; + return { bucket, puts }; +} + +const s3 = { + bucket: "assets", + accountId: "acct123", + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", +}; + +function makeWorkspace(sessionId = "sess-1"): Workspace { + return new Workspace({ storage: new SQLiteTestStorage(), sessionId }); +} + +// writeFile requires the parent directory to exist; create it then +// write the bytes. +async function writeAt(ws: Workspace, path: string, bytes: Uint8Array): Promise { + const dir = path.slice(0, path.lastIndexOf("/")); + if (dir.length > 0) await ws.fs.mkdir(dir, { recursive: true }); + await ws.fs.writeFile(path, bytes); +} + +const fixedClock = () => Date.UTC(2026, 0, 2, 3, 4, 5); + +describe("createAssets.share", () => { + it("uploads the VFS file's bytes as a stream", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/out/image.png", new TextEncoder().encode("PNGDATA")); + const { bucket, puts } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await assets.share("/workspace/out/image.png", { expiresAfter: 30_000 }); + + expect(puts).toHaveLength(1); + expect(puts[0].isStream).toBe(true); + expect(new TextDecoder().decode(puts[0].bytes)).toBe("PNGDATA"); + }); + + it("builds a key of prefix/id/basename without the full path", async () => { + const ws = makeWorkspace("sess-9"); + await writeAt(ws, "/workspace/deep/nested/photo.jpg", new Uint8Array([1, 2, 3])); + const { bucket, puts } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await assets.share("/workspace/deep/nested/photo.jpg", { + expiresAfter: 30_000, + prefix: "/agent-sess-9/", + }); + + expect(puts[0].key).toMatch(/^agent-sess-9\/[0-9a-z]{26}\/photo\.jpg$/); + expect(puts[0].key).not.toContain("deep"); + expect(puts[0].key).not.toContain("nested"); + }); + + it("produces a unique key each time the same file is shared", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/a.png", new Uint8Array([0])); + const { bucket, puts } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await assets.share("/workspace/a.png", { expiresAfter: 30_000 }); + await assets.share("/workspace/a.png", { expiresAfter: 30_000 }); + + expect(puts[0].key).not.toBe(puts[1].key); + }); + + it("sets content type, disposition, and custom metadata", async () => { + const ws = makeWorkspace("sess-meta"); + await writeAt(ws, "/workspace/out/image.png", new Uint8Array([1])); + const { bucket, puts } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await assets.share("/workspace/out/image.png", { expiresAfter: 30_000 }); + + expect(puts[0].httpMetadata?.contentType).toBe("image/png"); + expect(puts[0].httpMetadata?.contentDisposition).toBe('inline; filename="image.png"'); + expect(puts[0].customMetadata?.sourcePath).toBe("/workspace/out/image.png"); + expect(puts[0].customMetadata?.sessionId).toBe("sess-meta"); + // 30s after the fixed clock. + expect(puts[0].customMetadata?.expiresAt).toBe("2026-01-02T03:04:35.000Z"); + }); + + it("honours contentType, filename, and disposition overrides", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/data.bin", new Uint8Array([1])); + const { bucket, puts } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await assets.share("/workspace/data.bin", { + expiresAfter: 30_000, + contentType: "image/png", + filename: "renamed.png", + disposition: "attachment", + }); + + expect(puts[0].httpMetadata?.contentType).toBe("image/png"); + expect(puts[0].httpMetadata?.contentDisposition).toBe('attachment; filename="renamed.png"'); + }); + + it("returns a presigned URL pointing at the uploaded key", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/out/image.png", new Uint8Array([1])); + const { bucket, puts } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + const url = await assets.share("/workspace/out/image.png", { expiresAfter: 30_000 }); + const parsed = new URL(url); + + expect(parsed.host).toBe("acct123.r2.cloudflarestorage.com"); + expect(parsed.pathname).toBe(`/assets/${puts[0].key}`); + expect(parsed.searchParams.get("X-Amz-Expires")).toBe("30"); + expect(parsed.searchParams.get("X-Amz-Signature")).toMatch(/^[0-9a-f]{64}$/); + }); + + it("rounds a sub-second expiry up to one second", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/a.png", new Uint8Array([1])); + const { bucket } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + const url = await assets.share("/workspace/a.png", { expiresAfter: 500 }); + expect(new URL(url).searchParams.get("X-Amz-Expires")).toBe("1"); + }); + + it("rejects a non-positive expiry", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/a.png", new Uint8Array([1])); + const { bucket } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await expect(assets.share("/workspace/a.png", { expiresAfter: 0 })).rejects.toThrow( + /expiresAfter/, + ); + }); +}); + +describe("resolveS3", () => { + it("derives credentials, account, and endpoint from env", () => { + const resolved = resolveS3( + { bucket: "b" }, + { + CLOUDFLARE_ACCOUNT_ID: "acct999", + R2_ACCESS_KEY_ID: "AKID", + R2_SECRET_ACCESS_KEY: "SECRET", + }, + ); + expect(resolved.accessKeyId).toBe("AKID"); + expect(resolved.secretAccessKey).toBe("SECRET"); + expect(resolved.endpoint).toBe("https://acct999.r2.cloudflarestorage.com"); + }); + + it("falls back to AWS_* credential vars", () => { + const resolved = resolveS3( + { bucket: "b", endpoint: "https://example.com" }, + { AWS_ACCESS_KEY_ID: "AK", AWS_SECRET_ACCESS_KEY: "SK" }, + ); + expect(resolved.accessKeyId).toBe("AK"); + expect(resolved.secretAccessKey).toBe("SK"); + }); + + it("lets explicit s3 fields win over env", () => { + const resolved = resolveS3( + { bucket: "b", accessKeyId: "explicit", secretAccessKey: "x", endpoint: "https://e" }, + { R2_ACCESS_KEY_ID: "fromenv" }, + ); + expect(resolved.accessKeyId).toBe("explicit"); + }); + + it("throws when a credential cannot be found", () => { + expect(() => resolveS3({ bucket: "b", endpoint: "https://e" }, {})).toThrow(/access key id/); + }); +}); diff --git a/packages/workspace/src/assets/index.ts b/packages/workspace/src/assets/index.ts new file mode 100644 index 00000000..233c7c1f --- /dev/null +++ b/packages/workspace/src/assets/index.ts @@ -0,0 +1,208 @@ +// Public surface of @cloudflare/workspace/assets. +// +// `createAssets({ ws, bucket, s3 })` binds a workspace and an R2 +// bucket once and returns a client whose `share(path, opts)` +// uploads a VFS file to R2 and returns a time-limited presigned +// GET URL. +// +// import { createAssets } from "@cloudflare/workspace/assets"; +// +// const assets = createAssets({ ws, bucket: env.ASSETS, s3: { bucket: "agent-assets" } }); +// const url = await assets.share("/workspace/out/image.png", { +// expiresAfter: 30_000, +// prefix: `/agent-${ws.sessionId}`, +// }); +// +// Uploads go through the R2 binding (`bucket.put`); the returned +// URL is signed for R2's S3-compatible endpoint. The presigned GET +// uses UNSIGNED-PAYLOAD, so the file body is read exactly once — +// streamed from the VFS into `put` — and never buffered. + +import { presignUrl } from "./sigv4.js"; +import { + basename, + buildKey, + contentDisposition, + contentTypeForPath, + putObject, + type R2PutBucket, + randomId, +} from "./upload.js"; + +// Maximum presigned-URL lifetime AWS / R2 accept: 7 days. +const MAX_EXPIRES_SECONDS = 7 * 24 * 60 * 60; + +// Duck-typed workspace handle. Only the slice the assets module +// needs: a streaming reader and the session id used to tag objects. +export interface WorkspaceLike { + readonly sessionId: string; + readonly fs: { + readFile(path: string): Promise>; + }; +} + +// Environment record the S3 credential defaults are derived from. +// In a Worker this is the `env` binding object; the values are +// plain strings. +export type AssetsEnv = Record; + +export interface S3Config { + // R2 bucket name. The binding can't surface it and there's no + // conventional env var, so it stays required. + bucket: string; + // Cloudflare account id. Defaults to env.CLOUDFLARE_ACCOUNT_ID + // and also fixes the default endpoint. + accountId?: string; + accessKeyId?: string; + secretAccessKey?: string; + // S3 endpoint origin. Defaults to + // https://.r2.cloudflarestorage.com. + endpoint?: string; + // SigV4 region / service. R2 ignores the region; defaults match + // the documented values. + region?: string; + service?: string; +} + +export interface CreateAssetsOptions { + ws: WorkspaceLike; + // R2 binding used for uploads. + bucket: R2PutBucket; + s3: S3Config; + // Source for credential / account defaults. Optional; pass the + // Worker `env` so the standard R2 / Cloudflare vars are picked up. + env?: AssetsEnv; + // Injectable clock for deterministic tests. Defaults to Date.now. + now?: () => number; +} + +export interface ShareOptions { + // URL lifetime in milliseconds. Required. Mapped to + // X-Amz-Expires (seconds, rounded up). Capped at 7 days. + expiresAfter: number; + // R2 key prefix, e.g. `/agent-${sessionId}`. Slashes are + // normalised; this is a key prefix, not a VFS path. + prefix?: string; + // Override the inferred Content-Type. + contentType?: string; + // Override the Content-Disposition filename. Defaults to the + // shared file's basename. + filename?: string; + // inline (default) lets a browser render the asset; attachment + // forces a download. + disposition?: "inline" | "attachment"; +} + +export interface AssetsClient { + // Upload `path` from the VFS to R2 and return a presigned GET URL + // valid for `opts.expiresAfter` milliseconds. + share(path: string, opts: ShareOptions): Promise; +} + +interface ResolvedS3 { + bucket: string; + endpoint: string; + accessKeyId: string; + secretAccessKey: string; + region: string; + service: string; +} + +function firstDefined(...values: Array): string | undefined { + for (const v of values) if (v !== undefined && v.length > 0) return v; + return undefined; +} + +// Resolve the S3 config, filling gaps from the environment. Throws +// a clear error if a credential can't be found, since a missing +// secret only fails later as an opaque 403 from R2. +export function resolveS3(s3: S3Config, env: AssetsEnv): ResolvedS3 { + const accountId = firstDefined(s3.accountId, env.CLOUDFLARE_ACCOUNT_ID); + const accessKeyId = firstDefined(s3.accessKeyId, env.R2_ACCESS_KEY_ID, env.AWS_ACCESS_KEY_ID); + const secretAccessKey = firstDefined( + s3.secretAccessKey, + env.R2_SECRET_ACCESS_KEY, + env.AWS_SECRET_ACCESS_KEY, + ); + const endpoint = firstDefined( + s3.endpoint, + env.R2_ENDPOINT, + accountId ? `https://${accountId}.r2.cloudflarestorage.com` : undefined, + ); + + if (!accessKeyId) { + throw new Error( + "createAssets: missing access key id. Set s3.accessKeyId or the " + + "R2_ACCESS_KEY_ID / AWS_ACCESS_KEY_ID env var.", + ); + } + if (!secretAccessKey) { + throw new Error( + "createAssets: missing secret access key. Set s3.secretAccessKey or " + + "the R2_SECRET_ACCESS_KEY / AWS_SECRET_ACCESS_KEY env var.", + ); + } + if (!endpoint) { + throw new Error( + "createAssets: missing endpoint. Set s3.endpoint, s3.accountId, the " + + "R2_ENDPOINT env var, or CLOUDFLARE_ACCOUNT_ID.", + ); + } + + return { + bucket: s3.bucket, + endpoint, + accessKeyId, + secretAccessKey, + region: s3.region ?? "auto", + service: s3.service ?? "s3", + }; +} + +export function createAssets(options: CreateAssetsOptions): AssetsClient { + const { ws, bucket } = options; + const now = options.now ?? Date.now; + const s3 = resolveS3(options.s3, options.env ?? {}); + + return { + async share(path: string, opts: ShareOptions): Promise { + if (!(opts.expiresAfter > 0)) { + throw new Error("share: expiresAfter must be a positive number of milliseconds"); + } + const expiresIn = Math.min(Math.ceil(opts.expiresAfter / 1000), MAX_EXPIRES_SECONDS); + + const name = basename(path); + const key = buildKey(path, opts.prefix, randomId()); + const contentType = opts.contentType ?? contentTypeForPath(path); + const disposition = contentDisposition(opts.disposition ?? "inline", opts.filename ?? name); + + const body = await ws.fs.readFile(path); + await putObject({ + bucket, + key, + body, + contentType, + contentDisposition: disposition, + customMetadata: { + sourcePath: path, + sessionId: ws.sessionId, + expiresAt: new Date(now() + expiresIn * 1000).toISOString(), + }, + }); + + return presignUrl({ + endpoint: s3.endpoint, + bucket: s3.bucket, + key, + accessKeyId: s3.accessKeyId, + secretAccessKey: s3.secretAccessKey, + expiresIn, + region: s3.region, + service: s3.service, + now, + }); + }, + }; +} + +export type { R2PutBucket } from "./upload.js"; diff --git a/packages/workspace/src/assets/upload.test.ts b/packages/workspace/src/assets/upload.test.ts new file mode 100644 index 00000000..931927d6 --- /dev/null +++ b/packages/workspace/src/assets/upload.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { basename, buildKey, contentDisposition, normalisePrefix } from "./upload.js"; + +describe("basename", () => { + it("returns the last path segment", () => { + expect(basename("/workspace/a/b/image.png")).toBe("image.png"); + expect(basename("image.png")).toBe("image.png"); + }); + + it("ignores a trailing slash", () => { + expect(basename("/workspace/dir/")).toBe("dir"); + }); +}); + +describe("normalisePrefix", () => { + it("treats undefined and empty as no prefix", () => { + expect(normalisePrefix(undefined)).toBe(""); + expect(normalisePrefix("")).toBe(""); + expect(normalisePrefix("/")).toBe(""); + }); + + it("strips leading and trailing slashes", () => { + expect(normalisePrefix("/agent-x/")).toBe("agent-x"); + expect(normalisePrefix("agent-x")).toBe("agent-x"); + }); + + it("collapses internal slash runs", () => { + expect(normalisePrefix("/agent-x//sub/")).toBe("agent-x/sub"); + }); +}); + +describe("buildKey", () => { + it("joins prefix, id, and basename", () => { + expect(buildKey("/workspace/out/image.png", "/agent-7/", "abc123")).toBe( + "agent-7/abc123/image.png", + ); + }); + + it("omits the prefix segment when there is no prefix", () => { + expect(buildKey("/workspace/out/image.png", undefined, "abc123")).toBe("abc123/image.png"); + }); + + it("never includes the full VFS path", () => { + const key = buildKey("/workspace/deep/nested/secret.png", "p", "id0"); + expect(key).toBe("p/id0/secret.png"); + expect(key).not.toContain("deep"); + expect(key).not.toContain("nested"); + }); +}); + +describe("contentDisposition", () => { + it("formats an inline disposition with a quoted filename", () => { + expect(contentDisposition("inline", "image.png")).toBe('inline; filename="image.png"'); + }); + + it("formats an attachment disposition", () => { + expect(contentDisposition("attachment", "report.pdf")).toBe( + 'attachment; filename="report.pdf"', + ); + }); + + it("escapes quotes and backslashes in the filename", () => { + expect(contentDisposition("inline", 'a"b\\c.png')).toBe('inline; filename="a\\"b\\\\c.png"'); + }); + + it("adds an RFC 5987 filename* form for non-ASCII names", () => { + const value = contentDisposition("inline", "résumé.pdf"); + expect(value).toContain("filename*=UTF-8''"); + expect(value).toContain(encodeURIComponent("résumé.pdf")); + }); +}); diff --git a/packages/workspace/src/assets/upload.ts b/packages/workspace/src/assets/upload.ts new file mode 100644 index 00000000..d9a76113 --- /dev/null +++ b/packages/workspace/src/assets/upload.ts @@ -0,0 +1,88 @@ +// Asset key construction and the R2 upload step. +// +// The key shape is: +// +// ${normalisedPrefix}/${id}/${basename(path)} +// +// - `id` is a fresh Crockford base32 token, so the same VFS file +// shared twice yields two distinct keys (no overwrite). +// - only basename(path) lands in the key, so the full VFS path is +// never exposed in the URL. +// - the prefix is normalised: leading and trailing slashes +// stripped, internal runs of slashes collapsed. + +import { randomId } from "./base32.js"; +import { contentTypeForPath } from "./mime.js"; + +// The slice of R2Bucket we touch for uploads. Duck-typed so callers +// can pass the real binding or a test fake without importing +// @cloudflare/workers-types. +export interface R2PutBucket { + put( + key: string, + value: ReadableStream, + options?: { + httpMetadata?: { contentType?: string; contentDisposition?: string }; + customMetadata?: Record; + }, + ): Promise; +} + +export function basename(path: string): string { + const trimmed = path.endsWith("/") ? path.slice(0, -1) : path; + const slash = trimmed.lastIndexOf("/"); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +// Strip leading/trailing slashes and collapse internal runs. An +// empty or slash-only prefix normalises to "". +export function normalisePrefix(prefix: string | undefined): string { + if (!prefix) return ""; + return prefix + .split("/") + .filter((segment) => segment.length > 0) + .join("/"); +} + +export function buildKey(path: string, prefix: string | undefined, id: string): string { + const name = basename(path); + const normalised = normalisePrefix(prefix); + return normalised.length > 0 ? `${normalised}/${id}/${name}` : `${id}/${name}`; +} + +// RFC 6266 Content-Disposition. The filename is quoted; embedded +// quotes and backslashes are escaped. Non-ASCII filenames also get +// an RFC 5987 filename* form so clients that understand it recover +// the original bytes. +export function contentDisposition(disposition: "inline" | "attachment", filename: string): string { + const escaped = filename.replace(/["\\]/g, "\\$&"); + let value = `${disposition}; filename="${escaped}"`; + if (/[^\x20-\x7e]/.test(filename)) { + const encoded = encodeURIComponent(filename); + value += `; filename*=UTF-8''${encoded}`; + } + return value; +} + +export interface UploadInput { + bucket: R2PutBucket; + key: string; + body: ReadableStream; + contentType: string; + contentDisposition: string; + customMetadata: Record; +} + +// Upload one object. The body is a ReadableStream piped straight +// from the VFS, so nothing is buffered here. +export async function putObject(input: UploadInput): Promise { + await input.bucket.put(input.key, input.body, { + httpMetadata: { + contentType: input.contentType, + contentDisposition: input.contentDisposition, + }, + customMetadata: input.customMetadata, + }); +} + +export { contentTypeForPath, randomId }; diff --git a/packages/workspace/tsconfig.json b/packages/workspace/tsconfig.json index 3a7aa919..0d9b0289 100644 --- a/packages/workspace/tsconfig.json +++ b/packages/workspace/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, - "types": ["@cloudflare/workers-types", "vitest/globals"] + "types": ["@cloudflare/workers-types", "vitest/globals", "node"] }, "include": ["src/**/*.ts", "*.ts"] } From 107cf272beba2146b7ea2281c437eaa43ff3f02f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:25:15 +0000 Subject: [PATCH 06/17] workspace: expose the ./assets subpath export Add the ./assets entry to the package exports and the rolldown input map so consumers can import createAssets from @cloudflare/workspace/assets. The module builds to its own chunk; node:crypto stays external, as it already was in the config. --- packages/workspace/package.json | 4 ++++ packages/workspace/rolldown.config.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/workspace/package.json b/packages/workspace/package.json index 61a518c5..3aea6e57 100644 --- a/packages/workspace/package.json +++ b/packages/workspace/package.json @@ -19,6 +19,10 @@ "types": "./dist/git.d.ts", "import": "./dist/git.js" }, + "./assets": { + "types": "./dist/assets/index.d.ts", + "import": "./dist/assets/index.js" + }, "./backends/container": { "types": "./dist/backends/container/index.d.ts", "import": "./dist/backends/container/index.js" diff --git a/packages/workspace/rolldown.config.ts b/packages/workspace/rolldown.config.ts index 4252ea50..c07a60ca 100644 --- a/packages/workspace/rolldown.config.ts +++ b/packages/workspace/rolldown.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ input: { index: "src/index.ts", git: "src/git/index.ts", + "assets/index": "src/assets/index.ts", "backends/container/index": "src/backends/container/index.ts", "backends/worker/index": "src/backends/worker/index.ts", "observe/cloudflare": "src/observe/cloudflare.ts", From 8934543ac896b8ea5fca30bb3ae426c6bb64c53d Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:26:32 +0000 Subject: [PATCH 07/17] docs: assets interface Document the assets module: createAssets, the share options, the prefix/id/basename key shape and why every share is unique, the object metadata, the credential resolution from the environment, and the expiry-versus-cleanup distinction. Link it from the docs index and the workspace package README. --- docs/14_assets_interface.md | 125 +++++++++++++++++++++++++++++++++++ docs/README.md | 1 + packages/workspace/README.md | 4 ++ 3 files changed, 130 insertions(+) create mode 100644 docs/14_assets_interface.md diff --git a/docs/14_assets_interface.md b/docs/14_assets_interface.md new file mode 100644 index 00000000..c691f7d5 --- /dev/null +++ b/docs/14_assets_interface.md @@ -0,0 +1,125 @@ +# Assets interface + +> [!IMPORTANT] +> This document describes the **intended design**. Names, +> signatures, and behaviours described here are targets. When in +> doubt, treat the code as authoritative for what runs and this doc +> as authoritative for what we're moving toward. + +The assets module shares a file from the workspace with the outside +world. You give it a path inside the virtual filesystem and it +uploads the bytes to an R2 bucket, then hands back a presigned URL +that anyone can open until it expires. + +The shell can produce a chart, a screenshot, or a build artifact; +`share` turns that into a link you can drop into a chat message or a +webhook without exposing the bucket or the rest of the workspace. + +## Creating a client + +```ts +import { createAssets } from "@cloudflare/workspace/assets"; + +const assets = createAssets({ + ws, + bucket: env.ASSETS, + s3: { bucket: "agent-assets" }, + env, +}); +``` + +`createAssets` binds the workspace and the bucket once and returns a +client. The shape mirrors `createGitClient({ ws })`: bind the +dependencies up front so each `share` call only takes the path and +the options that vary. + +Two distinct things named "bucket" are in play. `bucket` is the R2 +binding the uploads go through. `s3.bucket` is the bucket's name, +which the binding can't report and the presigner needs to build the +URL. + +## Sharing a file + +```ts +const url = await assets.share("/workspace/out/chart.png", { + expiresAfter: 30 * 1000, + prefix: `/agent-${ws.sessionId}`, +}); +``` + +`share` reads the file, uploads it, and returns a presigned `GET` +URL valid for `expiresAfter` milliseconds. + +### Options + +| Option | Meaning | +| --- | --- | +| `expiresAfter` | URL lifetime in milliseconds. Required. Rounded up to whole seconds and capped at seven days, the maximum a presigned URL allows. | +| `prefix` | Key prefix in the bucket, for example `agent-`. Slashes are normalized. This is a key prefix, not a path inside the workspace. | +| `contentType` | Override the type inferred from the file extension. | +| `filename` | Override the download filename. Defaults to the basename of the shared path. | +| `disposition` | `inline` (the default) lets a browser render the file; `attachment` forces a download. | + +## Object keys + +The key written to R2 is: + +``` +// +``` + +`id` is a fresh token for every call: sixteen random bytes encoded +with Crockford base32, about twenty-six characters. Two consequences +fall out of this: + +- **Every share is unique.** Sharing the same file twice produces two + different keys, so a second share never overwrites the first and + the two URLs stay independent. +- **The path stays private.** Only the basename of the file appears + in the key. A share of `/workspace/secret/plans/q3.pdf` lands at + `//q3.pdf` — the directories never leave the workspace. + +## Object metadata + +Each upload sets: + +- `Content-Type`, inferred from the file extension or taken from the + `contentType` option. Unknown extensions fall back to + `application/octet-stream`. +- `Content-Disposition`, carrying the filename so a browser names the + download correctly. +- Custom metadata recording the source path inside the workspace, the + session id, and the expiry timestamp. + +## Configuration + +The presigner signs requests for R2's S3-compatible endpoint, so it +needs an account id, an access key id, a secret access key, and the +bucket name. Pass them on the `s3` object, or let the client read +them from the environment you hand it: + +| Value | `s3` field | Environment fallback | +| --- | --- | --- | +| Account id | `accountId` | `CLOUDFLARE_ACCOUNT_ID` | +| Access key id | `accessKeyId` | `R2_ACCESS_KEY_ID`, then `AWS_ACCESS_KEY_ID` | +| Secret access key | `secretAccessKey` | `R2_SECRET_ACCESS_KEY`, then `AWS_SECRET_ACCESS_KEY` | +| Endpoint | `endpoint` | `R2_ENDPOINT`, otherwise derived from the account id | + +Explicit `s3` fields win over the environment. The bucket name has no +environment fallback and is always required. When a credential can't +be found, `createAssets` throws with a message naming the missing +value rather than failing later as an opaque permission error from +R2. + +The R2 binding alone can't mint presigned URLs, which is why the +credentials are needed on top of it. Create an R2 API token scoped to +the bucket and supply its keys through the environment. + +## Expiry and cleanup + +`expiresAfter` controls how long the URL works, not how long the +object lives. When the signature expires the link stops working, but +the object stays in the bucket. To reclaim the space, set an R2 +lifecycle rule on the bucket, or sweep objects using the expiry +timestamp recorded in their custom metadata. Automatic cleanup is not +part of this module today. diff --git a/docs/README.md b/docs/README.md index d1b7683d..e4b2a7af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -232,6 +232,7 @@ above, then dive into the area you're working on. | [11. Lifecycle](./11_lifecycle.md) | DO incarnations, container lifetime, capnweb session lifecycle, and hibernation. | | [12. Worker backend](./12_worker_backend.md) | Running the shell as just-bash inside a Dynamic Worker loaded through `env.LOADER`. | | [13. Git interface](./13_git_interface.md) | `workspace.git` and the `git` CLI inside the shell, backed by isomorphic-git. | +| [14. Assets interface](./14_assets_interface.md) | `share` a workspace file to R2 and get back a presigned URL. | ## High-level API diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 194815ad..2c2158ea 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -52,6 +52,10 @@ uniform; the counts are just always zero. worker backend's shell exposes the same dispatcher through a built-in `git` custom command. See [`docs/13_git_interface.md`](../../docs/13_git_interface.md). +- `createAssets` (from `@cloudflare/workspace/assets`) — `share` a + workspace file to an R2 bucket and get back a presigned URL. + Binds the workspace and bucket once, like `workspace.git`. See + [`docs/14_assets_interface.md`](../../docs/14_assets_interface.md). ## Typical DO-side usage From 1ff724541a22d66be93cc2aa140fe526d64d897f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:31:12 +0000 Subject: [PATCH 08/17] examples/think: add a share tool backed by the assets module Give the triage agent a share tool that uploads a workspace file to R2 and returns a time-limited link, so it can hand the user an artifact it produced. The tool binds the bucket and credentials with createAssets and exposes only a path and an optional lifetime to the model. The presigner needs R2 S3 credentials the bucket binding can't surface, so the tool is registered only when R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are present. Without them the agent runs unchanged and the tool is omitted. Add the ASSETS bucket binding and the credential vars to the wrangler config. --- examples/think/src/agent.ts | 15 ++++++ examples/think/src/tools/share.ts | 69 ++++++++++++++++++++++++ examples/think/worker-configuration.d.ts | 4 ++ examples/think/wrangler.jsonc | 8 +++ 4 files changed, 96 insertions(+) create mode 100644 examples/think/src/tools/share.ts diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 6d9fa00c..9bec1253 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -51,6 +51,7 @@ import { WorkspaceFileStore, } from "./tools/fs/index.js"; import { createReportUpdateTool } from "./tools/report-update.js"; +import { createShareTool } from "./tools/share.js"; // Re-export so the runtime can build loopback bindings the DO // uses: WorkspaceProxy carries container egress traffic back to @@ -522,6 +523,20 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { defaultBackend: "shell", }), report_update: createReportUpdateTool({ webhookUrl: ctx.webhookUrl }), + // Only offered when R2 S3 credentials are configured; the + // bucket binding alone can't mint the presigned URL the tool + // returns. Absent credentials, the agent simply has no share + // tool rather than one that fails on every call. + ...(this.env.R2_ACCESS_KEY_ID && this.env.R2_SECRET_ACCESS_KEY + ? { + share: createShareTool({ + workspace: ws, + bucket: this.env.ASSETS, + s3Bucket: "think-example-assets", + env: this.env as unknown as Record, + }), + } + : {}), }; } diff --git a/examples/think/src/tools/share.ts b/examples/think/src/tools/share.ts new file mode 100644 index 00000000..170a92cf --- /dev/null +++ b/examples/think/src/tools/share.ts @@ -0,0 +1,69 @@ +/** + * `share` — upload a workspace file to R2 and return a link the + * caller can open. Built on `@cloudflare/workspace/assets`: the + * bucket binding and credentials are bound at construction time, so + * the model only supplies the path and an optional lifetime. + * + * The presigner needs R2 S3 credentials the bucket binding can't + * surface, so the tool is only registered when those are present in + * the environment (see `createTools` in `agent.ts`). Failures are + * returned, not thrown, so a bad path doesn't unwind the agentic + * loop. + */ + +import type { Workspace } from "@cloudflare/workspace"; +import { createAssets } from "@cloudflare/workspace/assets"; +import { tool } from "ai"; +import { z } from "zod"; + +export interface ShareToolOptions { + workspace: Workspace; + // R2 binding the upload goes through. + bucket: R2Bucket; + // S3 bucket name and credential source for the presigner. + s3Bucket: string; + env: Record; +} + +const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; // one hour + +export function createShareTool(opts: ShareToolOptions) { + const assets = createAssets({ + ws: opts.workspace, + bucket: opts.bucket, + s3: { bucket: opts.s3Bucket }, + env: opts.env, + }); + + return tool({ + description: + "Share a file from the workspace by uploading it to R2 and " + + "returning a time-limited link. Use this to hand the user an " + + "artifact you produced — a chart, screenshot, build output, or " + + "report. The link expires; pass expiresAfterMs to control how " + + "long it lives (default one hour).", + inputSchema: z.object({ + path: z.string().min(1).describe("Absolute workspace path, e.g. /workspace/out/chart.png."), + expiresAfterMs: z + .number() + .int() + .positive() + .optional() + .describe("Link lifetime in milliseconds. Defaults to one hour."), + }), + execute: async ({ path, expiresAfterMs }) => { + try { + const url = await assets.share(path, { + expiresAfter: expiresAfterMs ?? DEFAULT_EXPIRY_MS, + prefix: `agent-${opts.workspace.sessionId}`, + }); + return { ok: true, url }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }, + }); +} diff --git a/examples/think/worker-configuration.d.ts b/examples/think/worker-configuration.d.ts index bc1bdc1c..39468791 100644 --- a/examples/think/worker-configuration.d.ts +++ b/examples/think/worker-configuration.d.ts @@ -3,6 +3,10 @@ // Runtime types generated with workerd@1.20260529.1 2026-05-26 nodejs_compat interface __BaseEnv_Env { R2_SKILLS: R2Bucket; + ASSETS: R2Bucket; + R2_ACCESS_KEY_ID?: string; + R2_SECRET_ACCESS_KEY?: string; + CLOUDFLARE_ACCOUNT_ID?: string; LOADER: WorkerLoader; AI: Ai; TriageAgent: DurableObjectNamespace; diff --git a/examples/think/wrangler.jsonc b/examples/think/wrangler.jsonc index 48345245..fbf02742 100644 --- a/examples/think/wrangler.jsonc +++ b/examples/think/wrangler.jsonc @@ -44,6 +44,14 @@ { "binding": "R2_SKILLS", "bucket_name": "think-example-skills" + }, + // Bucket the `share` tool uploads to. The presigner also needs + // R2 S3 credentials, set as secrets: R2_ACCESS_KEY_ID, + // R2_SECRET_ACCESS_KEY, and CLOUDFLARE_ACCOUNT_ID. Without + // them the agent runs fine but the `share` tool is omitted. + { + "binding": "ASSETS", + "bucket_name": "think-example-assets" } ], From 600085a09f303298950c9f4f69fd372c6937953a Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:48:49 +0000 Subject: [PATCH 09/17] examples/assets: generate an image and share it over a link Add a Worker plus Durable Object example that turns a one-shot POST /prompt into an image. The Durable Object runs the prompt through a Workers AI text-to-image model, writes the generated PNG into a backend-less Workspace, then uploads the file to R2 and returns a presigned link through createAssets. The example is production-only: the presigner needs R2 S3 credentials set as secrets, and the image model runs on Cloudflare's network, so a local dev stack can't produce a working link. The README covers the bucket, credentials, deploy, and request shape. --- examples/assets/.gitignore | 3 + examples/assets/README.md | 98 +++++++++++ examples/assets/package.json | 20 +++ examples/assets/src/index.ts | 189 ++++++++++++++++++++++ examples/assets/tsconfig.json | 17 ++ examples/assets/worker-configuration.d.ts | 18 +++ examples/assets/wrangler.jsonc | 50 ++++++ package-lock.json | 20 ++- 8 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 examples/assets/.gitignore create mode 100644 examples/assets/README.md create mode 100644 examples/assets/package.json create mode 100644 examples/assets/src/index.ts create mode 100644 examples/assets/tsconfig.json create mode 100644 examples/assets/worker-configuration.d.ts create mode 100644 examples/assets/wrangler.jsonc diff --git a/examples/assets/.gitignore b/examples/assets/.gitignore new file mode 100644 index 00000000..d27f2ecb --- /dev/null +++ b/examples/assets/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +.wrangler/ diff --git a/examples/assets/README.md b/examples/assets/README.md new file mode 100644 index 00000000..cda14778 --- /dev/null +++ b/examples/assets/README.md @@ -0,0 +1,98 @@ +# assets example + +> [!IMPORTANT] +> **PREVIEW ONLY** This package is provided as a preview for feedback only. +> APIs are unstable and the design is subject to change. + +A Cloudflare Worker + Durable Object that turns a text prompt into +an image and hands back a shareable link. One shot: `POST /prompt`, +get a URL. + +The Durable Object runs the prompt through a Workers AI +text-to-image model, writes the generated PNG into its `Workspace`, +then uploads that file to R2 and returns a presigned URL through +[`@cloudflare/workspace/assets`](../../docs/14_assets_interface.md). + +> [!NOTE] +> This is a **production-only** example. The presigner needs R2 S3 +> credentials, and the image model runs on Cloudflare's network, so +> `wrangler dev` against a local stack won't produce a working link. +> Deploy it and hit the deployed URL. + +## Architecture + +``` +client ─► Worker POST /prompt + │ (DO RPC call) + ▼ + AssetWorkspace DO ──► env.AI.run(flux) generate the image + ──► Workspace.fs.writeFile store it in the VFS + ──► createAssets(...).share upload to R2 + presign + │ + ▼ + { path, url } +``` + +1. The Worker accepts `POST /prompt` and forwards the prompt to a + single `AssetWorkspace` Durable Object. +2. The DO holds a backend-less `Workspace` — it only needs the + filesystem, not a shell. It runs the prompt through the + [FLUX.2 \[klein\] 9B](https://developers.cloudflare.com/workers-ai/models/flux-2-klein-9b/) + model on `env.AI`, which returns the image as base64. +3. The DO decodes the image, writes it to + `/workspace/.png`, then calls `createAssets(...).share` + to upload the file to R2 and presign a `GET` URL. +4. The response is `{ path, url }`. The link is valid for one hour. + +## Configuration + +The bucket binding alone can't mint a presigned URL, so the +presigner needs R2 S3 credentials. Create an R2 API token scoped to +the bucket, then set the values as secrets: + +```sh +wrangler secret put R2_ACCESS_KEY_ID +wrangler secret put R2_SECRET_ACCESS_KEY +wrangler secret put CLOUDFLARE_ACCOUNT_ID +``` + +The bucket name is supplied to the assets client through the +`ASSETS_BUCKET_NAME` var in `wrangler.jsonc`; keep it in step with +the `bucket_name` on the `ASSETS` binding. + +Create the bucket once before the first deploy: + +```sh +wrangler r2 bucket create workspace-assets-example +``` + +## HTTP surface + +``` +POST /prompt { "prompt": "..." } + → { "path": "/workspace/.png", "url": "https://..." } +``` + +## Deploy and run + +```sh +npm run deploy --workspace @example/workspace-assets + +curl -X POST https://workspace-assets-example..workers.dev/prompt \ + -H 'content-type: application/json' \ + -d '{"prompt":"a sunset over the alps, oil painting"}' +``` + +The response carries a `url`; open it to see the generated image. +The link expires after an hour, after which the object stays in the +bucket but the URL stops working. See the +[assets interface](../../docs/14_assets_interface.md) for the +cleanup story. + +## Layout + +``` +examples/assets/ + wrangler.jsonc Worker + DO + AI + R2 bindings + src/index.ts Worker handler + DO (AssetWorkspace) +``` diff --git a/examples/assets/package.json b/examples/assets/package.json new file mode 100644 index 00000000..16ffc1ab --- /dev/null +++ b/examples/assets/package.json @@ -0,0 +1,20 @@ +{ + "name": "@example/workspace-assets", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object that turns a prompt into an image with Workers AI, writes it to the workspace, and returns a shareable link via @cloudflare/workspace/assets.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@cloudflare/workspace": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260601.1", + "typescript": "^6.0.3", + "wrangler": "^4.95.0" + } +} diff --git a/examples/assets/src/index.ts b/examples/assets/src/index.ts new file mode 100644 index 00000000..27075a51 --- /dev/null +++ b/examples/assets/src/index.ts @@ -0,0 +1,189 @@ +// Example Worker + Durable Object that turns a text prompt into an +// image and hands back a shareable link. +// +// One shot: POST /prompt with { "prompt": "..." }. The Durable +// Object runs the prompt through a Workers AI text-to-image model, +// writes the generated PNG into its Workspace, then uploads that +// file to R2 and returns a presigned URL through +// @cloudflare/workspace/assets. +// +// Wire shape: +// +// client ──► Worker POST /prompt +// │ (DO RPC call) +// ▼ +// AssetWorkspace DO ──► env.AI.run(flux) generate image +// ──► Workspace.fs.writeFile store in the VFS +// ──► createAssets(...).share upload + presign +// │ +// ▼ +// { path, url, expiresAt } +// +// This is a production-only example. The presigner needs R2 S3 +// credentials (set as secrets), and the image model runs on +// Cloudflare's network, so `wrangler dev` against a local stack +// won't produce a working link. Deploy it and hit the deployed URL. + +import { DurableObject } from "cloudflare:workers"; +import { type DurableObjectStorageLike, Workspace } from "@cloudflare/workspace"; +import { createAssets } from "@cloudflare/workspace/assets"; + +// Black Forest Labs FLUX.2 [klein] 9B — a fast text-to-image model. +// Returns the image as a base64 string. +const IMAGE_MODEL = "@cf/black-forest-labs/flux-2-klein-9b"; + +// How long a shared link stays valid: one hour. +const LINK_TTL_MS = 60 * 60 * 1000; + +interface GenerateResult { + path: string; + url: string; +} + +export class AssetWorkspace extends DurableObject { + readonly #workspace: Workspace; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.#workspace = new Workspace({ + // ctx.storage.sql.exec returns a narrower row type than + // DurableObjectStorageLike declares; the runtime shape + // matches. Cast through unknown to bypass invariance. + storage: ctx.storage as unknown as DurableObjectStorageLike, + // No backend: this workspace only needs its filesystem. The + // shell half throws if touched, which we never do. + sessionId: ctx.id.toString(), + }); + } + + // Generate an image from `prompt`, store it, and return a + // shareable link. + async generate(prompt: string): Promise { + const image = await this.#runModel(prompt); + + // One file per request, named by a random id so repeated + // prompts never collide on the VFS. + const path = `/workspace/${crypto.randomUUID()}.png`; + await this.#workspace.fs.mkdir("/workspace", { recursive: true }); + await this.#workspace.fs.writeFile(path, image); + + const assets = createAssets({ + ws: this.#workspace, + bucket: this.env.ASSETS, + s3: { bucket: this.env.ASSETS_BUCKET_NAME }, + env: this.env as unknown as Record, + }); + + const url = await assets.share(path, { + expiresAfter: LINK_TTL_MS, + prefix: "generated", + }); + + return { path, url }; + } + + // Run the text-to-image model and return the decoded PNG bytes. + // The model takes multipart form fields and returns the image as + // a base64 string. + async #runModel(prompt: string): Promise { + const form = new FormData(); + form.append("prompt", prompt); + form.append("width", "1024"); + form.append("height", "1024"); + + // FormData doesn't expose its serialized body or boundary. + // Passing it through a Response constructor serializes it and + // sets the multipart Content-Type with the boundary the model + // needs to parse the fields. + const formResponse = new Response(form); + const body = formResponse.body; + const contentType = formResponse.headers.get("content-type"); + if (body === null || contentType === null) { + throw new Error("failed to serialize the model request body"); + } + + // The model's multipart input shape isn't in the generated Ai + // types yet, so call through a minimal typed view of run(). + const run = this.env.AI.run as unknown as ( + model: string, + input: { multipart: { body: ReadableStream; contentType: string } }, + ) => Promise<{ image?: string }>; + const result = await run(IMAGE_MODEL, { multipart: { body, contentType } }); + + if (typeof result.image !== "string") { + throw new Error("image model returned no image"); + } + return decodeBase64(result.image); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/" || url.pathname === "") { + return new Response( + ["assets example", "", ' POST /prompt { "prompt": "..." }', ""].join("\n"), + { headers: { "content-type": "text/plain" } }, + ); + } + + if (url.pathname === "/prompt") { + if (request.method !== "POST") { + return new Response("method not allowed", { status: 405, headers: { allow: "POST" } }); + } + return handlePrompt(request, env); + } + + return new Response("not found", { status: 404 }); + }, +} satisfies ExportedHandler; + +interface PromptRequest { + prompt?: string; +} + +async function handlePrompt(request: Request, env: Env): Promise { + let body: PromptRequest; + try { + body = (await request.json()) as PromptRequest; + } catch { + return errorJSON(new Error("invalid JSON body"), 400); + } + + const prompt = body.prompt; + if (typeof prompt !== "string" || prompt.trim().length === 0) { + return errorJSON(new Error("must provide a non-empty prompt"), 400); + } + + // One Durable Object instance owns the workspace. A fixed name + // keeps every request on the same store; swap in a per-user name + // to give each caller an isolated workspace. + const stub = env.AssetWorkspace.get(env.AssetWorkspace.idFromName("default")); + try { + const result = await stub.generate(prompt); + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + return errorJSON(error, 500); + } +} + +function errorJSON(error: unknown, status: number): Response { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +// Decode a base64 string to bytes. atob is available in the Workers +// runtime; map each character to its byte value. +function decodeBase64(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} diff --git a/examples/assets/tsconfig.json b/examples/assets/tsconfig.json new file mode 100644 index 00000000..4a2585dc --- /dev/null +++ b/examples/assets/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/assets/worker-configuration.d.ts b/examples/assets/worker-configuration.d.ts new file mode 100644 index 00000000..e847a6b6 --- /dev/null +++ b/examples/assets/worker-configuration.d.ts @@ -0,0 +1,18 @@ +// Hand-written env shape for the platform Worker. Run +// `wrangler types` to regenerate from wrangler.jsonc when the +// bindings change. + +interface Env { + AssetWorkspace: DurableObjectNamespace; + AI: Ai; + ASSETS: R2Bucket; + ASSETS_BUCKET_NAME: string; + + // R2 S3 credentials for presigning, set as secrets: + // wrangler secret put R2_ACCESS_KEY_ID + // wrangler secret put R2_SECRET_ACCESS_KEY + // wrangler secret put CLOUDFLARE_ACCOUNT_ID + R2_ACCESS_KEY_ID?: string; + R2_SECRET_ACCESS_KEY?: string; + CLOUDFLARE_ACCOUNT_ID?: string; +} diff --git a/examples/assets/wrangler.jsonc b/examples/assets/wrangler.jsonc new file mode 100644 index 00000000..273374aa --- /dev/null +++ b/examples/assets/wrangler.jsonc @@ -0,0 +1,50 @@ +{ + // Example: Worker + Durable Object that turns a prompt into an + // image with Workers AI, writes it into a Workspace, and returns + // a shareable link via @cloudflare/workspace/assets. + // + // Production-only. The presigner needs R2 S3 credentials set as + // secrets (see the README), and the image model runs on + // Cloudflare's network, so `wrangler dev` against a local stack + // won't produce a working link. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "workspace-assets-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat"], + + // Workers AI binding used to run the text-to-image model. + "ai": { + "binding": "AI" + }, + + "durable_objects": { + "bindings": [ + { + "name": "AssetWorkspace", + "class_name": "AssetWorkspace" + } + ] + }, + + // R2 bucket the generated images are uploaded to. The same bucket + // name is passed to the assets client as ASSETS_BUCKET_NAME so the + // presigner can build the URL — the binding alone can't report it. + "r2_buckets": [ + { + "binding": "ASSETS", + "bucket_name": "workspace-assets-example" + } + ], + + "vars": { + "ASSETS_BUCKET_NAME": "workspace-assets-example" + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["AssetWorkspace"] + } + ] +} diff --git a/package-lock.json b/package-lock.json index 46ec10fe..5aff7632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,18 @@ "typescript": "^6.0.3" } }, + "examples/assets": { + "name": "@example/workspace-assets", + "version": "0.0.0", + "dependencies": { + "@cloudflare/workspace": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260601.1", + "typescript": "^6.0.3", + "wrangler": "^4.95.0" + } + }, "examples/container": { "name": "@example/workspace-container", "version": "0.0.0", @@ -3876,6 +3888,10 @@ "node": ">=18" } }, + "node_modules/@example/workspace-assets": { + "resolved": "examples/assets", + "link": true + }, "node_modules/@example/workspace-container": { "resolved": "examples/container", "link": true @@ -13712,7 +13728,7 @@ }, "packages/workspace": { "name": "@cloudflare/workspace", - "version": "0.0.0-alpha.7", + "version": "0.0.0-alpha.8", "license": "MIT", "dependencies": { "capnweb": "^0.8.0", @@ -13753,7 +13769,7 @@ }, "packages/wsd": { "name": "@cloudflare/workspace-wsd", - "version": "0.0.0-alpha.7", + "version": "0.0.0-alpha.8", "license": "MIT", "dependencies": { "@cloudflare/dofs": "*", From ced68ec82e8c524f1206d9e19bf444482721a2b4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:27:10 +0000 Subject: [PATCH 10/17] examples/assets: document R2 credentials in .dev.vars.example List the R2 S3 credential names the presigner needs in a .dev.vars.example file so the credentials are discoverable without reading the source. Ignore real .dev.vars files while keeping the example tracked, and point the README at it. --- examples/assets/.dev.vars.example | 14 ++++++++++++++ examples/assets/.gitignore | 2 ++ examples/assets/README.md | 9 ++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 examples/assets/.dev.vars.example diff --git a/examples/assets/.dev.vars.example b/examples/assets/.dev.vars.example new file mode 100644 index 00000000..e6d7a170 --- /dev/null +++ b/examples/assets/.dev.vars.example @@ -0,0 +1,14 @@ +# Copy to .dev.vars for local development, or set these as secrets +# in production with `wrangler secret put `. +# +# The R2 binding alone can't mint a presigned URL, so the assets +# client needs R2 S3 credentials. Create an R2 API token scoped to +# the bucket and fill in the values below. +# +# Note: this example is production-only. The image model runs on +# Cloudflare's network and the presigned link points at R2, so a +# local dev stack won't produce a working link even with these set. + +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +CLOUDFLARE_ACCOUNT_ID= diff --git a/examples/assets/.gitignore b/examples/assets/.gitignore index d27f2ecb..4faeb3eb 100644 --- a/examples/assets/.gitignore +++ b/examples/assets/.gitignore @@ -1,3 +1,5 @@ dist/ node_modules/ .wrangler/ +.dev.vars* +!.dev.vars.example diff --git a/examples/assets/README.md b/examples/assets/README.md index cda14778..2e0f654b 100644 --- a/examples/assets/README.md +++ b/examples/assets/README.md @@ -48,7 +48,9 @@ client ─► Worker POST /prompt The bucket binding alone can't mint a presigned URL, so the presigner needs R2 S3 credentials. Create an R2 API token scoped to -the bucket, then set the values as secrets: +the bucket. The credential names are listed in +[`.dev.vars.example`](.dev.vars.example); copy it to `.dev.vars` to +fill them in, then set the same values as secrets for production: ```sh wrangler secret put R2_ACCESS_KEY_ID @@ -93,6 +95,7 @@ cleanup story. ``` examples/assets/ - wrangler.jsonc Worker + DO + AI + R2 bindings - src/index.ts Worker handler + DO (AssetWorkspace) + wrangler.jsonc Worker + DO + AI + R2 bindings + .dev.vars.example R2 S3 credential names; copy to .dev.vars + src/index.ts Worker handler + DO (AssetWorkspace) ``` From 645fa096421fcc40205dc9cf90fea8ed45b5535c Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:07:17 +0000 Subject: [PATCH 11/17] examples/assets: call the AI binding without detaching run The model call extracted env.AI.run into a local and invoked it unbound, so the binding's run() ran with an undefined `this` and threw "Cannot set properties of undefined (setting '#options')" from inside the binding. Keep the call on env.AI through a typed view of the binding so run() retains its receiver. --- examples/assets/src/index.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/examples/assets/src/index.ts b/examples/assets/src/index.ts index 27075a51..d556ab0b 100644 --- a/examples/assets/src/index.ts +++ b/examples/assets/src/index.ts @@ -103,12 +103,17 @@ export class AssetWorkspace extends DurableObject { } // The model's multipart input shape isn't in the generated Ai - // types yet, so call through a minimal typed view of run(). - const run = this.env.AI.run as unknown as ( - model: string, - input: { multipart: { body: ReadableStream; contentType: string } }, - ) => Promise<{ image?: string }>; - const result = await run(IMAGE_MODEL, { multipart: { body, contentType } }); + // types yet, so call run() through a minimal typed view of the + // binding. Keep the call on env.AI: the binding's run() is a + // method that relies on its own `this`, so a detached reference + // would throw inside the binding. + const ai = this.env.AI as unknown as { + run( + model: string, + input: { multipart: { body: ReadableStream; contentType: string } }, + ): Promise<{ image?: string }>; + }; + const result = await ai.run(IMAGE_MODEL, { multipart: { body, contentType } }); if (typeof result.image !== "string") { throw new Error("image model returned no image"); From 6adefcc952672d73e5eb48eb23c69cbd0e6a8fbf Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:08:40 +0000 Subject: [PATCH 12/17] workspace, examples: address assets review findings Fix the assets example comment so the documented response shape matches the code, and document the think example's optional share tool setup: the ASSETS bucket, the R2 S3 credential secrets, and the fact that the tool is omitted when credentials are absent. Add tests for live assets error paths that reviewers identified as uncovered: NaN expiry, the seven-day presigned URL cap, missing-file rejection without uploading, and the remaining resolveS3 failure branches. --- examples/assets/src/index.ts | 2 +- examples/think/README.md | 26 +++++++++++ packages/workspace/src/assets/index.test.ts | 52 ++++++++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/examples/assets/src/index.ts b/examples/assets/src/index.ts index d556ab0b..a3fef446 100644 --- a/examples/assets/src/index.ts +++ b/examples/assets/src/index.ts @@ -17,7 +17,7 @@ // ──► createAssets(...).share upload + presign // │ // ▼ -// { path, url, expiresAt } +// { path, url } // // This is a production-only example. The presigner needs R2 S3 // credentials (set as secrets), and the image model runs on diff --git a/examples/think/README.md b/examples/think/README.md index 3f287597..d7ae2bd5 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -63,6 +63,13 @@ just emit JSON. | `edit` | vendored from `hackspace/fs-tools` | | `exec` | `src/tools/exec.ts` | | `report_update` | `src/tools/report-update.ts` | +| `share` | `src/tools/share.ts` (optional) | + +The `share` tool uploads a workspace file to R2 and returns a +time-limited link, so the agent can hand the user an artifact it +produced. It is registered only when the R2 credentials below are +set; without them the agent runs unchanged and the tool is omitted. +See [`docs/14_assets_interface.md`](../../docs/14_assets_interface.md). `exec` is wired to a Workspace with two backends: a `"shell"` backend (just-bash in a Dynamic Worker through `env.LOADER`) and @@ -168,6 +175,25 @@ The worker is configured in [`wrangler.jsonc`](./wrangler.jsonc): worker backend through `env.LOADER` and the container backend through `this.ctx.container`. - `TRIAGE_WORKFLOW` — workflow binding pointing at `TriageWorkflow`. +- `ASSETS` — R2 bucket the `share` tool uploads to. Create it once + before deploying: + + ```sh + wrangler r2 bucket create think-example-assets + ``` + +The `share` tool also needs R2 S3 credentials to presign URLs — the +bucket binding alone can't mint them. Create an R2 API token scoped +to the bucket and set the values as secrets: + +```sh +wrangler secret put R2_ACCESS_KEY_ID +wrangler secret put R2_SECRET_ACCESS_KEY +wrangler secret put CLOUDFLARE_ACCOUNT_ID +``` + +Without these the worker still runs; the `share` tool is simply not +offered to the model. No GitHub auth, no Artifacts. The issue must be on a public repository. diff --git a/packages/workspace/src/assets/index.test.ts b/packages/workspace/src/assets/index.test.ts index fdb77e39..7f4f27e7 100644 --- a/packages/workspace/src/assets/index.test.ts +++ b/packages/workspace/src/assets/index.test.ts @@ -186,6 +186,42 @@ describe("createAssets.share", () => { /expiresAfter/, ); }); + + it("rejects a NaN expiry", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/a.png", new Uint8Array([1])); + const { bucket } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await expect(assets.share("/workspace/a.png", { expiresAfter: Number.NaN })).rejects.toThrow( + /expiresAfter/, + ); + }); + + it("caps the expiry at seven days", async () => { + const ws = makeWorkspace(); + await writeAt(ws, "/workspace/a.png", new Uint8Array([1])); + const { bucket } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + // Ask for 30 days; the presigned URL is capped at 7 days + // (604800 seconds), the maximum a presigned URL allows. + const url = await assets.share("/workspace/a.png", { + expiresAfter: 30 * 24 * 60 * 60 * 1000, + }); + expect(new URL(url).searchParams.get("X-Amz-Expires")).toBe("604800"); + }); + + it("rejects a missing file without calling put", async () => { + const ws = makeWorkspace(); + const { bucket, puts } = fakeBucket(); + const assets = createAssets({ ws, bucket: bucket as never, s3, now: fixedClock }); + + await expect( + assets.share("/workspace/does-not-exist.png", { expiresAfter: 30_000 }), + ).rejects.toThrow(); + expect(puts).toHaveLength(0); + }); }); describe("resolveS3", () => { @@ -220,7 +256,21 @@ describe("resolveS3", () => { expect(resolved.accessKeyId).toBe("explicit"); }); - it("throws when a credential cannot be found", () => { + it("throws when the access key id cannot be found", () => { expect(() => resolveS3({ bucket: "b", endpoint: "https://e" }, {})).toThrow(/access key id/); }); + + it("throws when the secret access key cannot be found", () => { + expect(() => + resolveS3({ bucket: "b", endpoint: "https://e" }, { R2_ACCESS_KEY_ID: "AK" }), + ).toThrow(/secret access key/); + }); + + it("throws when the endpoint cannot be derived", () => { + // Credentials present but no endpoint and no account id to + // derive one from. + expect(() => + resolveS3({ bucket: "b" }, { R2_ACCESS_KEY_ID: "AK", R2_SECRET_ACCESS_KEY: "SK" }), + ).toThrow(/endpoint/); + }); }); From 672226caa00c8e6fb40b1c30ea84ea903c7121b9 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:18:42 +0000 Subject: [PATCH 13/17] workspace: stream shared assets through FixedLengthStream R2 rejects an arbitrary ReadableStream body because it cannot infer a content length. Size the VFS file with fs.stat, pipe the read stream through a FixedLengthStream, and pass the readable half to bucket.put so the binding receives a known-length stream without buffering the file. The regression test installs a Workers-compatible FixedLengthStream stand-in for the Node runner and asserts the fake R2 bucket receives the fixed-length readable with the file's byte length. --- packages/workspace/src/assets/index.test.ts | 22 +++++++ packages/workspace/src/assets/index.ts | 67 ++++++++++++++++----- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/workspace/src/assets/index.test.ts b/packages/workspace/src/assets/index.test.ts index 7f4f27e7..4383ad34 100644 --- a/packages/workspace/src/assets/index.test.ts +++ b/packages/workspace/src/assets/index.test.ts @@ -4,12 +4,31 @@ import { describe, expect, it } from "vitest"; import { Workspace } from "../workspace.js"; import { createAssets, resolveS3 } from "./index.js"; +const fixedLengthReads = new WeakMap, number>(); + +// Node's test runner does not provide Workers' FixedLengthStream. +// Install a tiny stand-in that records the expected length on the +// readable half so the fake bucket can assert that share() passed a +// known-length stream to R2. +class TestFixedLengthStream extends TransformStream { + constructor(expectedLength: number | bigint) { + super(); + fixedLengthReads.set(this.readable, Number(expectedLength)); + } +} + +Object.defineProperty(globalThis, "FixedLengthStream", { + value: TestFixedLengthStream, + configurable: true, +}); + // Captures every put() so tests can assert on the key, the bytes // streamed in, and the metadata — without a real R2. interface CapturedPut { key: string; bytes: Uint8Array; isStream: boolean; + fixedLength?: number; httpMetadata?: { contentType?: string; contentDisposition?: string }; customMetadata?: Record; } @@ -26,6 +45,7 @@ function fakeBucket(): { bucket: { put: unknown }; puts: CapturedPut[] } { }, ) { const isStream = value instanceof ReadableStream; + const fixedLength = fixedLengthReads.get(value); const reader = value.getReader(); const parts: Uint8Array[] = []; while (true) { @@ -46,6 +66,7 @@ function fakeBucket(): { bucket: { put: unknown }; puts: CapturedPut[] } { key, bytes, isStream, + fixedLength, httpMetadata: options?.httpMetadata, customMetadata: options?.customMetadata, }); @@ -87,6 +108,7 @@ describe("createAssets.share", () => { expect(puts).toHaveLength(1); expect(puts[0].isStream).toBe(true); + expect(puts[0].fixedLength).toBe(7); expect(new TextDecoder().decode(puts[0].bytes)).toBe("PNGDATA"); }); diff --git a/packages/workspace/src/assets/index.ts b/packages/workspace/src/assets/index.ts index 233c7c1f..da68a42d 100644 --- a/packages/workspace/src/assets/index.ts +++ b/packages/workspace/src/assets/index.ts @@ -14,7 +14,9 @@ // }); // // Uploads go through the R2 binding (`bucket.put`); the returned -// URL is signed for R2's S3-compatible endpoint. The presigned GET +// URL is signed for R2's S3-compatible endpoint. R2 requires a +// known-length stream, so the VFS stream is piped through a +// FixedLengthStream sized from `fs.stat(path)`. The presigned GET // uses UNSIGNED-PAYLOAD, so the file body is read exactly once — // streamed from the VFS into `put` — and never buffered. @@ -33,10 +35,12 @@ import { const MAX_EXPIRES_SECONDS = 7 * 24 * 60 * 60; // Duck-typed workspace handle. Only the slice the assets module -// needs: a streaming reader and the session id used to tag objects. +// needs: size lookup, a streaming reader, and the session id used +// to tag objects. export interface WorkspaceLike { readonly sessionId: string; readonly fs: { + stat(path: string): Promise<{ size: number }>; readFile(path: string): Promise>; }; } @@ -159,6 +163,31 @@ export function resolveS3(s3: S3Config, env: AssetsEnv): ResolvedS3 { }; } +interface FixedLengthBody { + readable: ReadableStream; + pipeDone: Promise; + abort(reason: unknown): void; +} + +function fixedLengthBody(source: ReadableStream, size: number): FixedLengthBody { + if (typeof FixedLengthStream === "undefined") { + throw new Error( + "share: FixedLengthStream is not available. Run this code in the Cloudflare Workers runtime.", + ); + } + + const fixed = new FixedLengthStream(size); + const abort = new AbortController(); + const pipeDone = source.pipeTo(fixed.writable, { signal: abort.signal }); + return { + readable: fixed.readable, + pipeDone, + abort(reason: unknown) { + abort.abort(reason); + }, + }; +} + export function createAssets(options: CreateAssetsOptions): AssetsClient { const { ws, bucket } = options; const now = options.now ?? Date.now; @@ -176,19 +205,27 @@ export function createAssets(options: CreateAssetsOptions): AssetsClient { const contentType = opts.contentType ?? contentTypeForPath(path); const disposition = contentDisposition(opts.disposition ?? "inline", opts.filename ?? name); - const body = await ws.fs.readFile(path); - await putObject({ - bucket, - key, - body, - contentType, - contentDisposition: disposition, - customMetadata: { - sourcePath: path, - sessionId: ws.sessionId, - expiresAt: new Date(now() + expiresIn * 1000).toISOString(), - }, - }); + const [{ size }, source] = await Promise.all([ws.fs.stat(path), ws.fs.readFile(path)]); + const body = fixedLengthBody(source, size); + try { + await putObject({ + bucket, + key, + body: body.readable, + contentType, + contentDisposition: disposition, + customMetadata: { + sourcePath: path, + sessionId: ws.sessionId, + expiresAt: new Date(now() + expiresIn * 1000).toISOString(), + }, + }); + await body.pipeDone; + } catch (error) { + body.abort(error); + await body.pipeDone.catch(() => undefined); + throw error; + } return presignUrl({ endpoint: s3.endpoint, From 071984011057a75c687d40c7c5522880facf0cef Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:00:21 +0000 Subject: [PATCH 14/17] workspace: add assets publish to the worker shell Expose an optional assets client on Workspace and WorkspaceStub so the worker-backend shell can publish files without receiving R2 bindings or signing secrets in the Dynamic Worker. The new `assets publish []` just-bash command forwards to that host-side capability and prints the share URL to stdout. The command resolves relative paths against cwd, defaults expiry to one hour, and accepts bare millisecond values plus ms, s, m, and h suffixes. When assets are not configured it exits with a clear error. Wire the think example to configure WorkspaceOptions.assets when the R2 credentials are present, and document the shell command in the worker backend and assets docs. --- docs/12_worker_backend.md | 23 +++++ docs/14_assets_interface.md | 29 ++++++ examples/think/README.md | 13 ++- examples/think/src/agent.ts | 12 +++ packages/workspace/README.md | 4 +- .../backends/worker/assets-command.test.ts | 99 +++++++++++++++++++ .../src/backends/worker/assets-command.ts | 79 +++++++++++++++ .../src/backends/worker/entrypoint.test.ts | 50 +++++++++- .../src/backends/worker/entrypoint.ts | 17 ++-- .../workspace/src/backends/worker/index.ts | 1 + packages/workspace/src/index.ts | 1 + packages/workspace/src/stub.test.ts | 32 +++++- packages/workspace/src/stub.ts | 36 +++++++ packages/workspace/src/workspace.ts | 16 +++ 14 files changed, 395 insertions(+), 17 deletions(-) create mode 100644 packages/workspace/src/backends/worker/assets-command.test.ts create mode 100644 packages/workspace/src/backends/worker/assets-command.ts diff --git a/docs/12_worker_backend.md b/docs/12_worker_backend.md index b18bf5d3..327861d0 100644 --- a/docs/12_worker_backend.md +++ b/docs/12_worker_backend.md @@ -85,6 +85,29 @@ another workspace's shell. `connect()` from inside the shell. The only path out of the isolate is back through the host DO over `env.HOST`. +## Built-in custom commands + +The worker backend registers two just-bash custom commands on every +exec: + +- `git ...` forwards to the host workspace's `workspace.git.cli(...)`. +- `assets publish []` forwards to the host + workspace's configured assets publisher and prints the share URL + to stdout. + +`assets publish` accepts an absolute path or a path relative to the +current working directory. The optional expiry defaults to one hour; +a bare number is milliseconds, and `ms`, `s`, `m`, and `h` suffixes +are accepted (`30000`, `30s`, `5m`, `2h`). If the Workspace was not +constructed with an assets client, the command exits 1 with a clear +message. + +The Dynamic Worker never receives the R2 bucket binding or signing +secrets. The host Durable Object configures the Workspace with an +assets client, and the command reaches that host-side capability over +the same `env.HOST.getWorkspace()` loopback as filesystem and git +calls. + ## Why a loopback proxy The natural impulse is to hand the Dynamic Worker the host DO's diff --git a/docs/14_assets_interface.md b/docs/14_assets_interface.md index c691f7d5..56c7597d 100644 --- a/docs/14_assets_interface.md +++ b/docs/14_assets_interface.md @@ -28,6 +28,17 @@ const assets = createAssets({ }); ``` +To make the worker-backend shell command available, attach the +client when constructing the `Workspace`: + +```ts +const ws = new Workspace({ + storage: ctx.storage, + backends: [new WorkerBackend(/* ... */)], + assets: (ws) => createAssets({ ws, bucket: env.ASSETS, s3: { bucket: "agent-assets" }, env }), +}); +``` + `createAssets` binds the workspace and the bucket once and returns a client. The shape mirrors `createGitClient({ ws })`: bind the dependencies up front so each `share` call only takes the path and @@ -115,6 +126,24 @@ The R2 binding alone can't mint presigned URLs, which is why the credentials are needed on top of it. Create an R2 API token scoped to the bucket and supply its keys through the environment. +## Worker-backend shell command + +When a `Workspace` is constructed with an assets client, the worker +backend's just-bash shell exposes: + +```sh +assets publish [] +``` + +The command writes the share URL to stdout. `` may be absolute +or relative to the current working directory. `` defaults to +one hour; a bare number is milliseconds, and `ms`, `s`, `m`, and `h` +suffixes are accepted. + +The command still runs the publish on the host Durable Object. The +Dynamic Worker does not receive the R2 bucket binding or signing +secrets. + ## Expiry and cleanup `expiresAfter` controls how long the URL works, not how long the diff --git a/examples/think/README.md b/examples/think/README.md index d7ae2bd5..2ac994d5 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -67,9 +67,12 @@ just emit JSON. The `share` tool uploads a workspace file to R2 and returns a time-limited link, so the agent can hand the user an artifact it -produced. It is registered only when the R2 credentials below are -set; without them the agent runs unchanged and the tool is omitted. -See [`docs/14_assets_interface.md`](../../docs/14_assets_interface.md). +produced. The worker backend shell also exposes +`assets publish []`, which prints the same kind of +link to stdout from `exec`. Both are registered only when the R2 +credentials below are set; without them the agent runs unchanged and +the share surfaces are omitted. See +[`docs/14_assets_interface.md`](../../docs/14_assets_interface.md). `exec` is wired to a Workspace with two backends: a `"shell"` backend (just-bash in a Dynamic Worker through `env.LOADER`) and @@ -192,8 +195,8 @@ wrangler secret put R2_SECRET_ACCESS_KEY wrangler secret put CLOUDFLARE_ACCOUNT_ID ``` -Without these the worker still runs; the `share` tool is simply not -offered to the model. +Without these the worker still runs; the `share` tool is not offered +to the model, and `assets publish` is not configured in the shell. No GitHub auth, no Artifacts. The issue must be on a public repository. diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 9bec1253..117b178f 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -34,6 +34,7 @@ import { WorkspaceServiceProxy, type WorkspaceStub, } from "@cloudflare/workspace"; +import { createAssets } from "@cloudflare/workspace/assets"; import { CloudflareContainerBackend, withWorkspaceContainer, @@ -194,6 +195,17 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { mounts: { "/workspace/.agents": R2Bucket(env.R2_SKILLS, { prefix: ".agents/" }), }, + ...(env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY + ? { + assets: (ws: Workspace) => + createAssets({ + ws, + bucket: env.ASSETS, + s3: { bucket: "think-example-assets" }, + env: env as unknown as Record, + }), + } + : {}), }); // Hand Think an adapter that satisfies its WorkspaceLike, so the diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 2c2158ea..09d008ce 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -54,7 +54,9 @@ uniform; the counts are just always zero. [`docs/13_git_interface.md`](../../docs/13_git_interface.md). - `createAssets` (from `@cloudflare/workspace/assets`) — `share` a workspace file to an R2 bucket and get back a presigned URL. - Binds the workspace and bucket once, like `workspace.git`. See + Binds the workspace and bucket once, like `workspace.git`. When + attached through `WorkspaceOptions.assets`, the worker backend's + shell also exposes `assets publish []`. See [`docs/14_assets_interface.md`](../../docs/14_assets_interface.md). ## Typical DO-side usage diff --git a/packages/workspace/src/backends/worker/assets-command.test.ts b/packages/workspace/src/backends/worker/assets-command.test.ts new file mode 100644 index 00000000..c1def334 --- /dev/null +++ b/packages/workspace/src/backends/worker/assets-command.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { defineAssetsCommand } from "./assets-command.js"; + +function ctx(cwd = "/workspace") { + return { fs: {} as never, cwd, env: new Map(), stdin: "" }; +} + +describe("defineAssetsCommand", () => { + it("publishes an absolute path and writes the URL to stdout", async () => { + const calls: Array<{ path: string; expiresAfter: number }> = []; + const command = defineAssetsCommand({ + assets: { + publish: async (path, options) => { + calls.push({ path, expiresAfter: options.expiresAfter }); + return "https://example.com/shared.png"; + }, + }, + }); + + const result = await command.execute(["publish", "/workspace/out/image.png"], ctx()); + + expect(result).toEqual({ stdout: "https://example.com/shared.png\n", stderr: "", exitCode: 0 }); + expect(calls).toEqual([{ path: "/workspace/out/image.png", expiresAfter: 60 * 60 * 1000 }]); + }); + + it("resolves a relative path against cwd", async () => { + const calls: string[] = []; + const command = defineAssetsCommand({ + assets: { + publish: async (path) => { + calls.push(path); + return "https://example.com/shared.png"; + }, + }, + }); + + await command.execute(["publish", "images/../out/image.png"], ctx("/workspace/project")); + + expect(calls).toEqual(["/workspace/project/out/image.png"]); + }); + + it("uses a numeric expiry as milliseconds", async () => { + const calls: number[] = []; + const command = defineAssetsCommand({ + assets: { + publish: async (_path, options) => { + calls.push(options.expiresAfter); + return "https://example.com/shared.png"; + }, + }, + }); + + await command.execute(["publish", "/workspace/out/image.png", "30000"], ctx()); + + expect(calls).toEqual([30_000]); + }); + + it("supports s, m, and h expiry suffixes", async () => { + const calls: number[] = []; + const command = defineAssetsCommand({ + assets: { + publish: async (_path, options) => { + calls.push(options.expiresAfter); + return "https://example.com/shared.png"; + }, + }, + }); + + await command.execute(["publish", "/workspace/a.png", "30s"], ctx()); + await command.execute(["publish", "/workspace/a.png", "5m"], ctx()); + await command.execute(["publish", "/workspace/a.png", "2h"], ctx()); + + expect(calls).toEqual([30_000, 5 * 60_000, 2 * 60 * 60_000]); + }); + + it("prints usage for an unknown subcommand", async () => { + const command = defineAssetsCommand({ assets: { publish: async () => "" } }); + + const result = await command.execute(["nope"], ctx()); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("usage: assets publish []"); + }); + + it("returns a command failure when publishing throws", async () => { + const command = defineAssetsCommand({ + assets: { + publish: async () => { + throw new Error("missing file"); + }, + }, + }); + + const result = await command.execute(["publish", "/workspace/nope.png"], ctx()); + + expect(result).toEqual({ stdout: "", stderr: "assets: missing file\n", exitCode: 1 }); + }); +}); diff --git a/packages/workspace/src/backends/worker/assets-command.ts b/packages/workspace/src/backends/worker/assets-command.ts new file mode 100644 index 00000000..455e3913 --- /dev/null +++ b/packages/workspace/src/backends/worker/assets-command.ts @@ -0,0 +1,79 @@ +// `assets` custom command for the worker-backend's shell isolate. +// +// Like the `git` command, this is only a thin bridge. The Dynamic +// Worker does not receive the R2 binding or signing secrets; it +// forwards `assets publish []` to the host Workspace +// stub, and the host publishes through the assets client configured +// on the Workspace. + +import { type CustomCommand, defineCommand } from "just-bash"; + +const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; +const USAGE = "usage: assets publish []\n"; + +export interface AssetsCommandHost { + assets?: { + publish(path: string, options: { expiresAfter: number }): Promise; + }; +} + +export function defineAssetsCommand(ws: AssetsCommandHost): CustomCommand { + return defineCommand("assets", async (args, ctx) => { + if (args[0] !== "publish" || args.length < 2 || args.length > 3) { + return { stdout: "", stderr: USAGE, exitCode: 2 }; + } + if (ws.assets === undefined) { + return { + stdout: "", + stderr: "assets: publishing is not configured for this workspace\n", + exitCode: 1, + }; + } + + const path = resolvePath(ctx.cwd, args[1]); + const expiresAfter = args[2] === undefined ? DEFAULT_EXPIRY_MS : parseExpiry(args[2]); + if (expiresAfter === undefined) { + return { + stdout: "", + stderr: `assets: invalid expiry ${JSON.stringify(args[2])}\n`, + exitCode: 2, + }; + } + + try { + const url = await ws.assets.publish(path, { expiresAfter }); + return { stdout: `${url}\n`, stderr: "", exitCode: 0 }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return { stdout: "", stderr: `assets: ${message}\n`, exitCode: 1 }; + } + }); +} + +function parseExpiry(input: string): number | undefined { + const match = input.match(/^(\d+)(ms|s|m|h)?$/); + if (!match) return undefined; + const value = Number(match[1]); + const unit = match[2] ?? "ms"; + const multiplier = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000; + const result = value * multiplier; + return result > 0 && Number.isSafeInteger(result) ? result : undefined; +} + +function resolvePath(cwd: string, path: string): string { + if (path.startsWith("/")) return normalizeAbsolute(path); + return normalizeAbsolute(`${cwd}/${path}`); +} + +function normalizeAbsolute(path: string): string { + const parts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + parts.pop(); + continue; + } + parts.push(part); + } + return `/${parts.join("/")}`; +} diff --git a/packages/workspace/src/backends/worker/entrypoint.test.ts b/packages/workspace/src/backends/worker/entrypoint.test.ts index 3574747a..2eb2d8a3 100644 --- a/packages/workspace/src/backends/worker/entrypoint.test.ts +++ b/packages/workspace/src/backends/worker/entrypoint.test.ts @@ -314,11 +314,55 @@ describe("ShellWorker", () => { }); }); + describe("`assets` custom command wiring", () => { + it("runs `assets publish` end-to-end through real Bash", async () => { + const calls: Array<{ path: string; expiresAfter: number }> = []; + const workspace = new Workspace({ + storage: new SQLiteTestStorage() as never, + backends: [noopBackend()], + assets: { + async share(path, options) { + calls.push({ path, expiresAfter: options.expiresAfter }); + return "https://example.com/shared.txt"; + }, + }, + }); + await workspace.ready(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + const stub = workspace.stub(); + try { + const worker = new ShellWorker( + undefined as never, + { + HOST: { + async getWorkspace() { + return stub as unknown as FakeWorkspace; + }, + }, + } as never, + ); + const events = (await drain( + ( + await worker.exec({ command: "assets publish out.txt 30s", cwd: "/workspace" }) + ).events, + )) as { name: string; value: string | number }[]; + const stdout = events.find((e) => e.name === "stdout"); + const exit = events.find((e) => e.name === "exit"); + expect(stdout?.value).toBe("https://example.com/shared.txt\n"); + expect(exit?.value).toBe(0); + expect(calls).toEqual([{ path: "/workspace/out.txt", expiresAfter: 30_000 }]); + } finally { + stub[Symbol.dispose](); + await workspace.close(); + } + }); + }); + // Lightweight structural check that doesn't need a real fs: the // protected extraCommands() hook is invoked and its output is - // appended after the built-in git command. + // appended after the built-in commands. describe("`extraCommands` ordering", () => { - it("appends extraCommands() output after the built-in git command", async () => { + it("appends extraCommands() output after the built-in commands", async () => { let seen: import("just-bash").CustomCommand[] = []; class WithExtras extends ShellWorker { protected override extraCommands(): import("just-bash").CustomCommand[] { @@ -340,7 +384,7 @@ describe("ShellWorker", () => { } const worker = TestWithExtras.spy(); await drain((await worker.exec({ command: "alpha" })).events); - expect(seen.map((c) => c.name)).toEqual(["git", "alpha", "beta"]); + expect(seen.map((c) => c.name)).toEqual(["git", "assets", "alpha", "beta"]); }); }); }); diff --git a/packages/workspace/src/backends/worker/entrypoint.ts b/packages/workspace/src/backends/worker/entrypoint.ts index f5fdfad0..4dc794b3 100644 --- a/packages/workspace/src/backends/worker/entrypoint.ts +++ b/packages/workspace/src/backends/worker/entrypoint.ts @@ -19,6 +19,7 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import { Bash, type CustomCommand } from "just-bash"; import { WorkspaceFsAdapter } from "./adapter.js"; +import { type AssetsCommandHost, defineAssetsCommand } from "./assets-command.js"; import { defineGitCommand, type GitCommandHost } from "./git-command.js"; export interface ExecInput { @@ -61,11 +62,11 @@ export interface ShellHostFetcher { } // The shape getWorkspace() returns. The shell touches .fs (for -// the adapter behind every just-bash filesystem call) and .git -// (for the `git` custom command the shell registers). Workers -// RPC happens to carry this with [Symbol.dispose]; the shell -// disposes it after Bash settles. -export interface HostWorkspaceStub extends GitCommandHost { +// the adapter behind every just-bash filesystem call), .git (for +// the `git` custom command), and optionally .assets (for +// `assets publish`). Workers RPC happens to carry this with +// [Symbol.dispose]; the shell disposes it after Bash settles. +export interface HostWorkspaceStub extends GitCommandHost, AssetsCommandHost { fs: import("./adapter.js").WorkspaceFs; [Symbol.dispose]?: () => void; } @@ -136,7 +137,11 @@ export class ShellWorker< // execs get two stubs; no instance field, no race. const ws = await this.env.HOST.getWorkspace(); - const customCommands: CustomCommand[] = [defineGitCommand(ws), ...this.extraCommands(ws)]; + const customCommands: CustomCommand[] = [ + defineGitCommand(ws), + defineAssetsCommand(ws), + ...this.extraCommands(ws), + ]; let result: { stdout: string; stderr: string; exitCode: number }; try { diff --git a/packages/workspace/src/backends/worker/index.ts b/packages/workspace/src/backends/worker/index.ts index 780149dd..90742fc2 100644 --- a/packages/workspace/src/backends/worker/index.ts +++ b/packages/workspace/src/backends/worker/index.ts @@ -22,6 +22,7 @@ // `loader` + `workspace` + `ctx`). export { type WorkspaceFs, WorkspaceFsAdapter } from "./adapter.js"; +export { type AssetsCommandHost, defineAssetsCommand } from "./assets-command.js"; export { type ExecInput, ShellWorker, type ShellWorkerOptions } from "./entrypoint.js"; export { SHELL_MODULES } from "./generated-bundle.js"; export { defineGitCommand, type GitCommandHost } from "./git-command.js"; diff --git a/packages/workspace/src/index.ts b/packages/workspace/src/index.ts index ee6af94b..102829e1 100644 --- a/packages/workspace/src/index.ts +++ b/packages/workspace/src/index.ts @@ -57,6 +57,7 @@ export type { } from "./shell.js"; export { WorkspaceShell } from "./shell.js"; export { + WorkspaceAssetsStub, WorkspaceExecHandleStub, type WorkspaceExecOptions, type WorkspaceExecResult, diff --git a/packages/workspace/src/stub.test.ts b/packages/workspace/src/stub.test.ts index 423182f7..1b6d198f 100644 --- a/packages/workspace/src/stub.test.ts +++ b/packages/workspace/src/stub.test.ts @@ -17,6 +17,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; import { + WorkspaceAssetsStub, WorkspaceExecHandleStub, WorkspaceFilesystemStub, WorkspaceGitStub, @@ -101,11 +102,14 @@ function snapshotOf(names: string[]): Record { async function withStub( fn: (ws: Workspace) => T | Promise, - options?: { backend?: WorkspaceBackend }, + options?: Pick[0], "assets"> & { + backend?: WorkspaceBackend; + }, ): Promise { const ws = new Workspace({ storage: new SQLiteTestStorage(), backends: [options?.backend ?? backend()], + assets: options?.assets, }); try { await ws.ready(); @@ -116,7 +120,7 @@ async function withStub( } describe("WorkspaceStub", () => { - it("exposes fs, shell, and git as accessor properties (RPC visibility)", async () => { + it("exposes fs, shell, git, and optional assets as accessor properties (RPC visibility)", async () => { // Plain readonly fields would land as private isolate state on // the RPC stub and report "method not implemented". The class // uses getters; pin that here by checking the descriptor. @@ -125,15 +129,39 @@ describe("WorkspaceStub", () => { const fsDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "fs"); const shellDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "shell"); const gitDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "git"); + const assetsDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(stub), "assets"); expect(fsDesc?.get).toBeTypeOf("function"); expect(shellDesc?.get).toBeTypeOf("function"); expect(gitDesc?.get).toBeTypeOf("function"); + expect(assetsDesc?.get).toBeTypeOf("function"); expect(stub.fs).toBeInstanceOf(WorkspaceFilesystemStub); expect(stub.shell).toBeInstanceOf(WorkspaceShellStub); expect(stub.git).toBeInstanceOf(WorkspaceGitStub); + expect(stub.assets).toBeUndefined(); }); }); + it("assets.publish forwards to the configured assets client", async () => { + const calls: Array<{ path: string; expiresAfter: number }> = []; + await withStub( + async (ws) => { + const stub = ws.stub(); + expect(stub.assets).toBeInstanceOf(WorkspaceAssetsStub); + const url = await stub.assets?.publish("/workspace/out.png", { expiresAfter: 30_000 }); + expect(url).toBe("https://example.com/out.png"); + expect(calls).toEqual([{ path: "/workspace/out.png", expiresAfter: 30_000 }]); + }, + { + assets: { + async share(path, options) { + calls.push({ path, expiresAfter: options.expiresAfter }); + return "https://example.com/out.png"; + }, + }, + }, + ); + }); + it("git.cli forwards through to the underlying Workspace", async () => { await withStub(async (ws) => { const stub = ws.stub(); diff --git a/packages/workspace/src/stub.ts b/packages/workspace/src/stub.ts index aa6083ad..988ee0f6 100644 --- a/packages/workspace/src/stub.ts +++ b/packages/workspace/src/stub.ts @@ -57,6 +57,7 @@ import type { import { trackStub, untrackStub } from "@cloudflare/workspace-rpc/debug"; import { RpcTarget } from "capnweb"; +import type { ShareOptions } from "./assets/index.js"; import type { GitCliInput, GitCliResult } from "./git/index.js"; import { withSpan } from "./observe.js"; import type { ExecResult } from "./shell.js"; @@ -285,6 +286,34 @@ export class WorkspaceExecHandleStub e // side; their inputs (progress callbacks, `onAuth`) don't // cross Workers RPC cleanly, and the CLI path covers every // consumer who needs git access through a stub. +export class WorkspaceAssetsStub extends RpcTarget { + readonly #ws: Workspace; + + constructor(ws: Workspace) { + super(); + this.#ws = ws; + trackStub(this); + } + + [Symbol.dispose](): void { + untrackStub(this); + } + + publish(path: string, options: Pick): Promise { + return withSpan( + this.#ws.observer, + "workspace.assets.publish", + { "workspace.fs.path": path, "workspace.assets.expires_after_ms": options.expiresAfter }, + async () => { + if (this.#ws.assets === undefined) { + throw new Error("Workspace assets are not configured"); + } + return this.#ws.assets.share(path, { expiresAfter: options.expiresAfter }); + }, + ); + } +} + export class WorkspaceGitStub extends RpcTarget { readonly #ws: Workspace; @@ -400,12 +429,14 @@ export class WorkspaceStub extends RpcTarget { readonly #fs: WorkspaceFilesystemStub; readonly #shell: WorkspaceShellStub; readonly #git: WorkspaceGitStub; + readonly #assets: WorkspaceAssetsStub | undefined; constructor(ws: Workspace) { super(); this.#fs = new WorkspaceFilesystemStub(ws); this.#shell = new WorkspaceShellStub(ws); this.#git = new WorkspaceGitStub(ws); + this.#assets = ws.assets === undefined ? undefined : new WorkspaceAssetsStub(ws); trackStub(this); } @@ -419,6 +450,7 @@ export class WorkspaceStub extends RpcTarget { this.#fs[Symbol.dispose](); this.#shell[Symbol.dispose](); this.#git[Symbol.dispose](); + this.#assets?.[Symbol.dispose](); untrackStub(this); } @@ -433,4 +465,8 @@ export class WorkspaceStub extends RpcTarget { get git(): WorkspaceGitStub { return this.#git; } + + get assets(): WorkspaceAssetsStub | undefined { + return this.#assets; + } } diff --git a/packages/workspace/src/workspace.ts b/packages/workspace/src/workspace.ts index bdabb067..44681ba0 100644 --- a/packages/workspace/src/workspace.ts +++ b/packages/workspace/src/workspace.ts @@ -19,6 +19,7 @@ import { } from "@cloudflare/dofs"; import { pullOnce, pushOnce, reconcileWatermarks } from "@cloudflare/workspace-rpc/driver"; +import type { AssetsClient } from "./assets/index.js"; import type { BackendHandle, WorkspaceBackend } from "./backend.js"; import { createGitClient, type GitClient, type GitIdentity } from "./git/index.js"; import { MountIndex } from "./mounts/index.js"; @@ -71,6 +72,12 @@ export interface WorkspaceOptions { // `GIT_COMMITTER_*` env vars supply one. Threaded through to // `createGitClient` on first access to `workspace.git`. defaultGitIdentity?: GitIdentity; + + // Optional assets publisher used by WorkspaceStub and the worker + // backend's `assets publish` shell command. Pass an AssetsClient + // directly, or a factory when the publisher needs the Workspace + // instance itself (for example, createAssets({ ws, ... })). + assets?: AssetsClient | ((ws: Workspace) => AssetsClient); } export class Workspace { @@ -88,6 +95,7 @@ export class Workspace { readonly #now: () => number; readonly #sessionId: string; readonly #defaultGitIdentity: GitIdentity | undefined; + readonly #assets: AssetsClient | undefined; // Lazily-constructed git client, cached so the dynamic // imports of isomorphic-git / diff land once per Workspace. #git: GitClient | undefined; @@ -148,6 +156,7 @@ export class Workspace { fs: this.#fs, mounts: this.#mounts, }); + this.#assets = typeof options.assets === "function" ? options.assets(this) : options.assets; } // Force every registered mount to materialize. Idempotent; safe to @@ -200,6 +209,13 @@ export class Workspace { return this.#sessionId; } + // Optional assets publisher. Exposed through WorkspaceStub so + // the worker backend's shell can run `assets publish` without + // receiving R2 bindings or signing secrets in the Dynamic Worker. + get assets(): AssetsClient | undefined { + return this.#assets; + } + // Git facade. Available immediately and does not require a // backend — every supported subcommand reads and writes through // the local SQLite-backed VFS. The dynamic imports for From 03ab061b4fcb06e95d8fc40068b4437e55b01893 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:24:39 +0100 Subject: [PATCH 15/17] examples/think: regenerate worker-configuration.d.ts --- examples/think/worker-configuration.d.ts | 14 ++++++++++---- examples/think/wrangler.jsonc | 6 ++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/examples/think/worker-configuration.d.ts b/examples/think/worker-configuration.d.ts index 39468791..3e9e60b1 100644 --- a/examples/think/worker-configuration.d.ts +++ b/examples/think/worker-configuration.d.ts @@ -1,14 +1,14 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: c70665f77cf21cbe8813ef49f865eb38) +// Generated by Wrangler by running `wrangler types` (hash: fe68b8cfdd57ae556525b0ee1a811942) // Runtime types generated with workerd@1.20260529.1 2026-05-26 nodejs_compat interface __BaseEnv_Env { R2_SKILLS: R2Bucket; ASSETS: R2Bucket; - R2_ACCESS_KEY_ID?: string; - R2_SECRET_ACCESS_KEY?: string; - CLOUDFLARE_ACCOUNT_ID?: string; LOADER: WorkerLoader; AI: Ai; + R2_ACCESS_KEY_ID: ""; + R2_SECRET_ACCESS_KEY: ""; + CLOUDFLARE_ACCOUNT_ID: ""; TriageAgent: DurableObjectNamespace; TRIAGE_WORKFLOW: Workflow[0]['payload']>; } @@ -20,6 +20,12 @@ declare namespace Cloudflare { interface Env extends __BaseEnv_Env {} } interface Env extends __BaseEnv_Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} // Begin runtime types /*! ***************************************************************************** diff --git a/examples/think/wrangler.jsonc b/examples/think/wrangler.jsonc index fbf02742..8a658c57 100644 --- a/examples/think/wrangler.jsonc +++ b/examples/think/wrangler.jsonc @@ -63,5 +63,11 @@ } ], + "vars": { + "R2_ACCESS_KEY_ID": "", + "R2_SECRET_ACCESS_KEY": "", + "CLOUDFLARE_ACCOUNT_ID": "" + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TriageAgent"] }] } From 63496f34a1d22319d662dce4db2a0c7420caea0f Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:40:31 +0000 Subject: [PATCH 16/17] examples/think, workspace: tighten assets configuration guard Require an endpoint source before wiring the think example's assets publisher or share tool. R2 credentials alone are not enough because the assets signer derives its endpoint from CLOUDFLARE_ACCOUNT_ID or uses R2_ENDPOINT when one is provided. Without this guard, a partially configured deployment could throw during Workspace construction or while building the toolset. Also restore the WorkspaceGitStub documentation to the git stub and add a separate comment for WorkspaceAssetsStub so each RPC surface is described by the right block. --- examples/think/README.md | 3 +++ examples/think/src/agent.ts | 12 +++++++++-- examples/think/worker-configuration.d.ts | 4 ++++ packages/workspace/src/stub.ts | 27 ++++++++++++++---------- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/examples/think/README.md b/examples/think/README.md index 2ac994d5..26052218 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -195,6 +195,9 @@ wrangler secret put R2_SECRET_ACCESS_KEY wrangler secret put CLOUDFLARE_ACCOUNT_ID ``` +`R2_ENDPOINT` can be set instead of `CLOUDFLARE_ACCOUNT_ID` when +using a custom S3-compatible endpoint. + Without these the worker still runs; the `share` tool is not offered to the model, and `assets publish` is not configured in the shell. diff --git a/examples/think/src/agent.ts b/examples/think/src/agent.ts index 117b178f..3113690e 100644 --- a/examples/think/src/agent.ts +++ b/examples/think/src/agent.ts @@ -115,6 +115,14 @@ const PHASE_KEY = "triage-phase"; const REPO_ROOT = "/workspace/repo"; const MODEL_ID = "@cf/moonshotai/kimi-k2.6"; +function hasAssetsConfig(env: Env): boolean { + return Boolean( + env.R2_ACCESS_KEY_ID && + env.R2_SECRET_ACCESS_KEY && + (env.CLOUDFLARE_ACCOUNT_ID || env.R2_ENDPOINT), + ); +} + // Anchor Think's generic before the mixin so withWorkspaceContainer // sees a concrete constructor. class TriageBase extends Think {} @@ -195,7 +203,7 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { mounts: { "/workspace/.agents": R2Bucket(env.R2_SKILLS, { prefix: ".agents/" }), }, - ...(env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY + ...(hasAssetsConfig(env) ? { assets: (ws: Workspace) => createAssets({ @@ -539,7 +547,7 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { // bucket binding alone can't mint the presigned URL the tool // returns. Absent credentials, the agent simply has no share // tool rather than one that fails on every call. - ...(this.env.R2_ACCESS_KEY_ID && this.env.R2_SECRET_ACCESS_KEY + ...(hasAssetsConfig(this.env) ? { share: createShareTool({ workspace: ws, diff --git a/examples/think/worker-configuration.d.ts b/examples/think/worker-configuration.d.ts index 3e9e60b1..bfb389b2 100644 --- a/examples/think/worker-configuration.d.ts +++ b/examples/think/worker-configuration.d.ts @@ -4,6 +4,10 @@ interface __BaseEnv_Env { R2_SKILLS: R2Bucket; ASSETS: R2Bucket; + R2_ACCESS_KEY_ID?: string; + R2_SECRET_ACCESS_KEY?: string; + CLOUDFLARE_ACCOUNT_ID?: string; + R2_ENDPOINT?: string; LOADER: WorkerLoader; AI: Ai; R2_ACCESS_KEY_ID: ""; diff --git a/packages/workspace/src/stub.ts b/packages/workspace/src/stub.ts index 988ee0f6..21602129 100644 --- a/packages/workspace/src/stub.ts +++ b/packages/workspace/src/stub.ts @@ -275,17 +275,11 @@ export class WorkspaceExecHandleStub e } } -// Git half. Pure value returns — every method takes JSRPC- -// friendly inputs (strings, plain objects) and resolves to a -// plain `{ stdout, stderr, exitCode }`. No nested stubs to -// dispose; the parent `WorkspaceStub` cascades disposal into -// this one for symmetry with `fs` / `shell`. -// -// Only `cli` is surfaced across the wire. The typed `clone` / -// `diff` / `log` / etc. methods stay on the durable-object -// side; their inputs (progress callbacks, `onAuth`) don't -// cross Workers RPC cleanly, and the CLI path covers every -// consumer who needs git access through a stub. +// Assets half. Pure value returns: `publish(path, { expiresAfter })` +// resolves to the share URL string. The configured assets client +// lives on the durable-object side where the R2 binding and signing +// secrets are available; the worker-backend shell only reaches this +// stub through the `assets publish` custom command. export class WorkspaceAssetsStub extends RpcTarget { readonly #ws: Workspace; @@ -314,6 +308,17 @@ export class WorkspaceAssetsStub extends RpcTarget { } } +// Git half. Pure value returns — every method takes JSRPC- +// friendly inputs (strings, plain objects) and resolves to a +// plain `{ stdout, stderr, exitCode }`. No nested stubs to +// dispose; the parent `WorkspaceStub` cascades disposal into +// this one for symmetry with `fs` / `shell`. +// +// Only `cli` is surfaced across the wire. The typed `clone` / +// `diff` / `log` / etc. methods stay on the durable-object +// side; their inputs (progress callbacks, `onAuth`) don't +// cross Workers RPC cleanly, and the CLI path covers every +// consumer who needs git access through a stub. export class WorkspaceGitStub extends RpcTarget { readonly #ws: Workspace; From 8534ff0903396af201d3ea84726df78b9b195cbf Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:07:22 +0100 Subject: [PATCH 17/17] examples/think: update worker-configuration.d.ts again --- examples/think/worker-configuration.d.ts | 9 +++------ examples/think/wrangler.jsonc | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/examples/think/worker-configuration.d.ts b/examples/think/worker-configuration.d.ts index bfb389b2..8534a26e 100644 --- a/examples/think/worker-configuration.d.ts +++ b/examples/think/worker-configuration.d.ts @@ -1,15 +1,12 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: fe68b8cfdd57ae556525b0ee1a811942) +// Generated by Wrangler by running `wrangler types` (hash: 44af592aef8c53af45699b694caac37e) // Runtime types generated with workerd@1.20260529.1 2026-05-26 nodejs_compat interface __BaseEnv_Env { R2_SKILLS: R2Bucket; ASSETS: R2Bucket; - R2_ACCESS_KEY_ID?: string; - R2_SECRET_ACCESS_KEY?: string; - CLOUDFLARE_ACCOUNT_ID?: string; - R2_ENDPOINT?: string; LOADER: WorkerLoader; AI: Ai; + R2_ENDPOINT: ""; R2_ACCESS_KEY_ID: ""; R2_SECRET_ACCESS_KEY: ""; CLOUDFLARE_ACCOUNT_ID: ""; @@ -28,7 +25,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/examples/think/wrangler.jsonc b/examples/think/wrangler.jsonc index 8a658c57..1b4dba2e 100644 --- a/examples/think/wrangler.jsonc +++ b/examples/think/wrangler.jsonc @@ -64,6 +64,7 @@ ], "vars": { + "R2_ENDPOINT": "", "R2_ACCESS_KEY_ID": "", "R2_SECRET_ACCESS_KEY": "", "CLOUDFLARE_ACCOUNT_ID": ""