@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/openapi.yaml +50 -4
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  10. package/src/__tests__/conversation-error.test.ts +4 -1
  11. package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
  12. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  13. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  14. package/src/__tests__/credential-routes.test.ts +38 -0
  15. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  16. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  17. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  18. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +185 -30
  20. package/src/__tests__/list-all-apps.test.ts +40 -8
  21. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  22. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  23. package/src/__tests__/mcp-health-check.test.ts +32 -32
  24. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  25. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  26. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  27. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  28. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  29. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  30. package/src/__tests__/skills.test.ts +10 -0
  31. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  32. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  33. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  34. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  35. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  36. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  37. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  38. package/src/acp/acp-claude-oauth.ts +328 -14
  39. package/src/acp/acp-credentials.ts +19 -0
  40. package/src/acp/claude-token-refresh.ts +150 -0
  41. package/src/acp/prepare-agent-env.ts +21 -6
  42. package/src/apps/app-store.ts +6 -5
  43. package/src/browser/operations.ts +2 -1
  44. package/src/browser/types.ts +7 -0
  45. package/src/browser/virtual-desktop-target.ts +34 -0
  46. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
  48. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
  49. package/src/calls/progress-narration.ts +15 -2
  50. package/src/calls/voice-control-protocol.ts +132 -0
  51. package/src/calls/voice-session-bridge.ts +56 -26
  52. package/src/calls/voice-triage-escalate.ts +3 -3
  53. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  54. package/src/cli/commands/browser.help.ts +43 -0
  55. package/src/cli/commands/browser.ts +52 -8
  56. package/src/cli/commands/oauth/status.ts +5 -0
  57. package/src/cli/commands/plugins.ts +16 -5
  58. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  59. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  60. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  62. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  63. package/src/cli/lib/bundled-marketplace.json +13 -0
  64. package/src/cli/lib/install-from-github.ts +17 -18
  65. package/src/cli/lib/install-from-platform.ts +14 -9
  66. package/src/cli/lib/list-installed-plugins.ts +21 -35
  67. package/src/cli/lib/plugin-details.ts +21 -8
  68. package/src/cli/lib/uninstall-plugin.ts +59 -1
  69. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  70. package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
  71. package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
  72. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  73. package/src/config/feature-flag-registry.json +11 -3
  74. package/src/config/loader.ts +1 -0
  75. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  76. package/src/config/schemas/mcp.ts +9 -7
  77. package/src/config/schemas/monitoring.ts +9 -0
  78. package/src/config/schemas/services.ts +10 -0
  79. package/src/config/schemas/voice.ts +2 -2
  80. package/src/config/skills.ts +13 -21
  81. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  82. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  83. package/src/daemon/conversation-client-surface.ts +26 -0
  84. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  85. package/src/daemon/conversation-surfaces.ts +6 -1
  86. package/src/daemon/conversation-tool-setup.ts +37 -43
  87. package/src/daemon/host-cu-proxy.ts +94 -31
  88. package/src/daemon/host-proxy-preactivation.ts +2 -0
  89. package/src/daemon/mcp-reload-service.ts +7 -0
  90. package/src/daemon/message-types/sync.ts +1 -0
  91. package/src/daemon/providers-setup.ts +4 -0
  92. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  93. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  94. package/src/desktop/desktop-automation-lease.ts +270 -0
  95. package/src/desktop/desktop-browser-client.test.ts +413 -0
  96. package/src/desktop/desktop-browser-client.ts +430 -0
  97. package/src/desktop/desktop-browser-cursor.ts +40 -0
  98. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  99. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  100. package/src/desktop/desktop-browser-operations.ts +130 -0
  101. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  102. package/src/desktop/desktop-chrome-session.ts +58 -8
  103. package/src/desktop/desktop-dependencies.test.ts +29 -0
  104. package/src/desktop/desktop-dependencies.ts +26 -5
  105. package/src/desktop/desktop-display.ts +9 -0
  106. package/src/desktop/desktop-panel-config.ts +14 -1
  107. package/src/desktop/desktop-session-manager.test.ts +86 -3
  108. package/src/desktop/desktop-session-manager.ts +144 -24
  109. package/src/desktop/desktop-stream-bridge.ts +1 -1
  110. package/src/desktop/virtual-desktop-feature.ts +18 -0
  111. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  112. package/src/i18n/__tests__/i18n.test.ts +9 -5
  113. package/src/i18n/messages.ts +13 -4
  114. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  115. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  116. package/src/ipc/assistant-server.ts +15 -1
  117. package/src/ipc/cli-client.ts +13 -5
  118. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
  119. package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
  120. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  121. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  122. package/src/live-voice/__tests__/protocol.test.ts +47 -0
  123. package/src/live-voice/__tests__/session-controls.test.ts +113 -0
  124. package/src/live-voice/live-voice-metrics.ts +9 -0
  125. package/src/live-voice/live-voice-session.ts +88 -7
  126. package/src/live-voice/protocol.ts +69 -0
  127. package/src/live-voice/session-controls.ts +139 -0
  128. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  129. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  130. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  131. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  132. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  133. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  134. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  135. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  136. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  137. package/src/mcp/client.ts +64 -16
  138. package/src/mcp/credential-target.ts +105 -0
  139. package/src/mcp/effective-config.ts +42 -15
  140. package/src/mcp/manager.ts +61 -8
  141. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  142. package/src/mcp/mcp-auth-state.ts +38 -6
  143. package/src/mcp/mcp-oauth-provider.ts +101 -33
  144. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  145. package/src/monitoring/mount-watch.ts +348 -0
  146. package/src/monitoring/worker.ts +9 -0
  147. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  148. package/src/oauth/connection-resolver.test.ts +27 -0
  149. package/src/oauth/connection-resolver.ts +25 -1
  150. package/src/oauth/provider-serializer.ts +9 -0
  151. package/src/oauth/seed-providers.ts +121 -2
  152. package/src/onboarding/checkin-event.ts +2 -1
  153. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  154. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  155. package/src/plugins/external-plugin-loader.ts +49 -86
  156. package/src/plugins/installed-plugin-dirs.ts +6 -4
  157. package/src/plugins/mcp-servers.ts +9 -15
  158. package/src/plugins/mtime-cache.ts +2 -1
  159. package/src/plugins/user-loader.ts +2 -1
  160. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  161. package/src/providers/inference/endpoint-probe.ts +27 -12
  162. package/src/providers/openai/responses-provider.ts +9 -2
  163. package/src/providers/opencode/client.test.ts +167 -0
  164. package/src/providers/opencode/client.ts +73 -4
  165. package/src/runtime/AGENTS.md +4 -0
  166. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  167. package/src/runtime/http-server.ts +6 -6
  168. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  169. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  170. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  171. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  172. package/src/runtime/routes/app-management-routes.ts +17 -2
  173. package/src/runtime/routes/browser-context.ts +39 -0
  174. package/src/runtime/routes/browser-routes.ts +16 -47
  175. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  176. package/src/runtime/routes/credential-routes.ts +1 -4
  177. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  178. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  179. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  180. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  181. package/src/runtime/routes/oauth-providers.ts +7 -0
  182. package/src/runtime/routes/plugins-routes.ts +14 -1
  183. package/src/runtime/sync/resource-sync-events.ts +4 -0
  184. package/src/tools/browser/browser-execution.ts +44 -13
  185. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  186. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  187. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  188. package/src/tools/computer-use/definitions.ts +3 -4
  189. package/src/tools/ui-surface/channel-variants.ts +10 -59
  190. package/src/util/abort-reasons.ts +12 -1
  191. package/src/util/plugin-manifest.ts +203 -0
  192. package/src/watch/watch-retro.ts +6 -8
  193. package/src/desktop/desktop-feature.test.ts +0 -18
  194. package/src/desktop/desktop-feature.ts +0 -19
