@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608050137.0c8f50a",
3
+ "version": "0.11.2-dev.202608050309.fa8263f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 4 Google API hosts", () => {
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(4);
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
+ });
@@ -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
- const cleaned = items
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: { providerKey: string };
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(provider: string): string {
16
- return (
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: { providerKey: provider },
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(provider);
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? } — managed OAuth connection CTA; use when the task needs a managed integration account (Google, Linear, GitHub, ...) instead of settings or shell OAuth. Do not include OAuth scopes; managed providers use the platform's configured scopes",
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