index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. /**
  2. * Host half of open-in-app: three routes on the composition's `webServer`
  3. * serving the resolved application catalog, per-application icons, and the
  4. * launch endpoint the browser split button
  5. * (`@deepseek-ai/dsh-client-ui-open-in-app`) posts to.
  6. *
  7. * Security has one home, here. Every route asks the composition's
  8. * `connection` service for a rejection first (`requestRejection`): its
  9. * Host/Origin fence defeats DNS rebinding and cross-site calls, and its
  10. * browser authentication (the login-token cookie) gates every caller before
  11. * any resolution result, icon, or launch is reachable. On top of that fence
  12. * the open route validates its body at the wire: an `application/json` media
  13. * type, a 64 KiB ceiling, string `app`/`path` fields, a resolved-available
  14. * catalog id, and an absolute path naming an existing directory.
  15. *
  16. * The catalog resolves lazily, once per plugin life, on the first request
  17. * that needs it, into one map of verified launchers: the apps route serves
  18. * its keys and the open route launches its values, so a click, menu open, or
  19. * page reload never re-runs detection. A launch that finds its executable
  20. * gone (`ENOENT`) invalidates that one entry and re-resolves it once.
  21. */
  22. import type { IncomingMessage, ServerResponse } from 'node:http'
  23. import { isAbsolute } from 'node:path'
  24. import { stat } from 'node:fs/promises'
  25. import type { Context } from '@deepseek-ai/cordis'
  26. import type {} from '@deepseek-ai/dsh-host-webserver'
  27. import type {} from '@deepseek-ai/dsh-subprocess'
  28. import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
  29. import z from '@deepseek-ai/schemastery'
  30. import { OPEN_IN_APP_CATALOG, type OpenInAppApp } from './catalog.ts'
  31. import {
  32. launchResolved, resolveLaunch, resolveOpenInAppApps,
  33. type OpenInAppInternals, type OpenInAppResolvedLaunch,
  34. } from './resolver.ts'
  35. import { extractAppIcon, type OpenInAppIcon } from './icons.ts'
  36. import { internals } from './internals.ts'
  37. import {
  38. OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_ICON_PREFIX, OPEN_IN_APP_OPEN_ROUTE,
  39. } from './shared.ts'
  40. export type * from './shared.ts'
  41. /** Cordis function-plugin name. */
  42. export const name = 'open-in-app'
  43. /** The route carrier, the trust fence guarding every route, and the PATH resolver. */
  44. export const inject = ['webServer', 'connection', 'subprocess']
  45. /** Open-in-app host configuration. */
  46. export interface Config {
  47. /**
  48. * Per-command deadline in milliseconds for catalog-resolution host
  49. * commands (`xcode-select`, the Windows registry reads).
  50. */
  51. readonly probeTimeoutMs: number
  52. /**
  53. * Per-command deadline in milliseconds for icon-extraction host commands
  54. * (`plutil`/`sips` on macOS, the PowerShell extraction on Windows).
  55. */
  56. readonly iconTimeoutMs: number
  57. /**
  58. * Early-failure watch window per launch, in milliseconds: a launcher still
  59. * running when the window closes counts as launched and keeps running, so
  60. * this bounds how long the open route holds a successful launch, not how
  61. * long an application may live.
  62. */
  63. readonly launchWatchMs: number
  64. }
  65. const boundedMs = (): z<number> => z.number().step(1).min(1).max(600_000).required()
  66. export const Config: z<Config> = z.object({
  67. probeTimeoutMs: boundedMs(),
  68. iconTimeoutMs: boundedMs(),
  69. launchWatchMs: boundedMs(),
  70. })
  71. /** Trust surface consumed here; the browser-side connection package owns the full type. */
  72. interface OpenInAppConnection {
  73. requestRejection(request: { readonly headers: IncomingMessage['headers'] }): 401 | 403 | undefined
  74. }
  75. /** The composition's connection service (typed locally: its package is browser-side). */
  76. function connectionOf(ctx: Context): OpenInAppConnection {
  77. return Reflect.get(ctx, 'connection') as OpenInAppConnection
  78. }
  79. /** Open-route request bodies are tiny JSON objects; anything larger is hostile. */
  80. const MAX_BODY_BYTES = 64 * 1024
  81. /** JSON response (no-store: availability and launch outcomes are live facts). */
  82. function sendJson(res: ServerResponse, status: number, payload: unknown): void {
  83. res.statusCode = status
  84. res.setHeader('content-type', 'application/json; charset=utf-8')
  85. res.setHeader('cache-control', 'no-store')
  86. res.end(JSON.stringify(payload))
  87. }
  88. /** 405 with the route's one supported method. */
  89. function sendMethodNotAllowed(res: ServerResponse, allow: 'GET' | 'POST'): void {
  90. res.statusCode = 405
  91. res.setHeader('allow', allow)
  92. res.end()
  93. }
  94. /** Collect a bounded request body as UTF-8 text; null past the ceiling (stream drained). */
  95. async function readBoundedBody(req: IncomingMessage): Promise<string | null> {
  96. const chunks: Buffer[] = []
  97. let size = 0
  98. // http server streams without setEncoding always yield Buffer chunks.
  99. for await (const chunk of req as AsyncIterable<Buffer>) {
  100. size += chunk.byteLength
  101. if (size > MAX_BODY_BYTES) {
  102. // Drain the remainder so the refusal is a readable response, not a socket cut.
  103. req.resume()
  104. return null
  105. }
  106. chunks.push(chunk)
  107. }
  108. return Buffer.concat(chunks, size).toString('utf8')
  109. }
  110. /** Validate one open-route body at the wire: JSON object with string app/path. */
  111. function parseOpenBody(text: string): { app: string; path: string } | null {
  112. let body: unknown
  113. try {
  114. body = JSON.parse(text)
  115. } catch {
  116. // Swallows the parse error: a non-JSON body is exactly the null case.
  117. return null
  118. }
  119. if (typeof body !== 'object' || body === null) return null
  120. const { app, path } = body as { app?: unknown; path?: unknown }
  121. return typeof app === 'string' && typeof path === 'string' ? { app, path } : null
  122. }
  123. /** Register the apps, icon, and open routes behind the connection trust fence. */
  124. export function apply(ctx: Context, config: Config): void {
  125. const ssh = launchedThroughSsh(launchEnvironmentOf(ctx))
  126. /** Test-seam facts completed with the composition's PATH resolver. */
  127. const catalogInternals = (): OpenInAppInternals => ({
  128. ssh,
  129. resolveExecutable: async (name) => {
  130. try {
  131. return await ctx.subprocess.resolveExecutable(name)
  132. } catch {
  133. // Swallows the provider's not-found rejection: for detection, a name
  134. // that does not resolve has exactly one meaning — unavailable.
  135. return null
  136. }
  137. },
  138. ...internals.catalog,
  139. })
  140. /** Lazy once-per-plugin-life resolution; the map is the mutable authority. */
  141. let resolutions: Promise<Map<string, OpenInAppResolvedLaunch>> | undefined
  142. const availability = (): Promise<Map<string, OpenInAppResolvedLaunch>> =>
  143. resolutions ??= resolveOpenInAppApps(config.probeTimeoutMs, catalogInternals())
  144. /** Per-app icon promise cache (null = resolved as unavailable). */
  145. const icons = new Map<string, Promise<OpenInAppIcon | null>>()
  146. const iconOf = (app: OpenInAppApp, resolved: OpenInAppResolvedLaunch): Promise<OpenInAppIcon | null> => {
  147. let cached = icons.get(app.id)
  148. if (cached === undefined) {
  149. cached = extractAppIcon(app, resolved, config.iconTimeoutMs, catalogInternals())
  150. icons.set(app.id, cached)
  151. }
  152. return cached
  153. }
  154. /**
  155. * Replace one stale resolution after a missing-executable launch: the
  156. * entry (and its icon) re-resolves once; an entry that no longer resolves
  157. * leaves the map and the next apps read no longer offers it.
  158. */
  159. const refreshResolution = async (app: OpenInAppApp): Promise<OpenInAppResolvedLaunch | undefined> => {
  160. const map = await availability()
  161. const fresh = await resolveLaunch(app, config.probeTimeoutMs, catalogInternals())
  162. icons.delete(app.id)
  163. if (fresh === null) {
  164. map.delete(app.id)
  165. return undefined
  166. }
  167. map.set(app.id, fresh)
  168. return fresh
  169. }
  170. /** Answer an untrusted/unauthenticated request; true when it was rejected. */
  171. const rejected = (req: IncomingMessage, res: ServerResponse): boolean => {
  172. const rejection = connectionOf(ctx).requestRejection(req)
  173. if (rejection === undefined) return false
  174. res.statusCode = rejection
  175. res.end()
  176. return true
  177. }
  178. ctx.effect(() => ctx.webServer.register({
  179. kind: 'exact',
  180. path: OPEN_IN_APP_APPS_ROUTE,
  181. handler: async (req, res) => {
  182. if (rejected(req, res)) return
  183. if (req.method !== 'GET') {
  184. sendMethodNotAllowed(res, 'GET')
  185. return
  186. }
  187. sendJson(res, 200, { apps: [...(await availability()).keys()] })
  188. },
  189. }), `open-in-app: GET ${OPEN_IN_APP_APPS_ROUTE}`)
  190. ctx.effect(() => ctx.webServer.register({
  191. kind: 'prefix',
  192. path: OPEN_IN_APP_ICON_PREFIX,
  193. handler: async (req, res) => {
  194. if (rejected(req, res)) return
  195. if (req.method !== 'GET') {
  196. sendMethodNotAllowed(res, 'GET')
  197. return
  198. }
  199. // Node always sets url on server requests; String keeps that fact local.
  200. const pathname = new URL(String(req.url), 'http://localhost').pathname
  201. const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX.length).replace(/^\//, '')
  202. const noIcon = (): void => { sendJson(res, 404, { code: 'not-found', message: `no icon for ${id}` }) }
  203. const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id)
  204. if (app === undefined) {
  205. noIcon()
  206. return
  207. }
  208. const resolved = (await availability()).get(app.id)
  209. if (resolved === undefined) {
  210. noIcon()
  211. return
  212. }
  213. const icon = await iconOf(app, resolved)
  214. if (icon === null) {
  215. noIcon()
  216. return
  217. }
  218. res.statusCode = 200
  219. res.setHeader('content-type', icon.contentType)
  220. res.setHeader('cache-control', 'public, max-age=3600')
  221. res.end(icon.bytes)
  222. },
  223. }), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX}/<id>`)
  224. ctx.effect(() => ctx.webServer.register({
  225. kind: 'exact',
  226. path: OPEN_IN_APP_OPEN_ROUTE,
  227. handler: async (req, res) => {
  228. if (rejected(req, res)) return
  229. if (req.method !== 'POST') {
  230. sendMethodNotAllowed(res, 'POST')
  231. return
  232. }
  233. // Body-format validation: the essence must be exactly application/json.
  234. // String(undefined) is 'undefined', which never matches.
  235. const essence = String(req.headers['content-type']).split(';', 1)[0]?.trim().toLowerCase()
  236. if (essence !== 'application/json') {
  237. sendJson(res, 415, { code: 'unsupported-media-type', message: 'content-type must be application/json' })
  238. return
  239. }
  240. let text: string | null
  241. try {
  242. text = await readBoundedBody(req)
  243. } catch {
  244. // Swallows connection errors mid-body: there is nothing left to answer precisely.
  245. sendJson(res, 400, { code: 'bad-request', message: 'request body unreadable' })
  246. return
  247. }
  248. if (text === null) {
  249. sendJson(res, 413, { code: 'payload-too-large', message: 'request body is too large' })
  250. return
  251. }
  252. const parsed = parseOpenBody(text)
  253. if (parsed === null) {
  254. sendJson(res, 400, { code: 'bad-request', message: 'request body must be JSON with string "app" and "path"' })
  255. return
  256. }
  257. const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === parsed.app)
  258. const resolved = app === undefined ? undefined : (await availability()).get(app.id)
  259. if (app === undefined || resolved === undefined) {
  260. sendJson(res, 400, { code: 'bad-request', message: `unknown or unavailable app: ${parsed.app}` })
  261. return
  262. }
  263. if (parsed.path === '' || !isAbsolute(parsed.path)) {
  264. sendJson(res, 400, { code: 'bad-request', message: 'path must be an absolute directory path' })
  265. return
  266. }
  267. let directory: boolean
  268. try {
  269. directory = (await stat(parsed.path)).isDirectory()
  270. } catch {
  271. // Swallows ENOENT/EACCES: both mean there is no directory to open.
  272. directory = false
  273. }
  274. if (!directory) {
  275. sendJson(res, 404, { code: 'not-found', message: `directory does not exist: ${parsed.path}` })
  276. return
  277. }
  278. let outcome = await launchResolved(resolved, parsed.path, config.launchWatchMs, catalogInternals())
  279. if (outcome === 'missing') {
  280. // The verified launcher is gone (uninstalled since resolution):
  281. // refresh this one entry and retry once with the fresh launcher.
  282. const fresh = await refreshResolution(app)
  283. outcome = fresh === undefined
  284. ? 'failed'
  285. : await launchResolved(fresh, parsed.path, config.launchWatchMs, catalogInternals())
  286. }
  287. if (outcome === 'launched') {
  288. sendJson(res, 200, { ok: true })
  289. } else {
  290. sendJson(res, 502, { code: 'launch-failed', message: `failed to launch ${app.id}` })
  291. }
  292. },
  293. }), `open-in-app: POST ${OPEN_IN_APP_OPEN_ROUTE}`)
  294. }