@@ -90,6 +90,13 @@ export const PROVIDER_SEED_DATA: Record<
90
90
  }>;
91
91
  appType?: string;
92
92
  setupNotes?: string[];
93
+ /**
94
+ * Per-tenant providers only: the host the user supplies at connect time,
95
+ * which the platform substitutes into `{tenant_host}` URL placeholders.
96
+ * `pattern` mirrors the platform registry's validation so clients can
97
+ * reject a malformed host before the request leaves the browser.
98
+ */
99
+ tenantHost?: { pattern: string; label: string; placeholder: string };
93
100
  identityUrl?: string;
94
101
  identityMethod?: string;
95
102
  identityHeaders?: Record<string, string>;
@@ -1003,7 +1010,12 @@ export const PROVIDER_SEED_DATA: Record<
1003
1010
  // default, because Figma rejects the whole authorization request if the
1004
1011
  // app cannot grant a requested scope. `selections:read` is withheld for
1005
1012
  // that same reason: it is not offered in the app's OAuth scope list, so
1006
- // requesting it would fail the entire authorization.
1013
+ // requesting it would fail the entire authorization. `folders:read` is
1014
+ // withheld likewise: Figma documents it, but the app's scope picker only
1015
+ // offers `folder_metadata:read` under Folders. `file_code_connect:write`
1016
+ // is withheld because Figma only offers it to apps owned by an
1017
+ // Organization-plan team, which not every managed app is, and nothing
1018
+ // here uses Code Connect; it stays in availableScopes for BYO apps.
1007
1019
  defaultScopes: [
1008
1020
  "current_user:read",
1009
1021
  "file_content:read",
@@ -1013,7 +1025,6 @@ export const PROVIDER_SEED_DATA: Record<
1013
1025
  "file_comments:write",
1014
1026
  "file_dev_resources:read",
1015
1027
  "file_dev_resources:write",
1016
- "folders:read",
1017
1028
  "folder_metadata:read",
1018
1029
  "library_content:read",
1019
1030
  "library_assets:read",
@@ -1053,6 +1064,10 @@ export const PROVIDER_SEED_DATA: Record<
1053
1064
  scope: "file_dev_resources:write",
1054
1065
  description: "Write dev resources to files",
1055
1066
  },
