Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-remote-dev-auth-error-ux.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/fix-types-check-multi-worker-secondary-configs.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/sigint-dismisses-skills-prompt.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/tall-secrets-sneeze.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions packages/wrangler/e2e/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_]+,?)*/
Expand Down Expand Up @@ -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(
Expand All @@ -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);
});
});
});
Expand Down
138 changes: 138 additions & 0 deletions packages/wrangler/src/__tests__/agents-skills-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,6 +9,8 @@ import {
import {
createRemoteWorkerInit,
getWorkerAccountAndContext,
handlePreviewSessionCreationError,
handlePreviewSessionUploadError,
} from "../../../dev/remote";
import { getAccessHeaders } from "../../../user/access";
import { FakeBus } from "../../helpers/fake-bus";
Expand Down Expand Up @@ -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",
});
});
});
});
Loading
Loading