Files
awesome-copilot/extensions/sentry-triage/server.mjs
T
Liz Tom b6da04c241 Add Sentry Triage canvas extension (#2802)
An on-call error-triage canvas for GitHub Copilot: scans live Sentry
issues, groups them by urgency, and hands issues off for tracking or a
fix PR. Registers a reusable canvas extension under extensions/ with a
matching plugins/sentry-triage manifest.

The extension runs a loopback HTTP/SSE server per canvas instance
(host-allow-listed, per-instance capability token never broadcast over
SSE), renders the triage board server- and client-side, and drives
model turns over a single shared Copilot session serialized so scan and
work turns can't interleave. Work state is prototype-pollution-guarded
against agent-controlled tracking keys, and sendAndWait timeouts are
handled without duplicating external side effects.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-26 10:04:22 +10:00

473 lines
20 KiB
JavaScript

import { createServer } from 'node:http'
import { randomUUID } from 'node:crypto'
import { createState, PERIODS } from './state.mjs'
import { PREFS_DISPLAY_PATH } from './prefs.mjs'
import { Page } from './components/page.mjs'
// Cap the buffered request body for the loopback mutation API. Even though every
// mutation is Host-checked and capability-token gated, an attacker-reachable page
// shouldn't be able to grow this process's memory without bound. On overflow we
// answer 413, destroy the request, and resolve null so the caller bails out.
const MAX_BODY_BYTES = 1 << 20 // 1 MiB
function readBody(req, res) {
return new Promise((resolve) => {
let body = ''
let size = 0
let aborted = false
req.on('data', (chunk) => {
if (aborted) return
size += chunk.length
if (size > MAX_BODY_BYTES) {
aborted = true
if (res && !res.headersSent) {
res.writeHead(413, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'Request body too large' }))
}
req.destroy()
resolve(null)
return
}
body += chunk
})
req.on('end', () => { if (!aborted) resolve(body) })
req.on('error', () => { if (!aborted) resolve(null) })
// 'end' never fires if the client aborts mid-stream; 'close' always does, so
// resolve here too (idempotent — a prior 'end' resolve wins) to avoid a hung
// handler promise.
req.on('close', () => { if (!aborted) resolve(null) })
})
}
function parseJson(body) {
try {
return body ? JSON.parse(body) : {}
} catch {
return {}
}
}
// Localhost trust model for this per-instance, unauthenticated API.
//
// Two distinct browser attacks reach a localhost port:
// 1. Plain cross-origin POST — a page on attacker.com POSTs a CORS-safelisted
// body to 127.0.0.1:PORT. The browser sends `Origin: https://attacker.com`
// and `Host: 127.0.0.1:PORT` (the real target), so Origin != Host.
// 2. DNS rebinding — attacker.com is re-resolved to 127.0.0.1, so the page's
// own origin now points AT this server. Here Origin == Host (both read
// attacker.com), so an Origin==Host check passes and the attack succeeds.
//
// Defenses (both required, applied to every request):
// * Host allow-list — the loopback server only ever answers on the exact
// `127.0.0.1:PORT` it bound. A rebinding page still sends `Host: attacker.com`
// (the name the browser navigated to), so requiring Host to equal the bound
// loopback authority rejects rebinding while the legitimate canvas — loaded
// from http://127.0.0.1:PORT/ — matches.
// * Per-instance capability token — a random secret minted at startup, embedded
// only in the served page and required as a header on every mutation. A
// cross-origin/rebinding page cannot read the page body cross-origin, so it
// cannot learn the token; same-origin canvas fetches replay it and pass.
function hostMatches(req, boundHost) {
return typeof req.headers.host === 'string' && req.headers.host === boundHost
}
function hasValidToken(req, token) {
const header = req.headers['x-sentry-triage-csrf']
const value = Array.isArray(header) ? header[0] : header
return value === token
}
export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onRecheck, onListProjects, onInvalidateEnrichment, defaults } = {}) {
// Per-instance state + SSE clients — never shared across canvas instances.
const state = createState()
state.applyRepoDefaults(defaults)
const sseClients = new Set()
// Per-instance capability token (see hostMatches/hasValidToken). Minted before
// the server binds; embedded in the served page and required on mutations.
const csrfToken = randomUUID()
// The exact `127.0.0.1:PORT` authority this server binds. Set in the listen
// callback (the port may be ephemeral) and used to reject DNS-rebinding hosts.
let boundHost = null
// Single source of truth for the full state snapshot pushed to clients (SSE
// initial payload, page render props, and every notifyClients broadcast).
function snapshot() {
return {
categories: state.getCategories(),
scanError: state.getScanError(),
scannedTotal: state.getScannedTotal(),
scannedCapped: state.getScannedCapped(),
scannedOlder: state.getScannedOlder(),
scannedOlderCapped: state.getScannedOlderCapped(),
org: state.getOrg(),
orgOptions: state.getOrgOptions(),
orgDefault: state.getOrgDefault(),
savedDefaultOrg: state.getSavedDefaultOrg(),
project: state.getProject(),
projectOptions: state.getProjectOptions(),
period: state.getPeriod(),
periods: PERIODS,
projects: state.getProjects(),
projectsOrg: state.getProjectsOrg(),
connections: state.getConnections(),
prTargets: state.getPrTargets(),
availableModels: state.getAvailableModels(),
prSettingsOpen: state.getPrSettingsOpen(),
plainEnglishView: state.getPlainEnglishView(),
issueTrackers: state.getIssueTrackers(),
selectedTracker: state.getSelectedTracker(),
workByIssueKey: state.getWorkByIssueKey(),
}
}
function notifyClients() {
const data = JSON.stringify(snapshot())
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
function notifyWork(key, patch) {
const status = state.setWorkStatus(key, patch)
if (!status) return
const data = JSON.stringify({ work: { key, ...status } })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
// Broadcast a transient toast message (e.g. "no project found") without
// touching state — the client shows it and moves on.
function notifyFlash(message, kind = 'info') {
const data = JSON.stringify({ flash: { message: String(message || ''), kind } })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
// Broadcast scan start/stop so the client can show a blocking overlay while a
// (slow, agent-driven) re-scan is in flight and hide it exactly when done.
function notifyScanning(isScanning) {
const data = JSON.stringify({ scanning: Boolean(isScanning) })
for (const res of sseClients) {
res.write(`data: ${data}\n\n`)
}
}
function handleRequest(req, res) {
// Reject DNS-rebinding hosts on EVERY request (see the trust-model comment):
// a rebinding page still carries its own hostname in `Host`, so anything but
// the exact bound loopback authority is untrusted — this also keeps the page
// and its embedded token from being served to a rebinding origin. boundHost
// is set before the server accepts connections, so it is always populated.
if (boundHost && !hostMatches(req, boundHost)) {
res.writeHead(403, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'untrusted host rejected' }))
return
}
// Every state-changing endpoint is a POST, so gating POST with the per-
// instance capability token covers all mutations in one place. GET (the page
// + SSE reads) is exempt so the page can load and hand the token to its own
// same-origin fetches.
if (req.method === 'POST' && !hasValidToken(req, csrfToken)) {
res.writeHead(403, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'missing or invalid request token' }))
return
}
// SSE endpoint
if (req.url === '/api/events') {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write(`data: ${JSON.stringify(snapshot())}\n\n`)
sseClients.add(res)
req.on('close', () => sseClients.delete(res))
return
}
// Bulk action endpoint
if (req.method === 'POST' && req.url === '/api/action') {
readBody(req, res).then((body) => {
if (body === null) return
const { categoryId, issueKeys } = parseJson(body)
state.removeIssues(categoryId, issueKeys)
notifyClients()
if (onAction) onAction(categoryId, issueKeys)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Refresh endpoint
if (req.method === 'POST' && req.url === '/api/refresh') {
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
}
// Re-run the MCP connection preflight (Sentry). Used by the setup
// gate / warning banner "Re-check" button after the user connects a server.
if (req.method === 'POST' && req.url === '/api/recheck-connections') {
Promise.resolve(onRecheck ? onRecheck() : null)
.then((connections) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: connections || state.getConnections() }))
})
.catch((err) => {
console.error('[sentry-triage] recheck failed:', err instanceof Error ? err.message : err)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, connections: state.getConnections() }))
})
return
}
// Fetch the Sentry project list for an org so the project field can render
// as a dropdown. Fire-and-forget from the client's perspective: the fetched
// list is broadcast to all clients over SSE by the handler. Used when the
// user changes the org on the setup screen (before committing to a scan).
if (req.method === 'POST' && req.url === '/api/list-projects') {
readBody(req, res).then((body) => {
if (body === null) return
const { org } = parseJson(body)
Promise.resolve(onListProjects ? onListProjects(typeof org === 'string' ? org : '') : null)
.catch((err) => console.error('[sentry-triage] list-projects failed:', err instanceof Error ? err.message : err))
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Set org endpoint
if (req.method === 'POST' && req.url === '/api/set-org') {
readBody(req, res).then((body) => {
if (body === null) return
const { org, project, repo } = parseJson(body)
// Normalize the incoming scope with the same rules the setters apply, so
// we can tell whether the org/project actually changed. Clicking Fetch on
// an unchanged scope must NOT clear work badges or bump the scope
// generation — doing so would abort an in-flight work batch and discard
// its later PR callback.
const nextOrg = typeof org === 'string' ? org.trim().toLowerCase() : ''
const nextProject = typeof project === 'string' ? project.trim() : ''
const scopeChanged = nextOrg !== state.getOrg() || nextProject !== state.getProject()
state.setOrg(org)
state.setProject(project)
if (typeof repo === 'string') {
const current = state.getPrTargets()
state.setPrTargets({
...current,
cloud: { ...current.cloud, repo: repo.trim() },
})
}
if (scopeChanged) state.clearWorkStatuses()
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Persist (or clear) the user's default org. Explicit user action only — the
// setup screen's ⭐ control POSTs here. Broadcasts so the control reflects the
// saved state immediately. Returns the display path so the UI can show the
// user exactly where the preference was written.
if (req.method === 'POST' && req.url === '/api/set-default-org') {
readBody(req, res).then((body) => {
if (body === null) return
const { org } = parseJson(body)
try {
const saved = state.setSavedDefaultOrg(typeof org === 'string' ? org : '')
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, savedDefaultOrg: saved, path: PREFS_DISPLAY_PATH }))
} catch (err) {
console.error('[sentry-triage] set-default-org failed:', err instanceof Error ? err.message : err)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'Could not save your default org — the preferences file could not be written.' }))
}
})
return
}
// Switch project endpoint — re-scans the same org scoped to the new project.
if (req.method === 'POST' && req.url === '/api/set-project') {
readBody(req, res).then((body) => {
if (body === null) return
const { project } = parseJson(body)
state.setProject(project)
state.clearWorkStatuses()
// Broadcast the new selection immediately so the dropdown + label update
// while the (slower) re-scan runs in the background.
notifyClients()
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
// Change the Sentry search window — re-scans the same org/project over the
// new period. Work statuses are kept: a wider window is a superset of the
// same issues, so their in-flight/handed-off state still applies.
if (req.method === 'POST' && req.url === '/api/set-period') {
readBody(req, res).then((body) => {
if (body === null) return
const { period } = parseJson(body)
state.setPeriod(period)
// Broadcast the new selection immediately so the dropdown updates while
// the (slower) re-scan runs in the background.
notifyClients()
if (onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
if (req.method === 'POST' && req.url === '/api/work-selected') {
readBody(req, res).then((body) => {
if (body === null) return
const parsed = parseJson(body)
const keys = parsed.keys
const issueKeys = Array.isArray(keys) ? keys.filter((key) => typeof key === 'string' && key) : []
const modelByKey = parsed.modelByKey && typeof parsed.modelByKey === 'object' ? parsed.modelByKey : {}
const assignCopilot = parsed.assignCopilot === true
if (onWorkSelected && issueKeys.length > 0) onWorkSelected(issueKeys, modelByKey, assignCopilot)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, queued: issueKeys.length }))
})
return
}
if (req.method === 'POST' && req.url === '/api/select-tracker') {
readBody(req, res).then((body) => {
if (body === null) return
const { tracker } = parseJson(body)
state.setSelectedTracker(tracker)
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
if (req.method === 'POST' && req.url === '/api/set-pr-config') {
readBody(req, res).then((body) => {
if (body === null) return
const payload = parseJson(body)
const current = state.getPrTargets()
const pick = (value, fallback) => (typeof value === 'string' ? value : fallback)
const next = {
mode: pick(payload.mode, current.mode),
model: pick(payload.model, current.model),
local: {
path: pick(payload.localPath, current.local.path),
baseBranch: pick(payload.localBranch, current.local.baseBranch),
projectId: pick(payload.localProjectId, current.local.projectId),
projectName: pick(payload.localProjectName, current.local.projectName),
},
cloud: {
repo: pick(payload.cloudRepo, current.cloud.repo),
baseBranch: pick(payload.cloudBranch, current.cloud.baseBranch),
},
}
// Proactively-detected tracking badges and "possibly related" hints are
// derived from the CURRENTLY selected repo. If the target repo identity
// changes, every such annotation on the board is now stale — and an
// enrichment turn still in flight from the previous repo could otherwise
// reapply the old links right after this save. So on a repo change: drop
// the stale annotations now, invalidate any pending enrichment, and
// re-derive against the new repo. Model/base-branch-only edits keep them.
const repoId = (t) =>
[t.cloud.repo, t.local.path, t.local.projectId].map((v) => (v || '').trim()).join('\u0000')
const repoChanged = repoId(next) !== repoId(current)
state.setPrTargets(next)
if (repoChanged) {
state.clearTrackedWorkStatuses()
state.clearRelatedIncidents()
if (onInvalidateEnrichment) onInvalidateEnrichment()
}
notifyClients()
if (repoChanged && onRefresh) onRefresh()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
})
return
}
if (req.method === 'POST' && req.url === '/api/toggle-pr-settings') {
state.togglePrSettingsOpen()
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
}
if (req.method === 'POST' && req.url === '/api/toggle-title-mode') {
const plainEnglishView = state.togglePlainEnglishView()
notifyClients()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, plainEnglishView }))
return
}
// Default: serve the page. The capability token is embedded here (and ONLY
// here — never in snapshot()/SSE broadcasts) so it reaches same-origin page
// scripts without being exposed on any readable data channel.
//
// Anti-clickjacking: the token defends reads, but a third-party page could
// still FRAME this loopback origin and trick the user into clicking the real
// controls — whose own same-origin script would attach the valid token to the
// state-changing request (create issue, start fix session). The Copilot host
// loads this canvas as a TOP-LEVEL webview (verified: window.top === window
// and no ancestor origins), never inside an iframe, so denying all framing
// costs the legitimate canvas nothing while blocking that attack outright.
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(Page({ ...snapshot(), csrfToken }))
}
const server = createServer(handleRequest)
// Gracefully shut down: an open EventSource keeps a socket alive, and
// server.close() only waits for idle connections. End every SSE response and
// force-drop any lingering sockets so onClose can't hang indefinitely.
function close() {
for (const res of sseClients) {
try { res.end() } catch { /* already gone */ }
}
sseClients.clear()
return new Promise((resolve) => {
server.close(() => resolve())
// Node >=18.2: terminate connections still open after close() is issued.
server.closeAllConnections?.()
})
}
return new Promise((resolve) => {
server.listen(port, '127.0.0.1', () => {
const address = server.address()
const actualPort = typeof address === 'object' && address ? address.port : 0
// Lock the Host allow-list to the exact loopback authority we bound before
// handling any request (see hostMatches / DNS-rebinding defense).
boundHost = `127.0.0.1:${actualPort}`
resolve({
server,
url: `http://127.0.0.1:${actualPort}/`,
state,
notifyClients,
notifyWork,
notifyFlash,
notifyScanning,
close,
})
})
})
}