1067
+ {
1068
+ scope: "file_code_connect:write",
1069
+ description: "Write and change component code (Code Connect)",
1070
+ },
1056
1071
  {
1057
1072
  scope: "folders:read",
1058
1073
  description: "List folders and files in folders",
@@ -1388,6 +1403,12 @@ export const PROVIDER_SEED_DATA: Record<
1388
1403
  "read_discounts",
1389
1404
  "write_discounts",
1390
1405
  "read_price_rules",
1406
+ // write_themes covers listing, duplicating, and publishing themes.
1407
+ // Writing theme files (settings, JSON templates, Liquid) also requires
1408
+ // Shopify's theme-code exemption on the app itself, which is granted
1409
+ // per app in the Shopify dashboard rather than through a scope.
1410
+ "read_themes",
1411
+ "write_themes",
1391
1412
  ],
1392
1413
  availableScopes: [
1393
1414
  {
@@ -1440,6 +1461,11 @@ export const PROVIDER_SEED_DATA: Record<
1440
1461
  scope: "write_price_rules",
1441
1462
  description: "Create and update price rules",
1442
1463
  },
1464
+ { scope: "read_themes", description: "Read themes and theme files" },
1465
+ {
1466
+ scope: "write_themes",
1467
+ description: "Create, publish, and edit themes and theme files",
1468
+ },
1443
1469
  { scope: "read_files", description: "Read files uploaded to the store" },
1444
1470
  { scope: "write_files", description: "Upload and update files" },
1445
1471
  { scope: "read_gift_cards", description: "Read gift cards" },
@@ -1485,6 +1511,12 @@ export const PROVIDER_SEED_DATA: Record<
1485
1511
  ],
1486
1512
  loopbackPort: 17341,
1487
1513
  managedServiceConfigKey: "shopify-oauth",
1514
+ // Mirrors `extra_config.tenant_host` in the platform provider registry.
1515
+ tenantHost: {
1516
+ pattern: "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$",
1517
+ label: "Shop domain",
1518
+ placeholder: "your-store.myshopify.com",
1519
+ },
1488
1520
  injectionTemplates: [
1489
1521
  {
1490
1522
  hostPattern: "*.myshopify.com",
@@ -1508,6 +1540,93 @@ export const PROVIDER_SEED_DATA: Record<
1508
1540
  // cannot complete.
1509
1541
  featureFlag: "shopify-oauth",
1510
1542
  },
1543
+
1544
+ quickbooks: {
1545
+ provider: "quickbooks",
1546
+ authorizeUrl: "https://appcenter.intuit.com/connect/oauth2",
1547
+ tokenExchangeUrl:
1548
+ "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
1549
+ // Access tokens live an hour; refresh tokens 100 days and are rotated on
1550
+ // every refresh, which the platform persists. Both token endpoints
1551
+ // authenticate the client with HTTP Basic.
1552
+ refreshUrl: "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
1553
+ tokenEndpointAuthMethod: "client_secret_basic",
1554
+ // No revokeUrl: Intuit's revocation endpoint wants a JSON body under HTTP
1555
+ // Basic client auth, and the daemon's revoke helper only posts an
1556
+ // unauthenticated form body, so a seeded URL would fail silently on every
1557
+ // your-own disconnect. The platform registry revokes managed tokens itself.
1558
+ // Every Accounting API path is scoped to the company (realm) the user
1559
+ // picked on Intuit's consent screen. The realm arrives only as the
1560
+ // callback's `realmId` query parameter; the platform captures it into the
1561
+ // connection's `provider_params` and fills the `{realm_id}` placeholder
1562
+ // server-side, so managed callers send paths relative to the company
1563
+ // (`/query`, `/customer/123`). Production and sandbox keys use different
1564
+ // hosts; the platform picks the host per environment.
1565
+ baseUrl: "https://quickbooks.api.intuit.com/v3/company/{realm_id}",
1566
+ // No pingUrl: the only company-independent probe would still need the
1567
+ // realm in its path, which the ping route cannot fill in (it sends the
1568
+ // URL's origin as a per-request base override, which would also pin a
1569
+ // managed sandbox connection to the production host).
1570
+ displayLabel: "QuickBooks",
1571
+ description: "Invoices, customers, vendors, and accounting data",
1572
+ dashboardUrl: "https://developer.intuit.com/app/developer/dashboard",
1573
+ clientIdPlaceholder: null,
1574
+ logoUrl: "https://cdn.simpleicons.org/quickbooks",
1575
+ // The Accounting scope covers the QuickBooks Online API. Payments is a
1576
+ // separate product the app must be enabled for, and the OpenID scopes
1577
+ // only add the signing-in user's profile, so none are on by default.
1578
+ defaultScopes: ["com.intuit.quickbooks.accounting"],
1579
+ availableScopes: [
1580
+ {
1581
+ scope: "com.intuit.quickbooks.accounting",
1582
+ description:
1583
+ "Read and write QuickBooks Online accounting data: customers, vendors, invoices, bills, payments, items, accounts, and reports",
1584
+ },
1585
+ {
1586
+ scope: "com.intuit.quickbooks.payment",
1587
+ description:
1588
+ "QuickBooks Payments: charges, refunds, bank accounts, and cards (requires a Payments-enabled app)",
1589
+ },
1590
+ { scope: "openid", description: "Sign in with Intuit (OpenID Connect)" },
1591
+ { scope: "profile", description: "The signing-in user's name" },
1592
+ { scope: "email", description: "The signing-in user's email address" },
1593
+ { scope: "phone", description: "The signing-in user's phone number" },
1594
+ { scope: "address", description: "The signing-in user's address" },
1595
+ ],
1596
+ loopbackPort: 17342,
1597
+ managedServiceConfigKey: "quickbooks-oauth",
1598
+ injectionTemplates: [
1599
+ {
1600
+ hostPattern: "quickbooks.api.intuit.com",
1601
+ injectionType: "header",
1602
+ headerName: "Authorization",
1603
+ valuePrefix: "Bearer ",
1604
+ },
1605
+ {
1606
+ hostPattern: "sandbox-quickbooks.api.intuit.com",
1607
+ injectionType: "header",
1608
+ headerName: "Authorization",
1609
+ valuePrefix: "Bearer ",
1610
+ },
1611
+ ],
1612
+ appType: "App",
1613
+ setupNotes: [
1614
+ "QuickBooks scopes every Accounting API call to the company (realm) chosen on Intuit's consent screen. Managed connections carry the realm as provider_params.realm_id and requests are sent relative to /v3/company/{realmId}.",
1615
+ "Intuit development keys only authorize sandbox companies, which live on sandbox-quickbooks.api.intuit.com; production keys use quickbooks.api.intuit.com.",
1616
+ "The Accounting API returns XML unless the request carries Accept: application/json.",
1617
+ ],
1618
+ // CompanyInfo does not repeat the realm (its Id is always "1"); the
1619
+ // platform keys the connection on the captured realm and labels it with
1620
+ // the company name.
1621
+ identityUrl:
1622
+ "https://quickbooks.api.intuit.com/v3/company/{realm_id}/companyinfo/{realm_id}",
1623
+ identityHeaders: { Accept: "application/json" },
1624
+ identityResponsePaths: ["CompanyInfo.CompanyName", "CompanyInfo.LegalName"],
1625
+ // Gated like figma/shopify: the platform side lands separately, and until
1626
+ // the client ids are live a visible tile would offer a connect flow that
1627
+ // cannot complete.
1628
+ featureFlag: "quickbooks-oauth",
1629
+ },
1511
1630
  };
