diff --git a/.changeset/sandbox-hint-network-failures.md b/.changeset/sandbox-hint-network-failures.md new file mode 100644 index 00000000..6464f691 --- /dev/null +++ b/.changeset/sandbox-hint-network-failures.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Stop showing the "possible sandboxed run" hint for ordinary network failures (unreachable host, VPN, DNS) in agent mode. The hint now requires a permission-like error before suggesting a sandbox, and is a single line. diff --git a/packages/cli-core/src/lib/host-execution.test.ts b/packages/cli-core/src/lib/host-execution.test.ts new file mode 100644 index 00000000..5e59a4a0 --- /dev/null +++ b/packages/cli-core/src/lib/host-execution.test.ts @@ -0,0 +1,59 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { observeHostCapabilityFailure, _resetAgentHostStateProbe } from "./host-execution.ts"; +import { setMode } from "../mode.ts"; +import { useCaptureLog } from "../test/lib/stubs.ts"; + +describe("observeHostCapabilityFailure sandbox hinting", () => { + const captured = useCaptureLog(); + + beforeEach(() => { + setMode("agent"); + _resetAgentHostStateProbe(); + }); + + afterEach(() => { + setMode("human"); + _resetAgentHostStateProbe(); + }); + + test("a plain connectivity failure (unreachable host, VPN, DNS) does not warn", () => { + observeHostCapabilityFailure("network", new Error("ECONNREFUSED 127.0.0.1:443")); + expect(captured.err).not.toContain("sandboxed run"); + }); + + test("a permission-like network failure warns about a possible sandbox (message)", () => { + observeHostCapabilityFailure("network", new Error("operation not permitted")); + expect(captured.err).toContain("sandboxed run"); + }); + + test("a Bun fetch error with permission-like code warns about a possible sandbox", () => { + const cause = Object.assign(new Error("operation not permitted"), { code: "EPERM" }); + const bunFetchError = Object.assign(new Error("fetch failed"), { cause }); + observeHostCapabilityFailure("network", bunFetchError); + expect(captured.err).toContain("sandboxed run"); + }); + + test("a Bun fetch error with non-permission code does not warn", () => { + const cause = Object.assign(new Error("Connection refused"), { code: "ConnectionRefused" }); + const bunFetchError = Object.assign(new Error("fetch failed"), { cause }); + observeHostCapabilityFailure("network", bunFetchError); + expect(captured.err).not.toContain("sandboxed run"); + }); + + test("browser-launch and localhost-bind failures always warn", () => { + observeHostCapabilityFailure("browser-launch", new Error("spawn ENOENT")); + expect(captured.err).toContain("sandboxed run"); + }); + + test("the hint is a single line", () => { + observeHostCapabilityFailure("network", new Error("EPERM")); + const warnLines = captured.err.split("\n").filter((line) => line.includes("agent mode")); + expect(warnLines).toHaveLength(1); + }); + + test("does not warn in human mode", () => { + setMode("human"); + observeHostCapabilityFailure("network", new Error("operation not permitted")); + expect(captured.err).not.toContain("sandboxed run"); + }); +}); diff --git a/packages/cli-core/src/lib/host-execution.ts b/packages/cli-core/src/lib/host-execution.ts index c50df0a0..bdd7280f 100644 --- a/packages/cli-core/src/lib/host-execution.ts +++ b/packages/cli-core/src/lib/host-execution.ts @@ -93,10 +93,7 @@ function warnAboutSandbox(detail?: string): void { warnedAboutSandbox = true; log.warn( - "Host-only Clerk state or system capabilities may be unavailable in agent mode. This may be a sandboxed run.", - ); - log.warn( - "Re-run this command on the host shell before trusting auth, link, env, or API failures.", + "Host-only Clerk state or capabilities may be unavailable in agent mode (possible sandboxed run). If this looks wrong, re-run on the host shell before trusting auth, link, env, or API failures.", ); if (detail) { @@ -104,25 +101,41 @@ function warnAboutSandbox(detail?: string): void { } } +const PERMISSION_PATTERNS = [ + /\bEPERM\b/i, + /\bEACCES\b/i, + /operation not permitted/i, + /permission denied/i, + /sandbox/i, + /interaction is not allowed/i, + /access denied/i, +]; + +function matchesPermissionPattern(s: string): boolean { + return PERMISSION_PATTERNS.some((pattern) => pattern.test(s)); +} + function isPermissionLikeFailure(error: unknown): boolean { - const message = errorMessage(error); - return [ - /\bEPERM\b/i, - /\bEACCES\b/i, - /operation not permitted/i, - /permission denied/i, - /sandbox/i, - /interaction is not allowed/i, - /access denied/i, - ].some((pattern) => pattern.test(message)); + if (!(error instanceof Error)) { + return matchesPermissionPattern(String(error)); + } + + if (matchesPermissionPattern(error.message)) return true; + + const code = (error as NodeJS.ErrnoException).code; + if (code && matchesPermissionPattern(code)) return true; + + if (error.cause instanceof Error && isPermissionLikeFailure(error.cause)) return true; + + return false; } function isLikelySandboxFailure(capability: HostCapability, error: unknown): boolean { - if ( - capability === "network" || - capability === "browser-launch" || - capability === "localhost-bind" - ) { + // browser-launch and localhost-bind only ever fail for host-capability + // reasons, so any failure is a meaningful sandbox signal. Network failures + // are different: a plain unreachable host (VPN, DNS, ECONNREFUSED) is not a + // sandbox, so require a permission-like error before hinting at a sandbox. + if (capability === "browser-launch" || capability === "localhost-bind") { return true; }