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 new file mode 100644 index 00000000..56c7597d --- /dev/null +++ b/docs/14_assets_interface.md @@ -0,0 +1,154 @@ +# 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, +}); +``` + +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 +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. + +## 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 +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/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 new file mode 100644 index 00000000..4faeb3eb --- /dev/null +++ b/examples/assets/.gitignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +.wrangler/ +.dev.vars* +!.dev.vars.example diff --git a/examples/assets/README.md b/examples/assets/README.md new file mode 100644 index 00000000..2e0f654b --- /dev/null +++ b/examples/assets/README.md @@ -0,0 +1,101 @@ +# 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. 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 +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 + .dev.vars.example R2 S3 credential names; copy to .dev.vars + 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..a3fef446 --- /dev/null +++ b/examples/assets/src/index.ts @@ -0,0 +1,194 @@ +// 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 } +// +// 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 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"); + } + 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/examples/think/README.md b/examples/think/README.md index 3f287597..26052218 100644 --- a/examples/think/README.md +++ b/examples/think/README.md @@ -63,6 +63,16 @@ 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. 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 @@ -168,6 +178,28 @@ 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 +``` + +`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. 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 6d9fa00c..3113690e 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, @@ -51,6 +52,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 @@ -113,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 {} @@ -193,6 +203,17 @@ export class TriageAgent extends withWorkspaceContainer(TriageBase) { mounts: { "/workspace/.agents": R2Bucket(env.R2_SKILLS, { prefix: ".agents/" }), }, + ...(hasAssetsConfig(env) + ? { + 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 @@ -522,6 +543,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. + ...(hasAssetsConfig(this.env) + ? { + 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..8534a26e 100644 --- a/examples/think/worker-configuration.d.ts +++ b/examples/think/worker-configuration.d.ts @@ -1,10 +1,15 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: c70665f77cf21cbe8813ef49f865eb38) +// 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; LOADER: WorkerLoader; AI: Ai; + R2_ENDPOINT: ""; + R2_ACCESS_KEY_ID: ""; + R2_SECRET_ACCESS_KEY: ""; + CLOUDFLARE_ACCOUNT_ID: ""; TriageAgent: DurableObjectNamespace; TRIAGE_WORKFLOW: Workflow[0]['payload']>; } @@ -16,6 +21,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 48345245..1b4dba2e 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" } ], @@ -55,5 +63,12 @@ } ], + "vars": { + "R2_ENDPOINT": "", + "R2_ACCESS_KEY_ID": "", + "R2_SECRET_ACCESS_KEY": "", + "CLOUDFLARE_ACCOUNT_ID": "" + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TriageAgent"] }] } 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": "*", diff --git a/packages/workspace/README.md b/packages/workspace/README.md index 194815ad..09d008ce 100644 --- a/packages/workspace/README.md +++ b/packages/workspace/README.md @@ -52,6 +52,12 @@ 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`. 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/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", 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; +} diff --git a/packages/workspace/src/assets/index.test.ts b/packages/workspace/src/assets/index.test.ts new file mode 100644 index 00000000..4383ad34 --- /dev/null +++ b/packages/workspace/src/assets/index.test.ts @@ -0,0 +1,298 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +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; +} + +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 fixedLength = fixedLengthReads.get(value); + 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, + fixedLength, + 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(puts[0].fixedLength).toBe(7); + 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/, + ); + }); + + 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", () => { + 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 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/); + }); +}); diff --git a/packages/workspace/src/assets/index.ts b/packages/workspace/src/assets/index.ts new file mode 100644 index 00000000..da68a42d --- /dev/null +++ b/packages/workspace/src/assets/index.ts @@ -0,0 +1,245 @@ +// 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. 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. + +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: 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>; + }; +} + +// 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", + }; +} + +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; + 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 [{ 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, + 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/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; +} 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"); +} 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/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..21602129 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"; @@ -274,6 +275,39 @@ export class WorkspaceExecHandleStub e } } +// 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; + + 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 }); + }, + ); + } +} + // 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 @@ -400,12 +434,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 +455,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 +470,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 3ee2aaf1..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 { @@ -86,7 +93,9 @@ export class Workspace { readonly #defaultBackendId: string | undefined; readonly #observer: WorkspaceObserver; 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; @@ -114,6 +123,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); @@ -146,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 @@ -190,6 +201,21 @@ 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; + } + + // 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 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"] }