@vellumai/assistant 0.10.9-staging.1 → 0.10.9-staging.2

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 (100) hide show
  1. package/AGENTS.md +4 -0
  2. package/knip.json +1 -4
  3. package/openapi.yaml +28 -18
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
  6. package/src/__tests__/app-routes-csp.test.ts +13 -4
  7. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
  8. package/src/__tests__/disk-pressure-tools.test.ts +4 -4
  9. package/src/__tests__/file-list-tool.test.ts +22 -12
  10. package/src/__tests__/list-all-apps.test.ts +204 -0
  11. package/src/__tests__/messaging-send-tool.test.ts +67 -0
  12. package/src/__tests__/mtime-cache.test.ts +42 -0
  13. package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
  14. package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
  15. package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
  16. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  17. package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
  18. package/src/__tests__/subagent-notify-parent.test.ts +4 -3
  19. package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
  20. package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
  21. package/src/apps/app-store.ts +302 -2
  22. package/src/cli/AGENTS.md +17 -0
  23. package/src/cli/commands/__tests__/gateway.test.ts +55 -0
  24. package/src/cli/commands/apps.help.ts +17 -11
  25. package/src/cli/commands/apps.ts +5 -15
  26. package/src/cli/commands/gateway.help.ts +30 -0
  27. package/src/cli/commands/gateway.ts +40 -1
  28. package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
  29. package/src/cli/commands/platform/index.help.ts +3 -2
  30. package/src/cli/commands/platform/index.ts +0 -9
  31. package/src/cli/commands/routes.help.ts +13 -7
  32. package/src/cli/commands/routes.ts +5 -3
  33. package/src/cli/commands/status.help.ts +24 -0
  34. package/src/cli/commands/status.ts +29 -0
  35. package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
  36. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
  37. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
  38. package/src/cli/lib/bundled-marketplace.json +187 -173
  39. package/src/cli/lib/global-json-option.ts +41 -0
  40. package/src/cli/lib/plugin-fingerprint.ts +18 -7
  41. package/src/cli/program.ts +5 -0
  42. package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
  43. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
  44. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  45. package/src/config/feature-flag-registry.json +1 -9
  46. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
  47. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
  48. package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
  49. package/src/daemon/conversation-tool-setup.ts +5 -90
  50. package/src/daemon/conversation.ts +2 -2
  51. package/src/messaging/provider-types.ts +9 -0
  52. package/src/messaging/providers/outlook/adapter.ts +17 -2
  53. package/src/messaging/providers/outlook/client.ts +11 -2
  54. package/src/messaging/providers/outlook/types.ts +9 -0
  55. package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
  56. package/src/monitoring/plugin-source-watch.ts +53 -0
  57. package/src/oauth/seed-providers.ts +6 -0
  58. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
  59. package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
  60. package/src/plugin-api/conversation-turn.ts +234 -0
  61. package/src/plugin-api/index.ts +12 -0
  62. package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
  64. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
  65. package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
  66. package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
  67. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
  68. package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
  69. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
  70. package/src/plugins/mtime-cache.ts +26 -13
  71. package/src/plugins/plugin-tree-walk.ts +30 -0
  72. package/src/plugins/source-fingerprint.ts +9 -2
  73. package/src/plugins/surface-import.ts +28 -0
  74. package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
  75. package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
  76. package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
  77. package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
  78. package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
  79. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
  80. package/src/runtime/routes/app-management-routes.ts +96 -32
  81. package/src/runtime/routes/app-routes.ts +33 -17
  82. package/src/runtime/routes/gateway-status-routes.ts +69 -0
  83. package/src/runtime/routes/index.ts +2 -0
  84. package/src/runtime/routes/platform-routes.ts +4 -14
  85. package/src/runtime/routes/plugins-routes.ts +58 -1
  86. package/src/runtime/routes/settings-routes.ts +8 -6
  87. package/src/runtime/routes/user-route-dispatcher.ts +6 -86
  88. package/src/runtime/routes/user-route-resolution.ts +141 -0
  89. package/src/runtime/routes/user-routes-cli.ts +56 -35
  90. package/src/telemetry/AGENTS.md +15 -0
  91. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
  92. package/src/telemetry/telemetry-wire-source.json +1 -1
  93. package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
  94. package/src/telemetry/telemetry-wire-validation.ts +125 -0
  95. package/src/telemetry/telemetry-wire.generated.ts +60 -9
  96. package/src/telemetry/types.test.ts +38 -0
  97. package/src/telemetry/types.ts +183 -91
  98. package/src/telemetry/usage-telemetry-reporter.ts +5 -0
  99. package/src/telemetry/watchdog-direct-emit.ts +6 -0
  100. package/src/tools/skills/scaffold-managed.ts +43 -11
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Unit tests for the gateway_status route handler.
3
+ */
4
+
5
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
6
+
7
+ import { ServiceUnavailableError } from "../errors.js";
8
+
9
+ let ipcResult: unknown = null;
10
+ let ipcError: Error | undefined;
11
+ let ipcCallCount = 0;
12
+
13
+ const ipcGetVelayStatusMock = mock(async () => {
14
+ ipcCallCount += 1;
15
+ if (ipcError) throw ipcError;
16
+ return ipcResult;
17
+ });
18
+
19
+ mock.module("../../../ipc/gateway-client.js", () => ({
20
+ ipcGetVelayStatus: ipcGetVelayStatusMock,
21
+ }));
22
+
23
+ import { ROUTES } from "../gateway-status-routes.js";
24
+
25
+ const gatewayStatusRoute = ROUTES.find(
26
+ (r) => r.operationId === "gateway_status",
27
+ )!;
28
+
29
+ describe("gateway_status route", () => {
30
+ beforeEach(() => {
31
+ ipcResult = null;
32
+ ipcError = undefined;
33
+ ipcCallCount = 0;
34
+ ipcGetVelayStatusMock.mockClear();
35
+ });
36
+
37
+ test("route is registered with correct operationId, method, and endpoint", () => {
38
+ expect(gatewayStatusRoute).toBeDefined();
39
+ expect(gatewayStatusRoute.operationId).toBe("gateway_status");
40
+ expect(gatewayStatusRoute.method).toBe("GET");
41
+ expect(gatewayStatusRoute.endpoint).toBe("gateway/status");
42
+ });
43
+
44
+ test("returns the tunnel URL when a tunnel is connected", async () => {
45
+ ipcResult = { connected: true, publicUrl: "https://abc123.vellum.ai" };
46
+
47
+ const result = await gatewayStatusRoute.handler({});
48
+
49
+ expect(result).toEqual({ tunnel: "https://abc123.vellum.ai" });
50
+ });
51
+
52
+ test("returns {} when the tunnel is disconnected", async () => {
53
+ ipcResult = { connected: false, publicUrl: null };
54
+
55
+ const result = await gatewayStatusRoute.handler({});
56
+
57
+ expect(result).toEqual({});
58
+ });
59
+
60
+ test("returns {} when connected but no public URL is registered yet", async () => {
61
+ ipcResult = { connected: true, publicUrl: null };
62
+
63
+ const result = await gatewayStatusRoute.handler({});
64
+
65
+ expect(result).toEqual({});
66
+ });
67
+
68
+ test("errors with 503 when the gateway is unreachable", async () => {
69
+ ipcResult = null;
70
+
71
+ await expect(gatewayStatusRoute.handler({})).rejects.toBeInstanceOf(
72
+ ServiceUnavailableError,
73
+ );
74
+ });
75
+
76
+ test("errors when the gateway IPC call throws (gateway not running)", async () => {
77
+ ipcError = new Error("Gateway IPC socket disconnected");
78
+
79
+ await expect(gatewayStatusRoute.handler({})).rejects.toBeInstanceOf(
80
+ ServiceUnavailableError,
81
+ );
82
+ expect(ipcCallCount).toBe(1);
83
+ });
84
+ });
@@ -112,6 +112,24 @@ import {
112
112
  type UpgradePluginOptions,
113
113
  } from "../../../cli/lib/upgrade-plugin.js";
