@vellumai/assistant 0.11.2-dev.202608050137.0c8f50a → 0.11.2-dev.202608050309.fa8263f
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/package.json +1 -1
- package/src/__tests__/oauth-provider-profiles.test.ts +4 -2
- package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -0
- package/src/api/surfaces-oauth-connect.test.ts +96 -0
- package/src/api/surfaces.ts +36 -8
- package/src/cli/commands/oauth/connect-surface-guidance.test.ts +16 -0
- package/src/cli/commands/oauth/connect-surface-guidance.ts +25 -7
- package/src/cli/commands/oauth/connect.test.ts +26 -1
- package/src/cli/commands/oauth/connect.ts +4 -1
- package/src/cli/commands/oauth/index.help.ts +3 -1
- package/src/oauth/seed-providers.ts +12 -0
- package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
|
|
|
23
23
|
);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test("google provider row contains bearer injection templates for
|
|
26
|
+
test("google provider row contains bearer injection templates for 6 Google API hosts", () => {
|
|
27
27
|
const provider = getProvider("google");
|
|
28
28
|
|
|
29
29
|
expect(provider).toBeDefined();
|
|
@@ -36,7 +36,7 @@ describe("oauth provider profiles (DB-seeded)", () => {
|
|
|
36
36
|
valuePrefix: string;
|
|
37
37
|
}>;
|
|
38
38
|
|
|
39
|
-
expect(templates).toHaveLength(
|
|
39
|
+
expect(templates).toHaveLength(6);
|
|
40
40
|
|
|
41
41
|
const byHost = new Map(templates.map((t) => [t.hostPattern, t]));
|
|
42
42
|
|
|
@@ -45,6 +45,8 @@ describe("oauth provider profiles (DB-seeded)", () => {
|
|
|
45
45
|
"www.googleapis.com",
|
|
46
46
|
"people.googleapis.com",
|
|
47
47
|
"docs.googleapis.com",
|
|
48
|
+
"tasks.googleapis.com",
|
|
49
|
+
"calendar.googleapis.com",
|
|
48
50
|
]) {
|
|
49
51
|
const tpl = byHost.get(host);
|
|
50
52
|
expect(tpl).toBeDefined();
|
|
@@ -204,6 +204,7 @@ describe("choice and copy_block surface proxying", () => {
|
|
|
204
204
|
providerKey: "google",
|
|
205
205
|
displayName: "Google",
|
|
206
206
|
description: "Connect Gmail for this task.",
|
|
207
|
+
requestedScopes: ["gmail.readonly"],
|
|
207
208
|
});
|
|
208
209
|
expect(ctx.pendingSurfaceActions.get(showMessage.surfaceId)).toEqual({
|
|
209
210
|
surfaceType: "oauth_connect",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type OAuthConnectSurfaceData,
|
|
5
|
+
OAuthConnectSurfaceDataSchema,
|
|
6
|
+
} from "./surfaces.js";
|
|
7
|
+
|
|
8
|
+
describe("OAuthConnectSurfaceDataSchema requestedScopes", () => {
|
|
9
|
+
test("parses a valid scopes array and preserves order", () => {
|
|
10
|
+
const parsed = OAuthConnectSurfaceDataSchema.parse({
|
|
11
|
+
providerKey: "google",
|
|
12
|
+
requestedScopes: [
|
|
13
|
+
"https://www.googleapis.com/auth/tasks",
|
|
14
|
+
"https://www.googleapis.com/auth/calendar",
|
|
15
|
+
"openid",
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
expect(parsed.requestedScopes).toEqual([
|
|
19
|
+
"https://www.googleapis.com/auth/tasks",
|
|
20
|
+
"https://www.googleapis.com/auth/calendar",
|
|
21
|
+
"openid",
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("absent requestedScopes parses to undefined", () => {
|
|
26
|
+
const parsed = OAuthConnectSurfaceDataSchema.parse({
|
|
27
|
+
providerKey: "google",
|
|
28
|
+
});
|
|
29
|
+
expect(parsed.requestedScopes).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("an empty array collapses to undefined", () => {
|
|
33
|
+
const parsed = OAuthConnectSurfaceDataSchema.parse({
|
|
34
|
+
providerKey: "google",
|
|
35
|
+
requestedScopes: [],
|
|
36
|
+
});
|
|
37
|
+
expect(parsed.requestedScopes).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("non-array values collapse to undefined instead of rejecting", () => {
|
|
41
|
+
for (const value of [
|
|
42
|
+
"https://www.googleapis.com/auth/tasks, openid",
|
|
43
|
+
42,
|
|
44
|
+
true,
|
|
45
|
+
{ scope: "openid" },
|
|
46
|
+
null,
|
|
47
|
+
]) {
|
|
48
|
+
const parsed = OAuthConnectSurfaceDataSchema.parse({
|
|
49
|
+
providerKey: "google",
|
|
50
|
+
requestedScopes: value,
|
|
51
|
+
});
|
|
52
|
+
expect(parsed.requestedScopes).toBeUndefined();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("an array of blanks collapses to undefined", () => {
|
|
57
|
+
const parsed = OAuthConnectSurfaceDataSchema.parse({
|
|
58
|
+
providerKey: "google",
|
|
59
|
+
requestedScopes: ["", " ", "\t"],
|
|
60
|
+
});
|
|
61
|
+
expect(parsed.requestedScopes).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("entries are trimmed and blank or non-string junk is dropped", () => {
|
|
65
|
+
const parsed = OAuthConnectSurfaceDataSchema.parse({
|
|
66
|
+
providerKey: "google",
|
|
67
|
+
requestedScopes: [
|
|
68
|
+
" https://www.googleapis.com/auth/tasks ",
|
|
69
|
+
"",
|
|
70
|
+
null,
|
|
71
|
+
{ nope: true },
|
|
72
|
+
"openid",
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
expect(parsed.requestedScopes).toEqual([
|
|
76
|
+
"https://www.googleapis.com/auth/tasks",
|
|
77
|
+
"openid",
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a bare string is not split on commas (scopes are opaque URIs)", () => {
|
|
82
|
+
const parsed = OAuthConnectSurfaceDataSchema.parse({
|
|
83
|
+
providerKey: "google",
|
|
84
|
+
requestedScopes: "a,b,c",
|
|
85
|
+
});
|
|
86
|
+
expect(parsed.requestedScopes).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("legacy payload { providerKey: 'google' } still parses (skew guard)", () => {
|
|
90
|
+
const parsed: OAuthConnectSurfaceData = OAuthConnectSurfaceDataSchema.parse(
|
|
91
|
+
{ providerKey: "google" },
|
|
92
|
+
);
|
|
93
|
+
expect(parsed.providerKey).toBe("google");
|
|
94
|
+
expect(parsed.requestedScopes).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/api/surfaces.ts
CHANGED
|
@@ -120,6 +120,21 @@ export const CardSurfaceDataSchema = z.object({
|
|
|
120
120
|
});
|
|
121
121
|
export type CardSurfaceData = z.infer<typeof CardSurfaceDataSchema>;
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Stringify+trim entries, drop blanks and non-scalars, and collapse an
|
|
125
|
+
* empty result to `undefined` so a schema treats it as "field absent".
|
|
126
|
+
*/
|
|
127
|
+
function cleanedStringEntries(items: unknown[]): string[] | undefined {
|
|
128
|
+
const cleaned = items
|
|
129
|
+
.map((item) =>
|
|
130
|
+
typeof item === "string" || typeof item === "number"
|
|
131
|
+
? String(item).trim()
|
|
132
|
+
: "",
|
|
133
|
+
)
|
|
134
|
+
.filter((item) => item.length > 0);
|
|
135
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
/**
|
|
124
139
|
* Accepted MIME-type / extension patterns for a `file_upload` surface.
|
|
125
140
|
*
|
|
@@ -137,14 +152,7 @@ const FileUploadAcceptedTypesSchema = z.preprocess((value) => {
|
|
|
137
152
|
: Array.isArray(value)
|
|
138
153
|
? value
|
|
139
154
|
: [];
|
|
140
|
-
|
|
141
|
-
.map((item) =>
|
|
142
|
-
typeof item === "string" || typeof item === "number"
|
|
143
|
-
? String(item).trim()
|
|
144
|
-
: "",
|
|
145
|
-
)
|
|
146
|
-
.filter((item) => item.length > 0);
|
|
147
|
-
return cleaned.length > 0 ? cleaned : undefined;
|
|
155
|
+
return cleanedStringEntries(items);
|
|
148
156
|
}, z.array(z.string()).optional());
|
|
149
157
|
|
|
150
158
|
export const FileUploadSurfaceDataSchema = z.object({
|
|
@@ -238,6 +246,19 @@ export const ChoiceSurfaceDataSchema = z.object({
|
|
|
238
246
|
});
|
|
239
247
|
export type ChoiceSurfaceData = z.infer<typeof ChoiceSurfaceDataSchema>;
|
|
240
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Requested OAuth scopes for an `oauth_connect` surface.
|
|
251
|
+
*
|
|
252
|
+
* Unlike `FileUploadAcceptedTypesSchema`, a bare string is NOT split on
|
|
253
|
+
* commas: OAuth scopes are opaque URIs, so only arrays are accepted.
|
|
254
|
+
* Entries are stringified and trimmed, blanks are dropped, and an empty
|
|
255
|
+
* result or any non-array value collapses to `undefined` (use defaults).
|
|
256
|
+
*/
|
|
257
|
+
const RequestedScopesSchema = z.preprocess(
|
|
258
|
+
(value) => (Array.isArray(value) ? cleanedStringEntries(value) : undefined),
|
|
259
|
+
z.array(z.string()).optional(),
|
|
260
|
+
);
|
|
261
|
+
|
|
241
262
|
export const OAuthConnectSurfaceDataSchema = z.object({
|
|
242
263
|
/** OAuth provider key from the managed provider catalog, e.g. "google". */
|
|
243
264
|
providerKey: z
|
|
@@ -246,6 +267,13 @@ export const OAuthConnectSurfaceDataSchema = z.object({
|
|
|
246
267
|
z.string(),
|
|
247
268
|
)
|
|
248
269
|
.catch(""),
|
|
270
|
+
/**
|
|
271
|
+
* Optional OAuth scopes to request for the managed connection. A full
|
|
272
|
+
* replacement set: when present, the platform uses exactly these scopes
|
|
273
|
+
* instead of its defaults (it does not merge). Omit to use the platform's
|
|
274
|
+
* default scopes for the provider.
|
|
275
|
+
*/
|
|
276
|
+
requestedScopes: RequestedScopesSchema,
|
|
249
277
|
/** Optional display label. The client falls back to the provider catalog. */
|
|
250
278
|
displayName: tolerantString(),
|
|
251
279
|
/** Optional helper text. The client falls back to the provider catalog. */
|
|
@@ -36,5 +36,21 @@ describe("OAuth connect surface guidance", () => {
|
|
|
36
36
|
expect(hint).toContain('surface_type "oauth_connect"');
|
|
37
37
|
expect(hint).toContain('data.providerKey "google"');
|
|
38
38
|
expect(hint).toContain("paste an OAuth URL");
|
|
39
|
+
expect(hint).not.toContain("requestedScopes");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("hint with scopes teaches the full replacement set semantics", () => {
|
|
43
|
+
const hint = oauthConnectSurfaceHint("google", ["a", "b"]);
|
|
44
|
+
expect(hint).toContain("data.requestedScopes");
|
|
45
|
+
expect(hint).toContain("full replacement set");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("redirect with scopes carries requestedScopes in the next action", () => {
|
|
49
|
+
const redirect = buildOAuthConnectSurfaceRedirect("google", ["a", "b"]);
|
|
50
|
+
expect(redirect.nextAction.data).toEqual({
|
|
51
|
+
providerKey: "google",
|
|
52
|
+
requestedScopes: ["a", "b"],
|
|
53
|
+
});
|
|
54
|
+
expect(redirect.hint).toBe(oauthConnectSurfaceHint("google", ["a", "b"]));
|
|
39
55
|
});
|
|
40
56
|
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import type { OAuthConnectSurfaceData } from "../../../api/surfaces.js";
|
|
2
|
+
|
|
1
3
|
export interface OAuthConnectSurfaceNextAction {
|
|
2
4
|
type: "ui_show";
|
|
3
5
|
surfaceType: "oauth_connect";
|
|
4
|
-
data:
|
|
6
|
+
data: Pick<OAuthConnectSurfaceData, "providerKey" | "requestedScopes">;
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
export interface OAuthConnectSurfaceRedirect {
|
|
@@ -12,35 +14,51 @@ export interface OAuthConnectSurfaceRedirect {
|
|
|
12
14
|
nextAction: OAuthConnectSurfaceNextAction;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export function oauthConnectSurfaceHint(
|
|
16
|
-
|
|
17
|
+
export function oauthConnectSurfaceHint(
|
|
18
|
+
provider: string,
|
|
19
|
+
requestedScopes?: string[],
|
|
20
|
+
): string {
|
|
21
|
+
const base =
|
|
17
22
|
`To let the user connect, render the connect button: call ` +
|
|
18
23
|
`\`ui_show\` with surface_type "oauth_connect" and ` +
|
|
19
24
|
`data.providerKey "${provider}". That surface is always available — do ` +
|
|
20
25
|
`not run further \`oauth\`/\`channels\` commands, paste an OAuth URL, or ` +
|
|
21
|
-
`load a setup skill just to display it
|
|
26
|
+
`load a setup skill just to display it.`;
|
|
27
|
+
if (!requestedScopes?.length) {
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
base +
|
|
32
|
+
` Include data.requestedScopes verbatim from nextAction.data in the ` +
|
|
33
|
+
`\`ui_show\` call: it is a full replacement set, so list every default ` +
|
|
34
|
+
`scope you want to keep, not just the new ones.`
|
|
22
35
|
);
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
export function buildOAuthConnectSurfaceNextAction(
|
|
26
39
|
provider: string,
|
|
40
|
+
requestedScopes?: string[],
|
|
27
41
|
): OAuthConnectSurfaceNextAction {
|
|
28
42
|
return {
|
|
29
43
|
type: "ui_show",
|
|
30
44
|
surfaceType: "oauth_connect",
|
|
31
|
-
data: {
|
|
45
|
+
data: {
|
|
46
|
+
providerKey: provider,
|
|
47
|
+
...(requestedScopes?.length ? { requestedScopes } : {}),
|
|
48
|
+
},
|
|
32
49
|
};
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
export function buildOAuthConnectSurfaceRedirect(
|
|
36
53
|
provider: string,
|
|
54
|
+
requestedScopes?: string[],
|
|
37
55
|
): OAuthConnectSurfaceRedirect {
|
|
38
56
|
return {
|
|
39
57
|
ok: false,
|
|
40
58
|
code: "use_oauth_connect_surface",
|
|
41
59
|
provider,
|
|
42
|
-
hint: oauthConnectSurfaceHint(provider),
|
|
43
|
-
nextAction: buildOAuthConnectSurfaceNextAction(provider),
|
|
60
|
+
hint: oauthConnectSurfaceHint(provider, requestedScopes),
|
|
61
|
+
nextAction: buildOAuthConnectSurfaceNextAction(provider, requestedScopes),
|
|
44
62
|
};
|
|
45
63
|
}
|
|
46
64
|
|
|
@@ -70,7 +70,7 @@ afterEach(() => {
|
|
|
70
70
|
process.exitCode = 0;
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
async function runConnectCommand(): Promise<{
|
|
73
|
+
async function runConnectCommand(extraArgs: string[] = []): Promise<{
|
|
74
74
|
stdout: string;
|
|
75
75
|
exitCode: number;
|
|
76
76
|
}> {
|
|
@@ -94,6 +94,7 @@ async function runConnectCommand(): Promise<{
|
|
|
94
94
|
"--json",
|
|
95
95
|
"connect",
|
|
96
96
|
"google",
|
|
97
|
+
...extraArgs,
|
|
97
98
|
]);
|
|
98
99
|
} catch {
|
|
99
100
|
// Commander may throw under exitOverride for parse errors; the assertions
|
|
@@ -123,4 +124,28 @@ describe("oauth connect", () => {
|
|
|
123
124
|
"oauth_mode_get",
|
|
124
125
|
]);
|
|
125
126
|
});
|
|
127
|
+
|
|
128
|
+
test("managed provider in a conversation shell includes requested scopes in the oauth_connect guidance", async () => {
|
|
129
|
+
const { stdout, exitCode } = await runConnectCommand([
|
|
130
|
+
"--scopes",
|
|
131
|
+
"https://www.googleapis.com/auth/tasks",
|
|
132
|
+
"https://www.googleapis.com/auth/calendar",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
expect(exitCode).toBe(1);
|
|
136
|
+
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
|
137
|
+
expect(parsed.code).toBe("use_oauth_connect_surface");
|
|
138
|
+
expect(parsed.nextAction).toEqual({
|
|
139
|
+
type: "ui_show",
|
|
140
|
+
surfaceType: "oauth_connect",
|
|
141
|
+
data: {
|
|
142
|
+
providerKey: "google",
|
|
143
|
+
requestedScopes: [
|
|
144
|
+
"https://www.googleapis.com/auth/tasks",
|
|
145
|
+
"https://www.googleapis.com/auth/calendar",
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
expect(openedUrls).toEqual([]);
|
|
150
|
+
});
|
|
126
151
|
});
|
|
@@ -141,7 +141,10 @@ export function registerConnectCommand(oauth: Command): void {
|
|
|
141
141
|
// =============================================================
|
|
142
142
|
|
|
143
143
|
if (isModelSpawnedConversationShell()) {
|
|
144
|
-
const redirect = buildOAuthConnectSurfaceRedirect(
|
|
144
|
+
const redirect = buildOAuthConnectSurfaceRedirect(
|
|
145
|
+
provider,
|
|
146
|
+
opts.scopes,
|
|
147
|
+
);
|
|
145
148
|
writeOutput(cmd, redirect);
|
|
146
149
|
process.exitCode = 1;
|
|
147
150
|
return;
|
|
@@ -691,7 +691,9 @@ Important for chat/UI turns: do not run this command just to let the user
|
|
|
691
691
|
connect a managed provider. Render the first-class connect surface instead:
|
|
692
692
|
call \`ui_show\` with surface_type "oauth_connect" and data.providerKey set to
|
|
693
693
|
the provider (for example, "google"). That surface starts the managed OAuth UI
|
|
694
|
-
and avoids pasting raw authorization links into the conversation.
|
|
694
|
+
and avoids pasting raw authorization links into the conversation. If the user
|
|
695
|
+
needs non-default scopes, also pass data.requestedScopes as a full replacement
|
|
696
|
+
set (list every scope the connection should keep, not just the new ones).
|
|
695
697
|
|
|
696
698
|
Examples:
|
|
697
699
|
$ assistant oauth connect google
|
|
@@ -137,6 +137,18 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
137
137
|
headerName: "Authorization",
|
|
138
138
|
valuePrefix: "Bearer ",
|
|
139
139
|
},
|
|
140
|
+
{
|
|
141
|
+
hostPattern: "tasks.googleapis.com",
|
|
142
|
+
injectionType: "header",
|
|
143
|
+
headerName: "Authorization",
|
|
144
|
+
valuePrefix: "Bearer ",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
hostPattern: "calendar.googleapis.com",
|
|
148
|
+
injectionType: "header",
|
|
149
|
+
headerName: "Authorization",
|
|
150
|
+
valuePrefix: "Bearer ",
|
|
151
|
+
},
|
|
140
152
|
],
|
|
141
153
|
revokeUrl: "https://oauth2.googleapis.com/revoke",
|
|
142
154
|
revokeBodyTemplate: { token: "{access_token}" },
|
|
@@ -132,7 +132,7 @@ export const SURFACE_SHAPE_DOCS: Record<string, SurfaceShapeDoc> = {
|
|
|
132
132
|
oauth_connect: {
|
|
133
133
|
purpose: "managed OAuth connection button for an integration account",
|
|
134
134
|
shape:
|
|
135
|
-
"{ providerKey, displayName?, description?, logoUrl? }
|
|
135
|
+
"{ providerKey, requestedScopes?, displayName?, description?, logoUrl? }: managed OAuth connection CTA; use when the task needs a managed integration account (Google, Linear, GitHub, ...) instead of settings or shell OAuth. requestedScopes is an optional FULL replacement set of OAuth scopes: when set, the connection is granted exactly these scopes instead of the platform defaults, so include every scope the connection should keep; omit it to use the platform's default scopes",
|
|
136
136
|
missingContent: (data) =>
|
|
137
137
|
isNonEmptyString(data.providerKey)
|
|
138
138
|
? null
|