@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.
- package/Dockerfile +5 -0
- package/docs/architecture/turn-actor.md +9 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/app-icons/package.json +18 -0
- package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
- package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
- package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/openapi.yaml +628 -0
- package/package.json +3 -1
- package/scripts/smoke-container-workspace-dependencies.ts +19 -0
- package/src/__tests__/app-builder-icon-names.test.ts +29 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/conversation-attachments.test.ts +142 -0
- package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
- package/src/__tests__/conversation-event-sink.test.ts +50 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
- package/src/__tests__/conversation-queue.test.ts +297 -0
- package/src/__tests__/credential-routes.test.ts +185 -6
- package/src/__tests__/drain-kick-guard.test.ts +2 -0
- package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
- package/src/__tests__/messaging-send-tool.test.ts +42 -0
- package/src/__tests__/oauth-commands-routes.test.ts +47 -0
- package/src/__tests__/subagent-manager-notify.test.ts +25 -4
- package/src/activation/progress-store.test.ts +1564 -0
- package/src/activation/progress-store.ts +1296 -0
- package/src/activation/turn-hooks.test.ts +246 -0
- package/src/activation/turn-hooks.ts +126 -0
- package/src/api/events/subagent-status-changed.ts +7 -5
- package/src/api/responses/activation.ts +136 -0
- package/src/apps/app-store.ts +8 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
- package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
- package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
- package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
- package/src/cli/commands/channels/index.help.ts +70 -18
- package/src/cli/commands/channels/index.ts +17 -6
- package/src/cli/commands/channels/request.ts +66 -0
- package/src/cli/commands/oauth/request.test.ts +2 -0
- package/src/cli/commands/oauth/request.ts +227 -186
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
- package/src/config/feature-flag-registry.json +35 -5
- package/src/daemon/assistant-attachments.ts +11 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
- package/src/daemon/conversation-agent-loop.ts +71 -3
- package/src/daemon/conversation-attachments.ts +42 -1
- package/src/daemon/conversation-event-sink.ts +25 -0
- package/src/daemon/conversation-process.ts +69 -21
- package/src/daemon/conversation-store.ts +4 -3
- package/src/daemon/conversation-surfaces.ts +19 -4
- package/src/daemon/message-types/sync.ts +2 -0
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
- package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
- package/src/notifications/conversation-pairing.ts +47 -5
- package/src/notifications/delivered-post-record.ts +3 -0
- package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
- package/src/persistence/conversation-crud.ts +39 -0
- package/src/persistence/delivery-crud.ts +13 -0
- package/src/plugins/AGENTS.md +1 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
- package/src/runtime/routes/activation-routes.test.ts +415 -0
- package/src/runtime/routes/activation-routes.ts +172 -0
- package/src/runtime/routes/credential-routes.ts +46 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/oauth-commands-routes.ts +21 -8
- package/src/runtime/routes/platform-managed-credentials.ts +48 -0
- package/src/runtime/routes/secret-routes.ts +3 -12
- package/src/runtime/routes/user-route-resolution.ts +21 -0
- package/src/runtime/routes/user-routes.ts +112 -9
- package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
- package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
- package/src/runtime/sync/resource-sync-events.ts +23 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
- package/src/runtime/sync/worker-daemon-notify.ts +39 -1
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
`
|
|
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 (
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
request
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
];
|