114
114
 
115
+ // Spy on the route logger so tests can assert that a platform-catalog outage
116
+ // is recorded before it is mapped to a client 503. The catalog fetcher and the
117
+ // transport adapters swallow the failure otherwise (`RouteError` → wire error,
118
+ // no capture), so the log line is the only trace an operator gets — assert it.
119
+ const logErrorSpy = mock((..._args: unknown[]) => {});
120
+ const logWarnSpy = mock((..._args: unknown[]) => {});
121
+
122
+ mock.module("../../../util/logger.js", () => ({
123
+ getLogger: () => ({
124
+ info: () => {},
125
+ warn: logWarnSpy,
126
+ error: logErrorSpy,
127
+ debug: () => {},
128
+ trace: () => {},
129
+ fatal: () => {},
130
+ }),
131
+ }));
132
+
115
133
  // Mutable list returned by the mocked library function. Tests reassign
116
134
  // `installedFixture` before invoking the handler.
117
135
  let installedFixture: InstalledPluginInfo[] = [];
@@ -302,6 +320,11 @@ const broadcastMessageSpy = mock((_msg: unknown): void => {});
302
320
 
303
321
  mock.module("../../assistant-event-hub.js", () => ({
304
322
  broadcastMessage: broadcastMessageSpy,
323
+ // Stub the hub singleton so the (now dynamically imported) plugins-routes
324
+ // dependency graph links — some transitive importer statically imports this
325
+ // binding. No tested path calls it; publishing goes through the mocked
326
+ // `broadcastMessage` above.
327
+ assistantEventHub: {},
305
328
  }));
