@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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
?
|
|
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
|
+
}
|