1512
1631
 
1513
1632
  export const SEEDED_PROVIDER_KEYS = new Set(Object.keys(PROVIDER_SEED_DATA));
@@ -69,7 +69,8 @@ export function buildCheckinTitle({
69
69
  * Google Calendar strips nearly all styling, so the body relies only on the
70
70
  * tags that survive (`<p>`, `<strong>`, `<a>`, emoji) and makes the CTA a bold
71
71
  * link rather than a styled button. The deep link opens a fresh conversation
72
- * (`uuid`) pre-seeded with the first-week prompt.
72
+ * (`uuid`) with the first-week prompt staged in the composer; the user sends
73
+ * it. A clicked link never auto-sends (`clients/web/src/utils/auto-send-prompt.ts`).
73
74
  */
74
75
  export function buildCheckinDescription(uuid: string): string {
75
76
  // `vref` is an app-owned attribution param, NOT a marketing `utm_*`: the
@@ -45,10 +45,23 @@ beforeEach(() => {
45
45
  describe("listInstalledPluginDirs", () => {
46
46
  test("lists real plugin directories and skips dot entries", () => {
47
47
  makePluginDir(pluginsDir, "alpha");
48
+ const standard = join(pluginsDir, "standard");
49
+ mkdirSync(standard, { recursive: true });
50
+ writeFileSync(
51
+ join(standard, "plugin.json"),
52
+ JSON.stringify({
53
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
54
+ name: "standard",
55
+ }),
56
+ );
48
57
  makePluginDir(pluginsDir, ".hidden");
49
58
  mkdirSync(join(pluginsDir, "no-manifest"), { recursive: true });
50
59
 
51
- expect(listInstalledPluginDirs().map((p) => p.name)).toEqual(["alpha"]);
60
+ expect(
61
+ listInstalledPluginDirs()
62
+ .map((p) => p.name)
63
+ .sort(),
64
+ ).toEqual(["alpha", "standard"]);
52
65
  });
53
66
 
54
67
  test("skips a symlinked plugin root that points outside the plugins dir", () => {
@@ -24,17 +24,27 @@ import {
24
24
  function makePluginsDir(
25
25
  plugins: Record<
26
26
  string,
27
- { mcpJson?: string; packageJson?: string; disabled?: boolean }
27
+ {
28
+ mcpJson?: string;
29
+ packageJson?: string | null;
30
+ pluginJson?: string;
31
+ disabled?: boolean;
32
+ }
28
33
  >,
29
34
  ): string {
30
35
  const root = mkdtempSync(join(tmpdir(), "vellum-plugin-mcp-"));
31
36
  for (const [name, spec] of Object.entries(plugins)) {
32
37
  const dir = join(root, name);
33
38
  mkdirSync(dir, { recursive: true });
34
- writeFileSync(
35
- join(dir, "package.json"),
36
- spec.packageJson ?? JSON.stringify({ name, version: "1.0.0" }),
37
- );
39
+ if (spec.packageJson !== null) {
40
+ writeFileSync(
41
+ join(dir, "package.json"),
42
+ spec.packageJson ?? JSON.stringify({ name, version: "1.0.0" }),
43
+ );
44
+ }
45
+ if (spec.pluginJson !== undefined) {
46
+ writeFileSync(join(dir, "plugin.json"), spec.pluginJson);
47
+ }
38
48
  if (spec.mcpJson !== undefined) {
39
49
  writeFileSync(join(dir, "mcp.json"), spec.mcpJson);
40
50
  }
@@ -137,6 +147,26 @@ describe("readPluginMcpServers", () => {
137
147
  });
138
148
  });
139
149
 
150
+ test("reads MCP servers from a standard-only plugin", () => {
151
+ const dir = makePluginsDir({
152
+ unabyss: {
153
+ packageJson: null,
154
+ pluginJson: JSON.stringify({
155
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
156
+ name: "unabyss",
157
+ }),
158
+ mcpJson: VALID_MANIFEST,
159
+ },
160
+ });
161
+
162
+ const { servers, issues } = readPluginMcpServers({
163
+ workspacePluginsDir: dir,
164
+ });
165
+
166
+ expect(issues).toEqual([]);
167
+ expect(servers.map((server) => server.id)).toEqual(["unabyss"]);
168
+ });
169
+
140
170
  test("defaults an omitted type to streamable-http", () => {
141
171
  const dir = makePluginsDir({
142
172
  openseo: {
@@ -205,7 +235,7 @@ describe("readPluginMcpServers", () => {
205
235
  workspacePluginsDir: root,
206
236
  });
207
237
  expect(servers).toEqual([]);
208
- expect(issues[0].message).toContain("package.json");
238
+ expect(issues[0].message).toContain("selected plugin manifest");
209
239
  });
210
240
 
211
241
  test("ignores a directory whose package.json is unparseable", () => {
@@ -216,7 +246,7 @@ describe("readPluginMcpServers", () => {
216
246
  workspacePluginsDir: dir,
217
247
  });
218
248
  expect(servers).toEqual([]);
219
- expect(issues[0].message).toContain("package.json");
249
+ expect(issues[0].message).toContain("selected plugin manifest");
220
250
  });
221
251
 
222
252
  test("ignores a directory whose package.json has no name", () => {
@@ -230,7 +260,7 @@ describe("readPluginMcpServers", () => {
230
260
  workspacePluginsDir: dir,
231
261
  });
232
262
  expect(servers).toEqual([]);
233
- expect(issues[0].message).toContain("package.json");
263
+ expect(issues[0].message).toContain("selected plugin manifest");
234
264
  });
235
265
 
236
266
  test("skips plugins with no mcp.json without complaining", () => {
@@ -10,12 +10,12 @@
10
10
  * need to be renamed when the convention shifts.
11
11
  *
12
12
  * <pluginDir>/
13
- * package.json ← manifest.name comes from `name`
14
- * (npm scope stripped);
13
+ * package.json ← legacy Vellum manifest, selected first;
15
14
  * peerDependencies["@vellumai/plugin-api"]
16
15
  * semver range is checked against the
17
16
  * running assistant version and rejects
18
17
  * the plugin if unsatisfied
18
+ * plugin.json ← standard fallback when package.json is absent
19
19
  * hooks/
20
20
  * <name>.ts ← default export → plugin.hooks[<name>]
21
21
  * (today the runtime invokes "init" at
@@ -41,7 +41,6 @@
41
41
  */
42
42
 
43
43
  import { existsSync, readdirSync, statSync } from "node:fs";
44
- import { readFile } from "node:fs/promises";
45
44
  import { basename, join } from "node:path";
46
45
  import { pathToFileURL } from "node:url";
47
46
 
@@ -53,6 +52,10 @@ import { PLUGIN_SECRET_PATTERN_LIMITS } from "../security/plugin-secret-patterns
53
52
  import { finalizeTool } from "../tools/tool-defaults.js";
54
53
  import type { Tool, ToolDefinition } from "../tools/types.js";
55
54
  import { getLogger } from "../util/logger.js";
55
+ import {
56
+ LEGACY_PLUGIN_MANIFEST,
57
+ readPluginManifest,
58
+ } from "../util/plugin-manifest.js";
56
59
  import { registerPlugin } from "./registry.js";
57
60
  import type {
58
61
  HookFunction,
@@ -69,39 +72,12 @@ const log = getLogger("external-plugin-loader");
69
72
  /** Default upper bound on how long a single plugin load may take. */
70
73
  const DEFAULT_IMPORT_TIMEOUT_MS = 10_000;
71
74
 
72
- /**
73
- * Zod schema for the subset of `package.json` the external loader reads.
74
- *
75
- * - `name` is the only required field; everything else is best-effort.
76
- * - `peerDependencies["@vellumai/plugin-api"]` is the canonical host-compat
77
- * declaration. If present, the loader checks `semver.satisfies(host, range)`
78
- * against the running assistant version and rejects the plugin on
79
- * mismatch. If absent, the plugin loads without a host-compat claim
80
- * (with a warning).
81
- * - `credentialKeyPatterns` is typed `unknown` here and shape-validated
82
- * separately ({@link parseCredentialKeyPatterns}) so a malformed
83
- * declaration degrades to `undefined` instead of failing the whole
84
- * `safeParse` and blocking plugin load.
85
- * - Unknown fields pass through (`passthrough`) so the loader does not
86
- * destructively reshape the file when the rest of the npm ecosystem
87
- * writes to it.
88
- */
89
- const PluginPackageJsonSchema = z
90
- .object({
91
- name: z.string().min(1, "package.json `name` must be a non-empty string"),
92
- version: z.string().optional(),
93
- peerDependencies: z.record(z.string(), z.string()).optional(),
94
- credentialKeyPatterns: z.unknown().optional(),
95
- /** Human title, when the package name is not one ("@vellumai/imessage"). */
96
- displayName: z.string().min(1).optional(),
97
- /** Standard npm field, reused as the one-line description clients show. */
98
- description: z.string().min(1).optional(),
99
- /** Lucide icon name without the `lucide-` prefix, matching `ChannelInfo`. */
100
- icon: z.string().min(1).optional(),
101
- })
102
- .passthrough();
103
-
104
- type PluginPackageJson = z.infer<typeof PluginPackageJsonSchema>;
75
+ interface LegacyPluginFields extends Record<string, unknown> {
76
+ readonly peerDependencies?: Record<string, string>;
77
+ readonly credentialKeyPatterns?: unknown;
78
+ readonly displayName?: string;
79
+ readonly icon?: string;
80
+ }
105
81
 
106
82
  /**
107
83
  * Shape-level caps for the `credentialKeyPatterns` manifest field. The
@@ -270,23 +246,8 @@ async function loadHooks(
270
246
  * timeout/try-catch/register triple.
271
247
  */
272
248
  async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
273
- const pkgPath = join(pluginDir, "package.json");
274
- let rawPkg: unknown;
275
- try {
276
- rawPkg = JSON.parse(await readFile(pkgPath, "utf8"));
277
- } catch (err) {
278
- const reason = err instanceof Error ? err.message : String(err);
279
- throw new Error(
280
- `package.json at ${pluginDir} could not be read or parsed: ${reason}`,
281
- );
282
- }
283
- const parsed = PluginPackageJsonSchema.safeParse(rawPkg);
284
- if (!parsed.success) {
285
- throw new Error(
286
- `package.json at ${pluginDir} failed schema validation: ${parsed.error.message}`,
287
- );
288
- }
289
- const pkg: PluginPackageJson = parsed.data;
249
+ const resolvedManifest = readPluginManifest(pluginDir);
250
+ const legacyFields = resolvedManifest.raw as LegacyPluginFields;
290
251
  // A plugin's identity is its install directory name — the slug the plugin
291
252
  // was installed under (a marketplace slug or a GitHub path leaf). This is
292
253
  // the identity every other surface uses: `plugins list`, enable/disable,
@@ -296,7 +257,10 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
296
257
  // registry from the identity the user toggles and the scope filters match.
297
258
  // The manifest is still required (schema-validated above) as the load gate.
298
259
  const name = basename(pluginDir);
299
- const version = pkg.version && pkg.version.length > 0 ? pkg.version : "0.0.0";
260
+ const version =
261
+ resolvedManifest.version && resolvedManifest.version.length > 0
262
+ ? resolvedManifest.version
263
+ : "0.0.0";
300
264
 
301
265
  // Host-compat negotiation: plugins declare their plugin-api version
302
266
  // range via standard `peerDependencies["@vellumai/plugin-api"]`. We
@@ -310,7 +274,7 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
310
274
  //
311
275
  // If the peerDep is absent, the plugin loads without a host-compat
312
276
  // claim; we log a warning so the omission is visible at boot.
313
- const range = pkg.peerDependencies?.[PLUGIN_API_PEER_DEP];
277
+ const range = legacyFields.peerDependencies?.[PLUGIN_API_PEER_DEP];
314
278
  if (range !== undefined) {
315
279
  if (!semver.validRange(range)) {
316
280
  log.error(
@@ -333,7 +297,7 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
333
297
  `external plugin ${name}: peerDependencies["${PLUGIN_API_PEER_DEP}"] requires "${range}" but assistant is ${assistantPkg.version} — loading anyway`,
334
298
  );
335
299
  }
336
- } else {
300
+ } else if (resolvedManifest.source === LEGACY_PLUGIN_MANIFEST) {
337
301
  log.warn(
338
302
  { pluginDir, plugin: name, peerDep: PLUGIN_API_PEER_DEP },
339
303
  "external plugin missing plugin-api peerDependency — loading without host-compat claim",
@@ -342,7 +306,9 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
342
306
 
343
307
  const manifest: PluginManifest = { name, version };
344
308
  const credentialKeyPatterns = parseCredentialKeyPatterns(
345
- pkg.credentialKeyPatterns,
309
+ resolvedManifest.source === LEGACY_PLUGIN_MANIFEST
310
+ ? legacyFields.credentialKeyPatterns
311
+ : undefined,
346
312
  pluginDir,
347
313
  );
348
314
  if (credentialKeyPatterns !== undefined) {
@@ -459,9 +425,9 @@ export async function loadExternalPlugin(
459
425
  }
460
426
 
461
427
  /**
462
- * Parse a plugin's `package.json` manifest from disk. Returns the plugin
428
+ * Parse a plugin's selected manifest from disk. Returns the plugin
463
429
  * identity (its install directory name), version, and any declared
464
- * `credentialKeyPatterns`, or `undefined` when the `package.json` is
430
+ * legacy `credentialKeyPatterns`, or `undefined` when the manifest is
465
431
  * missing, unparseable, or fails schema validation.
466
432
  *
467
433
  * Exported so the mtime cache can discover plugin identity without going
@@ -497,20 +463,23 @@ export interface PluginPresentation {
497
463
  export async function parsePluginPresentation(
498
464
  pluginDir: string,
499
465
  ): Promise<PluginPresentation | undefined> {
500
- let rawPkg: unknown;
501
466
  try {
502
- rawPkg = JSON.parse(
503
- await readFile(join(pluginDir, "package.json"), "utf8"),
504
- );
467
+ const manifest = readPluginManifest(pluginDir);
468
+ const legacyFields = manifest.raw as LegacyPluginFields;
469
+ return {
470
+ displayName:
471
+ manifest.source === LEGACY_PLUGIN_MANIFEST
472
+ ? legacyFields.displayName
473
+ : manifest.name,
474
+ description: manifest.description,
475
+ icon:
476
+ manifest.source === LEGACY_PLUGIN_MANIFEST
477
+ ? legacyFields.icon
478
+ : undefined,
479
+ };
505
480
  } catch {
506
481
  return undefined;
507
482
  }
508
- const parsed = PluginPackageJsonSchema.safeParse(rawPkg);
509
- if (!parsed.success) {
510
- return undefined;
511
- }
512
- const { displayName, description, icon } = parsed.data;
513
- return { displayName, description, icon };
514
483
  }
515
484
 
516
485
  export async function parsePluginManifest(
@@ -519,35 +488,29 @@ export async function parsePluginManifest(
519
488
  ): Promise<
520
489
  Pick<PluginManifest, "name" | "version" | "credentialKeyPatterns"> | undefined
521
490
  > {
522
- const pkgPath = join(pluginDir, "package.json");
523
- let rawPkg: unknown;
491
+ let resolvedManifest: ReturnType<typeof readPluginManifest>;
524
492
  try {
525
- rawPkg = JSON.parse(await readFile(pkgPath, "utf8"));
493
+ resolvedManifest = readPluginManifest(pluginDir);
526
494
  } catch (err) {
527
495
  const reason = err instanceof Error ? err.message : String(err);
528
496
  if (!opts.quiet) {
529
497
  log.error(
530
498
  { err, pluginDir },
531
- `package.json at ${pluginDir} could not be read or parsed: ${reason}`,
532
- );
533
- }
534
- return undefined;
535
- }
536
- const parsed = PluginPackageJsonSchema.safeParse(rawPkg);
537
- if (!parsed.success) {
538
- if (!opts.quiet) {
539
- log.error(
540
- { err: parsed.error, pluginDir },
541
- `package.json at ${pluginDir} failed schema validation: ${parsed.error.message}`,
499
+ `Plugin manifest at ${pluginDir} could not be read or validated: ${reason}`,
542
500
  );
543
501
  }
544
502
  return undefined;
545
503
  }
546
- const pkg: PluginPackageJson = parsed.data;
504
+ const legacyFields = resolvedManifest.raw as LegacyPluginFields;
547
505
  const name = basename(pluginDir);
548
- const version = pkg.version && pkg.version.length > 0 ? pkg.version : "0.0.0";
506
+ const version =
507
+ resolvedManifest.version && resolvedManifest.version.length > 0
508
+ ? resolvedManifest.version
509
+ : "0.0.0";
549
510
  const credentialKeyPatterns = parseCredentialKeyPatterns(
550
- pkg.credentialKeyPatterns,
511
+ resolvedManifest.source === LEGACY_PLUGIN_MANIFEST
512
+ ? legacyFields.credentialKeyPatterns
513
+ : undefined,
551
514
  pluginDir,
552
515
  );
553
516
  if (credentialKeyPatterns !== undefined) {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Enumerate installed plugin directories under the workspace plugins dir.
3
3
  *
4
- * An installed plugin is a non-hidden directory carrying a `package.json`,
5
- * whose realpath stays under the realpath of the plugins directory. This walk
4
+ * An installed plugin is a non-hidden directory carrying a `package.json` or
5
+ * root `plugin.json` whose realpath stays under the realpath of the plugins
6
+ * directory. This walk
6
7
  * is shared by the plugin source-version collector
7
8
  * (`./collect-source-versions.ts`) and the schedule reconciler
8
9
  * (`../schedule/plugin-schedule-reconciler.ts`) so both agree on what counts
@@ -17,10 +18,11 @@
17
18
  * declaration is available to run.
18
19
  */
19
20
 
20
- import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
21
+ import { readdirSync, realpathSync, statSync } from "node:fs";
21
22
  import { join, sep } from "node:path";
22
23
 
23
24
  import { getWorkspacePluginsDir } from "../util/platform.js";
25
+ import { hasPluginManifest } from "../util/plugin-manifest.js";
24
26
 
25
27
  export interface InstalledPluginDir {
26
28
  /** Directory basename: the plugin's install identity. */
@@ -92,7 +94,7 @@ export function listInstalledPluginDirs(): InstalledPluginDir[] {
92
94
  if (!isInsidePluginRoot(dir, pluginsDir)) {
93
95
  continue;
94
96
  }
95
- if (!existsSync(join(dir, "package.json"))) {
97
+ if (!hasPluginManifest(dir)) {
96
98
  continue;
97
99
  }
98
100
  out.push({ name: entry, dir });
@@ -21,19 +21,11 @@
21
21
  * disables only that entry. Neither ever throws, because a malformed file
22
22
  * in one plugin must not remove another plugin's servers from a listing.
23
23
  *
24
- * Authentication is deliberately absent. Agent Plugins 1.0.0 defines no
25
- * portable OAuth or credential-reference fields; authentication is
26
- * client-managed, and any `headers` in the file are literal package data.
27
- * A plugin therefore cannot ship a credential, and the assistant's own
28
- * credential store stays the only place secrets live.
29
- *
30
- * Consumers must not resolve a plugin server's id against the
31
- * `mcp:<serverId>:*` credential namespace. Those keys belong to
32
- * workspace-configured servers, and a plugin controls both its server key
33
- * and its URL, so honoring them for a plugin-declared server would send a
34
- * workspace credential to an endpoint the plugin chose. Every config built
35
- * here carries `source: "plugin"`, which is what `McpClient` reads to
36
- * decide, so the rule travels with the server rather than with the caller.
24
+ * Authentication is client-managed. Agent Plugins 1.0.0 defines no portable
25
+ * OAuth or credential-reference fields, and any `headers` in the file are
26
+ * literal package data. Runtime configs carry the declaring plugin directory
27
+ * name and original server key so OAuth credentials can be isolated from the
28
+ * public server id and from workspace credentials.
37
29
  */
38
30
 
39
31
  import { existsSync, readFileSync } from "node:fs";
@@ -141,7 +133,7 @@ export function interpolatePluginPaths(
141
133
  *
142
134
  * `listAllPlugins` is an inventory of directories and reports a malformed
143
135
  * entry rather than dropping it, so it happily returns a directory with no
144
- * usable `package.json`. The runtime loader rejects those in
136
+ * usable selected manifest. The runtime loader rejects those in
145
137
  * `parsePluginManifest`: the manifest must parse and carry a non-empty
146
138
  * `name`. Applying the same gate here keeps `mcp.json` from being honored
147
139
  * for a directory that will never load as a plugin.
@@ -192,7 +184,7 @@ export function readPluginMcpServers(
192
184
  if (!hasLoadableManifest(plugin)) {
193
185
  issues.push({
194
186
  pluginName: plugin.name,
195
- message: `${PLUGIN_MCP_MANIFEST} ignored: package.json is missing, unparseable, or has no name, so the runtime will not load this directory as a plugin`,
187
+ message: `${PLUGIN_MCP_MANIFEST} ignored: the selected plugin manifest is missing or invalid, so the runtime will not load this directory as a plugin`,
196
188
  });
197
189
  continue;
198
190
  }
@@ -257,6 +249,8 @@ export function readPluginMcpServers(
257
249
  config: {
258
250
  transport: projectTransport(entry.data, plugin.target),
259
251
  source: "plugin",
252
+ pluginName: plugin.name,
253
+ serverKey,
260
254
  },
261
255
  });
262
256
  }
@@ -59,6 +59,7 @@ import {
59
59
  getWorkspaceHooksDir,
60
60
  getWorkspacePluginsDir,
61
61
  } from "../util/platform.js";
62
+ import { hasPluginManifest } from "../util/plugin-manifest.js";
62
63
  import { collectSourceVersions } from "./collect-source-versions.js";
63
64
  import {
64
65
  deriveToolName,
@@ -768,7 +769,7 @@ async function scanPlugins(): Promise<void> {
768
769
  );
769
770
  continue;
770
771
  }
771
- if (!existsSync(join(pluginDir, "package.json"))) {
772
+ if (!hasPluginManifest(pluginDir)) {
772
773
  continue;
773
774
  }
774
775
 
@@ -2,7 +2,8 @@
2
2
  * User plugin loader — discovers plugins under `<workspaceDir>/plugins/*` and
3
3
  * populates the mtime cache.
4
4
  *
5
- * A plugin directory is recognized by a `package.json` manifest. The loader
5
+ * A plugin directory is recognized by a legacy `package.json` or standard
6
+ * `plugin.json` manifest. The loader
6
7
  * delegates to the mtime cache's `populateCacheAtBoot`, which builds each
7
8
  * plugin via `buildExternalPlugin` and stores it keyed by mtime. The cache
8
9
  * is the single source of truth for user plugin state — the registry is no