diff --git a/.changeset/calm-hosts-stay.md b/.changeset/calm-hosts-stay.md new file mode 100644 index 000000000..05fc8babc --- /dev/null +++ b/.changeset/calm-hosts-stay.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Resolve releases from `hunk.dev/api/release/latest` so macOS no longer blocks pasting the curl install command as malware. diff --git a/install.sh b/install.sh index 840d74ced..0ff7a2963 100755 --- a/install.sh +++ b/install.sh @@ -33,7 +33,11 @@ set -eu REPO="modem-dev/hunk" -RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest" +# Release discovery stays on the apex domain. macOS 26.4+ executes browser-pasted Terminal commands +# in an XProtect sandbox and blocks the paste when the script contacts a host Apple's website +# reputation service distrusts; the Worker's own hostname tripped that, so Vercel proxies this path +# to it instead (see vercel.json). +RELEASE_PROXY="https://hunk.dev/api/release/latest" RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest" DOWNLOAD_BASE="https://github.com/${REPO}/releases/download" diff --git a/packages/hunk/src/core/install/latestRelease.test.ts b/packages/hunk/src/core/install/latestRelease.test.ts index 9d00ea88c..03c64d079 100644 --- a/packages/hunk/src/core/install/latestRelease.test.ts +++ b/packages/hunk/src/core/install/latestRelease.test.ts @@ -54,7 +54,7 @@ describe("release channel lookups", () => { }, }), ).resolves.toEqual({ latest: "1.4.0" }); - expect(requested).toEqual(["https://updates.hunk.dev/v1/curl/latest"]); + expect(requested).toEqual(["https://hunk.dev/api/release/latest"]); expect(headers[0]?.get("x-hunk-request-source")).toBe("startup"); expect(headers[0]?.get("x-hunk-current-version")).toBe("1.3.0"); }); @@ -76,7 +76,7 @@ describe("release channel lookups", () => { }), ).resolves.toEqual({ latest: "1.4.0" }); expect(requested).toEqual([ - "https://updates.hunk.dev/v1/curl/latest", + "https://hunk.dev/api/release/latest", "https://api.github.com/repos/modem-dev/hunk/releases/latest", ]); expect(accepts).toEqual([null, "application/vnd.github+json"]); @@ -104,7 +104,7 @@ describe("release channel lookups", () => { fetchChannelVersions("curl", { env: {}, fetchImpl: async (input) => - String(input).includes("updates.hunk.dev") + String(input).includes("hunk.dev/api/release") ? jsonResponse({ version: "1.4.0-beta.1" }) : jsonResponse({ tag_name: "v1.4.0-beta.1" }), }), diff --git a/packages/hunk/src/core/install/latestRelease.ts b/packages/hunk/src/core/install/latestRelease.ts index d68ffeea6..e884520de 100644 --- a/packages/hunk/src/core/install/latestRelease.ts +++ b/packages/hunk/src/core/install/latestRelease.ts @@ -13,7 +13,9 @@ import { isPrereleaseVersion, isStableVersion } from "../run/version"; const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags"; const HOMEBREW_FORMULA_URL = "https://formulae.brew.sh/api/formula/hunk.json"; -const HUNK_CURL_RELEASE_URL = "https://updates.hunk.dev/v1/curl/latest"; +// Served by Vercel as a rewrite to the release-proxy Worker so clients only contact the apex +// domain; the Worker's own hostname is distrusted by macOS's paste-time reputation check. +const HUNK_CURL_RELEASE_URL = "https://hunk.dev/api/release/latest"; const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/modem-dev/hunk/releases/latest"; const DEFAULT_RELEASE_FETCH_TIMEOUT_MS = 5_000; const DISABLE_ANALYTICS_ENV = "HUNK_DISABLE_ANALYTICS"; diff --git a/packages/hunk/src/core/install/selfUpdate.test.ts b/packages/hunk/src/core/install/selfUpdate.test.ts index c02b319c2..857e3be25 100644 --- a/packages/hunk/src/core/install/selfUpdate.test.ts +++ b/packages/hunk/src/core/install/selfUpdate.test.ts @@ -249,7 +249,7 @@ describe("hunk update", () => { expect(result.commands).toEqual([]); expect(result.stdout).toContain("hunk 1.0.0 (installed with the install script)"); expect(result.stdout).toContain("latest 1.1.0"); - expect(result.releaseRequests[0]?.url).toBe("https://updates.hunk.dev/v1/curl/latest"); + expect(result.releaseRequests[0]?.url).toBe("https://hunk.dev/api/release/latest"); expect(result.releaseRequests[0]?.headers.get("x-hunk-request-source")).toBe("update-check"); expect(result.releaseRequests[0]?.headers.get("x-hunk-current-version")).toBe("1.0.0"); }); diff --git a/packages/hunk/src/core/process/updateNotice.test.ts b/packages/hunk/src/core/process/updateNotice.test.ts index be02fbf06..11f8f5992 100644 --- a/packages/hunk/src/core/process/updateNotice.test.ts +++ b/packages/hunk/src/core/process/updateNotice.test.ts @@ -147,7 +147,7 @@ describe("startup update notice", () => { key: "latest:0.7.1", message: "Update available: 0.7.1 (latest) • run `hunk update`", }); - expect(requested).toEqual(["https://updates.hunk.dev/v1/curl/latest"]); + expect(requested).toEqual(["https://hunk.dev/api/release/latest"]); expect(headers[0]?.get("x-hunk-request-source")).toBe("startup"); expect(headers[0]?.get("x-hunk-current-version")).toBe("0.7.0"); }); diff --git a/scripts/packaging/install-sh.test.ts b/scripts/packaging/install-sh.test.ts index ebe2de2d7..917ca57c7 100644 --- a/scripts/packaging/install-sh.test.ts +++ b/scripts/packaging/install-sh.test.ts @@ -148,7 +148,7 @@ function runReleaseResolution( 'for argument in "$@"; do url="$argument"; done', 'printf "%s\\n" "$url" >>"$CURL_LOG"', 'case "$url" in', - ' https://updates.hunk.dev/*) [ "${PROXY_FAILS:-0}" = "1" ] && exit 22; printf \'%s\\n\' \'{"version":"1.2.3"}\' ;;', + ' https://hunk.dev/api/release/*) [ "${PROXY_FAILS:-0}" = "1" ] && exit 22; printf \'%s\\n\' \'{"version":"1.2.3"}\' ;;', " https://api.github.com/*) printf '%s\\n' '{\"tag_name\":\"v1.2.3\"}' ;;", " *) exit 22 ;;", "esac", @@ -253,13 +253,13 @@ describe("hunk.dev install script", () => { () => { const proxied = runReleaseResolution(); expect(proxied.exitCode).toBe(0); - expect(proxied.requests).toEqual(["https://updates.hunk.dev/v1/curl/latest"]); + expect(proxied.requests).toEqual(["https://hunk.dev/api/release/latest"]); expect(proxied.stdout).toContain("hunk 1.2.3 is already installed."); const fallback = runReleaseResolution({ proxyFails: true }); expect(fallback.exitCode).toBe(0); expect(fallback.requests).toEqual([ - "https://updates.hunk.dev/v1/curl/latest", + "https://hunk.dev/api/release/latest", "https://api.github.com/repos/modem-dev/hunk/releases/latest", ]); }, @@ -283,6 +283,33 @@ describe("hunk.dev install script", () => { expect(INSTALL_SCRIPT).toContain('current_header="X-Hunk-Current-Version: $1"'); }); + test("resolves releases through the apex-domain rewrite that reaches the Worker route", () => { + // The installer, the in-app updater, the Vercel rewrite, and the Worker route must agree, or + // release discovery silently falls back to GitHub. The client side must stay on hunk.dev: macOS + // blocks browser-pasted commands whose sandbox run contacts the Worker's own hostname. + const vercelConfig = JSON.parse(readFileSync(join(REPO_ROOT, "vercel.json"), "utf8")) as { + rewrites: Array<{ source: string; destination: string }>; + }; + const workerSource = readFileSync( + join(REPO_ROOT, "workers/release-proxy/src/index.ts"), + "utf8", + ); + const workerRoute = workerSource.match(/const RELEASE_ROUTE = "([^"]+)"/)?.[1]; + const updaterSource = readFileSync( + join(REPO_ROOT, "packages/hunk/src/core/install/latestRelease.ts"), + "utf8", + ); + + const releaseProxy = INSTALL_SCRIPT.match(/^RELEASE_PROXY="([^"]+)"$/m)?.[1]; + expect(releaseProxy).toBe("https://hunk.dev/api/release/latest"); + expect(updaterSource).toContain(`const HUNK_CURL_RELEASE_URL = "${releaseProxy}";`); + + const rewrite = vercelConfig.rewrites.find( + (candidate) => candidate.source === new URL(releaseProxy ?? "").pathname, + ); + expect(rewrite?.destination).toBe(`https://updates.hunk.dev${workerRoute}`); + }); + test("installs beside the bundled skills so skill resolution still finds them", () => { // `resolveBundledSkillPath` walks up from the binary looking for `skills//SKILL.md`, // so the payload directory must be the binary's directory or one of its ancestors. diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts index 79cd6e652..9907384d7 100644 --- a/test/cli/install-vm/prepare-fixtures.test.ts +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -172,7 +172,7 @@ describe("install VM package fixtures", () => { test("keeps curl release fallback inside the isolated VM server", () => { const rewritten = rewriteCurlInstallerForVmServer( [ - 'RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"', + 'RELEASE_PROXY="https://hunk.dev/api/release/latest"', 'RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest"', 'DOWNLOAD_BASE="https://github.com/${REPO}/releases/download"', ].join("\n"), diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index 3c3ac421c..4b9bbfbfb 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -578,7 +578,7 @@ async function stageSyntheticCurlArchive( export function rewriteCurlInstallerForVmServer(installer: string) { const replacements = [ [ - 'RELEASE_PROXY="https://updates.hunk.dev/v1/curl/latest"', + 'RELEASE_PROXY="https://hunk.dev/api/release/latest"', 'RELEASE_PROXY="http://172.16.0.1:18080/unavailable-release-proxy"', ], [ diff --git a/vercel.json b/vercel.json index 2ce98fcae..1825f7e0a 100644 --- a/vercel.json +++ b/vercel.json @@ -5,7 +5,10 @@ "installCommand": "SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile && bun install --cwd website --frozen-lockfile", "buildCommand": "bun run website:build", "outputDirectory": "website/dist", - "rewrites": [{ "source": "/install", "destination": "/install.sh" }], + "rewrites": [ + { "source": "/install", "destination": "/install.sh" }, + { "source": "/api/release/latest", "destination": "https://updates.hunk.dev/v1/curl/latest" } + ], "headers": [ { "source": "/install(\\.sh)?", diff --git a/website/src/content/docs/docs/help/deployment.md b/website/src/content/docs/docs/help/deployment.md index df0d5512e..64e436505 100644 --- a/website/src/content/docs/docs/help/deployment.md +++ b/website/src/content/docs/docs/help/deployment.md @@ -28,7 +28,7 @@ Archive `website/dist/` as one deployable artifact. `bun run website:build` chec ## Deploy with Vercel -The repository-level `vercel.json` defines the install command, build command, Astro framework, and `website/dist` output directory. Configure one Vercel project with: +The repository-level `vercel.json` defines the install command, build command, Astro framework, and `website/dist` output directory. It also rewrites `/api/release/latest` to the release-proxy Worker (`workers/release-proxy/`) so the installer and `hunk update` resolve releases from the apex domain rather than the Worker's own hostname. Configure one Vercel project with: - **Git repository:** `modem-dev/hunk` - **Root directory:** repository root @@ -56,8 +56,11 @@ curl --fail https://hunk.dev/sitemap.xml curl --fail https://hunk.dev/pagefind/pagefind.js curl --fail https://hunk.dev/docs/hunk-review-skill.md curl --fail https://hunk.dev/og.png +curl --fail --location --silent --show-error --dump-header - https://hunk.dev/api/release/latest | grep --extended-regexp '^cache-control: no-store|"version"' ``` +The last check must print both the `no-store` header and a `"version"` line; an HTML page or a cached response means the release rewrite is misrouted. + In a browser, confirm the landing page links to the docs, theme previews switch, the install command copies, documentation search returns results, theme selection persists across docs navigation, and a GitHub edit link opens the matching source file. ## Roll back diff --git a/workers/release-proxy/README.md b/workers/release-proxy/README.md index 9b40b8d37..163263988 100644 --- a/workers/release-proxy/README.md +++ b/workers/release-proxy/README.md @@ -1,7 +1,11 @@ # Hunk release proxy -This Cloudflare Worker serves `GET /v1/curl/latest`. It normalizes GitHub's latest stable Hunk -release to: +This Cloudflare Worker serves `GET /v1/curl/latest`. Clients reach it as +`https://hunk.dev/api/release/latest`: the repository-level `vercel.json` rewrites that path to the +Worker's `updates.hunk.dev` custom domain so the installer and `hunk update` only ever contact the +apex domain. macOS 26.4+ runs browser-pasted Terminal commands in an XProtect sandbox and blocks +the paste when the command reaches a host Apple's website reputation service distrusts, which the +Worker hostname tripped. It normalizes GitHub's latest stable Hunk release to: ```json { "version": "0.20.1" } @@ -48,8 +52,12 @@ npm test npm run typecheck npm run deploy curl -fsS https://updates.hunk.dev/v1/curl/latest +curl -fsSL https://hunk.dev/api/release/latest ``` +The second check covers the Vercel rewrite. Keep the `updates.hunk.dev` custom domain while +releases that call it directly remain installed; they fall back to GitHub when it is unavailable. + The client and installer fall back directly to GitHub, so their rollout does not depend on deployment ordering. Verify the endpoint and its bounded structured logs after each deployment. Keep the GitHub credential in the Worker secret, never in Hunk or Wrangler configuration.