index.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989
  1. /**
  2. * Plugin management over the booted profile.
  3. *
  4. * The profile launcher composes the host tree from bundle layers and user
  5. * patch files and, since `profileRuntime`, can recompose it while running.
  6. * This service is the one place that changes what those layers are: it runs
  7. * pnpm in the profile directory to install or remove a package, moves a
  8. * bundle in and out of the profile's layer list, recomposes the tree through
  9. * the runtime and reports the outcome, retries a bundle whose rows failed,
  10. * adds and removes rows in the profile's global user layer or one agent
  11. * preset's, and folds the manifest, the probe record, and the live tree into
  12. * one view per package for the `plugins` Remote. Every change is followed by
  13. * `plugins/changed`; an install run streams pnpm's output as
  14. * `plugins/install-log`.
  15. * @module @deepseek-ai/dsh-host-plugin-manager
  16. */
  17. import { spawn as spawnChild, type ChildProcess, type SpawnOptions } from 'node:child_process'
  18. import { randomUUID } from 'node:crypto'
  19. import { readFileSync, rmSync, writeFileSync } from 'node:fs'
  20. import { join } from 'node:path'
  21. import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis'
  22. import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
  23. import z from '@deepseek-ai/schemastery'
  24. import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
  25. import {
  26. bundleGroupId,
  27. claimLayerIds,
  28. disableBundle,
  29. enableBundle,
  30. healProfilesModuleFallback,
  31. layerTrust,
  32. loadProfile,
  33. PLUGIN_PROBE_DIR,
  34. probePackage,
  35. readProbeCache,
  36. readProfileManifest,
  37. reconcileInstalledBundles,
  38. recordContainedStates,
  39. resolveBundleDir,
  40. resolveProfileLayer,
  41. writeProbeCache,
  42. type BundleStage,
  43. type PluginProbe,
  44. type ProfileManifest,
  45. type ProfileRuntime,
  46. } from '@deepseek-ai/dsh-app-boot'
  47. import type {} from '@deepseek-ai/dsh-agent'
  48. import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-patch-file'
  49. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  50. import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  51. // Typert-generated ./typert and ./remote artifacts import Zod at runtime.
  52. import type {} from 'zod'
  53. import type {
  54. PluginChangeReason,
  55. PluginDependents,
  56. PluginEnableResult,
  57. PluginInstallRejection, PluginInstallResult,
  58. PluginPackageAddableView,
  59. PluginPackageRowView,
  60. PluginPackageStatus,
  61. PluginPackageView,
  62. PluginRowAddition,
  63. PluginRowPhase,
  64. PluginRowReference,
  65. PluginRowTarget,
  66. PluginServiceDependent,
  67. } from './types.ts'
  68. export type * from './types.ts'
  69. declare module '@deepseek-ai/cordis' {
  70. interface Context {
  71. /** Plugin management over the booted profile; mounted by the web bundle. */
  72. pluginManager: PluginManager
  73. }
  74. }
  75. /** Diagnostic prefix on errors this service raises through app-boot helpers. */
  76. const NAME = 'plugin-manager'
  77. /** The message of a thrown value; a non-Error keeps its text. */
  78. function messageOf(error: unknown): string {
  79. return error instanceof Error ? error.message : String(error)
  80. }
  81. /** The profile's installed dependencies by name; a hand-written manifest may omit the field. */
  82. function dependenciesOf(manifest: ProfileManifest): Record<string, string> {
  83. return manifest.dependencies ?? {}
  84. }
  85. /** The profile's enabled layer list; a hand-written manifest may omit the section. */
  86. function bundlesOf(manifest: ProfileManifest): readonly string[] {
  87. return manifest.dsh?.profile?.bundles ?? []
  88. }
  89. /** Plugin config: the pnpm command and the bounds on the child processes this service runs. */
  90. export interface Config {
  91. /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
  92. pnpmCommand: string
  93. /** Bound on one install or remove run, in milliseconds. */
  94. installTimeoutMs: number
  95. /** Bound on one package probe, in milliseconds. */
  96. probeTimeoutMs: number
  97. /** How many trailing bytes of an install run's output an install failure reports. */
  98. installLogTailBytes: number
  99. }
  100. /** Runtime mirror: FiberState is a cross-package const enum. */
  101. const FIBER_STATE = {
  102. PENDING: 0 as FiberState.PENDING,
  103. LOADING: 1 as FiberState.LOADING,
  104. ACTIVE: 2 as FiberState.ACTIVE,
  105. FAILED: 3 as FiberState.FAILED,
  106. DISPOSED: 4 as FiberState.DISPOSED,
  107. UNLOADING: 5 as FiberState.UNLOADING,
  108. } as const
  109. /** Complete projection of Cordis Fiber states onto the row phase vocabulary. */
  110. const ROW_PHASE = {
  111. [FIBER_STATE.PENDING]: 'pending',
  112. [FIBER_STATE.LOADING]: 'loading',
  113. [FIBER_STATE.ACTIVE]: 'active',
  114. [FIBER_STATE.FAILED]: 'failed',
  115. [FIBER_STATE.DISPOSED]: null,
  116. [FIBER_STATE.UNLOADING]: 'unloading',
  117. } as const satisfies Record<FiberState, PluginRowPhase>
  118. /** The spawn function, replaceable in tests so no pnpm runs. */
  119. export type SpawnLike = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess
  120. /** Test seams: the child spawner and the package probe. */
  121. export interface PluginManagerInternals {
  122. spawn?: SpawnLike
  123. probe?: typeof probePackage
  124. }
  125. /** The installed package's manifest slice the view reads. */
  126. type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
  127. /** Fields of one row's provenance a package view needs. */
  128. interface RowFacts {
  129. readonly entry: Entry
  130. readonly rowId: string
  131. }
  132. /**
  133. * The `pluginManager` service and the `plugins` Remote.
  134. *
  135. * Every method that changes the profile reads the manifest afresh and writes
  136. * it through the app-boot helpers the `dsh plugin` command uses, so the CLI
  137. * and the manager never disagree on the file. The profile runtime is
  138. * resolved per call: a composition without it (a test, a launcher other than
  139. * the profile launcher) still mounts this service, and every call then
  140. * reports `plugins/unavailable` rather than the service failing to start.
  141. */
  142. export class PluginManager extends TypertRemoteService {
  143. static inject = ['loader']
  144. static Config: z<Config> = z.object({
  145. pnpmCommand: z.string().default('pnpm'),
  146. installTimeoutMs: z.number().min(1_000).default(600_000),
  147. probeTimeoutMs: z.number().min(1_000).default(20_000),
  148. installLogTailBytes: z.number().min(256).default(16_384),
  149. })
  150. private readonly spawn: SpawnLike
  151. private readonly probeRunner: typeof probePackage
  152. constructor(ctx: Context, public config: Config, internals: PluginManagerInternals = {}) {
  153. super(ctx, 'pluginManager', { namespace: 'plugins' })
  154. this.spawn = internals.spawn ?? spawnChild
  155. this.probeRunner = internals.probe ?? probePackage
  156. }
  157. /** The mutation in flight, while one is; a second caller is refused rather than queued. */
  158. private active: { operation: string; subject: string } | undefined
  159. /**
  160. * Run one mutation with the manager to itself. Every write touches the
  161. * profile manifest, the user layers, or `node_modules`, and two at once
  162. * would race on those files, so a second call while one runs is refused.
  163. * @throws {RemoteError} `plugins/busy` naming the operation in flight.
  164. */
  165. private async exclusive<T>(operation: string, subject: string, run: () => Promise<T>): Promise<T> {
  166. if (this.active !== undefined) {
  167. throw new RemoteError(
  168. 'plugins/busy',
  169. `plugin-manager: ${operation} ${subject} refused while ${this.active.operation} ${this.active.subject} is still running`,
  170. { operation, subject, active: this.active },
  171. )
  172. }
  173. this.active = { operation, subject }
  174. try {
  175. return await run()
  176. } finally {
  177. this.active = undefined
  178. }
  179. }
  180. /**
  181. * Refuse a change to `node_modules` while a session is running: pnpm
  182. * rewrites the directory the running agents import from.
  183. * @throws {RemoteError} `plugins/agents-running` with the count.
  184. */
  185. private assertNoRunningAgents(operation: string): void {
  186. const running = (this.ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length
  187. if (running === 0) return
  188. throw new RemoteError(
  189. 'plugins/agents-running',
  190. `plugin-manager: ${operation} waits for ${String(running)} running session(s) to go idle`,
  191. { operation, running },
  192. )
  193. }
  194. /** The profile runtime, or the failure a caller without one receives. */
  195. private runtime(): ProfileRuntime {
  196. const runtime = this.ctx.get('profileRuntime')
  197. if (runtime === undefined) {
  198. throw new RemoteError(
  199. 'plugins/unavailable',
  200. 'plugin-manager: no profile is composed in this process, so there are no plugins to manage',
  201. { reason: 'no profile runtime' },
  202. )
  203. }
  204. return runtime
  205. }
  206. /**
  207. * Every package the profile knows: its template and installed bundles,
  208. * and every other installed dependency.
  209. * @returns one view per package, bundles first in layer order.
  210. */
  211. @Remote('list')
  212. async list(): Promise<PluginPackageView[]> {
  213. const runtime = this.runtime()
  214. const manifest = readProfileManifest(NAME, runtime.dir)
  215. const names = [...new Set([...bundlesOf(manifest), ...Object.keys(dependenciesOf(manifest))])]
  216. const views: PluginPackageView[] = []
  217. for (const name of names) views.push(await this.view(runtime, manifest, name))
  218. return views
  219. }
  220. /** Fold one package's manifest, probe, and tree facts into its view. */
  221. private async view(runtime: ProfileRuntime, manifest: ProfileManifest, name: string): Promise<PluginPackageView> {
  222. const installed = name in dependenciesOf(manifest)
  223. const enabled = bundlesOf(manifest).includes(name)
  224. const layer = runtime.layers.find(candidate => candidate.packageName === name)
  225. const trust = layer?.trust ?? layerTrust(manifest, name)
  226. const liveReload = runtime.patchReload === 'live'
  227. let probe: PluginProbe | undefined
  228. let probeFailure: string | undefined
  229. if (installed) {
  230. try {
  231. probe = await this.probe(runtime, name)
  232. } catch (error) {
  233. probeFailure = messageOf(error)
  234. }
  235. }
  236. const packageManifest = readInstalledManifest(runtime, name)
  237. const stage: BundleStage = layer?.stage
  238. ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
  239. const kind = probe?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'library')
  240. const composed = layer !== undefined
  241. const rows = composed ? this.composedRows(runtime, name) : this.probedRows(probe)
  242. const status = this.status({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows })
  243. const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required'
  244. ? 'the profile applies layer changes at its next start'
  245. : status === 'failed' || status === 'partial'
  246. ? rows.find(row => row.failure !== undefined)?.failure?.message
  247. : undefined)
  248. return {
  249. name,
  250. ...optional('version', packageManifest?.version),
  251. ...optional('title', packageManifest?.dsh?.title),
  252. ...optional('description', packageManifest?.description),
  253. kind,
  254. trust,
  255. stage,
  256. installed,
  257. enabled,
  258. status,
  259. ...optional('reason', reason),
  260. ...optional('enginesDsh', probe?.enginesDsh),
  261. cordisSameCopy: probe?.cordisSameCopy ?? null,
  262. rows,
  263. overrides: probe?.overrides ?? [],
  264. addable: (probe?.addable ?? []).map(entry => addableView(name, entry)),
  265. ...optional('probedAt', probe?.checkedAt),
  266. liveReload,
  267. }
  268. }
  269. /** The package's status, folded from what the view already knows. */
  270. private status(facts: {
  271. kind: PluginPackageView['kind']
  272. installed: boolean
  273. enabled: boolean
  274. composed: boolean
  275. liveReload: boolean
  276. probe: PluginProbe | undefined
  277. probeFailure: string | undefined
  278. rows: readonly PluginPackageRowView[]
  279. }): PluginPackageStatus {
  280. if (facts.kind !== 'bundle') return 'plain'
  281. if (facts.installed && (facts.probeFailure !== undefined || facts.probe?.ok === false)) return 'not-enableable'
  282. if (facts.enabled !== facts.composed) return 'restart-required'
  283. if (!facts.enabled) return 'disabled'
  284. const live = facts.rows.filter(row => row.enabled)
  285. if (live.length === 0) return 'running'
  286. const active = live.filter(row => row.phase === 'active').length
  287. if (active === live.length) return 'running'
  288. return active === 0 ? 'failed' : 'partial'
  289. }
  290. /** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
  291. private composedRows(runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
  292. const failures = this.ctx.get('pluginFailures')
  293. const userDisabled = runtime.userDisabledRowIds()
  294. const rows: PluginPackageRowView[] = []
  295. const listed = new Set<string>()
  296. for (const { entry, rowId } of this.ownedEntries(runtime, name)) {
  297. listed.add(entry.id)
  298. const failure = failures?.get(entry.id)
  299. rows.push({
  300. entryId: entry.id,
  301. rowId,
  302. moduleName: entry.options.name,
  303. enabled: !entry.disabled,
  304. ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {},
  305. phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state],
  306. ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
  307. })
  308. }
  309. /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
  310. for (const failure of failures?.list() ?? []) {
  311. if (listed.has(failure.entryId)) continue
  312. // A conflict record names its bundle: the id's owner is the other layer.
  313. const owner = failure.packageName ?? runtime.originOf(failure.rowId)?.packageName
  314. if (owner !== name) continue
  315. rows.push({
  316. entryId: failure.entryId,
  317. rowId: failure.rowId,
  318. moduleName: failure.moduleName,
  319. enabled: true,
  320. phase: 'failed',
  321. failure: { stage: failure.stage, message: failure.message },
  322. })
  323. }
  324. return rows
  325. }
  326. /** The non-group tree entries a package's layer inserted. */
  327. private ownedEntries(runtime: ProfileRuntime, name: string): RowFacts[] {
  328. const found: RowFacts[] = []
  329. for (const entry of this.ctx.loader.entries()) {
  330. if (entry.options.group) continue
  331. const rowId = entry.options.id
  332. if (runtime.originOf(rowId)?.packageName !== name) continue
  333. found.push({ entry, rowId })
  334. }
  335. return found
  336. }
  337. /** The rows a bundle would contribute, from its probe record, when it is not composed. */
  338. private probedRows(probe: PluginProbe | undefined): PluginPackageRowView[] {
  339. return (probe?.rows ?? []).map(row => ({
  340. entryId: row.id ?? row.name,
  341. rowId: row.id ?? row.name,
  342. moduleName: row.name,
  343. enabled: !row.gated,
  344. ...row.gated ? { disabledBy: 'composition' as const } : {},
  345. phase: null,
  346. }))
  347. }
  348. /** The package's probe record, from the profile cache or a fresh probe written to it. */
  349. private async probe(runtime: ProfileRuntime, name: string): Promise<PluginProbe> {
  350. const version = readInstalledManifest(runtime, name)?.version
  351. const cached = readProbeCache(runtime.dir, name, version)
  352. if (cached !== undefined) return cached
  353. const probe = await this.probeRunner({
  354. binName: NAME,
  355. profileDir: runtime.dir,
  356. installAnchor: runtime.installAnchor,
  357. packageName: name,
  358. timeoutMs: this.config.probeTimeoutMs,
  359. })
  360. writeProbeCache(runtime.dir, probe)
  361. return probe
  362. }
  363. /**
  364. * Install a package into the profile with pnpm, probe it, and leave it
  365. * disabled unless asked otherwise. The run's output streams as
  366. * `plugins/install-log` chunks carrying the returned `jobId`.
  367. * @param spec - what to install, in pnpm's own vocabulary: a registry
  368. * name, a `github:` or git URL, a tarball, or an absolute path.
  369. * @param options - `enable` puts every newly installed bundle into the layer list at once.
  370. * @returns what the run installed and enabled.
  371. * @throws {RemoteError} `plugins/install-failed` when pnpm exits non-zero
  372. * or the run times out, `plugins/enable-failed` when enabling was asked
  373. * for and the tree rejected the bundle.
  374. */
  375. @Remote('install')
  376. async install(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
  377. return this.exclusive('install', spec, () => this.installNow(spec, options))
  378. }
  379. private async installNow(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
  380. const runtime = this.runtime()
  381. if (spec.trim().length === 0) {
  382. throw new RemoteError('gateway/bad-request', 'plugin-manager: the package spec must not be empty', {})
  383. }
  384. this.assertNoRunningAgents('install')
  385. const manifestPath = join(runtime.dir, 'package.json')
  386. const snapshot = readFileSync(manifestPath, 'utf8')
  387. const before = readProfileManifest(NAME, runtime.dir)
  388. let jobId: string
  389. try {
  390. jobId = await this.runPnpm(runtime, ['add', spec], spec)
  391. } catch (error) {
  392. // pnpm may have written the manifest before failing; the profile keeps
  393. // the manifest it had, and what pnpm left under node_modules is not a
  394. // dependency until a manifest names it.
  395. if (readFileSync(manifestPath, 'utf8') !== snapshot) writeFileSync(manifestPath, snapshot)
  396. throw error
  397. }
  398. const outcome = reconcileInstalledBundles(NAME, runtime.dir, runtime.installAnchor, before, { autoEnable: false })
  399. const after = readProfileManifest(NAME, runtime.dir)
  400. const added = Object.keys(dependenciesOf(after)).filter(name => !(name in dependenciesOf(before)))
  401. await healProfilesModuleFallback({ installAnchor: runtime.installAnchor, profile: runtime.current })
  402. const installed: string[] = []
  403. const removed: PluginInstallRejection[] = []
  404. for (const name of added) {
  405. const reason = await this.rejection(runtime, name)
  406. if (reason === undefined) {
  407. installed.push(name)
  408. continue
  409. }
  410. await this.removeDependency(runtime, name)
  411. removed.push({ name, reason })
  412. }
  413. const kept = new Set(installed)
  414. const enabled: string[] = []
  415. if (options?.enable === true) {
  416. for (const name of outcome.installedOnly) {
  417. if (!kept.has(name)) continue
  418. await this.enableNow(name)
  419. enabled.push(name)
  420. }
  421. }
  422. this.changed('install')
  423. return {
  424. installed,
  425. removed,
  426. enabled,
  427. installedOnly: outcome.installedOnly.filter(name => kept.has(name) && !enabled.includes(name)),
  428. plain: outcome.plain.filter(name => kept.has(name)),
  429. jobId,
  430. }
  431. }
  432. /**
  433. * The post-install check of one package pnpm added: a package that is
  434. * neither a bundle nor a plugin module has no place in a profile, and a
  435. * bundle whose row id another layer already owns could only mount as a
  436. * conflict record. A package the probe cannot run stays installed: the
  437. * view reports it as not enableable with the probe's own reason.
  438. * @returns why the package is removed again, or undefined to keep it.
  439. */
  440. private async rejection(runtime: ProfileRuntime, name: string): Promise<string | undefined> {
  441. let probe: PluginProbe
  442. try {
  443. probe = await this.probe(runtime, name)
  444. } catch {
  445. return undefined // the failure is re-derived on every list and shown there
  446. }
  447. // A refused probe (an import that throws, another cordis copy) keeps the
  448. // package: the view shows it as not enableable with the probe's reason.
  449. if (!probe.ok) return undefined
  450. if (probe.kind === 'library') return 'declares neither a dsh bundle nor a plugin module'
  451. if (probe.kind !== 'bundle') return undefined
  452. try {
  453. const layer = resolveProfileLayer(NAME, readProfileManifest(NAME, runtime.dir), name, runtime.installAnchor, runtime.dir)
  454. const lost = claimLayerIds([...runtime.current.layers, layer]).skipped.get(name)
  455. if (lost === undefined) return undefined
  456. return lost.map(conflict => `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`).join('; ')
  457. } catch (error) {
  458. return messageOf(error)
  459. }
  460. }
  461. /** Run `pnpm remove`, reconcile the layer list, and forget the probe record. */
  462. private async removeDependency(runtime: ProfileRuntime, name: string): Promise<void> {
  463. const before = readProfileManifest(NAME, runtime.dir)
  464. await this.runPnpm(runtime, ['remove', name], name)
  465. reconcileInstalledBundles(NAME, runtime.dir, runtime.installAnchor, before, { autoEnable: false })
  466. rmSync(join(runtime.dir, PLUGIN_PROBE_DIR, `${name.replaceAll('/', '__')}.json`), { force: true })
  467. }
  468. /**
  469. * Remove a package from the profile: disable it when enabled, drop every
  470. * user-layer row that names it, run `pnpm remove`, and forget its probe.
  471. * @param packageName - the installed dependency to remove.
  472. * @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.
  473. */
  474. @Remote('uninstall')
  475. async uninstall(packageName: string): Promise<void> {
  476. return this.exclusive('uninstall', packageName, () => this.uninstallNow(packageName))
  477. }
  478. private async uninstallNow(packageName: string): Promise<void> {
  479. const runtime = this.runtime()
  480. this.assertInstalled(runtime, packageName)
  481. this.assertNoRunningAgents('uninstall')
  482. const references = await this.rowReferences(runtime, packageName)
  483. if (bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
  484. await this.disableNow(packageName)
  485. }
  486. for (const reference of references) {
  487. await this.editLayer(runtime, reference.target, (document) => { document.removeInsert(reference.rowId) })
  488. }
  489. await this.removeDependency(runtime, packageName)
  490. if (references.some(reference => reference.target.kind === 'global') && runtime.patchReload === 'live') {
  491. await runtime.recompose()
  492. }
  493. this.changed('uninstall', packageName)
  494. }
  495. /**
  496. * Put an installed bundle into the layer list and, on a live profile,
  497. * recompose the tree with it. A rejected recomposition restores the list
  498. * and reports the tree's reason; the tree that was running keeps running.
  499. * @param packageName - the installed bundle.
  500. * @returns whether the list changed and whether the change is live.
  501. * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
  502. * for a package that declares no bundle or whose probe refused it, or
  503. * `plugins/enable-failed`.
  504. */
  505. @Remote('enable')
  506. async enable(packageName: string): Promise<PluginEnableResult> {
  507. return this.exclusive('enable', packageName, () => this.enableNow(packageName))
  508. }
  509. private async enableNow(packageName: string): Promise<PluginEnableResult> {
  510. const runtime = this.runtime()
  511. this.assertInstalled(runtime, packageName)
  512. const probe = await this.probe(runtime, packageName).catch((error: unknown) => {
  513. const reason = `cannot be probed: ${messageOf(error)}`
  514. throw new RemoteError('plugins/not-enableable', `plugin-manager: ${packageName} ${reason}`, { packageName, reason })
  515. })
  516. if (!probe.ok) {
  517. // The probe states a reason with every refusal; the fallback keeps the type total.
  518. /* v8 ignore next */
  519. const reason = probe.reason ?? 'the probe refused it'
  520. throw new RemoteError('plugins/not-enableable', `plugin-manager: ${packageName} cannot be enabled: ${reason}`, { packageName, reason })
  521. }
  522. let changed: boolean
  523. try {
  524. changed = enableBundle(NAME, runtime.dir, runtime.installAnchor, packageName)
  525. } catch (error) {
  526. const reason = messageOf(error)
  527. throw new RemoteError('plugins/not-enableable', reason, { packageName, reason })
  528. }
  529. if (runtime.patchReload !== 'live') {
  530. this.changed('enable', packageName)
  531. return { changed, effect: 'restart' }
  532. }
  533. if (changed) {
  534. try {
  535. // Links for the packages the bundle carries must exist before the
  536. // recomposition imports its rows.
  537. await healProfilesModuleFallback({
  538. installAnchor: runtime.installAnchor,
  539. profile: loadProfile(NAME, runtime.profileName, runtime.installAnchor, undefined, { userLayer: false }),
  540. })
  541. await runtime.recompose({ reloadBundles: true })
  542. // The boot audit does not run again; record what the bundle's rows
  543. // came to, so the list shows a waiting or failed row with its reason.
  544. await recordContainedStates(this.ctx)
  545. } catch (error) {
  546. const reason = messageOf(error)
  547. disableBundle(NAME, runtime.dir, packageName)
  548. // The tree without the bundle is the tree that was running a moment
  549. // ago; recomposing back to it is the update that already succeeded.
  550. /* v8 ignore next */
  551. await runtime.recompose({ reloadBundles: true }).catch(() => undefined)
  552. throw new RemoteError(
  553. 'plugins/enable-failed',
  554. `plugin-manager: enabling ${packageName} failed and the layer list was restored: ${reason}`,
  555. { packageName, reason },
  556. { cause: error },
  557. )
  558. }
  559. }
  560. this.changed('enable', packageName)
  561. return { changed, effect: 'live' }
  562. }
  563. /**
  564. * Take a bundle out of the layer list and, on a live profile, recompose
  565. * the tree without it.
  566. * @param packageName - the enabled bundle.
  567. * @returns whether the list changed and whether the change is live.
  568. * @throws {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.
  569. */
  570. @Remote('disable')
  571. async disable(packageName: string): Promise<PluginEnableResult> {
  572. return this.exclusive('disable', packageName, () => this.disableNow(packageName))
  573. }
  574. private async disableNow(packageName: string): Promise<PluginEnableResult> {
  575. const runtime = this.runtime()
  576. let changed: boolean
  577. try {
  578. changed = disableBundle(NAME, runtime.dir, packageName)
  579. } catch (error) {
  580. throw new RemoteError('gateway/bad-request', messageOf(error), {})
  581. }
  582. if (runtime.patchReload !== 'live') {
  583. this.changed('disable', packageName)
  584. return { changed, effect: 'restart' }
  585. }
  586. if (changed) await runtime.recompose({ reloadBundles: true })
  587. this.changed('disable', packageName)
  588. return { changed, effect: 'live' }
  589. }
  590. /**
  591. * Compose an enabled bundle again from scratch: its group leaves the tree
  592. * and returns, so rows that failed at boot get another start.
  593. * @param packageName - the enabled bundle.
  594. * @returns the enable outcome of the second step.
  595. * @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.
  596. */
  597. @Remote('retry')
  598. async retry(packageName: string): Promise<PluginEnableResult> {
  599. return this.exclusive('retry', packageName, () => this.retryNow(packageName))
  600. }
  601. private async retryNow(packageName: string): Promise<PluginEnableResult> {
  602. const runtime = this.runtime()
  603. if (!bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
  604. throw new RemoteError('gateway/bad-request', `plugin-manager: ${packageName} is not enabled`, {})
  605. }
  606. await this.disableNow(packageName)
  607. const result = await this.enableNow(packageName)
  608. this.changed('retry', packageName)
  609. return result
  610. }
  611. /**
  612. * Add a row naming one of the package's modules to a user layer: the
  613. * profile's global `cordis.patch.yml`, or an agent preset's.
  614. * @param packageName - the installed package.
  615. * @param target - which layer.
  616. * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`),
  617. * `id` overrides the derived row id, `config` overrides the declared default.
  618. * @returns where the row landed.
  619. * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
  620. * when the module is not one the probe found addable, `plugins/row-conflict`,
  621. * or `plugins/unavailable` for a preset target without a roster.
  622. */
  623. @Remote('addRow')
  624. async addRow(
  625. packageName: string,
  626. target: PluginRowTarget,
  627. options?: { module?: string; id?: string; config?: JsonValue },
  628. ): Promise<PluginRowAddition> {
  629. return this.exclusive('addRow', packageName, () => this.addRowNow(packageName, target, options))
  630. }
  631. private async addRowNow(
  632. packageName: string,
  633. target: PluginRowTarget,
  634. options?: { module?: string; id?: string; config?: JsonValue },
  635. ): Promise<PluginRowAddition> {
  636. const runtime = this.runtime()
  637. this.assertInstalled(runtime, packageName)
  638. const probe = await this.probe(runtime, packageName)
  639. const declared = options?.module ?? '.'
  640. const addable = declared === '.' && probe.kind === 'plugin'
  641. ? { ok: true, config: undefined, error: undefined }
  642. : probe.addable.find(entry => entry.name === declared)
  643. if (addable === undefined || !addable.ok) {
  644. const reason = addable?.error ?? `${declared} is not a module ${packageName} declares addable`
  645. throw new RemoteError('plugins/not-enableable', `plugin-manager: ${reason}`, { packageName, reason })
  646. }
  647. const moduleName = moduleSpecifier(packageName, declared)
  648. const rowId = options?.id ?? derivedRowId(packageName, declared)
  649. const row: PatchRow = { id: rowId, name: moduleName, config: options?.config ?? addable.config ?? {} }
  650. if (await this.rowExists(runtime, target, rowId)) {
  651. throw new RemoteError('plugins/row-conflict', `plugin-manager: a row ${JSON.stringify(rowId)} already exists`, { rowId, target })
  652. }
  653. const file = await this.editLayer(runtime, target, (document) => { document.appendInsert(row) })
  654. this.changed('row', packageName)
  655. return { target, rowId, file }
  656. }
  657. /**
  658. * Remove a row a user layer inserted.
  659. * @param target - which layer.
  660. * @param rowId - the inserted row's id.
  661. * @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row.
  662. */
  663. @Remote('removeRow')
  664. async removeRow(target: PluginRowTarget, rowId: string): Promise<void> {
  665. return this.exclusive('removeRow', rowId, () => this.removeRowNow(target, rowId))
  666. }
  667. private async removeRowNow(target: PluginRowTarget, rowId: string): Promise<void> {
  668. const runtime = this.runtime()
  669. // A holder rather than a `let`: the assignment happens inside the edit
  670. // callback, which control-flow narrowing does not see.
  671. const outcome = { removed: false }
  672. await this.editLayer(runtime, target, (document) => { outcome.removed = document.removeInsert(rowId) })
  673. if (!outcome.removed) {
  674. throw new RemoteError('gateway/bad-request', `plugin-manager: the layer inserts no row ${JSON.stringify(rowId)}`, {})
  675. }
  676. this.changed('row')
  677. }
  678. /**
  679. * Switch one row off or on in a user layer. Deny-only: `true` writes
  680. * `disabled: true` for the row, `false` removes that key, so a bundle's
  681. * own `!!js` gate is restored rather than overridden.
  682. * @param target - which layer.
  683. * @param rowId - the row's id as the composition declares it.
  684. * @param disabled - whether the layer should switch the row off.
  685. */
  686. @Remote('setRowDisabled')
  687. async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
  688. return this.exclusive('setRowDisabled', rowId, () => this.setRowDisabledNow(target, rowId, disabled))
  689. }
  690. private async setRowDisabledNow(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
  691. const runtime = this.runtime()
  692. await this.editLayer(runtime, target, (document) => {
  693. if (disabled) document.setRowField(rowId, 'disabled', true)
  694. else document.deleteRowField(rowId, 'disabled')
  695. })
  696. this.changed('row')
  697. }
  698. /**
  699. * What disabling or removing a package would strand: services its rows
  700. * provide that rows outside it inject, and user-layer rows naming its modules.
  701. * @param packageName - the package.
  702. * @returns the dependents.
  703. */
  704. @Remote('dependents')
  705. async dependents(packageName: string): Promise<PluginDependents> {
  706. const runtime = this.runtime()
  707. const owned = this.ownedEntries(runtime, packageName)
  708. const ownedIds = new Set(owned.map(({ entry }) => entry.id))
  709. const store = this.ctx.reflect.store
  710. const services: PluginServiceDependent[] = []
  711. for (const { entry } of owned) {
  712. const fiber = entry.fiber
  713. if (fiber === undefined) continue
  714. for (const key of Object.getOwnPropertySymbols(store)) {
  715. const impl = store[key]
  716. /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
  717. if (impl === undefined || !withinFiber(impl.fiber, fiber)) continue
  718. const injectedBy: string[] = []
  719. for (const other of this.ctx.loader.entries()) {
  720. if (ownedIds.has(other.id) || other.fiber === undefined) continue
  721. if (impl.name in other.fiber.inject) injectedBy.push(other.id)
  722. }
  723. if (injectedBy.length > 0) services.push({ service: impl.name, providedBy: entry.id, injectedBy })
  724. }
  725. }
  726. return { services, references: await this.rowReferences(runtime, packageName) }
  727. }
  728. /** Every user-layer row naming the package or one of its subpaths. */
  729. private async rowReferences(runtime: ProfileRuntime, packageName: string): Promise<PluginRowReference[]> {
  730. const found: PluginRowReference[] = []
  731. const names = (target: PluginRowTarget, rows: readonly PatchRow[]): void => {
  732. for (const row of rows) {
  733. // A group's config is a row list once the patch parser accepted the file.
  734. /* v8 ignore next */
  735. if (row.group === true) names(target, (row.config ?? []) as PatchRow[])
  736. if (typeof row.id !== 'string') continue
  737. if (row.name === packageName || row.name.startsWith(`${packageName}/`)) {
  738. found.push({ target, rowId: row.id, moduleName: row.name })
  739. }
  740. }
  741. }
  742. const scan = async (target: PluginRowTarget, file: string): Promise<void> => {
  743. let patches
  744. try {
  745. patches = await readPatchListFile(NAME, file, 'patches')
  746. } catch {
  747. // An unreadable layer names nothing this call can act on; the
  748. // launcher and the roster report it as their own failure.
  749. return
  750. }
  751. for (const patch of patches ?? []) if (patch.insert !== undefined) names(target, patch.insert)
  752. }
  753. await scan({ kind: 'global' }, runtime.patchPath)
  754. const presets = this.ctx.get('agentPresets')
  755. if (presets !== undefined) {
  756. for (const preset of await presets.list()) {
  757. if (preset.overlayPath !== undefined) await scan({ kind: 'preset', preset: preset.id }, preset.overlayPath)
  758. }
  759. }
  760. return found
  761. }
  762. /** Whether the target layer's composition already carries a row with `rowId`. */
  763. private async rowExists(runtime: ProfileRuntime, target: PluginRowTarget, rowId: string): Promise<boolean> {
  764. if (target.kind === 'global') {
  765. return [...this.ctx.loader.entries()].some(entry => entry.options.id === rowId)
  766. || (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId))
  767. }
  768. const presets = this.presets()
  769. const composition = (await presets.compositionInventory()).find(candidate => candidate.id === target.preset)
  770. return composition?.rows.some(row => row.entryId === rowId) ?? false
  771. }
  772. /** The roster, or the failure a preset target without one receives. */
  773. private presets(): AgentPresets {
  774. const presets = this.ctx.get('agentPresets')
  775. if (presets === undefined) {
  776. throw new RemoteError('plugins/unavailable', 'plugin-manager: no agent-preset roster is composed', { reason: 'no roster' })
  777. }
  778. return presets
  779. }
  780. /** Edit one user layer file and, for the live global layer, recompose. */
  781. private async editLayer(
  782. runtime: ProfileRuntime,
  783. target: PluginRowTarget,
  784. mutate: Parameters<typeof mutatePatchFile>[1],
  785. ): Promise<string> {
  786. const file = target.kind === 'global' ? runtime.patchPath : await this.presets().overlayPathFor(target.preset)
  787. await mutatePatchFile(file, mutate, { binName: NAME, mode: 0o600, dirMode: 0o700 })
  788. // The profile launcher's watcher reapplies the global file on its own;
  789. // recomposing here makes the change visible to this call's caller before
  790. // it returns, and the watcher's later pass composes the same text.
  791. if (target.kind === 'global' && runtime.patchReload === 'live') await runtime.recompose()
  792. return file
  793. }
  794. private assertInstalled(runtime: ProfileRuntime, packageName: string): void {
  795. if (packageName in dependenciesOf(readProfileManifest(NAME, runtime.dir))) return
  796. throw new RemoteError(
  797. 'plugins/not-installed',
  798. `plugin-manager: ${packageName} is not installed in profile ${runtime.profileName}`,
  799. { packageName },
  800. )
  801. }
  802. /**
  803. * Run one pnpm command in the profile directory, streaming its output as
  804. * `plugins/install-log` chunks.
  805. * @returns the run's job id.
  806. * @throws {RemoteError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout.
  807. */
  808. private async runPnpm(runtime: ProfileRuntime, args: readonly string[], spec: string): Promise<string> {
  809. const jobId = randomUUID()
  810. const tail: string[] = []
  811. let tailBytes = 0
  812. const record = (stream: 'stdout' | 'stderr', text: string): void => {
  813. tail.push(text)
  814. tailBytes += Buffer.byteLength(text)
  815. while (tailBytes > this.config.installLogTailBytes && tail.length > 1) {
  816. tailBytes -= Buffer.byteLength(tail.shift() as string)
  817. }
  818. this.ctx.emit('plugins/install-log', { jobId, spec, stream, text })
  819. }
  820. // Windows resolves pnpm through its .cmd shim, which spawn() refuses
  821. // without a shell since the CVE-2024-27980 hardening. The parent
  822. // environment is passed whole, as the `dsh plugin` command does: pnpm
  823. // needs the user's registry, proxy, and auth settings.
  824. const child = this.spawn(this.config.pnpmCommand, args, {
  825. cwd: runtime.dir,
  826. stdio: ['ignore', 'pipe', 'pipe'],
  827. shell: process.platform === 'win32',
  828. env: process.env,
  829. })
  830. child.stdout?.setEncoding('utf8')
  831. child.stderr?.setEncoding('utf8')
  832. child.stdout?.on('data', (text: string) => { record('stdout', text) })
  833. child.stderr?.on('data', (text: string) => { record('stderr', text) })
  834. const exitCode = await new Promise<number | null>((resolve, reject) => {
  835. let settled = false
  836. const settle = (outcome: () => void): void => {
  837. if (settled) return
  838. settled = true
  839. clearTimeout(timer)
  840. outcome()
  841. }
  842. const timer = setTimeout(() => {
  843. child.kill('SIGKILL')
  844. settle(() => { reject(new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`)) })
  845. }, this.config.installTimeoutMs)
  846. child.on('error', (error) => { settle(() => { reject(error) }) })
  847. child.on('close', (code) => { settle(() => { resolve(code) }) })
  848. }).catch((error: unknown) => {
  849. const message = messageOf(error)
  850. record('stderr', `${message}\n`)
  851. this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stderr', text: '', exitCode: null })
  852. throw new RemoteError('plugins/install-failed', `plugin-manager: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error })
  853. })
  854. this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stdout', text: '', exitCode })
  855. if (exitCode !== 0) {
  856. throw new RemoteError(
  857. 'plugins/install-failed',
  858. `plugin-manager: pnpm ${args.join(' ')} exited with ${String(exitCode)} in ${runtime.dir}`,
  859. { spec, exitCode, log: tail.join('') },
  860. )
  861. }
  862. return jobId
  863. }
  864. private changed(reason: PluginChangeReason, packageName?: string): void {
  865. this.ctx.emit('plugins/changed', { reason, ...optional('packageName', packageName) })
  866. }
  867. }
  868. /**
  869. * Whether `fiber` is `root` itself or sits anywhere inside its subtree: a
  870. * service is provided by a fiber under the row's root fiber (a `ctx.inject`
  871. * continuation, an `isolate` realm). An entry holds the thenable wrapper
  872. * `ctx.plugin()` returns, which is never identical to the fiber in a parent
  873. * chain, so fibers of one runtime compare by `uid`.
  874. */
  875. function withinFiber(fiber: Fiber, root: Fiber): boolean {
  876. let current = fiber
  877. for (;;) {
  878. if (current === root || (current.uid !== null && current.uid === root.uid)) return true
  879. const parent = current.parent.fiber
  880. if (parent === current) return false
  881. current = parent
  882. }
  883. }
  884. /** One optional field, present only when its value is. */
  885. function optional<K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } {
  886. return value === undefined ? {} : { [key]: value } as { [P in K]?: V }
  887. }
  888. /** The installed package's manifest, or undefined when it cannot be resolved from the profile. */
  889. function readInstalledManifest(runtime: ProfileRuntime, name: string): InstalledManifest | undefined {
  890. let dir: string
  891. try {
  892. dir = resolveBundleDir(NAME, name, runtime.installAnchor, runtime.dir)
  893. } catch {
  894. // A dependency pnpm removed underneath the manifest, or one that never
  895. // materialized; the view reports what the manifest still says.
  896. return undefined
  897. }
  898. // resolveBundleDir answers only a directory holding a package.json.
  899. return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as InstalledManifest
  900. }
  901. /** The row `name` for one declared addable module. */
  902. function moduleSpecifier(packageName: string, declared: string): string {
  903. if (declared === '.') return packageName
  904. return `${packageName}/${declared.replace(/^\.\//, '')}`
  905. }
  906. /** The row id derived from a package name and module: the unscoped name, then the subpath. */
  907. function derivedRowId(packageName: string, declared: string): string {
  908. const base = packageName.replace(/^@/, '')
  909. return declared === '.' ? base : `${base}/${declared.replace(/^\.\//, '')}`
  910. }
  911. /** The wire view of one probed addable module. */
  912. function addableView(packageName: string, entry: PluginProbe['addable'][number]): PluginPackageAddableView {
  913. return {
  914. moduleName: moduleSpecifier(packageName, entry.name),
  915. declaredName: entry.name,
  916. ...optional('title', entry.title),
  917. // The probe read both from JSON: a manifest field and a child's report.
  918. ...optional('config', entry.config as JsonValue | undefined),
  919. ok: entry.ok,
  920. ...optional('error', entry.error),
  921. ...optional('configSchema', entry.configSchema as JsonValue | undefined),
  922. }
  923. }
  924. /** Group id of an isolated bundle, for callers that address its rows in the tree. */
  925. export { bundleGroupId }
  926. export default PluginManager