From b205fb7ff0a1d897b5cbe2a9149978d9e581684c Mon Sep 17 00:00:00 2001 From: Kaido Iwamoto Date: Fri, 5 Jun 2026 20:33:25 +0900 Subject: [PATCH 1/4] [wrangler] Validate secret bulk JSON stdin values (#14196) --- .changeset/tall-secrets-sneeze.md | 7 +++++++ packages/wrangler/src/__tests__/secret.test.ts | 16 ++++++++++++++++ .../src/__tests__/versions/secrets/bulk.test.ts | 16 ++++++++++++++++ packages/wrangler/src/secret/index.ts | 16 ++++++++-------- 4 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 .changeset/tall-secrets-sneeze.md diff --git a/.changeset/tall-secrets-sneeze.md b/.changeset/tall-secrets-sneeze.md new file mode 100644 index 0000000000..c50120f722 --- /dev/null +++ b/.changeset/tall-secrets-sneeze.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Validate JSON stdin values for `wrangler secret bulk` + +JSON input piped through stdin now validates that secret values are strings or null before sending them to the API, matching the existing behavior for file input. diff --git a/packages/wrangler/src/__tests__/secret.test.ts b/packages/wrangler/src/__tests__/secret.test.ts index 34164ff0f9..ae10fc6800 100644 --- a/packages/wrangler/src/__tests__/secret.test.ts +++ b/packages/wrangler/src/__tests__/secret.test.ts @@ -1268,6 +1268,22 @@ describe("wrangler secret", () => { ); }); + it("should fail if JSON stdin contains a record with non-string values", async ({ + expect, + }) => { + mockReadlineInput( + JSON.stringify({ + "invalid-secret": 999, + }) + ); + + await expect( + runWrangler("secret bulk --name script-name") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The value for "invalid-secret" in "piped input" is not null or a "string" instead it is of type "number"]` + ); + }); + it("should count success and network failure on secret bulk", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts b/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts index 4f06054a5f..7287bdd838 100644 --- a/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts +++ b/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts @@ -251,6 +251,22 @@ describe("versions secret bulk", () => { `); }); + test("should error on json stdin with non-string values", async ({ + expect, + }) => { + mockReadlineInput( + JSON.stringify({ + SECRET_1: 1, + }) + ); + + await expect( + runWrangler(`versions secret bulk --name script-name`) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The value for "SECRET_1" in "piped input" is not null or a "string" instead it is of type "number"]` + ); + }); + test("unsafe metadata is provided", async ({ expect }) => { writeWranglerConfig({ name: "script-name", diff --git a/packages/wrangler/src/secret/index.ts b/packages/wrangler/src/secret/index.ts index 2581d9cd42..8b36d2c183 100644 --- a/packages/wrangler/src/secret/index.ts +++ b/packages/wrangler/src/secret/index.ts @@ -652,14 +652,6 @@ export async function parseBulkInputToObject( }); } } - validateFileSecrets(content, input); - if (!includeNull) { - content = Object.fromEntries( - Object.entries(content).filter( - (entry): entry is [string, string] => entry[1] != null - ) - ); - } } else { secretSource = "stdin"; try { @@ -684,5 +676,13 @@ export async function parseBulkInputToObject( return; } } + validateFileSecrets(content, input ?? "piped input"); + if (!includeNull) { + content = Object.fromEntries( + Object.entries(content).filter( + (entry): entry is [string, string] => entry[1] != null + ) + ); + } return { content, secretSource, secretFormat }; } From f38725612b7b8b054318f8e4f4d65ebe887816f6 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Fri, 5 Jun 2026 12:51:39 +0100 Subject: [PATCH 2/4] Make Ctrl+C triggered during the skills-install prompt dismiss it permanently (#14172) --- .changeset/sigint-dismisses-skills-prompt.md | 7 + .../__tests__/agents-skills-install.test.ts | 138 ++++++++++++++++++ .../wrangler/src/agents-skills-install.ts | 67 +++++++-- 3 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 .changeset/sigint-dismisses-skills-prompt.md diff --git a/.changeset/sigint-dismisses-skills-prompt.md b/.changeset/sigint-dismisses-skills-prompt.md new file mode 100644 index 0000000000..95e4980c64 --- /dev/null +++ b/.changeset/sigint-dismisses-skills-prompt.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Make Ctrl+C triggered during the skills-install prompt dismiss it permanently + +Previously, pressing Ctrl+C (SIGINT) during the "Would you like to install Cloudflare skills?" prompt terminated the process without writing the metadata file, causing the prompt to reappear on every subsequent `wrangler` invocation. A SIGINT handler is now registered around the prompt so that the metadata file is written with `accepted: "SIGINT"` before the process exits, preventing the prompt from being shown again. diff --git a/packages/wrangler/src/__tests__/agents-skills-install.test.ts b/packages/wrangler/src/__tests__/agents-skills-install.test.ts index f233cb09de..65bc511eec 100644 --- a/packages/wrangler/src/__tests__/agents-skills-install.test.ts +++ b/packages/wrangler/src/__tests__/agents-skills-install.test.ts @@ -6,6 +6,7 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { detectAgenticEnvironment } from "am-i-vibing"; import ci from "ci-info"; import { http, HttpResponse } from "msw"; +import prompts from "prompts"; import { afterEach, beforeEach, describe, test, vi } from "vitest"; import { sendMetricsEvent } from "../metrics/send-event"; import { mockConsoleMethods } from "./helpers/mock-console"; @@ -17,6 +18,7 @@ import type { telemetryCurrentAgentSkillsInstalled as TelemetryFnType, } from "../agents-skills-install"; import type * as SendEventModule from "../metrics/send-event"; +import type { Mock } from "vitest"; // Undo the global no-op mock from vitest.setup.ts so we test the real implementation vi.unmock("../agents-skills-install"); @@ -193,6 +195,23 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { ]); }); + test("skips silently when metadata file has accepted: 'SIGINT' (Ctrl+C dismissal)", async ({ + expect, + }) => { + writeMetadataFile({ + version: 1, + accepted: "SIGINT", + date: "2025-01-01T00:00:00Z", + }); + const maybeInstallCloudflareSkillsGlobally = await freshImport(); + + await maybeInstallCloudflareSkillsGlobally(false); + + expect(mockRosieAgents).not.toHaveBeenCalled(); + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(sendMetricsEvent).not.toHaveBeenCalled(); + }); + test("force=true ignores existing metadata file", async ({ expect }) => { writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" }); const maybeInstallCloudflareSkillsGlobally = await freshImport(); @@ -412,6 +431,62 @@ describe("maybeInstallCloudflareSkillsGlobally", () => { ); }); + test("writes SIGINT metadata when user presses Ctrl+C during the prompt", async ({ + expect, + }) => { + // Stub process.exit so the abort flow doesn't terminate the test runner. + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((() => {}) as never); + + // Simulate Ctrl+C: invoke the onState callback with + // { aborted: true }, then resolve with { value: undefined } + // just as the real prompts library does on abort. + (prompts as unknown as Mock).mockImplementationOnce( + ({ type, name, message, onState }) => { + expect({ type, name }).toStrictEqual({ + type: "confirm", + name: "value", + }); + expect(message).toContain("Claude Code"); + + // Trigger the abort handler (simulates Ctrl+C) + onState({ aborted: true }); + + return Promise.resolve({ value: undefined }); + } + ); + const maybeInstallCloudflareSkillsGlobally = await freshImport(); + + await maybeInstallCloudflareSkillsGlobally(false); + + // Should have warned the user that Ctrl+C was treated as a decline + expect(std.warn).toContain( + "Ctrl+C received — skipping Cloudflare skills installation. This prompt will not be shown again." + ); + + // The onState abort handler should have written metadata + // with accepted: "SIGINT" + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe("SIGINT"); + expect(metadata.version).toBe(1); + + // Should not have attempted installation + expect(mockRosieInstall).not.toHaveBeenCalled(); + + // Should have sent a skipped metrics event + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "User dismissed (SIGINT)" }, + {} + ); + + // Should have called process.exit(1) after flushing metrics + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + }); + test("force=true installs skills without prompting", async ({ expect }) => { // No mockConfirm — if a prompt fires, the test will fail with "Unexpected call to prompts" const maybeInstallCloudflareSkillsGlobally = await freshImport(); @@ -922,6 +997,69 @@ describe("telemetryCurrentAgentSkillsInstalled", () => { expect(result).toBe("manual"); }); + test("resolves to 'manual' when metadata has accepted: 'SIGINT' at primary path", async ({ + expect, + }) => { + vi.mocked(detectAgenticEnvironment).mockReturnValue({ + isAgentic: true, + id: "claude-code", + name: "Claude Code", + type: "agent", + }); + createAgentDir(".claude"); + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + const claudeGlobalSkillsPath = path.join(os.homedir(), ".claude", "skills"); + writeMetadataFile({ + version: 1, + accepted: "SIGINT", + date: new Date().toISOString(), + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeGlobalSkillsPath }, + }, + ], + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"]); + const telemetryCurrentAgentSkillsInstalled = await freshTelemetryImport(); + + const result = await telemetryCurrentAgentSkillsInstalled(); + + expect(result).toBe("manual"); + }); + + test("resolves to 'manual' when metadata has accepted: 'SIGINT' at alternativeGlobalPath", async ({ + expect, + }) => { + vi.mocked(detectAgenticEnvironment).mockReturnValue({ + isAgentic: true, + id: "opencode", + name: "OpenCode", + type: "agent", + }); + createAgentDir(".config/opencode"); + const agentsSkills = path.join(os.homedir(), ".agents", "skills"); + mkdirSync(path.join(agentsSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: "SIGINT", + date: new Date().toISOString(), + detectedAgents: [ + { + name: "Cline, Dexto, Warp", + rosie: { id: "warp", globalPath: agentsSkills }, + }, + ], + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"]); + const telemetryCurrentAgentSkillsInstalled = await freshTelemetryImport(); + + const result = await telemetryCurrentAgentSkillsInstalled(); + + expect(result).toBe("manual"); + }); + test("uses cached GitHub API response within TTL", async ({ expect }) => { vi.mocked(detectAgenticEnvironment).mockReturnValue({ isAgentic: true, diff --git a/packages/wrangler/src/agents-skills-install.ts b/packages/wrangler/src/agents-skills-install.ts index 8ba80be4d7..c6d3cff317 100644 --- a/packages/wrangler/src/agents-skills-install.ts +++ b/packages/wrangler/src/agents-skills-install.ts @@ -7,12 +7,13 @@ import { } from "@cloudflare/workers-utils"; import { detectAgenticEnvironment } from "am-i-vibing"; import ci from "ci-info"; +import prompts from "prompts"; import { install as rosieInstall, agents as rosieAgents } from "rosie-skills"; import { fetch } from "undici"; -import { confirm } from "./dialogs"; import isInteractive from "./is-interactive"; import { logger } from "./logger"; import { sendMetricsEvent } from "./metrics"; +import { allMetricsDispatchesCompleted } from "./metrics/metrics-dispatcher"; /** * Detects AI coding agents installed on the user's machine and, if @@ -95,12 +96,48 @@ export async function maybeInstallCloudflareSkillsGlobally( return; } - const accepted = - force || - (await confirm( - `Wrangler detected the following AI coding agents: ${detectedAgents.map(({ name }) => name).join(", ")}. Would you like to install Cloudflare skills for them?`, - { defaultValue: true, fallbackValue: false } - )); + let accepted: boolean; + let sigintReceived = false; + if (force) { + accepted = true; + } else { + // Use prompts directly (instead of the shared `confirm()` helper) so + // we can intercept the abort (Ctrl+C) and write SIGINT metadata + // before the process exits. The prompts library's readline interface + // swallows SIGINT — it never reaches `process.on("SIGINT")` — so this + // `onState` callback is the only reliable place to handle it. + const { value } = await prompts({ + type: "confirm", + name: "value", + message: `Wrangler detected the following AI coding agents: ${detectedAgents.map(({ name }) => name).join(", ")}. Would you like to install Cloudflare skills for them?`, + initial: true, + onState: (state) => { + if (state.aborted) { + sigintReceived = true; + logger.warn( + "Ctrl+C received — skipping Cloudflare skills installation. This prompt will not be shown again." + ); + // Write metadata synchronously so it survives the exit. + writeSkillsInstallMetadataFile({ + version: 1, + accepted: "SIGINT", + date: new Date().toISOString(), + detectedAgents, + }); + } + }, + }); + accepted = value; + } + + if (sigintReceived) { + // Metadata was already written in the onState callback. + // Send metrics and wait for the dispatch to complete before exiting. + sendResultMetricsEvent({ skippedBecause: "User dismissed (SIGINT)" }); + await allMetricsDispatchesCompleted(); + // Note: the return is unnecessary but it guards against tests that stub process.exit + return process.exit(1); + } if (!accepted) { writeSkillsInstallMetadataFile({ @@ -200,8 +237,16 @@ type AgentInfo = { interface SkillsInstallMetadata { /** Schema version for forward-compatibility. Currently always `1`. */ version: 1; - /** Whether the user accepted the prompt to install skills. */ - accepted: boolean; + /** + * Whether the user accepted the prompt to install skills. + * + * - `true` — the user explicitly accepted. + * - `false` — the user explicitly declined. + * - `"SIGINT"` — the user dismissed the prompt via Ctrl+C / SIGINT before + * answering. Treated as a decline but stored separately so we can + * distinguish these users in telemetry. + */ + accepted: boolean | "SIGINT"; /** ISO date string of when the user was prompted. */ date: string; /** All agents detected on the user's machine. */ @@ -653,7 +698,7 @@ async function computeTelemetryCurrentAgentSkillsInstalled(): Promise { @@ -700,7 +745,7 @@ async function computeTelemetryCurrentAgentSkillsInstalled(): Promise Date: Fri, 5 Jun 2026 17:27:02 +0530 Subject: [PATCH 3/4] [wrangler] Fix `types --check` incorrectly reporting out of date in multi-worker setups (#14034) --- ...es-check-multi-worker-secondary-configs.md | 9 +++ packages/wrangler/e2e/types.test.ts | 9 +-- .../src/__tests__/type-generation.test.ts | 59 ++++++++++++++++- .../wrangler/src/type-generation/index.ts | 64 ++++++++++++++++++- 4 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-types-check-multi-worker-secondary-configs.md diff --git a/.changeset/fix-types-check-multi-worker-secondary-configs.md b/.changeset/fix-types-check-multi-worker-secondary-configs.md new file mode 100644 index 0000000000..d4ad4b724a --- /dev/null +++ b/.changeset/fix-types-check-multi-worker-secondary-configs.md @@ -0,0 +1,9 @@ +--- +"wrangler": patch +--- + +Fix `wrangler types --check` reporting types as out of date in multi-worker setups + +Previously, running `wrangler types --check -c primary/wrangler.jsonc` in a multi-worker project would incorrectly report types as out of date, even when they were current. This happened because the secondary worker config paths (passed via additional `-c` flags during generation) were not stored in the generated types file header, so `--check` had no way to resolve the secondary workers' service bindings when verifying the hash. + +The fix stores secondary config paths in the generated file's header comment so that `--check` can recover them automatically. Users no longer need to re-pass every `-c` flag when running `--check` — only the primary config is required. diff --git a/packages/wrangler/e2e/types.test.ts b/packages/wrangler/e2e/types.test.ts index e4e2fb8cb7..cef6e7fc72 100644 --- a/packages/wrangler/e2e/types.test.ts +++ b/packages/wrangler/e2e/types.test.ts @@ -133,7 +133,7 @@ describe("types", () => { ).split("\n"); expect(lines[1]).toMatchInlineSnapshot( - `"// Generated by Wrangler by running \`wrangler types -c wranglerA.toml --env-interface MyCloudflareEnv ./cflare-env.d.ts\` (hash: b5768def7c11ba0a77ed50583b661706)"` + `"// Generated by Wrangler by running \`wrangler types --config=wranglerA.toml --env-interface=MyCloudflareEnv ./cflare-env.d.ts\` (hash: b5768def7c11ba0a77ed50583b661706)"` ); expect(lines[2]).match( /\/\/ Runtime types generated with workerd@1\.\d{8}\.\d \d{4}-\d{2}-\d{2} ([a-z_]+,?)*/ @@ -432,7 +432,7 @@ describe("types", () => { expect(output.status).toBe(1); }); - it("should error when --check omits secondary configs that were used during generation", async ({ + it("should not error when --check omits secondary configs (auto-recovered from header)", async ({ expect, }) => { await helper.run( @@ -442,8 +442,9 @@ describe("types", () => { const output = await helper.run( `wrangler types --check --include-runtime=false -c primary/wrangler.jsonc --path primary/worker-configuration.d.ts` ); - expect(output.stderr).toContain("out of date"); - expect(output.status).toBe(1); + expect(output.stderr).toBeFalsy(); + expect(output.stdout).toContain("up to date"); + expect(output.status).toBe(0); }); }); }); diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index 9039831e86..00aab22f81 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -1297,7 +1297,7 @@ describe("generate types - CLI", () => { expect(fs.readFileSync("./worker-configuration.d.ts", "utf-8")) .toMatchInlineSnapshot(` "/* eslint-disable */ - // Generated by Wrangler by running \`wrangler\` (hash: 87f2cdaf48add6af8118936351f2fdf6) + // Generated by Wrangler by running \`wrangler types\` (hash: 87f2cdaf48add6af8118936351f2fdf6) // Runtime types generated with workerd@ interface __BaseEnv_Env { SOMETHING: "asdasdfasdf"; @@ -1747,6 +1747,59 @@ describe("generate types - CLI", () => { ); }); + it("should report types as up to date for multi-worker setup without re-passing -c flags (--check)", async ({ + expect, + }) => { + fs.mkdirSync("primary", { recursive: true }); + fs.mkdirSync("secondary", { recursive: true }); + + fs.writeFileSync( + "./primary/index.ts", + `export default { async fetch() { return new Response("ok"); } };`, + "utf-8" + ); + fs.writeFileSync( + "./primary/wrangler.jsonc", + JSON.stringify({ + name: "primary-worker", + main: "./index.ts", + compatibility_date: "2024-01-01", + services: [{ binding: "SECONDARY", service: "secondary-worker" }], + }), + "utf-8" + ); + + fs.writeFileSync( + "./secondary/index.ts", + `export default { async fetch() { return new Response("ok"); } };`, + "utf-8" + ); + fs.writeFileSync( + "./secondary/wrangler.jsonc", + JSON.stringify({ + name: "secondary-worker", + main: "./index.ts", + compatibility_date: "2024-01-01", + }), + "utf-8" + ); + + // Generate types with both configs + await runWrangler( + "types --include-runtime=false -c primary/wrangler.jsonc -c secondary/wrangler.jsonc --path primary/worker-configuration.d.ts" + ); + + // --check with only the primary -c (no secondary -c) should still detect + // the types as up to date by recovering the secondary config from the header. + await runWrangler( + "types --check --include-runtime=false -c primary/wrangler.jsonc --path primary/worker-configuration.d.ts" + ); + + expect(std.out).toContain( + "Types at primary/worker-configuration.d.ts are up to date." + ); + }); + it("should include secret keys from .env, if there is no .dev.vars", async ({ expect, }) => { @@ -3317,7 +3370,7 @@ describe("generate types - CLI", () => { expect(fs.readFileSync("./cloudflare-env.d.ts", "utf-8")) .toMatchInlineSnapshot(` "/* eslint-disable */ - // Generated by Wrangler by running \`wrangler\` (hash: d3ba65ad57f6692e19d214b1925cf9fc) + // Generated by Wrangler by running \`wrangler types cloudflare-env.d.ts\` (hash: d3ba65ad57f6692e19d214b1925cf9fc) // Runtime types generated with workerd@ interface __BaseEnv_Env { SOMETHING: "asdasdfasdf"; @@ -3380,7 +3433,7 @@ describe("generate types - CLI", () => { expect(fs.readFileSync("./my-cloudflare-env-interface.d.ts", "utf-8")) .toMatchInlineSnapshot(` "/* eslint-disable */ - // Generated by Wrangler by running \`wrangler\` (hash: 98b5807d9daed69f691128b816dc9f4d) + // Generated by Wrangler by running \`wrangler types --env-interface=MyCloudflareEnvInterface my-cloudflare-env-interface.d.ts\` (hash: 98b5807d9daed69f691128b816dc9f4d) // Runtime types generated with workerd@ interface __BaseEnv_MyCloudflareEnvInterface { SOMETHING: "asdasdfasdf"; diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index aad17fe144..eea03d3859 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -12,6 +12,7 @@ import { import chalk from "chalk"; import * as find from "empathic/find"; import { getNodeCompat } from "miniflare"; +import yargs from "yargs"; import { readConfig } from "../config"; import { createCommand } from "../core/create-command"; import { getEntry } from "../deployment-bundle/entry"; @@ -208,6 +209,11 @@ export const typesCommand = createCommand({ validateOutputPath: false, }); + resolvedOptions.envHeaderCommand = buildGenerateTypesHeaderCommand( + args, + resolvedOptions + ); + const { config, envInterface, @@ -216,11 +222,19 @@ export const typesCommand = createCommand({ } = resolvedOptions; if (args.check) { + // If the user didn't supply secondary configs via -c, try to recover + // them from the paths stored in the types file header so --check is + // self-contained for multi-worker setups. + const effectiveSecondaryEntries = + secondaryEntries.size > 0 + ? secondaryEntries + : await recoverSecondaryEntriesFromTypesFile(outputPath); + const outOfDate = await checkTypesUpToDate( config, envInterface, outputPath, - secondaryEntries, + effectiveSecondaryEntries, args.envFile, args.env ); @@ -292,12 +306,60 @@ export async function generateTypesFromWranglerOptions( return generateTypesFromResolvedOptions(resolvedOptions, false); } +async function recoverSecondaryEntriesFromTypesFile( + typesPath: string +): Promise> { + try { + const lines = fs.readFileSync(typesPath, "utf-8").split("\n"); + const headerLine = lines.find((l) => + l.startsWith("// Generated by Wrangler by running `") + ); + const storedCommand = + headerLine?.match( + /\/\/ Generated by Wrangler by running `(?.*)`/ + )?.groups?.command ?? ""; + + const rawArgs = await yargs(storedCommand).parse(); + const configArg = rawArgs.config as string | string[] | undefined; + // The stored command has primary config first, then secondaries. + // Skip the first entry (primary — the user always re-passes it via -c). + const allConfigs = Array.isArray(configArg) + ? configArg + : typeof configArg === "string" + ? [configArg] + : []; + const secondaryPaths = allConfigs.slice(1); + + if (secondaryPaths.length === 0) { + return new Map(); + } + + const secondaryConfigs = secondaryPaths.map((p) => + readConfig({ config: p }) + ); + return resolveSecondaryEntries(secondaryConfigs, false); + } catch { + return new Map(); + } +} + function buildGenerateTypesHeaderCommand( options: GenerateTypesOptions, resolvedOptions: ResolvedGenerateTypesOptions ): string { const commandParts: string[] = ["wrangler", "types"]; + // Store all config paths (primary first, then secondaries) so --check can + // recover secondary entries without requiring the user to re-pass every -c. + const configs = Array.isArray(options.config) + ? options.config + : typeof options.config === "string" + ? [options.config] + : []; + for (const configPath of configs) { + commandParts.push(`--config=${configPath}`); + } + if (options.env !== undefined) { commandParts.push(`--env=${options.env}`); } From 165adb2084bde4bff453b54c4a984012b6999f29 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Fri, 5 Jun 2026 15:09:34 +0100 Subject: [PATCH 4/4] Show actionable error message when authentication fails during remote dev (#14195) --- .changeset/fix-remote-dev-auth-error-ux.md | 9 ++ .../RemoteRuntimeController.test.ts | 110 ++++++++++++++++ .../src/__tests__/core/handle-errors.test.ts | 123 +++++++++++++++++- .../wrangler/src/__tests__/dev/remote.test.ts | 81 ++++++++++++ .../start-remote-proxy-session.ts | 28 ++++ packages/wrangler/src/core/handle-errors.ts | 20 ++- packages/wrangler/src/dev/remote.ts | 62 ++++++++- 7 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-remote-dev-auth-error-ux.md create mode 100644 packages/wrangler/src/__tests__/dev/remote.test.ts diff --git a/.changeset/fix-remote-dev-auth-error-ux.md b/.changeset/fix-remote-dev-auth-error-ux.md new file mode 100644 index 0000000000..ba68d2d3c3 --- /dev/null +++ b/.changeset/fix-remote-dev-auth-error-ux.md @@ -0,0 +1,9 @@ +--- +"wrangler": patch +--- + +Show actionable error message when authentication fails during remote dev + +When `wrangler dev` with remote bindings encountered an authentication error (expired token, revoked OAuth, or invalid API token), the user saw a generic "A request to the Cloudflare API failed" message with no indication that authentication was the problem. + +Now, authentication failures during remote dev display a clear error message with actionable steps. diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts index 8499685e2b..20c27ce2c0 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts @@ -1,3 +1,4 @@ +import { APIError } from "@cloudflare/workers-utils"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { RemoteRuntimeController } from "../../../api/startDevWorker/RemoteRuntimeController"; // Import the mocked functions so we can set their behavior @@ -8,6 +9,8 @@ import { import { createRemoteWorkerInit, getWorkerAccountAndContext, + handlePreviewSessionCreationError, + handlePreviewSessionUploadError, } from "../../../dev/remote"; import { getAccessHeaders } from "../../../user/access"; import { FakeBus } from "../../helpers/fake-bus"; @@ -418,4 +421,111 @@ describe("RemoteRuntimeController", () => { }); }); }); + + describe("authentication error handling", () => { + /** + * Creates an APIError that simulates a Cloudflare API authentication failure. + * + * @param code - the Cloudflare API error code (e.g. 9106, 10000) + * @param noteText - the note text from the API response + * @returns an APIError with the specified code + */ + function makeAuthError(code: number, noteText: string): APIError { + const error = new APIError({ + text: "A request to the Cloudflare API (/accounts/test/workers/subdomain/edge-preview) failed.", + notes: [{ text: noteText }], + status: 400, + telemetryMessage: false, + }); + error.code = code; + return error; + } + + it("should call handlePreviewSessionCreationError when createPreviewSession throws a code 10000 auth error", async ({ + expect, + }) => { + const authError = makeAuthError( + 10000, + "Authentication error [code: 10000]" + ); + vi.mocked(createPreviewSession).mockRejectedValue(authError); + + const { controller, bus } = setup(); + const config = makeConfig(); + const bundle = makeBundle(); + + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + + const errorEvent = await bus.waitFor("error"); + + expect(handlePreviewSessionCreationError).toHaveBeenCalledWith( + authError, + "test-account-id" + ); + expect(errorEvent).toMatchObject({ + type: "error", + reason: "Error reloading remote server", + source: "RemoteRuntimeController", + }); + }); + + it("should call handlePreviewSessionCreationError when createPreviewSession throws a code 9106 auth error", async ({ + expect, + }) => { + const authError = makeAuthError( + 9106, + "Authentication failed (status: 400) [code: 9106]" + ); + vi.mocked(createPreviewSession).mockRejectedValue(authError); + + const { controller, bus } = setup(); + const config = makeConfig(); + const bundle = makeBundle(); + + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + + const errorEvent = await bus.waitFor("error"); + + expect(handlePreviewSessionCreationError).toHaveBeenCalledWith( + authError, + "test-account-id" + ); + expect(errorEvent).toMatchObject({ + type: "error", + reason: "Error reloading remote server", + source: "RemoteRuntimeController", + }); + }); + + it("should call handlePreviewSessionUploadError when createWorkerPreview throws a code 10000 auth error", async ({ + expect, + }) => { + const authError = makeAuthError( + 10000, + "Authentication error [code: 10000]" + ); + vi.mocked(createWorkerPreview).mockRejectedValue(authError); + + const { controller, bus } = setup(); + const config = makeConfig(); + const bundle = makeBundle(); + + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + + const errorEvent = await bus.waitFor("error"); + + expect(handlePreviewSessionUploadError).toHaveBeenCalledWith( + authError, + "test-account-id" + ); + expect(errorEvent).toMatchObject({ + type: "error", + reason: "Failed to obtain a preview token", + source: "RemoteRuntimeController", + }); + }); + }); }); diff --git a/packages/wrangler/src/__tests__/core/handle-errors.test.ts b/packages/wrangler/src/__tests__/core/handle-errors.test.ts index 9c805fad17..a956914678 100644 --- a/packages/wrangler/src/__tests__/core/handle-errors.test.ts +++ b/packages/wrangler/src/__tests__/core/handle-errors.test.ts @@ -1,5 +1,10 @@ +import { APIError, ParseError } from "@cloudflare/workers-utils"; import { beforeEach, describe, it, vi } from "vitest"; -import { getErrorType, handleError } from "../../core/handle-errors"; +import { + getErrorType, + handleError, + isAuthenticationError, +} from "../../core/handle-errors"; import { mockConsoleMethods } from "../helpers/mock-console"; describe("getErrorType", () => { @@ -256,6 +261,50 @@ describe("getErrorType", () => { }); }); + describe("Authentication errors", () => { + it("should return 'AuthenticationError' for code 10000 (expired/revoked token)", ({ + expect, + }) => { + const error = new APIError({ + text: "A request to the Cloudflare API failed.", + notes: [{ text: "Authentication error [code: 10000]" }], + status: 400, + telemetryMessage: false, + }); + error.code = 10000; + + expect(getErrorType(error)).toBe("AuthenticationError"); + }); + + it("should return 'AuthenticationError' for code 9106 (invalid token)", ({ + expect, + }) => { + const error = new APIError({ + text: "A request to the Cloudflare API failed.", + notes: [{ text: "Authentication failed (status: 400) [code: 9106]" }], + status: 400, + telemetryMessage: false, + }); + error.code = 9106; + + expect(getErrorType(error)).toBe("AuthenticationError"); + }); + + it("should NOT return 'AuthenticationError' for other API errors", ({ + expect, + }) => { + const error = new APIError({ + text: "A request to the Cloudflare API failed.", + notes: [{ text: "Some other error [code: 10063]" }], + status: 400, + telemetryMessage: false, + }); + error.code = 10063; + + expect(getErrorType(error)).not.toBe("AuthenticationError"); + }); + }); + describe("Fallback behavior", () => { it("should return constructor name for unknown Error types", ({ expect, @@ -273,6 +322,78 @@ describe("getErrorType", () => { }); }); +describe("isAuthenticationError", () => { + it("should return true for APIError with code 10000", ({ expect }) => { + const error = new APIError({ + text: "A request to the Cloudflare API failed.", + notes: [{ text: "Authentication error [code: 10000]" }], + status: 400, + telemetryMessage: false, + }); + error.code = 10000; + + expect(isAuthenticationError(error)).toBe(true); + }); + + it("should return true for APIError with code 9106", ({ expect }) => { + const error = new APIError({ + text: "A request to the Cloudflare API failed.", + notes: [{ text: "Authentication failed (status: 400) [code: 9106]" }], + status: 400, + telemetryMessage: false, + }); + error.code = 9106; + + expect(isAuthenticationError(error)).toBe(true); + }); + + it("should return true for ParseError with code 10000", ({ expect }) => { + const error = new ParseError({ + text: "Auth error", + telemetryMessage: false, + }); + (error as unknown as { code: number }).code = 10000; + + expect(isAuthenticationError(error)).toBe(true); + }); + + it("should return false for APIError with a non-auth code", ({ expect }) => { + const error = new APIError({ + text: "A request to the Cloudflare API failed.", + notes: [{ text: "Some other error [code: 10063]" }], + status: 404, + telemetryMessage: false, + }); + error.code = 10063; + + expect(isAuthenticationError(error)).toBe(false); + }); + + it("should return false for APIError with no code", ({ expect }) => { + const error = new APIError({ + text: "A request to the Cloudflare API failed.", + notes: [], + status: 500, + telemetryMessage: false, + }); + + expect(isAuthenticationError(error)).toBe(false); + }); + + it("should return false for a plain Error", ({ expect }) => { + const error = new Error("something went wrong"); + + expect(isAuthenticationError(error)).toBe(false); + }); + + it("should return false for non-Error values", ({ expect }) => { + expect(isAuthenticationError("string")).toBe(false); + expect(isAuthenticationError(null)).toBe(false); + expect(isAuthenticationError(undefined)).toBe(false); + expect(isAuthenticationError(10000)).toBe(false); + }); +}); + describe("handleError", () => { const std = mockConsoleMethods(); diff --git a/packages/wrangler/src/__tests__/dev/remote.test.ts b/packages/wrangler/src/__tests__/dev/remote.test.ts new file mode 100644 index 0000000000..54ce5d0afd --- /dev/null +++ b/packages/wrangler/src/__tests__/dev/remote.test.ts @@ -0,0 +1,81 @@ +import { describe, it, vi } from "vitest"; +import { RemoteSessionAuthenticationError } from "../../dev/remote"; + +describe("RemoteSessionAuthenticationError", () => { + it("preserves the original error as its cause", ({ expect }) => { + const cause = new Error("original API error"); + const error = new RemoteSessionAuthenticationError(cause); + expect(error.cause).toBe(cause); + }); + + it("sets a static telemetryMessage", ({ expect }) => { + const error = new RemoteSessionAuthenticationError(new Error("api error")); + expect(error.telemetryMessage).toBe("remote dev authentication error"); + }); + + describe("when authenticating via CLOUDFLARE_API_TOKEN", () => { + it("mentions the API token environment variable in the message", ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_API_TOKEN", "test-token"); + + const error = new RemoteSessionAuthenticationError( + new Error("api error") + ); + + expect(error.message).toMatchInlineSnapshot(` + "Failed to establish remote session due to an authentication issue. + It looks like you are authenticating via a custom API token (\`CLOUDFLARE_API_TOKEN\`) set in an environment variable. + The token may be invalid or lack the required permissions for this operation. + + To fix this, verify that your token is valid and has the correct permissions. + You can also run \`wrangler whoami\` to check your current authentication status." + `); + }); + }); + + describe("when authenticating via CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL", () => { + it("mentions the Global API Key environment variable in the message", ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_API_KEY", "test-key"); + vi.stubEnv("CLOUDFLARE_EMAIL", "test@example.com"); + + const error = new RemoteSessionAuthenticationError( + new Error("api error") + ); + + expect(error.message).toMatchInlineSnapshot(` + "Failed to establish remote session due to an authentication issue. + It looks like you are authenticating via a Global API Key (\`CLOUDFLARE_API_KEY\`) set in an environment variable. + The token may be invalid or lack the required permissions for this operation. + + To fix this, verify that your token is valid and has the correct permissions. + You can also run \`wrangler whoami\` to check your current authentication status." + `); + }); + }); + + describe("when authenticating via OAuth (no env credentials)", () => { + it("suggests re-authenticating with wrangler login", ({ expect }) => { + // Stub all auth env vars to empty strings so getAuthFromEnv() + // returns undefined (empty strings are falsy). + vi.stubEnv("CLOUDFLARE_API_TOKEN", ""); + vi.stubEnv("CLOUDFLARE_API_KEY", ""); + vi.stubEnv("CLOUDFLARE_EMAIL", ""); + + const error = new RemoteSessionAuthenticationError( + new Error("api error") + ); + + expect(error.message).toMatchInlineSnapshot(` + "Failed to establish remote session due to an authentication issue. + Your credentials may have expired or been revoked. + + To fix this, try to: + - Run \`wrangler whoami\` to check your current authentication status. + - Run \`wrangler logout\` and then \`wrangler login\` to re-authenticate." + `); + }); + }); +}); diff --git a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts index 392d45ff43..8d35fb71ce 100644 --- a/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts +++ b/packages/wrangler/src/api/remoteBindings/start-remote-proxy-session.ts @@ -3,6 +3,7 @@ import path from "node:path"; import chalk from "chalk"; import { DeferredPromise } from "miniflare"; import remoteBindingsWorkerPath from "worker:remoteBindings/ProxyServerWorker"; +import { RemoteSessionAuthenticationError } from "../../dev/remote"; import { logger } from "../../logger"; import { getBasePath } from "../../paths"; import { startWorker } from "../startDevWorker"; @@ -50,6 +51,28 @@ function getErrorMessage(error: unknown): string | undefined { return undefined; } +/** + * Walks the cause chain of an error (including {@link ErrorEvent} wrappers) + * looking for a {@link RemoteSessionAuthenticationError}. + * + * @param error - the error or ErrorEvent to inspect + * @returns the first {@link RemoteSessionAuthenticationError} found, or + * `undefined` if none exists in the chain + */ +function findRemoteSessionAuthError( + error: unknown +): RemoteSessionAuthenticationError | undefined { + if (error instanceof RemoteSessionAuthenticationError) { + return error; + } + + if (isErrorEvent(error) || (error instanceof Error && error.cause)) { + return findRemoteSessionAuthError(error.cause); + } + + return undefined; +} + function formatRemoteProxySessionError(error: unknown): string | undefined { if (isErrorEvent(error)) { const causeMessage = getErrorMessage(error.cause); @@ -118,6 +141,11 @@ export async function startRemoteProxySession( ]); if (maybeError && maybeError.error) { + const authError = findRemoteSessionAuthError(maybeError.error); + if (authError) { + throw authError; + } + const details = formatRemoteProxySessionError(maybeError.error); throw new Error( details diff --git a/packages/wrangler/src/core/handle-errors.ts b/packages/wrangler/src/core/handle-errors.ts index f6ce22f8d3..cc0c5305d9 100644 --- a/packages/wrangler/src/core/handle-errors.ts +++ b/packages/wrangler/src/core/handle-errors.ts @@ -259,10 +259,26 @@ function isContainersAuthenticationError(e: unknown): e is UserError { } /** - * @returns whether `e` is a standard Cloudflare API authentication error + * Cloudflare API error codes that indicate an authentication problem. + * + * - 9106: "Authentication failed" — token not recognized (invalid or malformed). + * - 10000: "Authentication error" — token is structurally valid but rejected + * (expired, revoked server-side, or wrong scope). + */ +const AUTHENTICATION_ERROR_CODES = [9106, 10000]; + +/** + * Checks whether `e` is a standard Cloudflare API authentication error. + * + * @param e - the error to inspect + * @returns whether the error is an APIError with an auth error code */ export function isAuthenticationError(e: unknown): e is ParseError { - return e instanceof ParseError && (e as { code?: number }).code === 10000; + if (!(e instanceof ParseError)) { + return false; + } + const code = (e as { code?: number }).code; + return code !== undefined && AUTHENTICATION_ERROR_CODES.includes(code); } /** diff --git a/packages/wrangler/src/dev/remote.ts b/packages/wrangler/src/dev/remote.ts index 1840a4b6fa..436c692580 100644 --- a/packages/wrangler/src/dev/remote.ts +++ b/packages/wrangler/src/dev/remote.ts @@ -2,13 +2,14 @@ import assert from "node:assert"; import path from "node:path"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { syncAssets } from "../assets"; +import { isAuthenticationError } from "../core/handle-errors"; import { printBundleSize } from "../deployment-bundle/bundle-reporter"; import { getBundleType } from "../deployment-bundle/bundle-type"; import { withSourceURLs } from "../deployment-bundle/source-url"; import { getInferredHost } from "../dev"; import { logger } from "../logger"; import { syncWorkersSite } from "../sites"; -import { requireApiToken } from "../user"; +import { getAuthFromEnv, requireApiToken } from "../user"; import { isAbortError } from "../utils/isAbortError"; import { getZoneIdForPreview } from "../zones"; import type { StartDevWorkerInput } from "../api"; @@ -26,6 +27,54 @@ import type { Route, } from "@cloudflare/workers-utils"; +/** + * Error thrown when a remote dev session fails due to an authentication + * problem. The error message is a user-friendly description with actionable + * guidance, tailored to the caller's authentication method (environment + * variable token vs. OAuth). The original API error is preserved as the + * error's {@link Error.cause | cause}. + * + * Consumers that catch this error can display {@link Error.message | message} + * directly — no additional logging helper is needed. + */ +export class RemoteSessionAuthenticationError extends UserError { + /** + * @param cause - The original error that triggered the authentication + * failure (e.g. an {@link APIError} with code 9106 or 10000). + */ + constructor(cause: unknown) { + const envAuth = getAuthFromEnv(); + + let errorMessage = + "Failed to establish remote session due to an authentication issue.\n"; + if (envAuth !== undefined) { + // The user is authenticating via an environment variable + const method = + "apiToken" in envAuth + ? "a custom API token (`CLOUDFLARE_API_TOKEN`)" + : "a Global API Key (`CLOUDFLARE_API_KEY`)"; + + errorMessage += + `It looks like you are authenticating via ${method} set in an environment variable.\n` + + "The token may be invalid or lack the required permissions for this operation.\n\n" + + "To fix this, verify that your token is valid and has the correct permissions.\n" + + "You can also run `wrangler whoami` to check your current authentication status."; + } else { + // The user is authenticating via OAuth (wrangler login) + errorMessage += + "Your credentials may have expired or been revoked.\n\n" + + "To fix this, try to:\n" + + " - Run `wrangler whoami` to check your current authentication status.\n" + + " - Run `wrangler logout` and then `wrangler login` to re-authenticate."; + } + + super(errorMessage, { + cause, + telemetryMessage: "remote dev authentication error", + }); + } +} + export function handlePreviewSessionUploadError( err: unknown, accountId: string @@ -58,8 +107,11 @@ export function handlePreviewSessionCreationError( assert(err && typeof err === "object"); // instead of logging the raw API error to the user, // give them friendly instructions + if (isAuthenticationError(err)) { + throw new RemoteSessionAuthenticationError(err); + } // for error 10063 (workers.dev subdomain required) - if ("code" in err && err.code === 10063) { + else if ("code" in err && err.code === 10063) { logger.error( `You need to register a workers.dev subdomain before running the dev command in remote mode. You can either enable local mode by pressing l, or register a workers.dev subdomain here: https://dash.cloudflare.com/${accountId}/workers/onboarding` ); @@ -253,6 +305,12 @@ export async function getWorkerAccountAndContext(props: { function handleUserFriendlyError(error: unknown, accountId?: string) { if (error instanceof APIError) { switch (error.code) { + // code 9106 and 10000 are authentication errors + case 9106: + case 10000: { + throw new RemoteSessionAuthenticationError(error); + } + // code 10021 is a validation error case 10021: { // if it is the following message, give a more user friendly