Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/adapters/providers/gcalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,20 @@ export const gcalendarManifest: ProviderManifest = {
auth: { type: 'bearer' },
// Google answers 401 for a bad credential and keeps 403 for a disabled API
// or a missing scope, neither of which reconnecting fixes.
errors: { forbidden: 'upstream_error' },
errors: {
forbidden: 'upstream_error',
// Google spends 403 on two unrelated things: a scope this grant never got,
// and an API that was never enabled. Only the first is fixed by
// reconnecting, and bodyFailure runs ahead of the 403 arm so it can say so.
// SERVICE_DISABLED is not in codes, so it keeps falling through to
// upstream_error.
bodyFailure: {
path: 'error.status',
equals: 'PERMISSION_DENIED',
codeFrom: 'error.errors.0.reason',
codes: { insufficientPermissions: 'reauth_required' },
},
},
pagination: {
style: 'cursor',
size: 25,
Expand Down
15 changes: 14 additions & 1 deletion src/adapters/providers/gdrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,20 @@ export const gdriveManifest: ProviderManifest = {
// every existing Google connection re-consents once either way.
scopes: ['https://www.googleapis.com/auth/drive'],
auth: { type: 'bearer' },
errors: { forbidden: 'upstream_error' },
errors: {
forbidden: 'upstream_error',
// Google spends 403 on two unrelated things: a scope this grant never got,
// and an API that was never enabled. Only the first is fixed by
// reconnecting, and bodyFailure runs ahead of the 403 arm so it can say so.
// SERVICE_DISABLED is not in codes, so it keeps falling through to
// upstream_error.
bodyFailure: {
path: 'error.status',
equals: 'PERMISSION_DENIED',
codeFrom: 'error.errors.0.reason',
codes: { insufficientPermissions: 'reauth_required' },
},
},
pagination: {
style: 'cursor',
size: 25,
Expand Down
15 changes: 14 additions & 1 deletion src/adapters/providers/gmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@ export const gmailManifest: ProviderManifest = {
// Google answers 401 when the credential is bad. A 403 is something else,
// most often an API that was never enabled on the project, and telling
// someone to reconnect a healthy grant sends them the wrong way.
errors: { forbidden: 'upstream_error' },
errors: {
forbidden: 'upstream_error',
// Google spends 403 on two unrelated things: a scope this grant never got,
// and an API that was never enabled. Only the first is fixed by
// reconnecting, and bodyFailure runs ahead of the 403 arm so it can say so.
// SERVICE_DISABLED is not in codes, so it keeps falling through to
// upstream_error.
bodyFailure: {
path: 'error.status',
equals: 'PERMISSION_DENIED',
codeFrom: 'error.errors.0.reason',
codes: { insufficientPermissions: 'reauth_required' },
},
},
pagination: {
style: 'cursor',
size: 25,
Expand Down
44 changes: 44 additions & 0 deletions test/providers/google-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,50 @@ describe('one google application, three prefixes', () => {
})
})

describe('a google grant that predates a scope widening', () => {
// Google's shape for both cases, differing only in reason.
const refusal = (reason: string) => ({
error: {
code: 403,
status: 'PERMISSION_DENIED',
message: 'refused',
errors: [{ domain: 'global', reason, message: 'refused' }],
},
})

it('tells a caller to reconnect when the grant is missing the scope', async () => {
for (const provider of [gmailProvider(), gcal, gdrive]) {
const upstream = fakeUpstream([
{ match: /./, status: 403, body: refusal('insufficientPermissions') },
])
const err = await provider
.callTool(ctx(upstream), provider.listTools()[0]!.name, {})
.catch((e) => e)
expect(err.code, provider.prefix).toBe('reauth_required')
}
})

it('still refuses to blame the credential when the API was never enabled', async () => {
// The case errors.forbidden exists for. Reconnecting cannot fix it, so it
// must not come back as reauth_required.
for (const provider of [gmailProvider(), gcal, gdrive]) {
const upstream = fakeUpstream([{ match: /./, status: 403, body: refusal('SERVICE_DISABLED') }])
const err = await provider
.callTool(ctx(upstream), provider.listTools()[0]!.name, {})
.catch((e) => e)
expect(err.code, provider.prefix).toBe('upstream_error')
}
})

it('leaves a 401 to the arm that already handles it', async () => {
const upstream = fakeUpstream([
{ match: /./, status: 401, body: { error: { code: 401, status: 'UNAUTHENTICATED' } } },
])
const err = await gcal.callTool(ctx(upstream), 'list_calendars', {}).catch((e) => e)
expect(err.code).toBe('reauth_required')
})
})

describe('google calendar writes', () => {
it('nests a start time under start.dateTime from a flat argument', async () => {
// The manifest reaches into the body with a dotted param, and Calendar
Expand Down
Loading