@vellumai/assistant 0.12.0-staging.1 → 0.12.0

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 (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Route handlers for the activation checklist.
3
+ *
4
+ * GET /v1/activation/progress : read the progress resource
5
+ * POST /v1/activation/tasks/:taskId/start : link a task to a conversation
6
+ * POST /v1/activation/dismiss : record a dismissed surface
7
+ *
8
+ * Every route returns the full progress resource so a client never has to
9
+ * follow a write with a read. The task catalog itself is client-side: the
10
+ * daemon only stores opaque task and list identifiers.
11
+ */
12
+
13
+ import type { z } from "zod";
14
+
15
+ import {
16
+ dismissActivation,
17
+ readActivationProgress,
18
+ startActivationTask,
19
+ } from "../../activation/progress-store.js";
20
+ import {
21
+ ActivationDismissRequestSchema,
22
+ ActivationProgressSchema,
23
+ ActivationTaskStartRequestSchema,
24
+ } from "../../api/responses/activation.js";
25
+ import { getConversation } from "../../persistence/conversation-crud.js";
26
+ import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
27
+ import { getOriginClientId } from "../sync/resource-sync-events.js";
28
+ import { BadRequestError, NotFoundError } from "./errors.js";
29
+ import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
30
+
31
+ function parseBody<S extends z.ZodType>(
32
+ schema: S,
33
+ body: Record<string, unknown> | undefined,
34
+ ): z.infer<S> {
35
+ const parsed = schema.safeParse(body ?? {});
36
+ if (!parsed.success) {
37
+ throw new BadRequestError(
38
+ `Invalid activation request body: ${parsed.error.issues[0]?.message ?? "unknown field"}`,
39
+ );
40
+ }
41
+ return parsed.data;
42
+ }
43
+
44
+ function handleGetActivationProgress() {
45
+ return readActivationProgress();
46
+ }
47
+
48
+ /**
49
+ * Link a task to the conversation its prompt is being sent to.
50
+ *
51
+ * The conversation is looked up the same way `POST /v1/messages` looks up an
52
+ * explicit `conversationId`, and a miss is the same 404, because the two
53
+ * calls are two halves of one launch: a link recorded against a row that no
54
+ * longer exists leaves the task stuck on Working with an action that opens
55
+ * nothing, while the send that follows fails anyway. Answering 404 instead
56
+ * tells the client the link was refused outright, which is the one answer
57
+ * that lets it take its freshly created conversation back.
58
+ *
59
+ * The store asks the same question again inside its lock, because the answer
60
+ * can change while a contended mutation waits its turn: a deletion that
61
+ * lands in that window has already run its own activation cleanup, so a link
62
+ * written afterwards is the one nothing will ever clear. The check here is
63
+ * the fast path that answers without taking a lock at all.
64
+ */
65
+ async function handleStartActivationTask({
66
+ pathParams = {},
67
+ body,
68
+ headers,
69
+ }: RouteHandlerArgs) {
70
+ const { conversationId, listId } = parseBody(
71
+ ActivationTaskStartRequestSchema,
72
+ body,
73
+ );
74
+ const conversationExists = () => getConversation(conversationId) !== null;
75
+ if (!conversationExists()) {
76
+ throw new NotFoundError(`Conversation ${conversationId} not found`);
77
+ }
78
+ const originClientId = getOriginClientId(headers);
79
+ return startActivationTask({
80
+ taskId: pathParams.taskId ?? "",
81
+ conversationId,
82
+ verify: conversationExists,
83
+ ...(listId !== undefined ? { listId } : {}),
84
+ ...(originClientId !== undefined ? { originClientId } : {}),
85
+ });
86
+ }
87
+
88
+ async function handleDismissActivation({ body, headers }: RouteHandlerArgs) {
89
+ const { kind, listId } = parseBody(ActivationDismissRequestSchema, body);
90
+ const originClientId = getOriginClientId(headers);
91
+ return dismissActivation({
92
+ kind,
93
+ ...(listId !== undefined ? { listId } : {}),
94
+ ...(originClientId !== undefined ? { originClientId } : {}),
95
+ });
96
+ }
97
+
98
+ export const ROUTES: RouteDefinition[] = [
99
+ {
100
+ operationId: "activation_progress_get",
101
+ endpoint: "activation/progress",
102
+ method: "GET",
103
+ policy: {
104
+ requiredScopes: ["chat.read"],
105
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
106
+ },
107
+ summary: "Get activation progress",
108
+ description:
109
+ "Return the activation checklist progress: the frozen list, dismissed surfaces, and per-task state.",
110
+ tags: ["activation"],
111
+ responseBody: ActivationProgressSchema,
112
+ handler: handleGetActivationProgress,
113
+ },
114
+ {
115
+ operationId: "activation_task_start_post",
116
+ endpoint: "activation/tasks/:taskId/start",
117
+ method: "POST",
118
+ policy: {
119
+ requiredScopes: ["chat.write"],
120
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
121
+ },
122
+ summary: "Start an activation task",
123
+ description:
124
+ "Link an activation task to the conversation its prompt was sent to. Idempotent per task; a different conversation replaces the link only while the task is not done.",
125
+ tags: ["activation"],
126
+ pathParams: [
127
+ { name: "taskId", description: "Catalog id of the launched task" },
128
+ ],
129
+ requestBody: ActivationTaskStartRequestSchema,
130
+ responseBody: ActivationProgressSchema,
131
+ handler: handleStartActivationTask,
132
+ additionalResponses: {
133
+ "400": { description: "Malformed task id, list id, or conversation id" },
134
+ "404": { description: "No such conversation; the link was not recorded" },
135
+ "409": {
136
+ description:
137
+ "Stored progress was written by a newer build; the link was not recorded",
138
+ },
139
+ "503": {
140
+ description:
141
+ "Stored progress is locked by another process; the link was not recorded",
142
+ },
143
+ },
144
+ },
145
+ {
146
+ operationId: "activation_dismiss_post",
147
+ endpoint: "activation/dismiss",
148
+ method: "POST",
149
+ policy: {
150
+ requiredScopes: ["chat.write"],
151
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
152
+ },
153
+ summary: "Dismiss an activation surface",
154
+ description:
155
+ "Record that the welcome modal or the celebration modal was dismissed.",
156
+ tags: ["activation"],
157
+ requestBody: ActivationDismissRequestSchema,
158
+ responseBody: ActivationProgressSchema,
159
+ handler: handleDismissActivation,
160
+ additionalResponses: {
161
+ "400": { description: "Malformed dismiss kind or list id" },
162
+ "409": {
163
+ description:
164
+ "Stored progress was written by a newer build; the dismissal was not recorded",
165
+ },
166
+ "503": {
167
+ description:
168
+ "Stored progress is locked by another process; the dismissal was not recorded",
169
+ },
170
+ },
171
+ },
172
+ ];
@@ -59,7 +59,12 @@ import {
59
59
  invalidateConnectionsAfterCredentialDelete,
60
60
  } from "./credential-in-use.js";
61
61
  import { InjectionTemplateSchema } from "./credential-prompt-routes.js";
62
- import { BadRequestError, InternalError } from "./errors.js";
62
+ import { BadRequestError, ForbiddenError, InternalError } from "./errors.js";
63
+ import {
64
+ isPlatformManagedCredential,
65
+ type PlatformManagedCredentialAction,
66
+ platformManagedCredentialRefusal,
67
+ } from "./platform-managed-credentials.js";
63
68
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
64
69
 
65
70
  // ---------------------------------------------------------------------------
@@ -157,7 +162,12 @@ interface CredentialLookup {
157
162
 
158
163
  /**
159
164
  * Resolve a credential lookup from service+field or UUID.
160
- * Throws BadRequestError when neither is provided or the UUID is not found.
165
+ * Throws BadRequestError when neither is provided or the UUID is not found,
166
+ * and ForbiddenError for a credential the platform owns.
167
+ *
168
+ * Every read handler that returns credential material (inspect, reveal)
169
+ * resolves through here, so the platform-managed refusal sits at the single
170
+ * point they share and cannot drift apart from the list filter.
161
171
  */
162
172
  function resolveCredentialLookup(
163
173
  body: Record<string, unknown>,
@@ -169,6 +179,7 @@ function resolveCredentialLookup(
169
179
  };
170
180
 
171
181
  if (service && field) {
182
+ assertNotPlatformManaged(service, field, "read");
172
183
  return {
173
184
  storageKey: credentialKey(service, field),
174
185
  metadata: getCredentialMetadata(service, field),
@@ -182,6 +193,7 @@ function resolveCredentialLookup(
182
193
  if (!metadata) {
183
194
  throw new BadRequestError("Credential not found");
184
195
  }
196
+ assertNotPlatformManaged(metadata.service, metadata.field, "read");
185
197
  return {
186
198
  storageKey: credentialKey(metadata.service, metadata.field),
187
199
  metadata,
@@ -193,6 +205,29 @@ function resolveCredentialLookup(
193
205
  throw new BadRequestError("Either service+field or id is required");
194
206
  }
195
207
 
208
+ /**
209
+ * Refuse any use of a credential the platform provisions for itself, whatever
210
+ * the calling principal. Reads are refused because the API key spends
211
+ * inference on Vellum's account. Writes are refused because they are a read
212
+ * by another name: repointing `vellum:platform_base_url` sends the next
213
+ * platform call, bearing that same key, to a host of the writer's choosing.
214
+ *
215
+ * The platform's own provisioning does not come through here. It writes over
216
+ * `POST /v1/secrets` (Django to vembda to the pod), as does the CLI and the
217
+ * local-mode connect flow in the web client.
218
+ */
219
+ function assertNotPlatformManaged(
220
+ service: string,
221
+ field: string,
222
+ action: PlatformManagedCredentialAction,
223
+ ): void {
224
+ if (isPlatformManagedCredential(service, field)) {
225
+ throw new ForbiddenError(
226
+ platformManagedCredentialRefusal(service, field, action),
227
+ );
228
+ }
229
+ }
230
+
196
231
  // ---------------------------------------------------------------------------
197
232
  // Handlers
198
233
  // ---------------------------------------------------------------------------
@@ -200,7 +235,11 @@ function resolveCredentialLookup(
200
235
  async function handleCredentialsList({ body }: RouteHandlerArgs) {
201
236
  const search = (body as { search?: string } | undefined)?.search;
202
237
 
203
- let allMetadata = listCredentialMetadata();
238
+ // Platform-provisioned credentials are not the user's to act on, so they
239
+ // never become a Settings row (and never a click-to-reveal).
240
+ let allMetadata = listCredentialMetadata().filter(
241
+ (m) => !isPlatformManagedCredential(m.service, m.field),
242
+ );
204
243
 
205
244
  if (search) {
206
245
  const query = search.toLowerCase();
@@ -453,6 +492,8 @@ async function handleCredentialsSet({ body }: RouteHandlerArgs) {
453
492
  throw new BadRequestError("value is required");
454
493
  }
455
494
 
495
+ assertNotPlatformManaged(service, field, "change");
496
+
456
497
  try {
457
498
  return await storeCredentialValue({
458
499
  service,
@@ -496,6 +537,8 @@ async function handleCredentialsDelete({ body }: RouteHandlerArgs) {
496
537
  throw new BadRequestError("field is required");
497
538
  }
498
539
 
540
+ assertNotPlatformManaged(service, field, "change");
541
+
499
542
  assertMetadataWritable();
500
543
 
501
544
  // The Slack user_token only grants read access to channels the bot isn't a
@@ -17,6 +17,7 @@ import { ROUTES as MEMORY_V3_ROUTES } from "../../plugins/defaults/memory/src/me
17
17
  import { ROUTES as MEMORY_WORKER_ROUTES } from "../../plugins/defaults/memory/src/memory-worker-routes.js";
18
18
  import { ROUTES as ACP_CLAUDE_AUTH_ROUTES } from "./acp-claude-auth-routes.js";
19
19
  import { ROUTES as ACP_ROUTES } from "./acp-routes.js";
20
+ import { ROUTES as ACTIVATION_ROUTES } from "./activation-routes.js";
20
21
  import { ROUTES as APP_MANAGEMENT_ROUTES } from "./app-management-routes.js";
21
22
  import { ROUTES as APP_ROUTES } from "./app-routes.js";
22
23
  import { ROUTES as APPROVAL_ROUTES } from "./approval-routes.js";
@@ -213,6 +214,7 @@ export const ROUTES: RouteDefinition[] = [
213
214
  ...CONVERSATION_COMPACTION_ROUTES,
214
215
  ...CONVERSATION_QUERY_ROUTES,
215
216
  ...CONVERSATION_STARTER_ROUTES,
217
+ ...ACTIVATION_ROUTES,
216
218
  ...DEBUG_BASH_ROUTES,
217
219
  ...DEBUG_ROUTES,
218
220
  ...DEFAULT_PROVIDER_ROUTES,
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
9
 
10
+ import { channelForBotProvider } from "@vellumai/service-contracts/channels";
11
+
10
12
  import {
11
13
  getConfig,
12
14
  loadRawConfig,
@@ -19,7 +21,10 @@ import {
19
21
  ServicesSchema,
20
22
  } from "../../config/schemas/services.js";
21
23
  import type { OAuthConnectionRequest } from "../../oauth/connection.js";
22
- import { isBinaryOAuthBody, jsonSafeOAuthBody } from "../../oauth/connection.js";
24
+ import {
25
+ isBinaryOAuthBody,
26
+ jsonSafeOAuthBody,
27
+ } from "../../oauth/connection.js";
23
28
  import {
24
29
  resolveOAuthConnection,
25
30
  type ResolveOAuthConnectionOptions,
@@ -966,13 +971,21 @@ export async function handleRequest({ body = {} }: RouteHandlerArgs) {
966
971
  }
967
972
 
968
973
  if (response.status === 401 || response.status === 403) {
969
- result.hint = managed
970
- ? `Request returned HTTP ${response.status}. The OAuth token may be expired or revoked.\n\n` +
971
- `Run 'assistant oauth status ${b.provider}' to check connection health.\n` +
972
- `To reconnect, run 'assistant oauth connect --help'.`
973
- : `Request returned HTTP ${response.status}. The OAuth token may be expired or revoked.\n\n` +
974
- `Run 'assistant oauth status ${b.provider}' to check connection status.\n` +
975
- `To reconnect, run 'assistant oauth connect --help'.`;
974
+ // The recovery steps follow the credential's kind, not the door the
975
+ // request came through: a channel bot's token was stored by the channel's
976
+ // setup, so the OAuth status and connect commands cannot repair it.
977
+ const botChannel = channelForBotProvider(b.provider);
978
+ result.hint = botChannel
979
+ ? `Request returned HTTP ${response.status}. The ${botChannel} bot credential was rejected; it may have been revoked or reinstalled with fewer scopes.\n\n` +
980
+ `Run 'assistant channels get ${botChannel}' to re-probe the channel and see what it reports.\n` +
981
+ `To reconnect, run the channel's setup skill again.`
982
+ : managed
983
+ ? `Request returned HTTP ${response.status}. The OAuth token may be expired or revoked.\n\n` +
984
+ `Run 'assistant oauth status ${b.provider}' to check connection health.\n` +
985
+ `To reconnect, run 'assistant oauth connect --help'.`
986
+ : `Request returned HTTP ${response.status}. The OAuth token may be expired or revoked.\n\n` +
987
+ `Run 'assistant oauth status ${b.provider}' to check connection status.\n` +
988
+ `To reconnect, run 'assistant oauth connect --help'.`;
976
989
  } else if (response.status === 404 && isHtmlResponse(response.headers)) {
977
990
  // An HTML 404 (rather than a JSON API error) is the signature of a request
978
991
  // reaching a valid host but a path that host does not serve — e.g. a
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Credentials the Vellum platform provisions onto a managed assistant pod for
3
+ * its own use: the API key the daemon authenticates to the managed LLM proxy
4
+ * with, and the base URL that proxy lives at.
5
+ *
6
+ * The platform writes them over `POST /v1/secrets`, and the daemon, the
7
+ * gateway, and CES read them straight out of the vault. The user never sets
8
+ * them through the credentials API and can do nothing useful with them there,
9
+ * so that API treats the pair as neither readable nor writable: omitted from
10
+ * listings, and refused by inspect, reveal, set, and delete, whatever the
11
+ * calling principal.
12
+ *
13
+ * The two fields travel together because they are one capability. The API key
14
+ * spends inference on Vellum's account, and the base URL decides which host
15
+ * receives it: the gateway resolves the platform host from
16
+ * `vellum:platform_base_url` (see `gateway/src/platform-url.ts`) and then
17
+ * sends `vellum:assistant_api_key` to it as the bearer. Leaving the URL
18
+ * writable while hiding the key would hand back the same key by another
19
+ * route, pointed at a host of the writer's choosing.
20
+ */
21
+ const MANAGED_PROXY_CREDENTIALS = [
22
+ { service: "vellum", field: "assistant_api_key" },
23
+ { service: "vellum", field: "platform_base_url" },
24
+ ] as const;
25
+
26
+ export function isPlatformManagedCredential(
27
+ service: string,
28
+ field: string,
29
+ ): boolean {
30
+ return MANAGED_PROXY_CREDENTIALS.some(
31
+ (c) => c.service === service && c.field === field,
32
+ );
33
+ }
34
+
35
+ /** What a caller was trying to do with a platform-managed credential. */
36
+ export type PlatformManagedCredentialAction = "read" | "change";
37
+
38
+ /** Message the credentials API refuses one of these with. */
39
+ export function platformManagedCredentialRefusal(
40
+ service: string | undefined,
41
+ field: string | undefined,
42
+ action: PlatformManagedCredentialAction,
43
+ ): string {
44
+ const name = service && field ? `${service}:${field}` : "This credential";
45
+ const verb =
46
+ action === "read" ? "inspected or revealed" : "changed or deleted";
47
+ return `${name} is provisioned and owned by the Vellum platform. It cannot be ${verb} through the credentials API.`;
48
+ }
@@ -71,19 +71,10 @@ import {
71
71
  NotFoundError,
72
72
  RouteError,
73
73
  } from "./errors.js";
74
+ import { isPlatformManagedCredential } from "./platform-managed-credentials.js";
74
75
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
75
76
 
76
77
  const log = getLogger("runtime-http");
77
- const MANAGED_PROXY_CREDENTIALS = [
78
- { service: "vellum", field: "assistant_api_key" },
79
- { service: "vellum", field: "platform_base_url" },
80
- ] as const;
81
-
82
- function isManagedProxyCredential(service: string, field: string): boolean {
83
- return MANAGED_PROXY_CREDENTIALS.some(
84
- (c) => c.service === service && c.field === field,
85
- );
86
- }
87
78
 
88
79
  const CES_READY_POLL_INTERVAL_MS = 500;
89
80
  const CES_READY_POLL_TIMEOUT_MS = 30_000;
@@ -401,7 +392,7 @@ async function handleAddSecret({ body }: RouteHandlerArgs) {
401
392
  setPlatformUserId(effectiveValue || undefined);
402
393
  }
403
394
  }
404
- if (isManagedProxyCredential(service, field)) {
395
+ if (isPlatformManagedCredential(service, field)) {
405
396
  await refreshProvidersAfterSecretChange();
406
397
  // Close the first-boot race where the startup capability seed ran before
407
398
  // the managed embedding credential was provisioned, leaving skill/CLI
@@ -645,7 +636,7 @@ async function handleDeleteSecret({ body }: RouteHandlerArgs) {
645
636
  if (service === "vellum" && field === "platform_user_id") {
646
637
  setPlatformUserId(undefined);
647
638
  }
648
- if (isManagedProxyCredential(service, field)) {
639
+ if (isPlatformManagedCredential(service, field)) {
649
640
  await refreshProvidersAfterSecretChange();
650
641
  }
651
642
  invalidateConnectionsAfterCredentialDelete(affectedConnections);
@@ -22,8 +22,11 @@
22
22
  */
23
23
 
24
24
  import { existsSync, readdirSync, statSync } from "node:fs";
25
+ import { posix } from "node:path";
25
26
  import { join, relative, resolve, sep } from "node:path";
26
27
 
28
+ import { PLUGIN_NOTICES_ROUTE_PREFIX } from "@vellumai/gateway-client";
29
+
27
30
  import {
28
31
  getDefaultPluginRouteRoots,
29
32
  getDefaultPluginRoutesDir,
@@ -197,6 +200,24 @@ export function isReservedWorkspaceRoutePath(routePath: string): boolean {
197
200
  );
198
201
  }
199
202
 
203
+ /**
204
+ * True when an `/x/` route path lands in a plugin's `notices/` namespace, the
205
+ * namespace root included.
206
+ *
207
+ * Judged on the path as the handler lookup will see it, not as it was
208
+ * spelled: the lookup joins the path onto the routes directory, which folds
209
+ * doubled slashes and `.` segments, and a case-insensitive filesystem serves
210
+ * `Notices/x` from `notices/x.ts`. Any spelling that resolves into the
211
+ * namespace counts, so the reservation cannot be sidestepped by respelling.
212
+ */
213
+ export function isPluginNoticeRoutePath(routePath: string): boolean {
214
+ const segments = posix.normalize(routePath.replace(/^\/+/, "")).split("/");
215
+ return (
216
+ segments[0] === PLUGIN_ROUTE_SEGMENT &&
217
+ segments[2]?.toLowerCase() === PLUGIN_NOTICES_ROUTE_PREFIX
218
+ );
219
+ }
220
+
200
221
  /**
201
222
  * Enumerate enabled plugins that ship a `routes/` directory, for route
202
223
  * discovery. Mirrors {@link resolveRouteLocation}'s plugin resolution — same
@@ -15,10 +15,17 @@
15
15
  * over both HTTP and IPC.
16
16
  */
17
17
 
18
- import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
18
+ import { PLUGIN_NOTICES_ROUTE_PREFIX } from "@vellumai/gateway-client";
19
+
20
+ import { ACTOR_PRINCIPALS, GATEWAY_PRINCIPALS } from "../auth/route-policy.js";
21
+ import { httpError } from "../http-errors.js";
19
22
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
20
23
  import { IDENTITY_HEADERS, RouteResponse } from "./types.js";
21
24
  import { UserRouteDispatcher } from "./user-route-dispatcher.js";
25
+ import {
26
+ isPluginNoticeRoutePath,
27
+ PLUGIN_ROUTE_SEGMENT,
28
+ } from "./user-route-resolution.js";
22
29
 
23
30
  const dispatcher = new UserRouteDispatcher();
24
31
 
@@ -156,7 +163,88 @@ const METHODS = [
156
163
  "OPTIONS",
157
164
  ] as const;
158
165
 
159
- export const ROUTES: RouteDefinition[] = METHODS.map((method) => ({
166
+ /**
167
+ * Serve one request from the handler file at `/x/<routePath>`.
168
+ *
169
+ * The matched path is written back as the single `path` param before the
170
+ * request is synthesized, so a handler sees the same URL whichever route
171
+ * definition matched and whichever transport carried the request (the IPC
172
+ * path rebuilds the URL from that param; see {@link reconstructUrl}).
173
+ */
174
+ async function serveUserRoute(
175
+ method: string,
176
+ args: RouteHandlerArgs,
177
+ routePath: string,
178
+ ): Promise<RouteResponse> {
179
+ const request = synthesizeRequest(method, {
180
+ ...args,
181
+ pathParams: { path: routePath },
182
+ });
183
+ const response = await dispatcher.dispatch(routePath, request);
184
+ return decomposeResponse(response);
185
+ }
186
+
187
+ /**
188
+ * A plugin's `notices/` routes, served to the gateway alone.
189
+ *
190
+ * The gateway posts a notice there when it has decided something on the
191
+ * plugin's behalf and the plugin has to act on it with its own vendor
192
+ * credentials, without running a turn (`PLUGIN_ADMISSION_DENIED_NOTICE_PATH`
193
+ * is the first). An actor client reaching the same path would hand the plugin
194
+ * a forged decision, so the whole prefix takes the gateway's service
195
+ * principal only, for every method.
196
+ *
197
+ * Listed ahead of the catch-all below: the HTTP router and the gateway's IPC
198
+ * proxy both take the first definition whose pattern matches, so this policy
199
+ * only holds while these come first. Two patterns, because a catch-all param
200
+ * needs at least one character and the namespace root (`routes/notices.ts`,
201
+ * `routes/notices/index.ts`) is a handler too.
202
+ *
203
+ * These patterns match the request as spelled, and only the plain spelling.
204
+ * A percent-encoded, doubled-slash, `.`-segment or case-folded spelling of
205
+ * the same path reaches the catch-all instead, which is why the catch-all
206
+ * refuses the namespace outright (see {@link isPluginNoticeRoutePath}).
207
+ */
208
+ const PLUGIN_NOTICE_ROUTES: RouteDefinition[] = METHODS.flatMap((method) => {
209
+ const policy: RouteDefinition["policy"] = {
210
+ requiredScopes: ["internal.write"],
211
+ allowedPrincipalTypes: GATEWAY_PRINCIPALS,
212
+ };
213
+ const namespace = `x/${PLUGIN_ROUTE_SEGMENT}/:plugin/${PLUGIN_NOTICES_ROUTE_PREFIX}`;
214
+ const routePath = (args: RouteHandlerArgs, rest?: string) =>
215
+ [
216
+ PLUGIN_ROUTE_SEGMENT,
217
+ args.pathParams?.plugin ?? "",
218
+ PLUGIN_NOTICES_ROUTE_PREFIX,
219
+ ...(rest === undefined ? [] : [rest]),
220
+ ].join("/");
221
+ return [
222
+ {
223
+ operationId: `plugin_notice_root_${method.toLowerCase()}`,
224
+ endpoint: namespace,
225
+ method,
226
+ summary: `Gateway ${method} notice to a plugin (namespace root)`,
227
+ description: `Dispatches ${method} requests to the root handler of a plugin's reserved ${PLUGIN_NOTICES_ROUTE_PREFIX}/ namespace. Served to the gateway's service principal only: a notice is the gateway's own decision, never a client's.`,
228
+ tags: ["user-routes"],
229
+ policy,
230
+ handler: (args: RouteHandlerArgs) =>
231
+ serveUserRoute(method, args, routePath(args)),
232
+ },
233
+ {
234
+ operationId: `plugin_notice_route_${method.toLowerCase()}`,
235
+ endpoint: `${namespace}/:path*`,
236
+ method,
237
+ summary: `Gateway ${method} notice to a plugin`,
238
+ description: `Dispatches ${method} requests under a plugin's reserved ${PLUGIN_NOTICES_ROUTE_PREFIX}/ namespace to that plugin's handler files. Served to the gateway's service principal only: a notice is the gateway's own decision, never a client's.`,
239
+ tags: ["user-routes"],
240
+ policy,
241
+ handler: (args: RouteHandlerArgs) =>
242
+ serveUserRoute(method, args, routePath(args, args.pathParams?.path)),
243
+ },
244
+ ];
245
+ });
246
+
247
+ const USER_ROUTES: RouteDefinition[] = METHODS.map((method) => ({
160
248
  operationId: `user_route_${method.toLowerCase()}`,
161
249
  endpoint: "x/:path*",
162
250
  method,
@@ -167,12 +255,27 @@ export const ROUTES: RouteDefinition[] = METHODS.map((method) => ({
167
255
  requiredScopes: ["settings.read"],
168
256
  allowedPrincipalTypes: ACTOR_PRINCIPALS,
169
257
  },
170
- handler: async (args: RouteHandlerArgs) => {
171
- const request = synthesizeRequest(method, args);
172
- const response = await dispatcher.dispatch(
173
- args.pathParams?.path ?? "",
174
- request,
175
- );
176
- return decomposeResponse(response);
258
+ handler: (args: RouteHandlerArgs) => {
259
+ const routePath = args.pathParams?.path ?? "";
260
+ if (isPluginNoticeRoutePath(routePath)) {
261
+ // Another spelling of a reserved path. The notice definitions above are
262
+ // the only ones that serve the namespace, and they saw a request that
263
+ // did not match them, so this is refused rather than dispatched under
264
+ // the actor policy. Same answer the policy check gives the plain
265
+ // spelling, so a prober learns nothing from the respelling.
266
+ return decomposeResponse(
267
+ httpError(
268
+ "FORBIDDEN",
269
+ "Principal type not permitted for this endpoint",
270
+ 403,
271
+ ),
272
+ );
273
+ }
274
+ return serveUserRoute(method, args, routePath);
177
275
  },
178
276
  }));
277
+
278
+ export const ROUTES: RouteDefinition[] = [
279
+ ...PLUGIN_NOTICE_ROUTES,
280
+ ...USER_ROUTES,
281
+ ];