@vellumai/assistant 0.10.9-staging.1 → 0.10.9
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.
- package/AGENTS.md +4 -0
- package/knip.json +1 -4
- package/openapi.yaml +27 -17
- package/package.json +1 -1
- package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
- package/src/__tests__/app-routes-csp.test.ts +13 -4
- package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
- package/src/__tests__/disk-pressure-tools.test.ts +4 -4
- package/src/__tests__/file-list-tool.test.ts +22 -12
- package/src/__tests__/list-all-apps.test.ts +204 -0
- package/src/__tests__/messaging-send-tool.test.ts +67 -0
- package/src/__tests__/mtime-cache.test.ts +42 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
- package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
- package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
- package/src/__tests__/subagent-notify-parent.test.ts +4 -3
- package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
- package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
- package/src/apps/app-store.ts +302 -2
- package/src/cli/AGENTS.md +17 -0
- package/src/cli/commands/__tests__/gateway.test.ts +55 -0
- package/src/cli/commands/apps.help.ts +17 -11
- package/src/cli/commands/apps.ts +5 -15
- package/src/cli/commands/gateway.help.ts +30 -0
- package/src/cli/commands/gateway.ts +40 -1
- package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
- package/src/cli/commands/platform/index.help.ts +3 -2
- package/src/cli/commands/platform/index.ts +0 -9
- package/src/cli/commands/routes.help.ts +13 -7
- package/src/cli/commands/routes.ts +5 -3
- package/src/cli/commands/status.help.ts +24 -0
- package/src/cli/commands/status.ts +29 -0
- package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
- package/src/cli/lib/bundled-marketplace.json +187 -173
- package/src/cli/lib/global-json-option.ts +41 -0
- package/src/cli/lib/plugin-fingerprint.ts +18 -7
- package/src/cli/program.ts +5 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
- package/src/config/feature-flag-registry.json +1 -9
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
- package/src/daemon/conversation-tool-setup.ts +5 -90
- package/src/daemon/conversation.ts +2 -2
- package/src/messaging/provider-types.ts +9 -0
- package/src/messaging/providers/outlook/adapter.ts +17 -2
- package/src/messaging/providers/outlook/client.ts +11 -2
- package/src/messaging/providers/outlook/types.ts +9 -0
- package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
- package/src/monitoring/plugin-source-watch.ts +53 -0
- package/src/oauth/seed-providers.ts +6 -0
- package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
- package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
- package/src/plugin-api/conversation-turn.ts +234 -0
- package/src/plugin-api/index.ts +12 -0
- package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
- package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
- package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
- package/src/plugins/mtime-cache.ts +26 -13
- package/src/plugins/plugin-tree-walk.ts +30 -0
- package/src/plugins/source-fingerprint.ts +9 -2
- package/src/plugins/surface-import.ts +28 -0
- package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
- package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
- package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
- package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
- package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
- package/src/runtime/routes/app-management-routes.ts +96 -32
- package/src/runtime/routes/app-routes.ts +33 -17
- package/src/runtime/routes/gateway-status-routes.ts +69 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/platform-routes.ts +4 -14
- package/src/runtime/routes/plugins-routes.ts +58 -1
- package/src/runtime/routes/settings-routes.ts +8 -6
- package/src/runtime/routes/user-route-dispatcher.ts +6 -86
- package/src/runtime/routes/user-route-resolution.ts +141 -0
- package/src/runtime/routes/user-routes-cli.ts +56 -35
- package/src/telemetry/AGENTS.md +15 -0
- package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
- package/src/telemetry/telemetry-wire-validation.ts +125 -0
- package/src/telemetry/telemetry-wire.generated.ts +60 -9
- package/src/telemetry/types.test.ts +38 -0
- package/src/telemetry/types.ts +183 -91
- package/src/telemetry/usage-telemetry-reporter.ts +5 -0
- package/src/telemetry/watchdog-direct-emit.ts +6 -0
- 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
|
-
|
|
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
|
|
342
|
-
}
|
|
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
|
-
|
|
26
|
+
type EnumeratedApp,
|
|
26
27
|
getAppDirPath,
|
|
27
28
|
getAppPreview,
|
|
28
|
-
|
|
29
|
+
isPluginAppId,
|
|
29
30
|
listApps,
|
|
30
31
|
listAppsByConversation,
|
|
32
|
+
listPluginApps,
|
|
31
33
|
queryAppRecords,
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
623
|
-
if (!
|
|
656
|
+
const source = resolveAppSource(appId);
|
|
657
|
+
if (!source) {
|
|
624
658
|
throw new NotFoundError(`App not found: ${appId}`);
|
|
625
659
|
}
|
|
626
660
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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(
|
|
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
|
-
|
|
641
|
-
const
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
99
|
-
if (!
|
|
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 (
|
|
105
|
-
return serveMultifileApp(
|
|
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 =
|
|
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 =
|
|
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(
|
|
143
|
-
|
|
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
|
|
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. `
|
|
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 =
|
|
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")) {
|