diff --git a/.changeset/fix-complete-skip-skills-prompt.md b/.changeset/fix-complete-skip-skills-prompt.md new file mode 100644 index 0000000000..063672d68c --- /dev/null +++ b/.changeset/fix-complete-skip-skills-prompt.md @@ -0,0 +1,9 @@ +--- +"wrangler": patch +--- + +Fix `wrangler complete` printing the AI skills prompt into shell completion output + +Previously, running `eval "$(wrangler complete zsh)"` (or any other shell) would fail with errors like `zsh: command not found: --install-skills` because the interactive AI agent skills installation prompt was included in the completion script output. + +The skills prompt is now skipped when running `wrangler complete`, so the generated completion script is clean and can be sourced correctly. diff --git a/.changeset/fix-workflow-schedules-cron-mapping.md b/.changeset/fix-workflow-schedules-cron-mapping.md new file mode 100644 index 0000000000..80dac3a565 --- /dev/null +++ b/.changeset/fix-workflow-schedules-cron-mapping.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fix Workflows `schedules` deploy payload to match the control plane API + +When deploying a Workflow with a `schedules` binding property, Wrangler sent the cron expressions as a list of strings. The Workflows API expects a list of objects of the form `{ cron: string }`, so the request was rejected. Wrangler now maps each configured cron expression to `{ cron }` (normalizing a single string or an array) when building the request. The user-facing config still accepts a string or an array of strings. diff --git a/.changeset/vite-plugin-websocket-upgrade-headers.md b/.changeset/vite-plugin-websocket-upgrade-headers.md new file mode 100644 index 0000000000..ea9fbd7751 --- /dev/null +++ b/.changeset/vite-plugin-websocket-upgrade-headers.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Forward response headers from the Worker on WebSocket upgrade responses + +Headers set on a `new Response(null, { status: 101, webSocket, headers })` returned from the Worker are now propagated to the upgrade response sent to the browser during `vite dev`. Previously the headers were dropped, so cookies (`Set-Cookie`) and custom headers (`X-*`) on WebSocket handshake responses were invisible client-side — even though they were delivered correctly by `wrangler dev`. diff --git a/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts index 022e12cc17..222a24871f 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts @@ -205,4 +205,129 @@ describe("handleWebSocket", () => { }); expect(`${url}`).toBe(`http://127.0.0.1:${port}/`); }); + + // https://github.com/cloudflare/workers-sdk/issues/10390 + test("does not forward framing headers from the Worker response", async ({ + expect, + }) => { + // Defense in depth: even if a Worker returns framing headers, they + // must not leak onto the 101 response (no body, so they're nonsensical). + // Handshake headers (`Sec-WebSocket-*`, `Connection`, `Upgrade`) are + // rejected upstream by miniflare's own validation before reaching the + // forwarding code, so they're not exercised here. + await miniflare.dispose(); + miniflare = new Miniflare({ + modules: true, + script: `export default { + fetch() { + const [client, server] = Object.values(new WebSocketPair()); + server.accept(); + const headers = new Headers({ + "X-Hello": "testing", + "Transfer-Encoding": "chunked", + "Content-Length": "42", + }); + return new Response(null, { + status: 101, + webSocket: client, + headers, + }); + } + }`, + }); + await listen(); + + const socket = net.connect(port, "127.0.0.1"); + await new Promise((r) => socket.on("connect", r)); + + const chunks: Buffer[] = []; + socket.on("data", (chunk) => chunks.push(chunk)); + + socket.write( + "GET / HTTP/1.1\r\n" + + `Host: 127.0.0.1:${port}\r\n` + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n" + ); + + await vi.waitFor(() => { + const raw = Buffer.concat(chunks).toString("utf8"); + expect(raw).toContain("HTTP/1.1 101"); + expect(raw).toContain("\r\n\r\n"); + }); + + const raw = Buffer.concat(chunks).toString("utf8"); + const headerBlock = raw.slice(0, raw.indexOf("\r\n\r\n")); + + // Non-excluded header still forwarded. + expect(headerBlock).toContain("x-hello: testing"); + + // Excluded framing headers must NOT appear on the 101 response. + expect(headerBlock).not.toMatch(/Transfer-Encoding: chunked/i); + expect(headerBlock).not.toMatch(/Content-Length: 42/i); + + socket.destroy(); + }); + + // https://github.com/cloudflare/workers-sdk/issues/10390 + test("forwards response headers from the Worker on the 101 upgrade response", async ({ + expect, + }) => { + // Override the default Miniflare instance with a Worker that returns + // custom headers (including two Set-Cookie entries) on the upgrade + // response. + await miniflare.dispose(); + miniflare = new Miniflare({ + modules: true, + script: `export default { + fetch() { + const [client, server] = Object.values(new WebSocketPair()); + server.accept(); + const headers = new Headers({ "X-Hello": "testing" }); + headers.append("Set-Cookie", "session=abc; Path=/"); + headers.append("Set-Cookie", "theme=dark; Path=/; HttpOnly"); + return new Response(null, { + status: 101, + webSocket: client, + headers, + }); + } + }`, + }); + await listen(); + + const socket = net.connect(port, "127.0.0.1"); + await new Promise((r) => socket.on("connect", r)); + + const chunks: Buffer[] = []; + socket.on("data", (chunk) => chunks.push(chunk)); + + socket.write( + "GET / HTTP/1.1\r\n" + + `Host: 127.0.0.1:${port}\r\n` + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n" + ); + + await vi.waitFor(() => { + const raw = Buffer.concat(chunks).toString("utf8"); + expect(raw).toContain("HTTP/1.1 101"); + expect(raw).toContain("\r\n\r\n"); + }); + + const raw = Buffer.concat(chunks).toString("utf8"); + const headerBlock = raw.slice(0, raw.indexOf("\r\n\r\n")); + + // Fetch `Headers` normalize names to lowercase; `Set-Cookie` keeps its + // canonical casing because it is appended manually via `getSetCookie()`. + expect(headerBlock).toContain("x-hello: testing"); + expect(headerBlock).toContain("Set-Cookie: session=abc; Path=/"); + expect(headerBlock).toContain("Set-Cookie: theme=dark; Path=/; HttpOnly"); + + socket.destroy(); + }); }); diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts index dce40aa1e9..98bd859c16 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -18,6 +18,27 @@ export function handleWebSocket( ) { const nodeWebSocket = new WebSocketServer({ noServer: true }); + // Stash Worker 101-response headers keyed by the upgrade request so a single + // persistent `headers` listener can apply them when `ws` emits the upgrade + // response. Matches the pattern in `packages/miniflare/src/index.ts`. + // + // Using `once()` per upgrade is unsafe: if `ws.handleUpgrade` aborts before + // emitting `headers` (e.g. malformed `Sec-WebSocket-Key`/`Sec-WebSocket- + // Version`), the listener stays attached and fires on the next successful + // upgrade with stale headers leaked from the previous Worker response. The + // WeakMap entry is GC'd if the request never completes. + const workerResponseHeaders = new WeakMap(); + nodeWebSocket.on( + "headers", + (responseHeaders: string[], request: IncomingMessage) => { + const extra = workerResponseHeaders.get(request); + workerResponseHeaders.delete(request); + if (extra) { + appendWorkerResponseHeaders(responseHeaders, extra); + } + } + ); + httpServer.on( "upgrade", async (request: IncomingMessage, socket: Duplex, head: Buffer) => { @@ -60,6 +81,14 @@ export function handleWebSocket( return; } + // Forward response headers (e.g. Set-Cookie, custom auth headers) from + // the Worker's 101 response onto the upgrade response sent to the + // client. Without this, headers set on a `new Response(null, { status: + // 101, webSocket, headers })` are silently dropped during `vite dev`, + // even though they are delivered correctly by `wrangler dev`. + // See cloudflare/workers-sdk#10390. + workerResponseHeaders.set(request, response.headers); + nodeWebSocket.handleUpgrade( request, socket, @@ -73,6 +102,47 @@ export function handleWebSocket( ); } +/** + * Headers that must not be forwarded on the 101 upgrade response — they are + * either part of the WebSocket handshake managed by `ws` or irrelevant on a + * response with no body. + */ +const EXCLUDED_RESPONSE_HEADERS = new Set([ + "connection", + "content-length", + "sec-websocket-accept", + "sec-websocket-extensions", + "sec-websocket-protocol", + "transfer-encoding", + "upgrade", +]); + +function appendWorkerResponseHeaders( + responseHeaders: string[], + workerHeaders: Headers +) { + // `Set-Cookie` can appear multiple times in a single response. + // `Headers.forEach` collapses them into a single comma-joined value, which + // breaks cookies that themselves contain commas (e.g. Expires). + // `getSetCookie` returns them as a string array. + if (typeof workerHeaders.getSetCookie === "function") { + for (const cookie of workerHeaders.getSetCookie()) { + responseHeaders.push(`Set-Cookie: ${cookie}`); + } + } + + workerHeaders.forEach((value, name) => { + const lower = name.toLowerCase(); + if (lower === "set-cookie") { + return; + } + if (EXCLUDED_RESPONSE_HEADERS.has(lower)) { + return; + } + responseHeaders.push(`${name}: ${value}`); + }); +} + /** * Matches the origin of a Sandbox SDK preview URL. * See: https://developers.cloudflare.com/sandbox/concepts/preview-urls/ diff --git a/packages/wrangler/src/__tests__/deploy/workflows.test.ts b/packages/wrangler/src/__tests__/deploy/workflows.test.ts index 2d28b01620..12fc48862e 100644 --- a/packages/wrangler/src/__tests__/deploy/workflows.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workflows.test.ts @@ -318,7 +318,7 @@ describe("deploy", () => { expect(body).toEqual({ script_name: "test-name", class_name: "MyWorkflow", - schedules: "0 * * * *", + schedules: [{ cron: "0 * * * *" }], }); return HttpResponse.json( createFetchResult({ id: "mock-new-workflow-id" }) @@ -375,7 +375,7 @@ describe("deploy", () => { expect(body).toEqual({ script_name: "test-name", class_name: "MyWorkflow", - schedules: ["0 * * * *", "0 9 * * 1"], + schedules: [{ cron: "0 * * * *" }, { cron: "0 9 * * 1" }], }); return HttpResponse.json( createFetchResult({ id: "mock-new-workflow-id" }) @@ -434,7 +434,7 @@ describe("deploy", () => { script_name: "test-name", class_name: "MyWorkflow", limits: { steps: 5000 }, - schedules: "*/15 * * * *", + schedules: [{ cron: "*/15 * * * *" }], }); return HttpResponse.json( createFetchResult({ id: "mock-new-workflow-id" }) @@ -818,7 +818,7 @@ describe("deploy", () => { expect(body).toEqual({ script_name: "my-app-staging", class_name: "MyWorkflow", - schedules: "0 * * * *", + schedules: [{ cron: "0 * * * *" }], }); return HttpResponse.json( createFetchResult({ id: "mock-new-workflow-id" }) diff --git a/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts b/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts index b83ec19961..c44cb0b72e 100644 --- a/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts +++ b/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts @@ -44,4 +44,23 @@ describe("register-yargs-command skills integration", () => { expect(maybeInstallCloudflareSkillsGlobally).toHaveBeenCalledWith(true); }); + + test("does not call maybeInstallCloudflareSkillsGlobally for `wrangler complete`", async ({ + expect, + }) => { + // `wrangler complete zsh` output is captured by `eval "$(wrangler complete zsh)"`. + // The interactive skills prompt must not appear in that output. + await runWrangler("complete zsh"); + + expect(maybeInstallCloudflareSkillsGlobally).not.toHaveBeenCalled(); + }); + + test("still calls maybeInstallCloudflareSkillsGlobally when --install-skills is explicit on `wrangler complete`", async ({ + expect, + }) => { + // Explicit --install-skills should be honored even for `complete`. + await runWrangler("complete zsh --install-skills"); + + expect(maybeInstallCloudflareSkillsGlobally).toHaveBeenCalledWith(true); + }); }); diff --git a/packages/wrangler/src/complete.ts b/packages/wrangler/src/complete.ts index 97cecf971e..0d9c48e269 100644 --- a/packages/wrangler/src/complete.ts +++ b/packages/wrangler/src/complete.ts @@ -132,6 +132,7 @@ export const completionsCommand = createCommand({ behaviour: { printBanner: false, provideConfig: false, + skipSkillsPrompt: true, }, positionalArgs: ["shell"], args: { diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index 7e48c79e44..b82ddde8b6 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -138,7 +138,9 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) { await printWranglerBanner(); } - await maybeInstallCloudflareSkillsGlobally(args.installSkills); + if (!def.behaviour?.skipSkillsPrompt || args.installSkills) { + await maybeInstallCloudflareSkillsGlobally(args.installSkills); + } if (!getWranglerHideBanner()) { if (def.metadata.deprecated) { diff --git a/packages/wrangler/src/core/types.ts b/packages/wrangler/src/core/types.ts index 8d8111b873..48f2b1c69e 100644 --- a/packages/wrangler/src/core/types.ts +++ b/packages/wrangler/src/core/types.ts @@ -202,6 +202,14 @@ export type CommandDefinition< * @default true */ sendMetrics?: boolean; + + /** + * Skip the AI coding agent skills installation prompt for this command. + * Set to `true` for commands whose stdout is captured by the shell (e.g. `complete`), + * where interactive prompts would corrupt the output. + * @default false + */ + skipSkillsPrompt?: boolean; }; /** diff --git a/packages/wrangler/src/triggers/deploy.ts b/packages/wrangler/src/triggers/deploy.ts index be74a89fc7..117dae0684 100644 --- a/packages/wrangler/src/triggers/deploy.ts +++ b/packages/wrangler/src/triggers/deploy.ts @@ -322,7 +322,12 @@ export default async function triggersDeploy( script_name: scriptName, class_name: workflow.class_name, ...(workflow.limits && { limits: workflow.limits }), - ...(workflow.schedules && { schedules: workflow.schedules }), + ...(workflow.schedules && { + schedules: (Array.isArray(workflow.schedules) + ? workflow.schedules + : [workflow.schedules] + ).map((cron) => ({ cron })), + }), }), headers: { "Content-Type": "application/json",