@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,69 @@
1
+ /**
2
+ * Gateway status route — public tunnel status via the gateway IPC proxy.
3
+ *
4
+ * The tunnel is the gateway's outbound public-ingress transport. It is only
5
+ * used to tunnel inbound Twilio webhooks and live voice/audio WebSockets to
6
+ * this assistant — it plays no part in platform credentials or the managed
7
+ * LLM proxy. The handler reads the live status from the gateway over the local
8
+ * IPC socket, so the assistant does not need gateway signing material.
9
+ *
10
+ * The response is deliberately tunnel-agnostic: a single `tunnel` field holding
11
+ * the active public URL, omitted entirely when a tunnel is up but has no URL.
12
+ * This keeps the contract stable if the underlying transport (currently Velay)
13
+ * is ever swapped for another tunnel. If the gateway is not running (no IPC
14
+ * answer), the handler errors instead of returning an empty result.
15
+ */
16
+ import { z } from "zod";
17
+
18
+ import { ipcGetVelayStatus } from "../../ipc/gateway-client.js";
19
+ import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
20
+ import { ServiceUnavailableError } from "./errors.js";
21
+ import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
22
+
23
+ // ── Schemas ─────────────────────────────────────────────────────────────
24
+
25
+ const GatewayStatusResponseSchema = z.object({
26
+ // Present with the public tunnel URL when a tunnel is connected; omitted
27
+ // (so the object is `{}`) when no tunnel is up or the gateway is not running.
28
+ tunnel: z.string().optional(),
29
+ });
30
+ type GatewayStatusResponse = z.infer<typeof GatewayStatusResponseSchema>;
31
+
32
+ // ── Handlers ────────────────────────────────────────────────────────────
33
+
34
+ async function handleGatewayStatus(
35
+ _args: RouteHandlerArgs,
36
+ ): Promise<GatewayStatusResponse> {
37
+ const status = await ipcGetVelayStatus().catch(() => null);
38
+ // A null status means the gateway did not answer over IPC — i.e. it is not
39
+ // running. That is a fatal condition for this command, not a "no tunnel"
40
+ // state, so surface it as an error rather than an empty result.
41
+ if (status === null) {
42
+ throw new ServiceUnavailableError(
43
+ "Gateway is not running or is unreachable over IPC.",
44
+ );
45
+ }
46
+ return status.connected && status.publicUrl
47
+ ? { tunnel: status.publicUrl }
48
+ : {};
49
+ }
50
+
51
+ // ── Route definitions ───────────────────────────────────────────────────
52
+
53
+ export const ROUTES: RouteDefinition[] = [
54
+ {
55
+ operationId: "gateway_status",
56
+ method: "GET",
57
+ policy: {
58
+ requiredScopes: ["settings.read"],
59
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
60
+ },
61
+ endpoint: "gateway/status",
62
+ handler: handleGatewayStatus,
63
+ summary: "Get gateway status",
64
+ description:
65
+ "Reports the gateway's public tunnel status. `tunnel` holds the active public URL when a tunnel is connected and is omitted otherwise. Errors with 503 when the gateway is not running. The tunnel only matters for routing inbound Twilio webhooks and live voice/audio WebSockets.",
66
+ tags: ["gateway"],
67
+ responseBody: GatewayStatusResponseSchema,
68
+ },
69
+ ];
@@ -68,6 +68,7 @@ import { ROUTES as EMAIL_ROUTES } from "./email-routes.js";
68
68
  import { ROUTES as EVENTS_ROUTES } from "./events-routes.js";
69
69
  import { ROUTES as FILING_ROUTES } from "./filing-routes.js";
70
70
  import { ROUTES as GATEWAY_LOG_ROUTES } from "./gateway-log-routes.js";
71
+ import { ROUTES as GATEWAY_STATUS_ROUTES } from "./gateway-status-routes.js";
71
72
  import { ROUTES as GLOBAL_SEARCH_ROUTES } from "./global-search-routes.js";
