diff --git a/Makefile b/Makefile index f18838e..d40fdab 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,9 @@ app: build mkdir -p "$(APP)/Contents/Resources/scripts" cp -R bridge integrations "$(APP)/Contents/Resources/" cp scripts/install.sh scripts/uninstall.sh scripts/lib.sh "$(APP)/Contents/Resources/scripts/" + # Vercel is off unless the release supplies the Worker URL: nothing is committed, so a dev build stays inert. + @if [ -n "$$VERCEL_WORKER_URL" ]; then plutil -replace VercelWorkerURL -string "$$VERCEL_WORKER_URL" "$(APP)/Contents/Info.plist"; fi + @if [ -n "$$VERCEL_INTEGRATION_SLUG" ]; then plutil -replace VercelIntegrationSlug -string "$$VERCEL_INTEGRATION_SLUG" "$(APP)/Contents/Info.plist"; fi codesign --force -s - "$(APP)" @echo "Built $(APP)" diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md index 1cf7d3d..29afd5b 100644 --- a/docs/vercel-deployment-monitoring.md +++ b/docs/vercel-deployment-monitoring.md @@ -19,6 +19,6 @@ Show production deployments from every project available to a Vercel integration ## Current status -An unreleased Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker URL is unset and the Vercel menu is hidden. The integration form is not submitted. Security review, real installation, deployment, and end-to-end verification remain required; see `integrations/vercel-worker/README.md` before any deployment. +A Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker is deployed at `agent-notch-vercel.onilabs-dev.workers.dev` with its three secrets set, the Vercel integration is registered but not submitted for public listing, and the pairing flow completed end to end once on 2026-11-04 (team installation). A review of the pairing flow is in `docs/vercel-security-review.md` (not independent), the privacy wording to ship with it is drafted in `docs/vercel-privacy.md`, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Still required: an independent review, a personal-account run, revocation on disconnect, and a real deployment rendered end to end; see `integrations/vercel-worker/README.md` before any redeployment. References: [Vercel integration authorization](https://vercel.com/docs/integrations/create-integration/vercel-api-integrations), [Vercel integration registration](https://vercel.com/docs/integrations/create-integration/submit-integration), [Cloudflare Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/). diff --git a/docs/vercel-privacy.md b/docs/vercel-privacy.md new file mode 100644 index 0000000..34f4322 --- /dev/null +++ b/docs/vercel-privacy.md @@ -0,0 +1,57 @@ +# Vercel privacy wording (proposal) + +The app currently promises, in all three READMEs and in `CONTRIBUTING.md`, that it never sends +anything anywhere. Connecting Vercel necessarily breaks that promise for users who opt in, so the +wording has to change *with* the feature, not after it. This is the proposed change; it is not +applied yet because it changes a public promise. + +## README.md + +Replace the opening sentence of the "Privacy" section: + +> It runs locally, listens only on `127.0.0.1`, and never sends anything anywhere. + +with: + +> It runs locally and listens only on `127.0.0.1`. It never sends your agent activity anywhere. +> The only outbound request the app can make is the optional Vercel connection described below, +> and only after you start it yourself. + +Then add, after the Vercel feature section: + +> ### Vercel (optional) +> +> Agent Notch can show production deployments from the Vercel projects your integration can see. +> It is off until you choose **Connect Vercel…**: before that, the app makes no network requests +> and the menu item is hidden. +> +> When you connect, the app keeps a device credential in the Keychain and talks only to the +> Agent Notch Worker. The Worker holds the integration's client secret and exchanges it for a +> Vercel token scoped to reading deployments; that token never reaches your Mac. The app asks for +> deployment read access only, and sends no prompts, code, session data or file paths. +> +> **Disconnect** removes the local credential. To revoke the integration's access as well, +> remove it in Vercel; the confirmation dialog says so. +> +> Deployment notifications are off by default and, when enabled, arrive silently (no sound, no +> banner that interrupts). + +## CONTRIBUTING.md + +Replace: + +> The server must stay loopback-only and the app must not make network requests. + +with: + +> The server must stay loopback-only. The app makes no network requests except the optional +> Vercel connection, which must stay off by default, hidden until configured, started only by an +> explicit user action, and documented in the README of every language. + +## Notes + +- The Spanish and German READMEs must carry the same wording; translate after the English text is + approved so the promise does not drift between languages. +- If the integration is never finished, this change should not land: the current promise is true + today and should stay that way. +- `SECURITY.md` should point at `docs/vercel-security-review.md` once the review is independent. diff --git a/docs/vercel-security-review.md b/docs/vercel-security-review.md new file mode 100644 index 0000000..ad2761e --- /dev/null +++ b/docs/vercel-security-review.md @@ -0,0 +1,51 @@ +# Vercel integration: security review + +Scope: the pairing flow in `integrations/vercel-worker/index.mjs` and its client in +`Sources/AgentNotch/Infrastructure/VercelMonitor.swift`. Reviewed against the design in +`docs/vercel-deployment-monitoring.md` on 2026-11-04. This is a code review by the author of the +change, not an independent audit; treat the "residual risk" items as decisions to confirm, not as +cleared findings. + +## Design summary + +1. The desktop app generates a 256-bit `state` and a 256-bit `verifier`, sends only + `SHA-256(verifier)` to the Worker, and opens Vercel's integration page with `state`. +2. Vercel redirects to the Worker with an authorization `code`; the Worker exchanges it using + `VERCEL_CLIENT_SECRET`, which never leaves Cloudflare, and stores the resulting token in the + Durable Object for that `state`. +3. The app proves possession of `verifier` on every later call. The Worker compares + `SHA-256(verifier)` with the stored challenge; the token is never returned to the app. +4. The app keeps `state` + `verifier` in the Keychain + (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and calls `/deployments` with the bearer. + +## Findings + +| # | Severity | Finding | Status | +|---|----------|---------|--------| +| F1 | Medium | **Disconnect deletes the local session but does not revoke the Vercel token.** Access remains until the user removes the integration in Vercel; the confirmation dialog says so, but a user who disconnects and forgets leaves a live token. | Open: `POST https://api.vercel.com/v2/oauth/revoke` answers `415` instead of `404` to an empty request, so the endpoint likely exists, but its contract is not in the pages reviewed. Confirm the parameters before calling it; until then keep the current copy and make it louder. | +| F2 | Medium | **An expired Vercel token was reported as a generic failure.** A `401` from Vercel became `502` to the client, so the app showed "Could not connect to Vercel" forever instead of asking to reconnect. | Fixed: Vercel `401` now maps to `401`, which the client already treats as expired and clears. | +| F3 | Low | The callback response carried no `X-Content-Type-Options`. | Fixed: `nosniff` added. | +| F4 | Low | **The callback is bound to Vercel only by the `code` and the secrecy of `state`.** Someone who learns a live `state` (it travels in the Vercel redirect URL, so it can appear in logs) could attach their own authorization to that session; they still cannot read deployments or disconnect, which require `verifier`. | Accepted: standard OAuth-with-state trade-off. Shorten the 10-minute window if that ever matters. | +| F5 | Low | Rate limiting is per `CF-Connecting-IP` and shared across all routes; clients behind one NAT share 120 requests/minute. | Accepted. The limit exists to stop brute force, not to meter legitimate use. | +| F6 | Info | The digest comparison is not constant-time. | Accepted: both values are 256-bit secrets; a timing attack needs an infeasible number of samples. | +| F7 | Info | `state` also selects the Durable Object, and any 64-hex value creates one. | Accepted: creations are rate-limited and unclaimed sessions delete themselves after 10 minutes. | +| F8 | Info | Token retention: a claimed session keeps the token until `/disconnect`; an unclaimed one is deleted by the alarm at 10 minutes. Cloudflare encrypts Durable Object storage at rest. | Documented. There is no periodic rotation; reconnecting is the rotation. | +| F9 | Info | The verifier never leaves the device, and the token never reaches it. A stolen `state` alone is not enough to read deployments. | Design property; keep it. | +| F10 | Info | The Worker returns only `uid`, `name`, `state` and git metadata for production deployments of the stored team. No project list, env vars or logs. | Design property; keep it. | +| F11 | Medium | **Personal-account installations failed.** Vercel answers the token exchange with `team_id: null` when the integration is installed on a personal account, and the Worker demanded a string, so the callback returned `502` after a successful authorization. | Fixed: a null or absent team is stored as empty and `teamId` is omitted from Vercel requests. Covered by a test. | +| F12 | Low | The Worker called `https://api.vercel.com/v7/deployments`, which is not the endpoint in Vercel's current reference. | Fixed: uses the documented `/v6/deployments`. | +| F13 | High | **The callback threw an unhandled exception on the edge.** `fetch(..., { redirect: 'error' })` is accepted by Node but rejected by the Workers runtime ("must be one of follow or manual"), so the token exchange failed with a Cloudflare `1101` page after a successful authorization. The Node test suite could not catch it. | Fixed: `redirect: 'manual'` plus an explicit 3xx check, a `try/catch` that answers `502` with a reason, and logs that state whether each binding is present. Add `wrangler dev` to the test pass so runtime-only rejections surface before deploy. | + +## Residual risk before a public release + +1. **Independent review.** This document is not one. Someone who did not write the flow should + read it against the Vercel integration contract before real users connect. +2. **Real end-to-end run.** The pairing flow has now completed once against production on + 2026-11-04 (team installation: `/start` → Vercel authorization → callback → `/status` connected), + which is how F13 was found. Still unproven: a personal-account installation, deployment + rendering after a real build, disconnect with token revocation, and a run on a clean Mac. Repeat + the checklist in `integrations/vercel-worker/README.md` after any change to the Worker. +3. **Secret handling in Cloudflare.** `VERCEL_CLIENT_SECRET` must be a Worker secret + (`wrangler secret put`), never a `var` or a committed file; rotation must be documented. +4. **Privacy promise.** The app currently states it never sends anything anywhere. Shipping this + requires the wording change drafted in `docs/vercel-privacy.md` to land with it. diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md index 93fa285..8671320 100644 --- a/integrations/vercel-worker/README.md +++ b/integrations/vercel-worker/README.md @@ -1,7 +1,105 @@ # Vercel connection prototype -This Worker is an **unreleased local prototype**, not a deployable production service. It is not connected to Agent Notch. Do not deploy it or use real credentials until the authentication flow, token lifecycle, abuse controls, revocation and storage retention have been security-reviewed and integration-tested. +This Worker is an **unreleased prototype** and is not connected to Agent Notch. Do not deploy it or +use real credentials until the security review in `docs/vercel-security-review.md` has an +independent pass and the flow has run end to end. -Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use mocked Vercel responses and no credentials. +Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use +mocked Vercel responses and no credentials. -The intended deployment would require `VERCEL_CLIENT_ID`, `VERCEL_CLIENT_SECRET`, and `VERCEL_REDIRECT_URI` bindings and a Durable Object namespace. Use Cloudflare secret bindings for credentials, not `vars` or committed files. +## Where the credentials come from + +There are three values, and only two are secrets: + +| Value | Where it comes from | +|-------|---------------------| +| `VERCEL_CLIENT_ID` | Vercel assigns it when you create the integration (bottom of its settings page) | +| `VERCEL_CLIENT_SECRET` | Same page; rotatable with **Rotate Secret** | +| `VERCEL_REDIRECT_URI` | Not a credential: the callback URL you type in the integration form, exactly | + +None of them live in a `.env` you generate. Vercel creates a client ID and secret per integration, +so the integration has to exist before the Worker can hold them. That forces the order below: the +Worker is deployed first so its URL can be used as the redirect URL. + +## 1. Deploy the Worker (no secrets yet) + +Run from the repository root. If `cd` fails, you are in the wrong directory: `wrangler deploy` +without a `wrangler.toml` looks for a Pages project and fails with "Could not detect a directory +containing static files", deploying nothing. + +```bash +cd integrations/vercel-worker +npx wrangler login +npx wrangler whoami # confirms the login +npx wrangler deploy +``` + +`wrangler deploy` prints the URL, normally +`https://agent-notch-vercel..workers.dev`. Smoke-test it with any 64-hex state; a +`401` means the Worker and its Durable Object are up and the session is unauthenticated: + +```bash +curl -s -o /dev/null -w '%{http_code}\n' \ + "https:///status?state=$(openssl rand -hex 32)" # expect 401 +``` + +Use that URL (or a custom domain) as the +callback host everywhere below. The Worker deploys fine without secrets because they are only read +at request time; `wrangler secret put` would also refuse to run before the Worker exists. + +The Durable Object namespace, its SQLite migration and the rate limit are declared in +`wrangler.toml`. Workers Free supports SQLite-backed Durable Objects; if `wrangler deploy` objects +to the `[[ratelimits]]` binding, that is the one piece that may need a paid plan — remove the binding +and the check in `index.mjs` to stay on Free. + +## 2. Create the Vercel integration (this is where the credentials are born) + +1. Open the Vercel dashboard, pick your team, then **Integrations → Integrations Console → Create**. +2. Fill in the required profile fields (name, URL slug, developer, contact emails, logo, category, + website, documentation, EULA and privacy URLs, overview). +3. Set **Redirect URL** to `https:///callback` and keep the API scopes to + **deployments read** only. +4. Save. The **client ID and client secret** are at the bottom of the integration's settings page, + under **Credentials**. +5. The **URL slug** you chose is `VERCEL_INTEGRATION_SLUG`; it is also the install URL + `https://vercel.com/integrations//new`. + +You do not need to submit the integration for review to use it yourself; the review is for listing +it publicly. + +## 3. Store the values as Worker secrets + +```bash +npx wrangler secret put VERCEL_CLIENT_ID +npx wrangler secret put VERCEL_CLIENT_SECRET +npx wrangler secret put VERCEL_REDIRECT_URI # https:///callback +``` + +Secrets take effect immediately; no redeploy needed. Never put the secret in `wrangler.toml`, a +committed file, or a `var`. Rotating the client secret is `wrangler secret put` again with the new +value from **Rotate Secret**: devices keep working because they never see it. + +## 4. Build a release that can reach the Worker + +The URL is injected at packaging time, so it is not committed: + +```bash +VERCEL_WORKER_URL=https:// VERCEL_INTEGRATION_SLUG= scripts/release.sh +``` + +Without those variables the app ships with the Vercel UI hidden and makes no Vercel requests. + +## 5. Verify it end to end + +The pairing flow completed once against production on 2026-11-04 (a team installation on +`agent-notch-vercel.onilabs-dev.workers.dev`), which is how the `redirect: 'error'` rejection in F13 +was found. Repeat this list after any Worker change: + +- Connect from a build that has the Worker URL, complete the Vercel flow, and confirm the app says + connected. +- Start a real production deployment from a throwaway repository; watch the badge appear when idle + and the row in the expanded list. +- Disconnect, then confirm the Worker session is gone (`/status` returns 401) and the Vercel + integration's access was revoked or flagged for manual removal. +- Restart the app while a deployment runs; the deployment must not notify for work that finished + while it was closed. diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs index 762fb45..81b5b63 100644 --- a/integrations/vercel-worker/index.mjs +++ b/integrations/vercel-worker/index.mjs @@ -2,6 +2,9 @@ const pattern = /^[a-f0-9]{64}$/; const json = (body, status = 200) => Response.json(body, { status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } }); const hex = bytes => Array.from(new Uint8Array(bytes), b => b.toString(16).padStart(2, '0')).join(''); const digest = async value => hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); +// Workers only accepts "follow" or "manual" (Node also takes "error", which the edge rejects at runtime). +const noRedirects = { redirect: 'manual' }; +const redirected = response => response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400); export default { async fetch(request, env) { @@ -34,18 +37,30 @@ export class Session { } if (url.pathname === '/callback' && request.method === 'GET') { return this.state.blockConcurrencyWhile(async () => { - if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400); - const code = url.searchParams.get('code'); - if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400); - const response = await fetch('https://api.vercel.com/v2/oauth/access_token', { - method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ client_id: this.env.VERCEL_CLIENT_ID, client_secret: this.env.VERCEL_CLIENT_SECRET, code, redirect_uri: this.env.VERCEL_REDIRECT_URI }) - }); - if (!response.ok) return json({ error: 'Authorization failed' }, 502); - const result = await response.json(); - if (typeof result.access_token !== 'string' || !result.access_token || typeof result.team_id !== 'string') return json({ error: 'Invalid authorization response' }, 502); - await storage.put({ token: result.access_token, team: result.team_id }); - return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'none'" } }); + try { + if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400); + const code = url.searchParams.get('code'); + if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400); + // Log presence, never values: a missing secret is the most common cause of a failed exchange. + console.log('callback', { hasCode: Boolean(code), clientId: Boolean(this.env.VERCEL_CLIENT_ID), clientSecret: Boolean(this.env.VERCEL_CLIENT_SECRET), redirectUri: this.env.VERCEL_REDIRECT_URI ?? null }); + const response = await fetch('https://api.vercel.com/v2/oauth/access_token', { + ...noRedirects, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: this.env.VERCEL_CLIENT_ID, client_secret: this.env.VERCEL_CLIENT_SECRET, code, redirect_uri: this.env.VERCEL_REDIRECT_URI }) + }); + console.log('token exchange', response.status); + if (redirected(response)) return json({ error: 'Authorization failed' }, 502); + if (!response.ok) return json({ error: 'Authorization failed' }, 502); + let result; + try { result = JSON.parse(await response.text()); } catch { return json({ error: 'Authorization response was not JSON' }, 502); } + // A personal account installs with team_id null; only a team install has an id to scope requests with. + if (typeof result?.access_token !== 'string' || !result.access_token) return json({ error: 'Invalid authorization response' }, 502); + await storage.put({ token: result.access_token, team: typeof result.team_id === 'string' ? result.team_id : '' }); + return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } }); + } catch (error) { + // Never surface a 1101 here: the browser should show a short reason and the log keeps the stack. + console.log('callback failed', error?.stack ?? String(error)); + return json({ error: 'Callback failed' }, 502); + } }); } const verifier = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? ''; @@ -60,13 +75,17 @@ export class Session { return json({ status: 'connected' }); } if (url.pathname === '/deployments' && request.method === 'GET') { - const endpoint = new URL('https://api.vercel.com/v7/deployments'); - endpoint.searchParams.set('teamId', await storage.get('team')); + const endpoint = new URL('https://api.vercel.com/v6/deployments'); + const team = await storage.get('team'); + if (typeof team === 'string' && team) endpoint.searchParams.set('teamId', team); endpoint.searchParams.set('target', 'production'); endpoint.searchParams.set('limit', '100'); const until = url.searchParams.get('until'); if (until && /^\d{1,16}$/.test(until)) endpoint.searchParams.set('until', until); - const result = await fetch(endpoint, { redirect: 'error', headers: { Authorization: `Bearer ${token}` } }); + const result = await fetch(endpoint, { ...noRedirects, headers: { Authorization: `Bearer ${token}` } }); + // An expired or revoked token must ask the app to reconnect, not look like a network failure. + if (redirected(result)) return json({ error: 'Vercel request failed' }, 502); + if (result.status === 401) return json({ error: 'Expired' }, 401); if (!result.ok) return json({ error: 'Vercel request failed' }, result.status === 429 ? 429 : 502); const page = await result.json(); if (!Array.isArray(page.deployments)) return json({ error: 'Invalid Vercel response' }, 502); diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs index 77bac74..7bcca2a 100644 --- a/integrations/vercel-worker/index.test.mjs +++ b/integrations/vercel-worker/index.test.mjs @@ -43,6 +43,60 @@ test('expired pairing deletes an unclaimed authorization', async () => { assert.equal(values.size, 0); }); +test('a personal account installs with team_id null and calls Vercel without teamId', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async input => { + const url = new URL(input); + if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id: null}); + assert.equal(url.pathname, '/v6/deployments'); + assert.equal(url.searchParams.get('teamId'), null); + return Response.json({deployments: [], pagination: {next: null}}); + }; + try { + assert.equal((await session.fetch(req('/callback?code=example'))).status, 200); + await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + assert.equal((await session.fetch(req('/deployments', {headers:{Authorization:'Bearer ' + secret}}))).status, 200); + } finally { globalThis.fetch = oldFetch; } +}); + +test('a failing token exchange answers 502 instead of throwing a 1101', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new TypeError('network down'); }; + try { + const response = await session.fetch(req('/callback?code=example')); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), {error: 'Callback failed'}); + } finally { globalThis.fetch = oldFetch; } +}); + +test('a non-JSON authorization response answers 502 instead of throwing', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('nope', {status: 200, headers: {'Content-Type': 'text/html'}}); + try { + const response = await session.fetch(req('/callback?code=example')); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), {error: 'Authorization response was not JSON'}); + } finally { globalThis.fetch = oldFetch; } +}); + +test('a redirect from Vercel is refused instead of followed', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(null, {status: 302, headers: {Location: 'https://evil.test/'}}); + try { + const response = await session.fetch(req('/callback?code=example')); + assert.equal(response.status, 502); + assert.deepEqual(await response.json(), {error: 'Authorization failed'}); + } finally { globalThis.fetch = oldFetch; } +}); + test('deployments proxy scopes Vercel requests and never returns its token', async () => { const { session } = setup(); await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); @@ -65,6 +119,25 @@ test('deployments proxy scopes Vercel requests and never returns its token', asy }); +test('an expired Vercel token asks the client to reconnect and the callback is hardened', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async input => { + const url = new URL(input); + if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'}); + return new Response('unauthorized', {status: 401}); + }; + try { + const callback = await session.fetch(req('/callback?code=example')); + assert.equal(callback.headers.get('X-Content-Type-Options'), 'nosniff'); + await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + const response = await session.fetch(req('/deployments', {headers:{Authorization:'Bearer ' + secret}})); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), {error: 'Expired'}); + } finally { globalThis.fetch = oldFetch; } +}); + test('router rejects invalid state and rate-limited clients', async () => { assert.equal((await worker.fetch(req('/status?state=no'), {})).status, 400); const env = { RATE_LIMITER: { limit: async () => ({ success: false }) } }; @@ -90,7 +163,8 @@ test('simulated installation stays isolated across devices and does not leak met }), env); const previousFetch = globalThis.fetch; globalThis.fetch = async (input, options) => { - assert.equal(options.redirect, 'error'); + // Workers rejects "error"; manual is the only way to refuse redirects at the edge. + assert.equal(options.redirect, 'manual'); if (new URL(input).pathname === '/v2/oauth/access_token') return Response.json({access_token:'server-secret',team_id:'team_123'}); assert.equal(options.headers.Authorization, 'Bearer server-secret'); return Response.json({deployments:[{uid:'dpl_1',name:'demo',state:'BUILDING',meta:{githubRepo:'repo',privateField:'sensitive'},secret:'sensitive'}],pagination:{next:null}}); diff --git a/scripts/release.sh b/scripts/release.sh index 26ecb28..1091eaa 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -9,6 +9,9 @@ # xcrun notarytool store-credentials --apple-id --team-id # (it prompts for the app-specific password, so it never lands in your shell history). # Needs DEVELOPER_ID too. Notarizes the DMG and staples the ticket. +# VERCEL_WORKER_URL Vercel integration Worker URL to build into the app (optional). +# VERCEL_INTEGRATION_SLUG Vercel integration slug, defaults to "agent-notch" (optional). +# Without them the Vercel UI stays hidden and the app makes no Vercel requests. # Full setup: docs/notarization.md set -euo pipefail