306
329
 
307
330
  // Make the valid-slug source deterministic: the real `getLocalCategorySlugs`
@@ -335,11 +358,15 @@ import {
335
358
  NotFoundError,
336
359
  ServiceUnavailableError,
337
360
  } from "../errors.js";
338
- import {
361
+ // `plugins-routes.js` is dynamically imported AFTER the logger mock above so
362
+ // its module-level `const log = getLogger(...)` binds to the mocked logger. A
363
+ // static import evaluates before any `mock.module` runs and would capture the
364
+ // real logger at module init (same reason as `request-logger.test.ts`).
365
+ const {
339
366
  loadCategoryMapBounded,
340
367
  normalizeMarketplaceCategory,
341
- ROUTES as PLUGINS_ROUTES,
342
- } from "../plugins-routes.js";
368
+ ROUTES: PLUGINS_ROUTES,
369
+ }: typeof import("../plugins-routes.js") = await import("../plugins-routes.js");
343
370
  import type { RouteDefinition, RouteHandlerArgs } from "../types.js";
344
371
  import { RouteResponse } from "../types.js";
345
372
 
@@ -1099,6 +1126,7 @@ describe("GET /v1/plugins/search", () => {
1099
1126
  });
1100
1127
 
1101
1128
  test("PluginCatalogUnavailableError → ServiceUnavailableError (503)", async () => {
1129
+ logErrorSpy.mockClear();
1102
1130
  getCatalogSpy.mockImplementation(async () => {
1103
1131
  throw new PluginCatalogUnavailableError(
1104
1132
  "GitHub contents listing failed for plugins @ main: HTTP 403",
@@ -1111,6 +1139,14 @@ describe("GET /v1/plugins/search", () => {
1111
1139
  await expect(
1112
1140
  invokeSearch({ queryParams: { q: "memory" } }),
1113
1141
  ).rejects.toBeInstanceOf(ServiceUnavailableError);
1142
+
1143
+ // The outage must be logged before it is mapped to a 503 — otherwise the
1144
+ // transport adapters return the RouteError with no capture and the incident
1145
+ // is invisible. The log carries the true upstream status the 503 collapses.
1146
+ expect(logErrorSpy).toHaveBeenCalledTimes(1);
1147
+ const [fields, msg] = logErrorSpy.mock.calls[0]!;
1148
+ expect(msg).toBe("Platform plugin catalog unavailable");
1149
+ expect(fields).toMatchObject({ operation: "search", upstreamStatus: 403 });
1114
1150
  });
1115
1151
 
1116
1152
  test("unknown errors → InternalError with original message preserved", async () => {
@@ -1579,6 +1615,7 @@ describe("POST /v1/plugins/install", () => {
1579
1615
  test("a catalog outage on the no-pin path → ServiceUnavailableError (503)", async () => {
1580
1616
  // A rate-limited or unavailable platform catalog (no stale fallback) is
1581
1617
  // transient — the route surfaces it as retryable, not a misleading 500.
1618
+ logErrorSpy.mockClear();
1582
1619
  getCatalogSpy.mockImplementation(async () => {
1583
1620
  throw new PluginCatalogUnavailableError("HTTP 403", 403);
1584
1621
  });
@@ -1587,6 +1624,13 @@ describe("POST /v1/plugins/install", () => {
1587
1624
  invokeInstall({ body: { name: "caveman" } }),
1588
1625
  ).rejects.toBeInstanceOf(ServiceUnavailableError);
1589
1626
  expect(installSpy).not.toHaveBeenCalled();
1627
+
1628
+ // The outage is logged (operation "install") before the 503 mapping, so
1629
+ // an install-path catalog failure is diagnosable rather than invisible.
1630
+ expect(logErrorSpy).toHaveBeenCalledTimes(1);
1631
+ const [fields, msg] = logErrorSpy.mock.calls[0]!;
1632
+ expect(msg).toBe("Platform plugin catalog unavailable");
1633
+ expect(fields).toMatchObject({ operation: "install", upstreamStatus: 403 });
1590
1634
  });
1591
1635
 
1592
1636
  test("a missing name short-circuits to BadRequestError without calling the lib", async () => {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tests for the `assistant routes` discovery handlers (`user_routes_list`,
3
+ * `user_routes_inspect`).
4
+ *
5
+ * These assert the CLI's view of the route surface matches what the dispatcher
6
+ * serves: workspace routes appear at `/x/<path>`, plugin routes at
7
+ * `/x/plugins/<name>/<path>`, files shadowed by the reserved plugin prefix are
8
+ * excluded, and disabled plugins contribute nothing. Both handlers resolve
9
+ * through the shared `user-route-resolution` module the dispatcher uses.
10
+ */
11
+
12
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+
16
+ import {
17
+ getWorkspacePluginsDir,
18
+ getWorkspaceRoutesDir,
19
+ } from "../../../util/platform.js";
20
+ import type { RouteHandlerArgs } from "../types.js";
21
+ import { ROUTES } from "../user-routes-cli.js";
22
+
23
+ interface RouteEntry {
24
+ routePath: string;
25
+ methods: string[];
26
+ filePath: string;
27
+ }
28
+
29
+ const listHandler = ROUTES.find((r) => r.operationId === "user_routes_list")!
30
+ .handler as () => Promise<{ ok: true; routes: RouteEntry[] }>;
31
+ const inspectHandler = ROUTES.find(
32
+ (r) => r.operationId === "user_routes_inspect",
33
+ )!.handler as (args: RouteHandlerArgs) => Promise<{ route: RouteEntry }>;
34
+
35
+ const GET_HANDLER = `export async function GET() { return Response.json({ ok: true }); }\n`;
36
+
37
+ function writeWorkspaceRoute(relPath: string): void {
38
+ const full = join(getWorkspaceRoutesDir(), relPath);
39
+ mkdirSync(full.slice(0, full.lastIndexOf("/")), { recursive: true });
40
+ writeFileSync(full, GET_HANDLER);
41
+ }
42
+
43
+ function writePluginRoute(plugin: string, relPath: string): void {
44
+ const full = join(getWorkspacePluginsDir(), plugin, "routes", relPath);
45
+ mkdirSync(full.slice(0, full.lastIndexOf("/")), { recursive: true });
46
+ writeFileSync(full, GET_HANDLER);
47
+ }
48
+
49
+ function disablePlugin(plugin: string): void {
50
+ writeFileSync(join(getWorkspacePluginsDir(), plugin, ".disabled"), "");
51
+ }
52
+
53
+ beforeEach(() => {
54
+ mkdirSync(getWorkspaceRoutesDir(), { recursive: true });
55
+ mkdirSync(getWorkspacePluginsDir(), { recursive: true });
56
+ });
57
+
58
+ afterEach(() => {
59
+ rmSync(getWorkspaceRoutesDir(), { recursive: true, force: true });
60
+ rmSync(getWorkspacePluginsDir(), { recursive: true, force: true });
61
+ });
62
+
63
+ describe("routes list", () => {
64
+ test("lists workspace routes and plugin routes together", async () => {
65
+ writeWorkspaceRoute("ping.ts");
66
+ writePluginRoute("demo", "status.ts");
67
+ writePluginRoute("demo", "webhooks/incoming.ts");
68
+ writePluginRoute("demo", "index.ts");
69
+
70
+ const { routes } = await listHandler();
71
+ const byPath = new Map(routes.map((r) => [r.routePath, r]));
72
+
73
+ expect(byPath.has("/x/ping")).toBe(true);
74
+ expect(byPath.get("/x/ping")!.filePath).toBe("routes/ping.ts");
75
+
76
+ expect(byPath.has("/x/plugins/demo/status")).toBe(true);
77
+ expect(byPath.get("/x/plugins/demo/status")!.filePath).toBe(
78
+ "plugins/demo/routes/status.ts",
79
+ );
80
+ expect(byPath.has("/x/plugins/demo/webhooks/incoming")).toBe(true);
81
+ // routes/index.ts maps to the plugin namespace root.
82
+ expect(byPath.has("/x/plugins/demo")).toBe(true);
83
+ });
84
+
85
+ test("excludes workspace files shadowed by the reserved plugin prefix", async () => {
86
+ writeWorkspaceRoute("ping.ts");
87
+ // A workspace file under routes/plugins/ is unreachable (the dispatcher
88
+ // routes /x/plugins/* to plugin dirs), so it must not be listed.
89
+ writeWorkspaceRoute("plugins/foo.ts");
90
+
91
+ const { routes } = await listHandler();
92
+ const paths = routes.map((r) => r.routePath);
93
+
94
+ expect(paths).toContain("/x/ping");
95
+ expect(paths).not.toContain("/x/plugins/foo");
96
+ });
97
+
98
+ test("excludes disabled plugins' routes", async () => {
99
+ writePluginRoute("live", "status.ts");
100
+ writePluginRoute("dead", "status.ts");
101
+ disablePlugin("dead");
102
+
103
+ const { routes } = await listHandler();
104
+ const paths = routes.map((r) => r.routePath);
105
+
106
+ expect(paths).toContain("/x/plugins/live/status");
107
+ expect(paths).not.toContain("/x/plugins/dead/status");
108
+ });
109
+ });
110
+
111
+ describe("routes inspect", () => {
112
+ test("inspects a plugin route by sub-path and by /x/-prefixed path", async () => {
113
+ writePluginRoute("demo", "status.ts");
114
+
115
+ const bare = await inspectHandler({
116
+ body: { path: "plugins/demo/status" },
117
+ });
118
+ expect(bare.route.routePath).toBe("/x/plugins/demo/status");
119
+ expect(bare.route.methods).toEqual(["GET"]);
120
+ expect(bare.route.filePath).toBe("plugins/demo/routes/status.ts");
121
+
122
+ // The `/x/`-prefixed form that `routes list` prints is accepted too.
123
+ const prefixed = await inspectHandler({
124
+ body: { path: "/x/plugins/demo/status" },
125
+ });
126
+ expect(prefixed.route.routePath).toBe("/x/plugins/demo/status");
127
+ });
128
+
129
+ test("inspects a workspace route", async () => {
130
+ writeWorkspaceRoute("ping.ts");
131
+ const res = await inspectHandler({ body: { path: "ping" } });
132
+ expect(res.route.routePath).toBe("/x/ping");
133
+ expect(res.route.filePath).toBe("routes/ping.ts");
134
+ });
135
+
136
+ test("404s a shadowed, missing, or disabled route", async () => {
137
+ writeWorkspaceRoute("plugins/foo.ts"); // shadowed workspace file
138
+ writePluginRoute("dead", "status.ts");
139
+ disablePlugin("dead");
140
+
141
+ // Shadowed: resolves to plugin "foo" (which has no routes dir) → not found.
142
+ await expect(
143
+ inspectHandler({ body: { path: "plugins/foo" } }),
144
+ ).rejects.toThrow();
145
+ // Missing plugin.
146
+ await expect(
147
+ inspectHandler({ body: { path: "plugins/ghost/status" } }),
148
+ ).rejects.toThrow();
149
+ // Disabled plugin.
150
+ await expect(
151
+ inspectHandler({ body: { path: "plugins/dead/status" } }),
152
+ ).rejects.toThrow();
153
+ });
154
+ });
@@ -8,6 +8,7 @@ import {
8
8
  mkdirSync,
9
9
  readdirSync,
10
10
  readFileSync,
11
+ statSync,
11
12
  writeFileSync,
12
13
  } from "node:fs";
13
14
  import { stat, unlink } from "node:fs/promises";
@@ -22,15 +23,16 @@ import {
22
23
  createAppRecord,
23
24
  deleteApp,
24
25
  deleteAppRecord,
25
- getApp,
26
+ type EnumeratedApp,
26
27
  getAppDirPath,
27
28
  getAppPreview,
28
- isMultifileApp,
29
+ isPluginAppId,
29
30
  listApps,
30
31
  listAppsByConversation,
32
+ listPluginApps,
31
33
  queryAppRecords,
32
- resolveAppDir,
33
- resolveEffectiveAppHtml,
34
+ resolveAppSource,
35
+ resolveEffectiveAppHtmlFromDir,
34
36
  updateApp,
35
37
  updateAppRecord,
36
38
  } from "../../apps/app-store.js";
@@ -75,7 +77,7 @@ function getSharedAppsDir(): string {
75
77
  // Extracted business logic
76
78
  // ---------------------------------------------------------------------------
77
79
 
78
- function listAppsFiltered(apps?: AppDefinition[]): Array<{
80
+ interface AppListItem {
79
81
  id: string;
80
82
  name: string;
81
83
  description?: string;
@@ -84,21 +86,44 @@ function listAppsFiltered(apps?: AppDefinition[]): Array<{
84
86
  updatedAt: number;
85
87
  version: string;
86
88
  contentId: string;
87
- }> {
88
- return (apps ?? listApps()).map((a) => {
89
- const version = a.version ?? "1.0.0";
90
- const contentId = computeContentId(a.name);
91
- return {
92
- id: a.id,
93
- name: a.name,
94
- description: a.description,
95
- icon: a.icon,
96
- createdAt: a.createdAt,
97
- updatedAt: a.updatedAt,
98
- version,
99
- contentId,
100
- };
101
- });
89
+ /** "workspace" or "plugin:<name>" — identifies where the app comes from. */
90
+ origin: string;
91
+ }
92
+
93
+ function workspaceAppItem(a: AppDefinition): AppListItem {
94
+ return {
95
+ id: a.id,
96
+ name: a.name,
97
+ description: a.description,
98
+ icon: a.icon,
99
+ createdAt: a.createdAt,
100
+ updatedAt: a.updatedAt,
101
+ version: a.version ?? "1.0.0",
102
+ contentId: computeContentId(a.name),
103
+ origin: "workspace",
104
+ };
105
+ }
106
+
107
+ function pluginAppItem(app: EnumeratedApp): AppListItem {
108
+ // Plugin apps carry no stored timestamps; use the source dir mtime so the
109
+ // library can still order them sensibly.
110
+ let mtime = 0;
111
+ try {
112
+ mtime = statSync(app.sourcePath).mtimeMs;
113
+ } catch {
114
+ // Directory vanished between enumeration and stat — leave mtime at 0.
115
+ }
116
+ const pluginName =
117
+ app.origin.kind === "plugin" ? app.origin.pluginName : "unknown";
118
+ return {
119
+ id: app.id,
120
+ name: app.name,
121
+ createdAt: mtime,
122
+ updatedAt: mtime,
123
+ version: "1.0.0",
124
+ contentId: computeContentId(app.name),
125
+ origin: `plugin:${pluginName}`,
126
+ };
102
127
  }
103
128
 
104
129
  function getAppDataResult(
@@ -507,9 +532,17 @@ async function importBundle(
507
532
  function handleListApps({ queryParams }: RouteHandlerArgs) {
508
533
  const conversationId = queryParams?.conversationId;
509
534
  if (conversationId) {
510
- return { apps: listAppsFiltered(listAppsByConversation(conversationId)) };
535
+ // Conversation scoping is a workspace-app concept; plugin apps are not
536
+ // associated with conversations, so they are omitted from this view.
537
+ return {
538
+ apps: listAppsByConversation(conversationId).map(workspaceAppItem),
539
+ };
511
540
  }
512
- return { apps: listAppsFiltered() };
541
+ const apps: AppListItem[] = [
542
+ ...listApps().map(workspaceAppItem),
543
+ ...listPluginApps().map(pluginAppItem),
544
+ ];
545
+ return { apps };
513
546
  }
514
547
 
515
548
  async function handleOpenBundle({ body }: RouteHandlerArgs) {
@@ -607,6 +640,7 @@ function handleQueryAppData({ pathParams, queryParams }: RouteHandlerArgs) {
607
640
 
608
641
  function handleMutateAppData({ pathParams, body }: RouteHandlerArgs) {
609
642
  const appId = pathParams?.id as string;
643
+ assertNotPluginApp(appId, "modify a plugin app's data");
610
644
  const method = (body?.method as string) ?? "create";
611
645
  const result = getAppDataResult(
612
646
  method,
@@ -619,16 +653,19 @@ function handleMutateAppData({ pathParams, body }: RouteHandlerArgs) {
619
653
 
620
654
  async function handleOpenApp({ pathParams }: RouteHandlerArgs) {
621
655
  const appId = pathParams?.id as string;
622
- const app = getApp(appId);
623
- if (!app) {
656
+ const source = resolveAppSource(appId);
657
+ if (!source) {
624
658
  throw new NotFoundError(`App not found: ${appId}`);
625
659
  }
626
660
 
627
- if (isMultifileApp(app)) {
628
- const appDir = getAppDirPath(appId);
629
- const distIndex = join(appDir, "dist", "index.html");
661
+ // Multifile workspace apps auto-compile on open when their dist is missing.
662
+ // Plugin apps are not compiled here — a compile cache for plugin sources is a
663
+ // follow-up so a multifile plugin app without a prebuilt dist/ renders its
664
+ // fallback message.
665
+ if (source.formatVersion === 2 && source.origin.kind === "workspace") {
666
+ const distIndex = join(source.sourceDir, "dist", "index.html");
630
667
  if (!existsSync(distIndex)) {
631
- const result = await compileApp(appDir);
668
+ const result = await compileApp(source.sourceDir);
632
669
  if (!result.ok) {
633
670
  log.warn(
634
671
  { appId, errors: result.errors },
@@ -637,13 +674,36 @@ async function handleOpenApp({ pathParams }: RouteHandlerArgs) {
637
674
  }
638
675
  }
639
676
  }
640
- const html = resolveEffectiveAppHtml(app);
641
- const { dirName } = resolveAppDir(app.id);
642
- return { appId: app.id, dirName, name: app.name, html };
677
+
678
+ const html = resolveEffectiveAppHtmlFromDir(
679
+ source.sourceDir,
680
+ source.formatVersion,
681
+ );
682
+ return {
683
+ appId: source.id,
684
+ dirName: source.dirName,
685
+ name: source.name,
686
+ html,
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Reject a mutation targeting a plugin-bundled app. Plugin apps are owned by
692
+ * their plugin and are read-only over this surface — their source is not
693
+ * user-editable and their lifecycle is the plugin's.
694
+ */
695
+ function assertNotPluginApp(appId: string, action: string): void {
696
+ if (isPluginAppId(appId)) {
697
+ throw new BadRequestError(
698
+ `Plugin-bundled apps are read-only; cannot ${action}. This app is owned by its plugin.`,
699
+ );
700
+ }
643
701
  }
644
702
 
645
703
  function handleDeleteApp({ pathParams, headers }: RouteHandlerArgs) {
646
- deleteApp(pathParams?.id as string);
704
+ const appId = pathParams?.id as string;
705
+ assertNotPluginApp(appId, "delete a plugin app");
706
+ deleteApp(appId);
647
707
  publishAppsChanged(getOriginClientId(headers));
648
708
  return { success: true };
649
709
  }
@@ -656,6 +716,7 @@ function handleGetPreview({ pathParams }: RouteHandlerArgs) {
656
716
 
657
717
  function handleUpdatePreview({ pathParams, body }: RouteHandlerArgs) {
658
718
  const appId = pathParams?.id as string;
719
+ assertNotPluginApp(appId, "update a plugin app's preview");
659
720
  if (!body?.preview) {
660
721
  throw new BadRequestError("preview is required");
661
722
  }
@@ -665,6 +726,7 @@ function handleUpdatePreview({ pathParams, body }: RouteHandlerArgs) {
665
726
 
666
727
  async function handleBundle({ pathParams }: RouteHandlerArgs) {
667
728
  const appId = pathParams?.id as string;
729
+ assertNotPluginApp(appId, "bundle a plugin app");
668
730
  const result = await packageApp(appId);
669
731
  return {
670
732
  type: "bundle_app_response",
@@ -676,6 +738,7 @@ async function handleBundle({ pathParams }: RouteHandlerArgs) {
676
738
 
677
739
  async function handleShareCloud({ pathParams }: RouteHandlerArgs) {
678
740
  const appId = pathParams?.id as string;
741
+ assertNotPluginApp(appId, "share a plugin app");
679
742
  const result = await packageApp(appId);
680
743
  const bundleData = readFileSync(result.bundlePath);
681
744
  const { shareToken } = createSharedAppLink(bundleData, result.manifest);
@@ -721,6 +784,7 @@ export const ROUTES: RouteDefinition[] = [
721
784
  updatedAt: z.number(),
722
785
  version: z.string(),
723
786
  contentId: z.string(),
787
+ origin: z.string(),
724
788
  }),
725
789
  ),
726
790
  }),
@@ -9,10 +9,8 @@ import JSZip from "jszip";
9
9
  import { z } from "zod";
10
10
 
11
11
  import {
12
- getApp,
13
- getAppDirPath,
14
- isMultifileApp,
15
- readAppFileBytes,
12
+ readAppFileBytesFromDir,
13
+ resolveAppSource,
16
14
  } from "../../apps/app-store.js";
17
15
  import {
18
16
  createSharedAppLink,
@@ -78,11 +76,11 @@ function servePageHeaders({
78
76
  pathParams,
79
77
  }: ResponseHeaderArgs): Record<string, string> {
80
78
  const appId = pathParams?.appId as string;
81
- const app = getApp(appId);
79
+ const source = resolveAppSource(appId);
82
80
  // Multifile apps use external scripts — no 'unsafe-inline' for script-src.
83
81
  // Legacy apps contain inline event handlers that require 'unsafe-inline'.
84
82
  const scriptSrc =
85
- app && isMultifileApp(app) ? "'self'" : "'self' 'unsafe-inline'";
83
+ source && source.formatVersion === 2 ? "'self'" : "'self' 'unsafe-inline'";
86
84
  return {
87
85
  "Content-Type": "text/html; charset=utf-8",
88
86
  "Content-Security-Policy": buildCsp(scriptSrc),
@@ -95,18 +93,18 @@ function servePageHeaders({
95
93
 
96
94
  function handleServePage({ pathParams }: RouteHandlerArgs): string {
97
95
  const appId = pathParams?.appId as string;
98
- const app = getApp(appId);
99
- if (!app) {
96
+ const source = resolveAppSource(appId);
97
+ if (!source) {
100
98
  throw new NotFoundError("App not found");
101
99
  }
102
100
 
103
101
  // Multifile apps serve the compiled dist/index.html directly.
104
- if (isMultifileApp(app)) {
105
- return serveMultifileApp(appId, app.name);
102
+ if (source.formatVersion === 2) {
103
+ return serveMultifileApp(source.id, source.sourceDir, source.name);
106
104
  }
107
105
 
108
106
  const css = loadDesignSystemCss();
109
- const escapedName = app.name.replace(
107
+ const escapedName = source.name.replace(
110
108
  /[<>&"]/g,
111
109
  (c) => HTML_ESCAPE_MAP[c] ?? c,
112
110
  );
@@ -114,9 +112,13 @@ function handleServePage({ pathParams }: RouteHandlerArgs): string {
114
112
  // Per-response nonce for inline <style> and <script> tags.
115
113
  const nonce = randomBytes(16).toString("base64");
116
114
 
115
+ // Single-file apps serve their root index.html.
116
+ const indexPath = join(source.sourceDir, "index.html");
117
+ const rawHtml = existsSync(indexPath) ? readFileSync(indexPath, "utf-8") : "";
118
+
117
119
  // Inject the nonce into any inline <script> tags from the app HTML definition
118
120
  // so they are allowed by the nonce-based CSP without 'unsafe-inline'.
119
- const noncedHtml = app.htmlDefinition.replace(
121
+ const noncedHtml = rawHtml.replace(
120
122
  /<script(?=[\s>])/gi,
121
123
  `<script nonce="${nonce}"`,
122
124
  );
@@ -139,8 +141,12 @@ ${noncedHtml}
139
141
  * Serve compiled output for multifile TSX apps.
140
142
  * Falls back to a "not compiled yet" message if dist/index.html is missing.
141
143
  */
142
- function serveMultifileApp(appId: string, appName: string): string {
143
- const distDir = join(getAppDirPath(appId), "dist");
144
+ function serveMultifileApp(
145
+ appId: string,
146
+ appDir: string,
147
+ appName: string,
148
+ ): string {
149
+ const distDir = join(appDir, "dist");
144
150
  const indexPath = join(distDir, "index.html");
145
151
 
146
152
  if (!existsSync(indexPath)) {
@@ -236,7 +242,12 @@ function handleServeDistFile({ pathParams }: RouteHandlerArgs): Uint8Array {
236
242
  throw new BadRequestError("Invalid filename");
237
243
  }
238
244
 
239
- const filePath = join(getAppDirPath(appId), "dist", filename);
245
+ const source = resolveAppSource(appId);
246
+ if (!source) {
247
+ throw new NotFoundError("App not found");
248
+ }
249
+
250
+ const filePath = join(source.sourceDir, "dist", filename);
240
251
  if (!existsSync(filePath)) {
241
252
  throw new NotFoundError("File not found");
242
253
  }
@@ -250,7 +261,7 @@ const MAX_APP_ASSET_BYTES = 25 * 1024 * 1024;
250
261
  /**
251
262
  * Serve a bundled file from anywhere in an app's directory (e.g.
252
263
  * `assets/intro.mp4`), for binary media an app can't practically inline as a
253
- * data-URI. `readAppFileBytes` runs the app-store path validation
264
+ * data-URI. `readAppFileBytesFromDir` runs the app-store path validation
254
265
  * (rejects `..`, absolute paths, symlink escapes, and the protected
255
266
  * `records/` directory), so authors bundle assets under the app dir and load
256
267
  * them via `window.vellum.asset(path)`.
@@ -272,9 +283,14 @@ function handleServeAppAsset({ pathParams }: RouteHandlerArgs): Uint8Array {
272
283
  throw new BadRequestError("Invalid asset path");
273
284
  }
274
285
 
286
+ const source = resolveAppSource(appId);
287
+ if (!source) {
288
+ throw new NotFoundError("Asset not found");
289
+ }
290
+
275
291
  let bytes: Buffer;
276
292
  try {
277
- bytes = readAppFileBytes(appId, assetPath);
293
+ bytes = readAppFileBytesFromDir(source.sourceDir, assetPath);
278
294
  } catch (err) {
279
295
  const message = err instanceof Error ? err.message : "";
280
296
  if (message.includes("not found")) {