72
73
  import { ROUTES as GROUP_ROUTES } from "./group-routes.js";
73
74
  import { ROUTES as GUARDIAN_ACTION_ROUTES } from "./guardian-action-routes.js";
@@ -210,6 +211,7 @@ export const ROUTES: RouteDefinition[] = [
210
211
  ...EVENTS_ROUTES,
211
212
  ...FILING_ROUTES,
212
213
  ...GATEWAY_LOG_ROUTES,
214
+ ...GATEWAY_STATUS_ROUTES,
213
215
  ...GLOBAL_SEARCH_ROUTES,
214
216
  ...GROUP_ROUTES,
215
217
  ...GUARDIAN_ACTION_ROUTES,
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Serves six operations:
5
5
  * - platform_status (GET platform/status): aggregates platform context,
6
- * credentials, assistant ID, webhook secret, and Velay tunnel status.
6
+ * credentials, assistant ID, and webhook secret. (Velay tunnel status
7
+ * lives on the gateway — see gateway_status.)
7
8
  * - platform_connect (POST platform/connect): checks existing credentials
8
9
  * and emits the show_platform_login signal to connected clients.
9
10
  * - platform_disconnect (POST platform/disconnect): deletes stored platform
@@ -23,7 +24,6 @@ import {
23
24
  registerCallbackRoute,
24
25
  resolvePlatformCallbackRegistrationContext,
25
26
  } from "../../inbound/platform-callback-registration.js";
26
- import { ipcGetVelayStatus } from "../../ipc/gateway-client.js";
27
27
  import { credentialKey } from "../../security/credential-key.js";
28
28
  import {
29
29
  deleteSecureKeyAsync,
@@ -56,11 +56,6 @@ const CREDENTIAL_KEYS = {
56
56
  // Schemas
57
57
  // ---------------------------------------------------------------------------
58
58
 
59
- const VelayTunnelStatusSchema = z.object({
60
- connected: z.boolean(),
61
- publicUrl: z.string().nullable(),
62
- });
63
-
64
59
  const PlatformStatusResponseSchema = z.object({
65
60
  isPlatform: z.boolean(),
66
61
  baseUrl: z.string(),
@@ -71,7 +66,6 @@ const PlatformStatusResponseSchema = z.object({
71
66
  available: z.boolean(),
72
67
  organizationId: z.string().nullable(),
73
68
  userId: z.string().nullable(),
74
- velayTunnel: VelayTunnelStatusSchema.nullable(),
75
69
  });
76
70
  type PlatformStatusResponse = z.infer<typeof PlatformStatusResponseSchema>;
77
71
 
@@ -137,10 +131,7 @@ type PlatformCreditsResponse = z.infer<typeof PlatformCreditsResponseSchema>;
137
131
  async function handlePlatformStatus(
138
132
  _args: RouteHandlerArgs,
139
133
  ): Promise<PlatformStatusResponse> {
140
- const [context, velayTunnel] = await Promise.all([
141
- resolvePlatformCallbackRegistrationContext(),
142
- ipcGetVelayStatus().catch(() => null),
143
- ]);
134
+ const context = await resolvePlatformCallbackRegistrationContext();
144
135
 
145
136
  const [orgIdRaw, userIdRaw, webhookSecretRaw] = await Promise.all([
146
137
  getSecureKeyAsync(
@@ -172,7 +163,6 @@ async function handlePlatformStatus(
172
163
  available: context.enabled,
173
164
  organizationId: organizationId || null,
174
165
  userId: userId || null,
175
- velayTunnel,
176
166
  };
177
167
  }
178
168
 
@@ -433,7 +423,7 @@ export const ROUTES: RouteDefinition[] = [
433
423
  },
434
424
  summary: "Get platform deployment context and connection status",
435
425
  description:
436
- "Aggregates platform context, credentials, assistant ID, webhook secret, and Velay tunnel status.",
426
+ "Aggregates platform context, credentials, assistant ID, and webhook secret. Velay tunnel status is reported separately by gateway_status.",
437
427
  tags: ["platform"],
438
428
  handler: handlePlatformStatus,
439
429
  responseBody: PlatformStatusResponseSchema,
@@ -88,9 +88,11 @@ import {
88
88
  PluginNotUpgradableError,
89
89
  upgradePlugin,
90
90
  } from "../../cli/lib/upgrade-plugin.js";
91
+ import { getPlatformBaseUrl } from "../../config/env.js";
91
92
  import { isPluginDisabled } from "../../plugins/disabled-state.js";
92
93
  import { ensurePluginApiShim } from "../../plugins/ensure-plugin-api-shim.js";
93
94
  import { getLocalCategorySlugs } from "../../skills/categories-cache.js";
95
+ import { getLogger } from "../../util/logger.js";
94
96
  import { getWorkspacePluginsDir } from "../../util/platform.js";
95
97
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
96
98
  import {
@@ -701,6 +703,37 @@ const pluginDiffResponseSchema = z.object({
701
703
  // Helpers
702
704
  // ---------------------------------------------------------------------------
703
705
 
706
+ const log = getLogger("plugins-routes");
707
+
708
+ /**
709
+ * Emit a structured log for a platform plugin-catalog outage before the caller
710
+ * maps it to a client-facing error.
711
+ *
712
+ * The catalog fetcher (`plugin-catalog-platform.ts`) throws
713
+ * `PluginCatalogUnavailableError` without logging, and the transport adapters
714
+ * return the resulting `RouteError` (a 503) without any Sentry capture — so an
715
+ * outage on the platform-first paths (`search`, `install`, the remote-only
716
+ * detail view) otherwise leaves no trace beyond the daemon access-log status
717
+ * code, and the real upstream status / URL is lost. Log at `error` so the
718
+ * outage is time-correlatable in the daemon log and surfaces in Sentry;
719
+ * `upstreamStatus` preserves the true platform status (e.g. 404 / 500 / 503)
720
+ * that the client-facing 503 collapses.
721
+ */
722
+ function logPluginCatalogUnavailable(
723
+ operation: string,
724
+ err: PluginCatalogUnavailableError,
725
+ ): void {
726
+ log.error(
727
+ {
728
+ err,
729
+ operation,
730
+ upstreamStatus: err.status,
731
+ platformBaseUrl: getPlatformBaseUrl(),
732
+ },
733
+ "Platform plugin catalog unavailable",
734
+ );
735
+ }
736
+
704
737
  interface PluginView {
705
738
  id: string;
706
739
  name: string;
@@ -880,10 +913,31 @@ export async function loadCategoryMapBounded(
880
913
  }),
881
914
  ]);
882
915
  if (!catalog) {
916
+ // Timed out past the budget: the installed list degrades to no
917
+ // categories rather than stalling. Non-fatal, but log it so a slow
918
+ // marketplace is diagnosable and not silently invisible.
919
+ log.warn(
920
+ { timeoutMs, platformBaseUrl: getPlatformBaseUrl() },
921
+ "Plugin catalog lookup timed out; listing without categories",
922
+ );
883
923
  return new Map();
884
924
  }
885
925
  return new Map(catalog.matches.map((m) => [m.name, m.category]));
886
- } catch {
926
+ } catch (err) {
927
+ // The installed list must never fail on a catalog outage, so this degrades
928
+ // to no categories — but log it (warn: the request still succeeds) so the
929
+ // same platform outage that hard-fails search/install is not invisible on
930
+ // the list path. `upstreamStatus` is preserved when the failure carries one.
931
+ log.warn(
932
+ {
933
+ err,
934
+ platformBaseUrl: getPlatformBaseUrl(),
935
+ ...(err instanceof PluginCatalogUnavailableError
936
+ ? { upstreamStatus: err.status }
937
+ : {}),
938
+ },
939
+ "Plugin catalog lookup failed; listing without categories",
940
+ );
887
941
  return new Map();
888
942
  } finally {
889
943
  if (timer) {
@@ -999,6 +1053,7 @@ async function handleSearchPlugins({
999
1053
  // misleading 500 so the client can show a "temporarily unavailable"
1000
1054
  // state and retry later.
1001
1055
  if (err instanceof PluginCatalogUnavailableError) {
1056
+ logPluginCatalogUnavailable("search", err);
1002
1057
  throw new ServiceUnavailableError(err.message);
1003
1058
  }
1004
1059
  throw new InternalError(
@@ -1080,6 +1135,7 @@ async function handleGetPluginDetails({
1080
1135
  // transient — surface it as retryable rather than a misleading 404/500 so
1081
1136
  // clients retry instead of treating the plugin as permanently gone.
1082
1137
  if (err instanceof PluginCatalogUnavailableError) {
1138
+ logPluginCatalogUnavailable("details", err);
1083
1139
  throw new ServiceUnavailableError(err.message);
1084
1140
  }
1085
1141
  throw new InternalError(
@@ -1207,6 +1263,7 @@ async function handleInstallPlugin({ body = {}, headers }: RouteHandlerArgs) {
1207
1263
  // A rate-limited or unavailable platform catalog (no stale fallback) is
1208
1264
  // transient — surface it as retryable rather than a misleading 500.
1209
1265
  if (err instanceof PluginCatalogUnavailableError) {
1266
+ logPluginCatalogUnavailable("install", err);
1210
1267
  throw new ServiceUnavailableError(err.message);
1211
1268
  }
1212
1269
  throw new InternalError(
@@ -16,11 +16,11 @@ import {
16
16
  import { loadRawConfig, saveRawConfig } from "../../config/loader.js";
17
17
  import { loadSkillCatalog } from "../../config/skills.js";
18
18
  import { getGuardianDelivery } from "../../contacts/guardian-delivery-reader.js";
19
+ import type { Conversation } from "../../daemon/conversation.js";
19
20
  import { findConversation } from "../../daemon/conversation-registry.js";
20
21
  import {
21
22
  createResolveToolsCallback,
22
23
  DEFAULT_PREACTIVATED_SKILL_IDS,
23
- type SkillProjectionContext,
24
24
  } from "../../daemon/conversation-tool-setup.js";
25
25
  import {
26
26
  computeGatewayTarget,
@@ -487,8 +487,8 @@ function handleToolNamesList(
487
487
  * Resolve the tool surface a subagent would receive, identified either by a
488
488
  * role name (e.g. "researcher") or a live subagent id.
489
489
  *
490
- * For a role name: build a {@link SkillProjectionContext} matching what the
491
- * {@link SubagentManager} sets up at spawn time (isSubagent, allowedTools,
490
+ * For a role name: build a minimal {@link Conversation} stand-in matching what
491
+ * the {@link SubagentManager} sets up at spawn time (isSubagent, allowedTools,
492
492
  * preactivated skill ids, no client), then run the exact same
493
493
  * {@link createResolveToolsCallback} the real subagent uses to project tools
494
494
  * for its first turn. This ensures the diagnostic reports the same tool set
@@ -516,7 +516,9 @@ function handleAgentToolList(agent: string): ToolNamesListResponse {
516
516
  );
517
517
  }
518
518
 
519
- // Build the same context the SubagentManager creates at spawn time:
519
+ // Build the same context the SubagentManager creates at spawn time. The
520
+ // resolver reads only this subset of Conversation state, so a minimal
521
+ // stand-in cast to Conversation is enough:
520
522
  // - isSubagent: true (gates subagent-only tools like notify_parent)
521
523
  // - subagentAllowedTools: the role's allowlist (wire gate mode by default)
522
524
  // - preactivatedSkillIds: role skill ids merged with defaults
@@ -527,7 +529,7 @@ function handleAgentToolList(agent: string): ToolNamesListResponse {
527
529
  roleConfig.skillIds,
528
530
  DEFAULT_PREACTIVATED_SKILL_IDS,
529
531
  );
530
- const ctx: SkillProjectionContext = {
532
+ const ctx = {
531
533
  isSubagent: true,
532
534
  subagentAllowedTools: roleConfig.allowedTools
533
535
  ? new Set(roleConfig.allowedTools)
@@ -539,7 +541,7 @@ function handleAgentToolList(agent: string): ToolNamesListResponse {
539
541
  skillProjectionCache: {},
540
542
  hasNoClient: true,
541
543
  preactivatedSkillIds: mergedSkillIds,
542
- };
544
+ } as unknown as Conversation;
543
545
 
544
546
  // Run the real tool resolver — the same callback a subagent conversation
545
547
  // uses each turn. An empty message history simulates the first turn before
@@ -27,17 +27,15 @@
27
27
  * whose file does not exist 404s — nothing is registered ahead of time.
28
28
  */
29
29
 
30
- import { existsSync, statSync } from "node:fs";
31
- import { join, resolve } from "node:path";
30
+ import { statSync } from "node:fs";
32
31
 
33
- import { isPluginDisabled } from "../../plugins/disabled-state.js";
34
32
  import { getLogger } from "../../util/logger.js";
35
- import {
36
- getWorkspacePluginsDir,
37
- getWorkspaceRoutesDir,
38
- } from "../../util/platform.js";
39
33
  import type { AssistantEventHub } from "../assistant-event-hub.js";
40
34
  import { httpError } from "../http-errors.js";
35
+ import {
36
+ resolveHandlerFile,
37
+ resolveRouteLocation,
38
+ } from "./user-route-resolution.js";
41
39
 
42
40
  const log = getLogger("user-routes");
43
41
 
@@ -131,44 +129,6 @@ interface CachedModule {
131
129
  /** Default per-request timeout for user-defined route handlers (30 seconds). */
132
130
  const DEFAULT_HANDLER_TIMEOUT_MS = 30_000;
133
131
 
134
- /** Supported file extensions for handler modules. */
135
- const HANDLER_EXTENSIONS = [".ts", ".js"] as const;
136
-
137
- /** Path segment reserved for plugin-namespaced routes under `/x/`. */
138
- const PLUGIN_ROUTE_SEGMENT = "plugins";
139
-
140
- /**
141
- * Resolve an `/x/` route path to the base directory + sub-path the handler file
142
- * is looked up under.
143
- *
144
- * `plugins/<name>/<rest>` resolves against that plugin's own
145
- * `<workspaceDir>/plugins/<name>/routes/` directory (`<rest>` may be empty,
146
- * mapping to the namespace's `index` handler). Everything else resolves against
147
- * the workspace `routes/` directory. Returns `null` (caller 404s) when:
148
- *
149
- * - the path is a malformed plugin path (`plugins` with no name segment), so it
150
- * never falls back to a workspace route — the `plugins/` prefix is reserved
151
- * for plugin routes; or
152
- * - the named plugin is disabled (`.disabled` sentinel present), so a disabled
153
- * plugin serves no routes even though its files remain on disk.
154
- */
155
- function resolveRouteLocation(
156
- routePath: string,
157
- ): { routesDir: string; subPath: string } | null {
158
- const segments = routePath.split("/");
159
- if (segments[0] === PLUGIN_ROUTE_SEGMENT) {
160
- const pluginName = segments[1];
161
- if (!pluginName || isPluginDisabled(pluginName)) {
162
- return null;
163
- }
164
- return {
165
- routesDir: join(getWorkspacePluginsDir(), pluginName, "routes"),
166
- subPath: segments.slice(2).join("/"),
167
- };
168
- }
169
- return { routesDir: getWorkspaceRoutesDir(), subPath: routePath };
170
- }
171
-
172
132
  export class UserRouteDispatcher {
173
133
  private moduleCache = new Map<string, CachedModule>();
174
134
  private handlerTimeoutMs: number;
@@ -197,7 +157,7 @@ export class UserRouteDispatcher {
197
157
 
198
158
  const location = resolveRouteLocation(routePath);
199
159
  const filePath = location
200
- ? this.resolveHandlerFile(location.routesDir, location.subPath)
160
+ ? resolveHandlerFile(location.routesDir, location.subPath)
201
161
  : null;
202
162
 
203
163
  if (!filePath) {
@@ -223,46 +183,6 @@ export class UserRouteDispatcher {
223
183
  return this.executeHandler(handler, request, routePath);
224
184
  }
225
185
 
226
- /**
227
- * Resolve a route path to a handler file on disk.
228
- *
229
- * Checks for direct file matches first (`<path>.ts`, `<path>.js`),
230
- * then falls back to index files (`<path>/index.ts`, `<path>/index.js`).
231
- *
232
- * Returns the absolute path to the handler file, or null if not found.
233
- */
234
- private resolveHandlerFile(
235
- routesDir: string,
236
- routePath: string,
237
- ): string | null {
238
- const basePath = join(routesDir, routePath);
239
- const resolved = resolve(basePath);
240
-
241
- // Ensure the resolved path is within the routes directory to prevent
242
- // any path traversal that slipped through the initial check.
243
- if (!resolved.startsWith(resolve(routesDir))) {
244
- return null;
245
- }
246
-
247
- // Direct file match: routes/<path>.ts or routes/<path>.js
248
- for (const ext of HANDLER_EXTENSIONS) {
249
- const candidate = `${resolved}${ext}`;
250
- if (existsSync(candidate)) {
251
- return candidate;
252
- }
253
- }
254
-
255
- // Index file convention: routes/<path>/index.ts or routes/<path>/index.js
256
- for (const ext of HANDLER_EXTENSIONS) {
257
- const candidate = join(resolved, `index${ext}`);
258
- if (existsSync(candidate)) {
259
- return candidate;
260
- }
261
- }
262
-
263
- return null;
264
- }
265
-
266
186
  /**
267
187
  * Load a handler module, using the mtime-based cache when possible.
268
188
  *
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Shared path resolution for `/x/*` user routes.
3
+ *
4
+ * Both the request dispatcher (`user-route-dispatcher.ts`) and the CLI
5
+ * discovery surface (`user-routes-cli.ts`) resolve `/x/` paths to on-disk
6
+ * handler files through this module, so what the CLI lists can never drift
7
+ * from what the dispatcher actually serves.
8
+ *
9
+ * Two locations back the surface:
10
+ * - `<workspaceDir>/routes/<path>` — workspace routes, served at `/x/<path>`.
11
+ * - `<workspaceDir>/plugins/<name>/routes/<path>` — a plugin's routes, served
12
+ * in that plugin's reserved namespace at `/x/plugins/<name>/<path>`.
13
+ */
14
+
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
17
+
18
+ import { isPluginDisabled } from "../../plugins/disabled-state.js";
19
+ import {
20
+ getWorkspacePluginsDir,
21
+ getWorkspaceRoutesDir,
22
+ } from "../../util/platform.js";
23
+
24
+ /** Supported file extensions for handler modules (`.js` preferred over `.ts`). */
25
+ export const HANDLER_EXTENSIONS = [".ts", ".js"] as const;
26
+
27
+ /** Path segment reserved for plugin-namespaced routes under `/x/`. */
28
+ export const PLUGIN_ROUTE_SEGMENT = "plugins";
29
+
30
+ export interface RouteLocation {
31
+ /** Absolute directory the handler file is resolved under. */
32
+ routesDir: string;
33
+ /** Path within `routesDir`, relative and without the `/x/` prefix. */
34
+ subPath: string;
35
+ }
36
+
37
+ /**
38
+ * Resolve an `/x/` route path to the base directory + sub-path the handler file
39
+ * is looked up under.
40
+ *
41
+ * `plugins/<name>/<rest>` resolves against that plugin's own
42
+ * `<workspaceDir>/plugins/<name>/routes/` directory (`<rest>` may be empty,
43
+ * mapping to the namespace's `index` handler). Everything else resolves against
44
+ * the workspace `routes/` directory. Returns `null` (caller 404s) when:
45
+ *
46
+ * - the path is a malformed plugin path (`plugins` with no name segment), so it
47
+ * never falls back to a workspace route — the `plugins/` prefix is reserved
48
+ * for plugin routes; or
49
+ * - the named plugin is disabled (`.disabled` sentinel present), so a disabled
50
+ * plugin serves no routes even though its files remain on disk.
51
+ */
52
+ export function resolveRouteLocation(routePath: string): RouteLocation | null {
53
+ const segments = routePath.split("/");
54
+ if (segments[0] === PLUGIN_ROUTE_SEGMENT) {
55
+ const pluginName = segments[1];
56
+ if (!pluginName || isPluginDisabled(pluginName)) {
57
+ return null;
58
+ }
59
+ return {
60
+ routesDir: join(getWorkspacePluginsDir(), pluginName, "routes"),
61
+ subPath: segments.slice(2).join("/"),
62
+ };
63
+ }
64
+ return { routesDir: getWorkspaceRoutesDir(), subPath: routePath };
65
+ }
66
+
67
+ /**
68
+ * Resolve a sub-path within `routesDir` to a handler file on disk.
69
+ *
70
+ * Checks for direct file matches first (`<path>.ts`, `<path>.js`), then falls
71
+ * back to index files (`<path>/index.ts`, `<path>/index.js`). Returns the
72
+ * absolute path to the handler file, or `null` if not found. Rejects any path
73
+ * that escapes `routesDir` (traversal backstop).
74
+ */
75
+ export function resolveHandlerFile(
76
+ routesDir: string,
77
+ subPath: string,
78
+ ): string | null {
79
+ const resolved = resolve(join(routesDir, subPath));
80
+
81
+ if (!resolved.startsWith(resolve(routesDir))) {
82
+ return null;
83
+ }
84
+
85
+ for (const ext of HANDLER_EXTENSIONS) {
86
+ const candidate = `${resolved}${ext}`;
87
+ if (existsSync(candidate)) {
88
+ return candidate;
89
+ }
90
+ }
91
+
92
+ for (const ext of HANDLER_EXTENSIONS) {
93
+ const candidate = join(resolved, `index${ext}`);
94
+ if (existsSync(candidate)) {
95
+ return candidate;
96
+ }
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * True when a workspace `/x/<path>` collides with the reserved plugin prefix
104
+ * (`plugins` or `plugins/…`). Such a file lives under `<workspaceDir>/routes/`
105
+ * but is shadowed by the plugin namespace and never served, so discovery must
106
+ * exclude it — {@link resolveRouteLocation} routes the same path to a plugin
107
+ * directory instead.
108
+ */
109
+ export function isReservedWorkspaceRoutePath(routePath: string): boolean {
110
+ return (
111
+ routePath === PLUGIN_ROUTE_SEGMENT ||
112
+ routePath.startsWith(`${PLUGIN_ROUTE_SEGMENT}/`)
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Enumerate enabled workspace plugins that ship a `routes/` directory, for
118
+ * route discovery. Mirrors {@link resolveRouteLocation}'s plugin resolution —
119
+ * same base directory, same disabled-sentinel gate — so discovery and dispatch
120
+ * agree on which plugin routes exist.
121
+ */
122
+ export function listPluginRouteRoots(): {
123
+ pluginName: string;
124
+ routesDir: string;
125
+ }[] {
126
+ const pluginsDir = getWorkspacePluginsDir();
127
+ if (!existsSync(pluginsDir)) {
128
+ return [];
129
+ }
130
+ const roots: { pluginName: string; routesDir: string }[] = [];
131
+ for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) {
132
+ if (!entry.isDirectory() || isPluginDisabled(entry.name)) {
133
+ continue;
134
+ }
135
+ const routesDir = join(pluginsDir, entry.name, "routes");
136
+ if (existsSync(routesDir) && statSync(routesDir).isDirectory()) {
137
+ roots.push({ pluginName: entry.name, routesDir });
138
+ }
139
+ }
140
+ return roots;
141
+ }