@vellumai/assistant 0.12.6 → 0.12.7-staging.1
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/.env.example +2 -0
- package/ARCHITECTURE.md +14 -14
- package/docs/architecture/integrations.md +5 -5
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
- package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
- package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
- package/openapi.yaml +153 -5
- package/package.json +1 -1
- package/src/__tests__/channel-policy.test.ts +7 -0
- package/src/__tests__/conversation-routes-disk-view.test.ts +141 -2
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
- package/src/__tests__/openai-provider.test.ts +27 -0
- package/src/__tests__/skill-execute-input.test.ts +11 -0
- package/src/__tests__/tool-approval-handler.test.ts +4 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
- package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
- package/src/agent/loop.ts +133 -12
- package/src/calls/__tests__/call-controller.test.ts +273 -1
- package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
- package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-controller.ts +359 -25
- package/src/calls/media-stream-server.ts +18 -0
- package/src/calls/media-stream-stt-session.ts +30 -9
- package/src/channels/config.ts +8 -0
- package/src/cli/lib/bundled-marketplace.json +39 -0
- package/src/config/composer-preferences.test.ts +97 -0
- package/src/config/composer-preferences.ts +95 -0
- package/src/config/feature-flag-registry.json +16 -8
- package/src/config/schemas/llm.ts +1 -0
- package/src/contacts/types.ts +1 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
- package/src/daemon/conversation-agent-loop.ts +3 -0
- package/src/daemon/conversation-messaging.ts +4 -0
- package/src/daemon/conversation-process.ts +15 -0
- package/src/daemon/conversation-queue-manager.ts +2 -0
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/handlers/shared.ts +13 -2
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/process-message.ts +5 -0
- package/src/i18n/__tests__/i18n.test.ts +25 -0
- package/src/i18n/assistant-text.ts +18 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/messages.ts +11 -0
- package/src/ipc/routes/__tests__/route-adapter.test.ts +40 -0
- package/src/ipc/routes/route-adapter.ts +9 -0
- package/src/notifications/README.md +1 -1
- package/src/notifications/signal.ts +4 -0
- package/src/persistence/conversation-key-store.ts +12 -11
- package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
- package/src/providers/inference/adapter-factory.ts +6 -0
- package/src/providers/ionet/client.test.ts +143 -0
- package/src/providers/ionet/client.ts +37 -0
- package/src/providers/model-catalog.ts +40 -0
- package/src/providers/openai/chat-completions-provider.ts +33 -2
- package/src/providers/retry.ts +10 -4
- package/src/providers/unparseable-tool-args.ts +29 -5
- package/src/runtime/__tests__/http-router-trust.test.ts +360 -0
- package/src/runtime/__tests__/shared-principal-lookup.test.ts +346 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -1
- package/src/runtime/auth/__tests__/route-policy.test.ts +85 -1
- package/src/runtime/auth/__tests__/scopes.test.ts +36 -0
- package/src/runtime/auth/route-policy.ts +44 -0
- package/src/runtime/auth/scopes.ts +15 -0
- package/src/runtime/auth/types.ts +1 -0
- package/src/runtime/channel-reply-delivery.ts +2 -0
- package/src/runtime/http-router.ts +61 -1
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
- package/src/runtime/migrations/vbundle-builder.ts +19 -3
- package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
- package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
- package/src/runtime/migrations/vbundle-validator.ts +54 -1
- package/src/runtime/routes/composer-settings-routes.test.ts +71 -0
- package/src/runtime/routes/composer-settings-routes.ts +76 -0
- package/src/runtime/routes/conversation-routes.ts +54 -14
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/runtime/routes/types.ts +4 -1
- package/src/runtime/shared-principal-lookup.ts +235 -0
- package/src/tools/host-terminal/host-shell.ts +4 -2
- package/src/tools/shared/shell-output.test.ts +42 -0
- package/src/tools/shared/shell-output.ts +27 -4
- package/src/tools/skills/execute.ts +10 -7
- package/src/tools/terminal/shell.ts +2 -0
- package/src/tools/tool-approval-handler.ts +9 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared-conversation principal trust lookup.
|
|
3
|
+
*
|
|
4
|
+
* The gateway classifies; this module surfaces that classification, caches it
|
|
5
|
+
* per principal, and fails closed. These pin the verdict passthrough for each
|
|
6
|
+
* ACL state, the fail-closed paths (unreachable gateway, could-not-vouch) and
|
|
7
|
+
* that neither is cached, single-flight coalescing, and the fresh bypass
|
|
8
|
+
* superseding the cached entry.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import type { TrustVerdict } from "@vellumai/gateway-client";
|
|
14
|
+
|
|
15
|
+
type ReadResult =
|
|
16
|
+
| { ok: true; verdict: TrustVerdict; admissionPolicy: null }
|
|
17
|
+
| { ok: false };
|
|
18
|
+
|
|
19
|
+
let nextResult: ReadResult = { ok: false };
|
|
20
|
+
let readCalls: Array<{ channelType: string; actorExternalId?: string }> = [];
|
|
21
|
+
|
|
22
|
+
/** Set to hold the next gateway read open; consumed by that one read. */
|
|
23
|
+
let gate: Promise<void> | null = null;
|
|
24
|
+
|
|
25
|
+
mock.module("../../calls/inbound-trust-reader.js", () => ({
|
|
26
|
+
readInboundTrust: async (input: {
|
|
27
|
+
channelType: string;
|
|
28
|
+
actorExternalId?: string;
|
|
29
|
+
}) => {
|
|
30
|
+
readCalls.push(input);
|
|
31
|
+
const result = nextResult;
|
|
32
|
+
const held = gate;
|
|
33
|
+
gate = null;
|
|
34
|
+
if (held) {
|
|
35
|
+
await held;
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
function defer(): { promise: Promise<void>; release: () => void } {
|
|
42
|
+
let release!: () => void;
|
|
43
|
+
const promise = new Promise<void>((resolve) => {
|
|
44
|
+
release = resolve;
|
|
45
|
+
});
|
|
46
|
+
return { promise, release };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const {
|
|
50
|
+
resolveSharedPrincipal,
|
|
51
|
+
resolveSharedPrincipalFresh,
|
|
52
|
+
__resetSharedPrincipalCacheForTest,
|
|
53
|
+
__sharedPrincipalCacheSizeForTest,
|
|
54
|
+
MAX_ENTRIES,
|
|
55
|
+
} = await import("../shared-principal-lookup.js");
|
|
56
|
+
|
|
57
|
+
const PRINCIPAL = "11111111-2222-3333-4444-555555555555";
|
|
58
|
+
|
|
59
|
+
function verdict(overrides: Partial<TrustVerdict>): ReadResult {
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
verdict: {
|
|
63
|
+
trustClass: "unknown",
|
|
64
|
+
canonicalSenderId: PRINCIPAL,
|
|
65
|
+
...overrides,
|
|
66
|
+
},
|
|
67
|
+
admissionPolicy: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
readCalls = [];
|
|
73
|
+
nextResult = { ok: false };
|
|
74
|
+
gate = null;
|
|
75
|
+
__resetSharedPrincipalCacheForTest();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("resolveSharedPrincipal", () => {
|
|
79
|
+
test("asks the gateway for the vellum-shared channel and the principal as actor", async () => {
|
|
80
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
81
|
+
|
|
82
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
83
|
+
|
|
84
|
+
expect(readCalls).toEqual([
|
|
85
|
+
{ channelType: "vellum-shared", actorExternalId: PRINCIPAL },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a principal with no contact row resolves unknown", async () => {
|
|
90
|
+
nextResult = verdict({ trustClass: "unknown" });
|
|
91
|
+
|
|
92
|
+
expect(await resolveSharedPrincipal(PRINCIPAL)).toEqual({
|
|
93
|
+
trustClass: "unknown",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("the guardian's principal resolves guardian", async () => {
|
|
98
|
+
nextResult = verdict({
|
|
99
|
+
trustClass: "guardian",
|
|
100
|
+
guardianPrincipalId: PRINCIPAL,
|
|
101
|
+
guardianDisplayName: "Owner",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(await resolveSharedPrincipal(PRINCIPAL)).toEqual({
|
|
105
|
+
trustClass: "guardian",
|
|
106
|
+
displayName: "Owner",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an active contact channel resolves trusted_contact with identity", async () => {
|
|
111
|
+
nextResult = verdict({
|
|
112
|
+
trustClass: "trusted_contact",
|
|
113
|
+
contactId: "c-1",
|
|
114
|
+
memberDisplayName: "Alice",
|
|
115
|
+
status: "active",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(await resolveSharedPrincipal(PRINCIPAL)).toEqual({
|
|
119
|
+
trustClass: "trusted_contact",
|
|
120
|
+
contactId: "c-1",
|
|
121
|
+
displayName: "Alice",
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test.each(["pending", "unverified"])(
|
|
126
|
+
"a %s contact channel resolves unverified_contact",
|
|
127
|
+
async (status) => {
|
|
128
|
+
nextResult = verdict({
|
|
129
|
+
trustClass: "unverified_contact",
|
|
130
|
+
contactId: "c-1",
|
|
131
|
+
status,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(await resolveSharedPrincipal(PRINCIPAL)).toMatchObject({
|
|
135
|
+
trustClass: "unverified_contact",
|
|
136
|
+
contactId: "c-1",
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
test.each(["revoked", "blocked"])(
|
|
142
|
+
"a %s contact channel resolves unknown",
|
|
143
|
+
async (status) => {
|
|
144
|
+
nextResult = verdict({
|
|
145
|
+
trustClass: "unknown",
|
|
146
|
+
contactId: "c-1",
|
|
147
|
+
status,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect((await resolveSharedPrincipal(PRINCIPAL)).trustClass).toBe(
|
|
151
|
+
"unknown",
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
test("a blank principal resolves unknown without reading the gateway", async () => {
|
|
157
|
+
expect(await resolveSharedPrincipal(" ")).toEqual({
|
|
158
|
+
trustClass: "unknown",
|
|
159
|
+
});
|
|
160
|
+
expect(readCalls).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("fail-closed reads", () => {
|
|
165
|
+
test("an unreachable gateway resolves unknown", async () => {
|
|
166
|
+
nextResult = { ok: false };
|
|
167
|
+
|
|
168
|
+
expect(await resolveSharedPrincipal(PRINCIPAL)).toEqual({
|
|
169
|
+
trustClass: "unknown",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a could-not-vouch verdict resolves unknown", async () => {
|
|
174
|
+
nextResult = verdict({ trustClass: "unknown", resolutionFailed: true });
|
|
175
|
+
|
|
176
|
+
expect(await resolveSharedPrincipal(PRINCIPAL)).toEqual({
|
|
177
|
+
trustClass: "unknown",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("an unreachable gateway is retried rather than cached", async () => {
|
|
182
|
+
nextResult = { ok: false };
|
|
183
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
184
|
+
|
|
185
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
186
|
+
const second = await resolveSharedPrincipal(PRINCIPAL);
|
|
187
|
+
|
|
188
|
+
expect(second.trustClass).toBe("trusted_contact");
|
|
189
|
+
expect(readCalls).toHaveLength(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a could-not-vouch verdict is retried rather than cached", async () => {
|
|
193
|
+
nextResult = verdict({ trustClass: "unknown", resolutionFailed: true });
|
|
194
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
195
|
+
|
|
196
|
+
nextResult = verdict({ trustClass: "guardian" });
|
|
197
|
+
const second = await resolveSharedPrincipal(PRINCIPAL);
|
|
198
|
+
|
|
199
|
+
expect(second.trustClass).toBe("guardian");
|
|
200
|
+
expect(readCalls).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("caching", () => {
|
|
205
|
+
test("a resolved principal is served from cache", async () => {
|
|
206
|
+
nextResult = verdict({ trustClass: "trusted_contact", contactId: "c-1" });
|
|
207
|
+
|
|
208
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
209
|
+
const second = await resolveSharedPrincipal(PRINCIPAL);
|
|
210
|
+
|
|
211
|
+
expect(second).toEqual({
|
|
212
|
+
trustClass: "trusted_contact",
|
|
213
|
+
contactId: "c-1",
|
|
214
|
+
});
|
|
215
|
+
expect(readCalls).toHaveLength(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("concurrent reads coalesce into one gateway call", async () => {
|
|
219
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
220
|
+
|
|
221
|
+
const [a, b] = await Promise.all([
|
|
222
|
+
resolveSharedPrincipal(PRINCIPAL),
|
|
223
|
+
resolveSharedPrincipal(PRINCIPAL),
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
expect(a).toEqual(b);
|
|
227
|
+
expect(readCalls).toHaveLength(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("an older read does not overwrite a newer verdict", async () => {
|
|
231
|
+
const stale = defer();
|
|
232
|
+
|
|
233
|
+
// An ordinary read misses the cache and is still waiting on the gateway.
|
|
234
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
235
|
+
gate = stale.promise;
|
|
236
|
+
const pending = resolveSharedPrincipal(PRINCIPAL);
|
|
237
|
+
|
|
238
|
+
// The contact is revoked; a fresh read starts later and answers first.
|
|
239
|
+
nextResult = verdict({ trustClass: "unknown", status: "revoked" });
|
|
240
|
+
expect((await resolveSharedPrincipalFresh(PRINCIPAL)).trustClass).toBe(
|
|
241
|
+
"unknown",
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
stale.release();
|
|
245
|
+
expect((await pending).trustClass).toBe("trusted_contact");
|
|
246
|
+
|
|
247
|
+
// The cache must still hold the revocation.
|
|
248
|
+
expect((await resolveSharedPrincipal(PRINCIPAL)).trustClass).toBe(
|
|
249
|
+
"unknown",
|
|
250
|
+
);
|
|
251
|
+
expect(readCalls).toHaveLength(2);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("distinct principals are cached separately", async () => {
|
|
255
|
+
nextResult = verdict({ trustClass: "guardian" });
|
|
256
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
257
|
+
|
|
258
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
259
|
+
const other = await resolveSharedPrincipal("other-principal");
|
|
260
|
+
|
|
261
|
+
expect(other.trustClass).toBe("trusted_contact");
|
|
262
|
+
expect(readCalls).toHaveLength(2);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("resolveSharedPrincipalFresh", () => {
|
|
267
|
+
test("bypasses a cached entry and re-reads", async () => {
|
|
268
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
269
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
270
|
+
|
|
271
|
+
nextResult = verdict({ trustClass: "unknown", status: "revoked" });
|
|
272
|
+
const fresh = await resolveSharedPrincipalFresh(PRINCIPAL);
|
|
273
|
+
|
|
274
|
+
expect(fresh.trustClass).toBe("unknown");
|
|
275
|
+
expect(readCalls).toHaveLength(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("cached reads made while it is pending share its result", async () => {
|
|
279
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
280
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
281
|
+
|
|
282
|
+
const refresh = defer();
|
|
283
|
+
nextResult = verdict({ trustClass: "unknown", status: "revoked" });
|
|
284
|
+
gate = refresh.promise;
|
|
285
|
+
const fresh = resolveSharedPrincipalFresh(PRINCIPAL);
|
|
286
|
+
const cached = resolveSharedPrincipal(PRINCIPAL);
|
|
287
|
+
|
|
288
|
+
refresh.release();
|
|
289
|
+
expect((await fresh).trustClass).toBe("unknown");
|
|
290
|
+
expect((await cached).trustClass).toBe("unknown");
|
|
291
|
+
expect(readCalls).toHaveLength(2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test.each([
|
|
295
|
+
["an unreachable gateway", { ok: false } as ReadResult],
|
|
296
|
+
[
|
|
297
|
+
"a could-not-vouch verdict",
|
|
298
|
+
verdict({ trustClass: "unknown", resolutionFailed: true }),
|
|
299
|
+
],
|
|
300
|
+
])(
|
|
301
|
+
"a refresh that meets %s does not leave the cached verdict in place",
|
|
302
|
+
async (_label, failure) => {
|
|
303
|
+
nextResult = verdict({ trustClass: "trusted_contact" });
|
|
304
|
+
await resolveSharedPrincipal(PRINCIPAL);
|
|
305
|
+
|
|
306
|
+
nextResult = failure;
|
|
307
|
+
expect((await resolveSharedPrincipalFresh(PRINCIPAL)).trustClass).toBe(
|
|
308
|
+
"unknown",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect((await resolveSharedPrincipal(PRINCIPAL)).trustClass).toBe(
|
|
312
|
+
"unknown",
|
|
313
|
+
);
|
|
314
|
+
expect(readCalls).toHaveLength(3);
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
test("repopulates the cache for later cached reads", async () => {
|
|
319
|
+
nextResult = verdict({ trustClass: "guardian" });
|
|
320
|
+
await resolveSharedPrincipalFresh(PRINCIPAL);
|
|
321
|
+
|
|
322
|
+
const cached = await resolveSharedPrincipal(PRINCIPAL);
|
|
323
|
+
|
|
324
|
+
expect(cached.trustClass).toBe("guardian");
|
|
325
|
+
expect(readCalls).toHaveLength(1);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("cache bounds", () => {
|
|
330
|
+
test("a burst past the limit is trimmed once the reads settle", async () => {
|
|
331
|
+
nextResult = verdict({ trustClass: "guardian" });
|
|
332
|
+
const burst = Array.from({ length: MAX_ENTRIES + 50 }, (_, i) =>
|
|
333
|
+
resolveSharedPrincipal(`burst-principal-${i}`),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// Nothing was evictable on insert: every entry is mid-read.
|
|
337
|
+
expect(__sharedPrincipalCacheSizeForTest()).toBeGreaterThan(MAX_ENTRIES);
|
|
338
|
+
|
|
339
|
+
await Promise.all(burst);
|
|
340
|
+
|
|
341
|
+
// No later lookup is needed to bring it back down.
|
|
342
|
+
expect(__sharedPrincipalCacheSizeForTest()).toBeLessThanOrEqual(
|
|
343
|
+
MAX_ENTRIES,
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* 2. No X-Actor-Token references in production code.
|
|
9
9
|
* 3. No legacy gateway-origin proof in production code.
|
|
10
10
|
* 4. Scope profile contract — every profile resolves to the expected scopes.
|
|
11
|
+
* 5. Contact profile agreement: the gateway mints what this runtime honors.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { execSync } from "node:child_process";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
14
16
|
import { resolve } from "node:path";
|
|
15
17
|
import { describe, expect, test } from "bun:test";
|
|
16
18
|
|
|
17
|
-
import { resolveScopeProfile } from "../scopes.js";
|
|
19
|
+
import { isNarrowScopeProfile, resolveScopeProfile } from "../scopes.js";
|
|
18
20
|
import type { Scope, ScopeProfile } from "../types.js";
|
|
19
21
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
@@ -253,6 +255,7 @@ describe("scope profile contract", () => {
|
|
|
253
255
|
"feature_flags.read",
|
|
254
256
|
"feature_flags.write",
|
|
255
257
|
],
|
|
258
|
+
contact_client_v1: ["chat.write", "approval.write", "attachments.write"],
|
|
256
259
|
gateway_ingress_v1: ["ingress.write", "internal.write"],
|
|
257
260
|
gateway_service_v1: [
|
|
258
261
|
"chat.read",
|
|
@@ -290,3 +293,50 @@ describe("scope profile contract", () => {
|
|
|
290
293
|
}
|
|
291
294
|
});
|
|
292
295
|
});
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// 5. Contact profile agreement with the gateway
|
|
299
|
+
//
|
|
300
|
+
// The gateway mints `contact_client_v1` and this runtime decides what it may
|
|
301
|
+
// reach. The two registries are separate tables, so a contact token carries
|
|
302
|
+
// whichever scopes the narrower one grants: a scope missing here 403s a route
|
|
303
|
+
// the gateway believes it opened.
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
describe("contact_client_v1 agrees with the gateway registry", () => {
|
|
307
|
+
const GATEWAY_SCOPES_PATH = resolve(
|
|
308
|
+
PROJECT_ROOT,
|
|
309
|
+
"gateway/src/auth/scopes.ts",
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
function gatewaySource(): string {
|
|
313
|
+
return readFileSync(GATEWAY_SCOPES_PATH, "utf8");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
test("grants the same scopes on both sides", () => {
|
|
317
|
+
const block = /contact_client_v1: new Set<Scope>\(\[([^\]]*)\]\)/.exec(
|
|
318
|
+
gatewaySource(),
|
|
319
|
+
);
|
|
320
|
+
expect(
|
|
321
|
+
block,
|
|
322
|
+
`contact_client_v1 not found in ${GATEWAY_SCOPES_PATH}`,
|
|
323
|
+
).not.toBeNull();
|
|
324
|
+
|
|
325
|
+
const gatewayScopes = [...block![1].matchAll(/"([^"]+)"/g)]
|
|
326
|
+
.map((m) => m[1])
|
|
327
|
+
.sort();
|
|
328
|
+
const runtimeScopes: string[] = [
|
|
329
|
+
...resolveScopeProfile("contact_client_v1"),
|
|
330
|
+
].sort();
|
|
331
|
+
|
|
332
|
+
expect(gatewayScopes.length).toBeGreaterThan(0);
|
|
333
|
+
expect(runtimeScopes).toEqual(gatewayScopes);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("is narrow on both sides", () => {
|
|
337
|
+
const broad = /contact_client_v1: (true|false)/.exec(gatewaySource());
|
|
338
|
+
expect(broad).not.toBeNull();
|
|
339
|
+
expect(broad![1]).toBe("false");
|
|
340
|
+
expect(isNarrowScopeProfile("contact_client_v1")).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -28,7 +28,16 @@ mock.module("../../../config/env.js", () => ({
|
|
|
28
28
|
hasUngatedHttpAuthDisabled: () => false,
|
|
29
29
|
}));
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import { TRUST_CLASS_VALUES } from "@vellumai/gateway-client";
|
|
32
|
+
|
|
33
|
+
import { isContactTrustClass } from "../../trust-class.js";
|
|
34
|
+
import {
|
|
35
|
+
CONTACT_ALLOWED,
|
|
36
|
+
enforcePolicy,
|
|
37
|
+
GUARDIAN_ONLY,
|
|
38
|
+
type RoutePolicy,
|
|
39
|
+
trustClassAllowed,
|
|
40
|
+
} from "../route-policy.js";
|
|
32
41
|
import { resolveScopeProfile } from "../scopes.js";
|
|
33
42
|
import type { AuthContext, Scope } from "../types.js";
|
|
34
43
|
|
|
@@ -572,3 +581,78 @@ describe("ROUTES policy declarations", () => {
|
|
|
572
581
|
).toBeNull();
|
|
573
582
|
});
|
|
574
583
|
});
|
|
584
|
+
|
|
585
|
+
describe("trustClassAllowed", () => {
|
|
586
|
+
const basePolicy: RoutePolicy = {
|
|
587
|
+
requiredScopes: ["chat.write"],
|
|
588
|
+
allowedPrincipalTypes: ["actor"],
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
test("a policy naming no trust classes admits only the guardian", () => {
|
|
592
|
+
expect(trustClassAllowed(basePolicy, "guardian")).toBe(true);
|
|
593
|
+
expect(trustClassAllowed(basePolicy, "trusted_contact")).toBe(false);
|
|
594
|
+
expect(trustClassAllowed(basePolicy, "unverified_contact")).toBe(false);
|
|
595
|
+
expect(trustClassAllowed(basePolicy, "unknown")).toBe(false);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("a null policy admits only the guardian", () => {
|
|
599
|
+
expect(trustClassAllowed(null, "guardian")).toBe(true);
|
|
600
|
+
expect(trustClassAllowed(null, "trusted_contact")).toBe(false);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("CONTACT_ALLOWED admits the guardian and both contact classes", () => {
|
|
604
|
+
const policy: RoutePolicy = {
|
|
605
|
+
...basePolicy,
|
|
606
|
+
allowedTrustClasses: CONTACT_ALLOWED,
|
|
607
|
+
};
|
|
608
|
+
expect(trustClassAllowed(policy, "guardian")).toBe(true);
|
|
609
|
+
expect(trustClassAllowed(policy, "trusted_contact")).toBe(true);
|
|
610
|
+
expect(trustClassAllowed(policy, "unverified_contact")).toBe(true);
|
|
611
|
+
expect(trustClassAllowed(policy, "unknown")).toBe(false);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("CONTACT_ALLOWED covers every class isContactTrustClass names", () => {
|
|
615
|
+
for (const trustClass of TRUST_CLASS_VALUES) {
|
|
616
|
+
if (isContactTrustClass(trustClass)) {
|
|
617
|
+
expect(CONTACT_ALLOWED).toContain(trustClass);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("an explicit list admits exactly what it names", () => {
|
|
623
|
+
const policy: RoutePolicy = {
|
|
624
|
+
...basePolicy,
|
|
625
|
+
allowedTrustClasses: ["trusted_contact"],
|
|
626
|
+
};
|
|
627
|
+
expect(trustClassAllowed(policy, "trusted_contact")).toBe(true);
|
|
628
|
+
expect(trustClassAllowed(policy, "guardian")).toBe(false);
|
|
629
|
+
expect(trustClassAllowed(policy, "unknown")).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("an explicit GUARDIAN_ONLY list matches the absent-field default", () => {
|
|
633
|
+
const policy: RoutePolicy = {
|
|
634
|
+
...basePolicy,
|
|
635
|
+
allowedTrustClasses: GUARDIAN_ONLY,
|
|
636
|
+
};
|
|
637
|
+
for (const trustClass of [
|
|
638
|
+
"guardian",
|
|
639
|
+
"trusted_contact",
|
|
640
|
+
"unverified_contact",
|
|
641
|
+
"unknown",
|
|
642
|
+
] as const) {
|
|
643
|
+
expect(trustClassAllowed(policy, trustClass)).toBe(
|
|
644
|
+
trustClassAllowed(basePolicy, trustClass),
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("a value outside the vocabulary is refused", () => {
|
|
650
|
+
const policy: RoutePolicy = {
|
|
651
|
+
...basePolicy,
|
|
652
|
+
allowedTrustClasses: CONTACT_ALLOWED,
|
|
653
|
+
};
|
|
654
|
+
expect(trustClassAllowed(policy, "non_guardian")).toBe(false);
|
|
655
|
+
expect(trustClassAllowed(policy, "")).toBe(false);
|
|
656
|
+
expect(trustClassAllowed(policy, undefined)).toBe(false);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { isTrustCheckedScopeProfile } from "@vellumai/gateway-client";
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
hasAllScopes,
|
|
5
7
|
hasScope,
|
|
@@ -11,6 +13,7 @@ import type { AuthContext, Scope, ScopeProfile } from "../types.js";
|
|
|
11
13
|
/** Every profile name; the Record keeps this list exhaustive. */
|
|
12
14
|
const KNOWN_PROFILES = Object.keys({
|
|
13
15
|
actor_client_v1: true,
|
|
16
|
+
contact_client_v1: true,
|
|
14
17
|
gateway_ingress_v1: true,
|
|
15
18
|
gateway_service_v1: true,
|
|
16
19
|
local_v1: true,
|
|
@@ -129,6 +132,7 @@ describe("isNarrowScopeProfile", () => {
|
|
|
129
132
|
// flipped onto a single-route grant would open every unscoped route to it.
|
|
130
133
|
const narrow: Record<ScopeProfile, boolean> = {
|
|
131
134
|
actor_client_v1: false,
|
|
135
|
+
contact_client_v1: true,
|
|
132
136
|
gateway_ingress_v1: false,
|
|
133
137
|
gateway_service_v1: false,
|
|
134
138
|
local_v1: false,
|
|
@@ -155,6 +159,38 @@ describe("isNarrowScopeProfile", () => {
|
|
|
155
159
|
});
|
|
156
160
|
});
|
|
157
161
|
|
|
162
|
+
describe("isTrustCheckedScopeProfile", () => {
|
|
163
|
+
test("exempts every profile but the contact's", () => {
|
|
164
|
+
// Keyed by this package's ScopeProfile, so a profile added to the union
|
|
165
|
+
// has to be listed here, and the assertion catches it being missing from
|
|
166
|
+
// the shared exempt list (or a contact profile landing on it).
|
|
167
|
+
const checked: Record<ScopeProfile, boolean> = {
|
|
168
|
+
actor_client_v1: false,
|
|
169
|
+
contact_client_v1: true,
|
|
170
|
+
gateway_ingress_v1: false,
|
|
171
|
+
gateway_service_v1: false,
|
|
172
|
+
local_v1: false,
|
|
173
|
+
oauth_proxy_v1: false,
|
|
174
|
+
speech_relay_v1: false,
|
|
175
|
+
ui_page_v1: false,
|
|
176
|
+
};
|
|
177
|
+
for (const [profile, expected] of Object.entries(checked)) {
|
|
178
|
+
expect(isTrustCheckedScopeProfile(profile)).toBe(expected);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("unknown profiles and prototype keys are trust-checked", () => {
|
|
183
|
+
for (const profile of [
|
|
184
|
+
"bogus_v1",
|
|
185
|
+
"toString",
|
|
186
|
+
"constructor",
|
|
187
|
+
"__proto__",
|
|
188
|
+
]) {
|
|
189
|
+
expect(isTrustCheckedScopeProfile(profile)).toBe(true);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
158
194
|
describe("hasScope", () => {
|
|
159
195
|
test("returns true for a scope the profile includes", () => {
|
|
160
196
|
const ctx = makeCtx("actor_client_v1");
|
|
@@ -11,8 +11,15 @@
|
|
|
11
11
|
* type safety but always allow the request through.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_ROUTE_TRUST_CLASSES,
|
|
16
|
+
routeAdmitsTrustClass,
|
|
17
|
+
TRUST_CLASS_VALUES,
|
|
18
|
+
} from "@vellumai/gateway-client";
|
|
19
|
+
|
|
14
20
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
15
21
|
import { getLogger } from "../../util/logger.js";
|
|
22
|
+
import { isContactTrustClass, type TrustClass } from "../trust-class.js";
|
|
16
23
|
import { isNarrowScopeProfile } from "./scopes.js";
|
|
17
24
|
import type { AuthContext, PrincipalType, Scope } from "./types.js";
|
|
18
25
|
|
|
@@ -25,6 +32,8 @@ const log = getLogger("route-policy");
|
|
|
25
32
|
export interface RoutePolicy {
|
|
26
33
|
requiredScopes: Scope[];
|
|
27
34
|
allowedPrincipalTypes: PrincipalType[];
|
|
35
|
+
/** Trust classes whose turn may call this route. Absent means guardian only. */
|
|
36
|
+
allowedTrustClasses?: TrustClass[];
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
// ---------------------------------------------------------------------------
|
|
@@ -63,6 +72,41 @@ export const GATEWAY_PRINCIPALS: PrincipalType[] = ["svc_gateway"];
|
|
|
63
72
|
*/
|
|
64
73
|
export const LOCAL_PRINCIPALS: PrincipalType[] = ["local"];
|
|
65
74
|
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Trust-class bundles
|
|
77
|
+
//
|
|
78
|
+
// The second "who can call this" axis: principal type names what kind of
|
|
79
|
+
// credential arrived, trust class names whose turn it speaks for.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** Only the guardian's turn. The default for a route naming no classes. */
|
|
83
|
+
export const GUARDIAN_ONLY: TrustClass[] = [...DEFAULT_ROUTE_TRUST_CLASSES];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The guardian's turn and an admitted contact's, derived rather than listed
|
|
87
|
+
* so a new contact class joins it automatically. Both contact classes belong:
|
|
88
|
+
* they differ on admission, not on what they may do once admitted. Keeping an
|
|
89
|
+
* unverified contact out is the admission floor's job, not a route's.
|
|
90
|
+
*/
|
|
91
|
+
export const CONTACT_ALLOWED: TrustClass[] = TRUST_CLASS_VALUES.filter(
|
|
92
|
+
(trustClass) => trustClass === "guardian" || isContactTrustClass(trustClass),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether an actor of `trustClass` may call a route carrying `policy`.
|
|
97
|
+
*
|
|
98
|
+
* A null policy and an absent `allowedTrustClasses` both resolve to
|
|
99
|
+
* {@link GUARDIAN_ONLY}. A value outside the vocabulary is refused: a field
|
|
100
|
+
* statically typed {@link TrustClass} can still carry a legacy or wire-sourced
|
|
101
|
+
* value.
|
|
102
|
+
*/
|
|
103
|
+
export function trustClassAllowed(
|
|
104
|
+
policy: RoutePolicy | null,
|
|
105
|
+
trustClass: TrustClass | (string & {}) | undefined,
|
|
106
|
+
): boolean {
|
|
107
|
+
return routeAdmitsTrustClass(policy?.allowedTrustClasses, trustClass);
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
// ---------------------------------------------------------------------------
|
|
67
111
|
// Enforcement
|
|
68
112
|
// ---------------------------------------------------------------------------
|
|
@@ -27,6 +27,18 @@ const PROFILE_SCOPES: Record<ScopeProfile, ReadonlySet<Scope>> = {
|
|
|
27
27
|
"feature_flags.read",
|
|
28
28
|
"feature_flags.write",
|
|
29
29
|
]),
|
|
30
|
+
// A non-guardian principal. Excludes the settings, feature-flag and call
|
|
31
|
+
// scopes `actor_client_v1` carries: those reach the control plane, which
|
|
32
|
+
// only the guardian may write.
|
|
33
|
+
// Carries no read scope. The routes behind `chat.read`, `approval.read` and
|
|
34
|
+
// `attachments.read` take a caller-supplied conversation or attachment id
|
|
35
|
+
// and never check who is asking, so they return the guardian's data to any
|
|
36
|
+
// holder. Each read is added back once its routes filter by caller.
|
|
37
|
+
contact_client_v1: new Set<Scope>([
|
|
38
|
+
"chat.write",
|
|
39
|
+
"approval.write",
|
|
40
|
+
"attachments.write",
|
|
41
|
+
]),
|
|
30
42
|
gateway_ingress_v1: new Set<Scope>(["ingress.write", "internal.write"]),
|
|
31
43
|
gateway_service_v1: new Set<Scope>([
|
|
32
44
|
"chat.read",
|
|
@@ -91,6 +103,9 @@ export function hasAllScopes(ctx: AuthContext, ...scopes: Scope[]): boolean {
|
|
|
91
103
|
*/
|
|
92
104
|
const BROAD_SCOPE_PROFILES: Record<ScopeProfile, boolean> = {
|
|
93
105
|
actor_client_v1: true,
|
|
106
|
+
// Narrow: a route naming no scope is a route no one has judged a contact
|
|
107
|
+
// against, so it refuses them rather than admitting any valid token.
|
|
108
|
+
contact_client_v1: false,
|
|
94
109
|
gateway_ingress_v1: true,
|
|
95
110
|
gateway_service_v1: true,
|
|
96
111
|
local_v1: true,
|