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
2 changes: 1 addition & 1 deletion docs/vercel-deployment-monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ Show production deployments from every project available to a Vercel integration

## Current status

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.
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 shipped with 0.3.4, and the Worker URL is injected at packaging time with `VERCEL_WORKER_URL`. Disconnect now asks Vercel to revoke the token, best effort. Still required: an independent review, a personal-account run, a live confirmation that the revocation call returns 200, 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/).
2 changes: 1 addition & 1 deletion docs/vercel-security-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ cleared 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. |
| F1 | Medium | **Disconnect deleted the local session but did not revoke the Vercel token.** Access stayed alive until the user removed the integration in Vercel. | Fixed, best effort: on disconnect the Worker deletes the session and then calls the documented `POST https://api.vercel.com/login/oauth/token/revoke` with Basic client credentials (`client_id:client_secret`) and the token, logging the status. A revocation failure is logged and ignored, so the local session always ends. Caveat: Vercel documents that endpoint for Sign in with Vercel tokens; this is the first use against an integration token, so confirm the log says `revoke 200` on the next real disconnect. |
| 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. |
Expand Down
7 changes: 4 additions & 3 deletions integrations/vercel-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,11 @@ The pairing flow completed once against production on 2026-11-04 (a team install
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.
connected. The browser shows a confirmation page you can close.
- 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.
- Disconnect, then confirm the Worker session is gone (`/status` returns 401) and the Worker logs
`revoke 200` for the token revocation. If it logs another status, Vercel rejected the revocation
for that token and the integration must be removed manually.
- Restart the app while a deployment runs; the deployment must not notify for work that finished
while it was closed.
61 changes: 52 additions & 9 deletions integrations/vercel-worker/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ const digest = async value => hex(await crypto.subtle.digest('SHA-256', new Text
const noRedirects = { redirect: 'manual' };
const redirected = response => response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400);

// Browser-facing page for the callback. Every string is static: no user input is interpolated, so no escaping is needed.
const page = (status, title, message) => new Response(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} · Agent Notch</title>
<style>
:root { color-scheme: dark }
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#0a0a0b; color:#f5f5f7;
font:16px/1.5 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif }
main { max-width:26rem; padding:2.5rem 2rem; text-align:center }
.mark { width:3rem; height:3rem; margin:0 auto 1.25rem; border-radius:50%; display:grid; place-items:center;
font-size:1.5rem; font-weight:600; background:${status < 400 ? '#12351f' : '#3a1417'}; color:${status < 400 ? '#4ade80' : '#f87171'} }
h1 { font-size:1.35rem; margin:0 0 .5rem; letter-spacing:-.01em }
p { margin:0 0 1.5rem; color:#a1a1aa }
.brand { font-size:.75rem; letter-spacing:.08em; text-transform:uppercase; color:#52525b }
</style></head>
<body><main>
<div class="mark">${status < 400 ? '✓' : '!'}</div>
<h1>${title}</h1>
<p>${message}</p>
<p class="brand">Agent Notch</p>
</main></body></html>`, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'" } });

export default {
async fetch(request, env) {
const url = new URL(request.url);
Expand Down Expand Up @@ -38,34 +60,40 @@ export class Session {
if (url.pathname === '/callback' && request.method === 'GET') {
return this.state.blockConcurrencyWhile(async () => {
try {
if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400);
if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token'))
return page(400, 'This connection expired', 'Start again from Agent Notch: choose Connect Vercel… and authorize.');
const code = url.searchParams.get('code');
if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400);
if (!code || code.length > 1024) return page(400, 'Vercel did not send an authorization code', 'Start again from Agent Notch.');
// 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);
if (redirected(response)) return page(502, 'Vercel rejected the connection', 'Check that the redirect URL matches the integration settings, then try again from Agent Notch.');
if (!response.ok) return page(502, 'Vercel rejected the connection', 'Check that the client ID, secret and redirect URL match, then try again from Agent Notch.');
let result;
try { result = JSON.parse(await response.text()); } catch { return json({ error: 'Authorization response was not JSON' }, 502); }
try { result = JSON.parse(await response.text()); } catch { return page(502, 'Unexpected response from Vercel', 'Try again from Agent Notch.'); }
// 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);
if (typeof result?.access_token !== 'string' || !result.access_token) return page(502, 'Unexpected response from Vercel', 'Try again from Agent Notch.');
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'" } });
return page(200, 'Agent Notch is connected', 'You can close this tab and return to the app. Deployments appear in the notch while they run.');
} 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);
return page(502, 'The connection could not be completed', 'Try again from Agent Notch. If it keeps failing, check the Worker logs.');
}
});
}
const verifier = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? '';
if (!pattern.test(verifier) || await digest(verifier) !== await storage.get('challenge')) return json({ error: 'Unauthorized' }, 401);
if (url.pathname === '/disconnect' && request.method === 'POST') { await storage.deleteAll(); return json({ ok: true }); }
if (url.pathname === '/disconnect' && request.method === 'POST') {
const token = await storage.get('token');
await storage.deleteAll();
if (typeof token === 'string' && token) await this.revoke(token);
return json({ ok: true });
}
if (!await storage.get('claimed') && Date.now() > (await storage.get('expires'))) { await storage.deleteAll(); return json({ error: 'Expired' }, 410); }
const token = await storage.get('token');
if (!token) return Date.now() > (await storage.get('expires')) ? json({ error: 'Expired' }, 410) : json({ status: 'pending' });
Expand Down Expand Up @@ -100,4 +128,19 @@ export class Session {
}

async alarm() { if (!await this.state.storage.get('claimed')) await this.state.storage.deleteAll(); }

/// Best effort: revokes the Vercel token when the user disconnects. A failure here must never keep the local session alive.
async revoke(token) {
try {
const basic = btoa(`${this.env.VERCEL_CLIENT_ID}:${this.env.VERCEL_CLIENT_SECRET}`);
const response = await fetch('https://api.vercel.com/login/oauth/token/revoke', {
...noRedirects, method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token })
});
console.log('revoke', response.status);
} catch (error) {
console.log('revoke failed', String(error));
}
}
}
74 changes: 70 additions & 4 deletions integrations/vercel-worker/index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ test('a failing token exchange answers 502 instead of throwing a 1101', async ()
try {
const response = await session.fetch(req('/callback?code=example'));
assert.equal(response.status, 502);
assert.deepEqual(await response.json(), {error: 'Callback failed'});
assert.match(response.headers.get('Content-Type') ?? '', /text\/html/);
assert.match(await response.text(), /could not be completed/);
} finally { globalThis.fetch = oldFetch; }
});

Expand All @@ -81,7 +82,7 @@ test('a non-JSON authorization response answers 502 instead of throwing', async
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'});
assert.match(await response.text(), /Unexpected response from Vercel/);
} finally { globalThis.fetch = oldFetch; }
});

Expand All @@ -93,7 +94,70 @@ test('a redirect from Vercel is refused instead of followed', async () => {
try {
const response = await session.fetch(req('/callback?code=example'));
assert.equal(response.status, 502);
assert.deepEqual(await response.json(), {error: 'Authorization failed'});
assert.match(await response.text(), /Vercel rejected the connection/);
} finally { globalThis.fetch = oldFetch; }
});

test('disconnect revokes the Vercel token with client credentials', async () => {
let revoked = null;
const { session } = setup();
await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}}));
const oldFetch = globalThis.fetch;
globalThis.fetch = async (input, options) => {
const url = new URL(input);
if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'});
if (url.pathname === '/login/oauth/token/revoke') {
revoked = { auth: options.headers.Authorization, token: new URLSearchParams(options.body).get('token') };
return new Response(null, {status: 200});
}
throw new Error('unexpected request: ' + url.pathname);
};
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('/disconnect', {method:'POST', headers:{Authorization:'Bearer ' + secret}}))).status, 200);
assert.equal(revoked.token, 'private-token');
assert.match(revoked.auth, /^Basic /);
assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 401);
} finally { globalThis.fetch = oldFetch; }
});

test('the callback renders a human page, including for an expired session', async () => {
const { session } = setup();
await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}}));
const oldFetch = globalThis.fetch;
globalThis.fetch = async () => Response.json({access_token:'private-token', team_id:'team_123'});
try {
const success = await session.fetch(req('/callback?code=example'));
assert.equal(success.status, 200);
assert.match(success.headers.get('Content-Type') ?? '', /text\/html/);
assert.match(success.headers.get('Content-Security-Policy') ?? '', /style-src 'unsafe-inline'/);
const html = await success.text();
assert.match(html, /Agent Notch is connected/);
assert.match(html, /close this tab/);
assert.doesNotMatch(html, /private-token/);

const replay = await session.fetch(req('/callback?code=example'));
assert.equal(replay.status, 400);
assert.match(await replay.text(), /This connection expired/);
} finally { globalThis.fetch = oldFetch; }
});

test('disconnect still succeeds when revocation throws', 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 path = new URL(input).pathname;
if (path === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'});
if (path === '/login/oauth/token/revoke') throw new TypeError('network down');
throw new Error('unexpected request: ' + path);
};
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('/disconnect', {method:'POST', headers:{Authorization:'Bearer ' + secret}}))).status, 200);
assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 401);
} finally { globalThis.fetch = oldFetch; }
});

Expand Down Expand Up @@ -165,7 +229,9 @@ test('simulated installation stays isolated across devices and does not leak met
globalThis.fetch = async (input, options) => {
// 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'});
const path = new URL(input).pathname;
if (path === '/v2/oauth/access_token') return Response.json({access_token:'server-secret',team_id:'team_123'});
if (path === '/login/oauth/token/revoke') return new Response(null, {status: 200});
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}});
};
Expand Down
Loading