verify-no-bare-dispatcher.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. /**
  2. * Verify that no package builds its own undici agent or hands `fetch` an explicit dispatcher.
  3. *
  4. * Node's built-in `fetch` routes through undici's global dispatcher, which `@deepseek-ai/dsh-http-proxy`
  5. * installs at launch. An explicitly supplied `dispatcher` overrides that global one, so a call site
  6. * that constructs `new Agent(...)` itself connects directly no matter what proxy the user configured
  7. * — the exact defect `web-fetch-http` carried before proxy support existed, where its DNS-pinning
  8. * agent silently bypassed every proxy.
  9. *
  10. * `proxyRouteFor(url)` from that package is the sanctioned way to ask where one request goes and to
  11. * get the transport that answer assumed. A call site that genuinely owns its transport — because it
  12. * carries per-request state the process-wide dispatcher cannot, as `web-fetch-http`'s address
  13. * pinning does — says so with the marker below.
  14. *
  15. * Discovery is syntax-aware, as `scripts/AGENTS.md` requires: a line-wise regex misses the
  16. * `{ dispatcher }` shorthand and a `new Alias(...)` whose import renamed `Agent`, and both bypass the
  17. * proxy exactly as the spelled-out forms do. Bindings from a dynamic `await import('undici')` count
  18. * the same as static ones — that is how this repository loads undici wherever the transport must
  19. * stay out of a browser-worker's startup graph.
  20. */
  21. import { globSync, readFileSync } from 'node:fs'
  22. import { relative, resolve } from 'node:path'
  23. import ts from 'typescript'
  24. const root = resolve(import.meta.dirname, '..')
  25. /** The package that owns dispatcher construction; its own agents are the implementation. */
  26. export const DISPATCHER_OWNER = 'packages/util/http-proxy/'
  27. /**
  28. * A comment carrying this marker states why the construction or option is exempt. It counts on the
  29. * offending line or the line directly above it, because a syntax-aware match anchors on the property
  30. * or `new` expression rather than the statement, and the explanation belongs above a long line.
  31. */
  32. export const ALLOW_MARKER = 'proxy-exempt:'
  33. /** Undici agent classes whose construction selects a transport, under any local name. */
  34. const AGENT_EXPORTS = new Set(['Agent', 'ProxyAgent', 'EnvHttpProxyAgent'])
  35. /** The module those classes must come from; a same-named class from elsewhere selects no transport. */
  36. const AGENT_MODULE = 'undici'
  37. /** The request option that overrides the global dispatcher, however it is written. */
  38. const DISPATCHER_PROPERTY = 'dispatcher'
  39. /** One source position that would bypass the configured proxy. */
  40. export interface DispatcherViolation {
  41. /** Repository-relative path, in POSIX separators. */
  42. readonly file: string
  43. /** One-based line number. */
  44. readonly line: number
  45. /** Which rule the position broke. */
  46. readonly what: string
  47. /** The offending source text, trimmed. */
  48. readonly text: string
  49. }
  50. /**
  51. * Whether an expression is `import('undici')`, with or without `await`. The dynamic form is how
  52. * this repository loads undici everywhere the transport must stay out of a browser-worker's startup
  53. * graph, so a gate blind to it would miss the repository's own idiom.
  54. *
  55. * @param expression - a variable declaration's initializer, when it has one.
  56. * @returns true when evaluating it yields the undici module.
  57. */
  58. function isUndiciImport(expression: ts.Expression | undefined): boolean {
  59. if (expression === undefined) return false
  60. const call = ts.isAwaitExpression(expression) ? expression.expression : expression
  61. if (!ts.isCallExpression(call) || call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false
  62. const [specifier] = call.arguments
  63. return specifier !== undefined && ts.isStringLiteral(specifier) && specifier.text === AGENT_MODULE
  64. }
  65. /**
  66. * Record the names one destructured dynamic import binds to an agent class.
  67. *
  68. * @param pattern - the binding pattern of `const { Agent, ProxyAgent: P } = await import('undici')`.
  69. * @param agents - collector the local names are added to.
  70. */
  71. function collectDestructuredAgents(pattern: ts.ObjectBindingPattern, agents: Set<string>): void {
  72. for (const element of pattern.elements) {
  73. if (!ts.isIdentifier(element.name)) continue
  74. const property = element.propertyName
  75. const imported = property === undefined
  76. ? element.name.text
  77. : ts.isIdentifier(property) || ts.isStringLiteral(property) ? property.text : undefined
  78. if (imported !== undefined && AGENT_EXPORTS.has(imported)) agents.add(element.name.text)
  79. }
  80. }
  81. /**
  82. * Local names bound to an undici agent class, including `import { Agent as X }` renames, the
  83. * destructured and namespace forms of a dynamic `import('undici')`, and a namespace import's own
  84. * name so `undici.Agent` is recognised too.
  85. *
  86. * @param source - the parsed file.
  87. * @returns agent identifiers and namespace identifiers bound in this file.
  88. */
  89. function agentBindings(source: ts.SourceFile): { agents: Set<string>; namespaces: Set<string> } {
  90. const agents = new Set<string>()
  91. const namespaces = new Set<string>()
  92. const visit = (node: ts.Node): void => {
  93. if (ts.isImportDeclaration(node)) {
  94. if (ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === AGENT_MODULE) {
  95. const bindings = node.importClause?.namedBindings
  96. if (bindings !== undefined && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text)
  97. else if (bindings !== undefined) {
  98. for (const element of bindings.elements) {
  99. const imported = (element.propertyName ?? element.name).text
  100. if (AGENT_EXPORTS.has(imported)) agents.add(element.name.text)
  101. }
  102. }
  103. }
  104. } else if (ts.isVariableDeclaration(node) && isUndiciImport(node.initializer)) {
  105. if (ts.isIdentifier(node.name)) namespaces.add(node.name.text)
  106. else if (ts.isObjectBindingPattern(node.name)) collectDestructuredAgents(node.name, agents)
  107. }
  108. ts.forEachChild(node, visit)
  109. }
  110. ts.forEachChild(source, visit)
  111. return { agents, namespaces }
  112. }
  113. /**
  114. * Whether an expression names an undici agent class: a bound identifier, or a `<namespace>.Agent`
  115. * property access.
  116. *
  117. * @param expression - the `new` expression's callee.
  118. * @param bound - identifiers this file bound to an agent class or a namespace.
  119. * @returns true when constructing it selects a transport.
  120. */
  121. function namesAgent(expression: ts.Expression, bound: ReturnType<typeof agentBindings>): boolean {
  122. if (ts.isIdentifier(expression)) return bound.agents.has(expression.text)
  123. if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
  124. return bound.namespaces.has(expression.expression.text) && AGENT_EXPORTS.has(expression.name.text)
  125. }
  126. return false
  127. }
  128. /**
  129. * Whether an object literal member supplies `dispatcher`, covering `dispatcher: x`, the `{ dispatcher }`
  130. * shorthand, and `{ 'dispatcher': x }`.
  131. *
  132. * @param member - one object-literal element.
  133. * @returns true when the member names the dispatcher option.
  134. */
  135. function suppliesDispatcher(member: ts.ObjectLiteralElementLike): boolean {
  136. if (ts.isShorthandPropertyAssignment(member)) return member.name.text === DISPATCHER_PROPERTY
  137. if (!ts.isPropertyAssignment(member)) return false
  138. const name = member.name
  139. if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text === DISPATCHER_PROPERTY
  140. return false
  141. }
  142. /**
  143. * Find every bare-dispatcher position in one source file.
  144. *
  145. * @param file - repository-relative path, used to exempt the owning package and to report location.
  146. * @param sourceText - the file's contents.
  147. * @returns one violation per offending position, in source order.
  148. */
  149. export function findDispatcherViolations(file: string, sourceText: string): DispatcherViolation[] {
  150. const posix = file.replaceAll('\\', '/')
  151. if (posix.startsWith(DISPATCHER_OWNER)) return []
  152. // Both violations name one of these two words in source: an agent construction needs a binding
  153. // from the undici module, and the option is a property called `dispatcher`. Parsing the rest of
  154. // the repository anyway made this the slowest gate — 21 of 1597 files survive the filter.
  155. if (!sourceText.includes(AGENT_MODULE) && !sourceText.includes(DISPATCHER_PROPERTY)) return []
  156. const source = ts.createSourceFile(posix, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
  157. const bound = agentBindings(source)
  158. const lines = sourceText.split('\n')
  159. const violations: DispatcherViolation[] = []
  160. const record = (node: ts.Node, what: string): void => {
  161. const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line
  162. const exempt = [lines[line], lines[line - 1]].some(text => text?.includes(ALLOW_MARKER) === true)
  163. if (exempt) return
  164. violations.push({ file: posix, line: line + 1, what, text: (lines[line] ?? '').trim() })
  165. }
  166. const visit = (node: ts.Node): void => {
  167. if (ts.isNewExpression(node) && namesAgent(node.expression, bound)) {
  168. record(node, 'constructs an undici agent')
  169. }
  170. if (ts.isObjectLiteralExpression(node) && node.properties.some(suppliesDispatcher)) {
  171. record(node.properties.find(suppliesDispatcher) as ts.Node, 'passes an explicit `dispatcher`')
  172. }
  173. ts.forEachChild(node, visit)
  174. }
  175. ts.forEachChild(source, visit)
  176. return violations
  177. }
  178. /**
  179. * Scan every package and app source file in the repository.
  180. *
  181. * @returns every violation found, in scan order.
  182. * @throws when the corpus is empty, which would make the gate pass by scanning nothing.
  183. */
  184. export function scanRepository(): DispatcherViolation[] {
  185. const files = [
  186. ...globSync('packages/*/*/src/**/*.ts', { cwd: root }),
  187. ...globSync('apps/*/src/**/*.ts', { cwd: root }),
  188. ]
  189. if (files.length === 0) throw new Error('verify-no-bare-dispatcher: scanned an empty corpus; the globs no longer match.')
  190. return files.flatMap(file => findDispatcherViolations(file, readFileSync(resolve(root, file), 'utf8')))
  191. }
  192. function main(): void {
  193. const violations = scanRepository()
  194. if (violations.length === 0) {
  195. console.log(`verify-no-bare-dispatcher: no bare dispatcher outside ${DISPATCHER_OWNER}.`)
  196. return
  197. }
  198. console.error('verify-no-bare-dispatcher: a dispatcher built outside @deepseek-ai/dsh-http-proxy bypasses the configured proxy.\n')
  199. for (const violation of violations) {
  200. console.error(` ${relative('.', violation.file)}:${String(violation.line)} ${violation.what}`)
  201. console.error(` ${violation.text}`)
  202. }
  203. console.error('\nUse `proxyRouteFor(url)` from @deepseek-ai/dsh-http-proxy, or annotate the line')
  204. console.error(`with a \`${ALLOW_MARKER} <reason>\` comment when the request must genuinely ignore the proxy.`)
  205. process.exit(1)
  206. }
  207. if (import.meta.filename === resolve(process.argv[1] ?? '')) main()