@stina/extension-api 1.3.1 → 1.7.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/dist/{chunk-ZB7GJUPS.js → chunk-S3YP4QPF.js} +1 -1
- package/dist/{chunk-ZB7GJUPS.js.map → chunk-S3YP4QPF.js.map} +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -4
- package/dist/index.d.ts +19 -4
- package/dist/index.js +1 -1
- package/dist/runtime.cjs +53 -6
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.d.cts +2 -2
- package/dist/runtime.d.ts +2 -2
- package/dist/runtime.js +54 -7
- package/dist/runtime.js.map +1 -1
- package/dist/schemas/index.cjs +34 -2
- package/dist/schemas/index.cjs.map +1 -1
- package/dist/schemas/index.d.cts +91 -5
- package/dist/schemas/index.d.ts +91 -5
- package/dist/schemas/index.js +30 -2
- package/dist/schemas/index.js.map +1 -1
- package/dist/{types.tools-C0GqXQlu.d.cts → types.tools-DcFBsfRV.d.cts} +223 -17
- package/dist/{types.tools-C0GqXQlu.d.ts → types.tools-DcFBsfRV.d.ts} +223 -17
- package/package.json +1 -1
- package/schema/extension-manifest.schema.json +40 -0
- package/src/background.test.ts +33 -0
- package/src/background.ts +9 -0
- package/src/index.ts +8 -0
- package/src/messages.ts +5 -0
- package/src/runtime/accountsApi.ts +26 -0
- package/src/runtime/executionContext.test.ts +113 -0
- package/src/runtime/executionContext.ts +28 -2
- package/src/runtime/index.ts +1 -0
- package/src/runtime.ts +38 -3
- package/src/schemas/accounts.schema.test.ts +58 -0
- package/src/schemas/contributions.schema.ts +48 -0
- package/src/schemas/index.ts +6 -0
- package/src/schemas/permissions.schema.ts +11 -1
- package/src/types.context.ts +111 -0
- package/src/types.contributions.ts +47 -0
- package/src/types.permissions.ts +15 -0
- package/src/types.provider.ts +51 -1
- package/src/types.tools.ts +29 -15
- package/src/types.ts +9 -0
|
@@ -110,6 +110,8 @@
|
|
|
110
110
|
"user.location.read",
|
|
111
111
|
"chat.history.read",
|
|
112
112
|
"chat.current.read",
|
|
113
|
+
"attachments.read",
|
|
114
|
+
"accounts.use",
|
|
113
115
|
"chat.message.write",
|
|
114
116
|
"provider.register",
|
|
115
117
|
"tools.register",
|
|
@@ -852,6 +854,44 @@
|
|
|
852
854
|
],
|
|
853
855
|
"additionalProperties": false,
|
|
854
856
|
"description": "Storage collection declarations"
|
|
857
|
+
},
|
|
858
|
+
"accounts": {
|
|
859
|
+
"type": "array",
|
|
860
|
+
"items": {
|
|
861
|
+
"type": "object",
|
|
862
|
+
"properties": {
|
|
863
|
+
"provider": {
|
|
864
|
+
"type": "string",
|
|
865
|
+
"enum": [
|
|
866
|
+
"microsoft"
|
|
867
|
+
],
|
|
868
|
+
"description": "External account provider"
|
|
869
|
+
},
|
|
870
|
+
"scopes": {
|
|
871
|
+
"type": "array",
|
|
872
|
+
"items": {
|
|
873
|
+
"type": "string",
|
|
874
|
+
"pattern": "^[A-Za-z]+(\\.[A-Za-z]+)+$",
|
|
875
|
+
"errorMessage": {
|
|
876
|
+
"pattern": "Must be a Microsoft Graph permission name, such as \"Mail.Read\""
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
"minItems": 1,
|
|
880
|
+
"description": "Microsoft Graph delegated permissions"
|
|
881
|
+
},
|
|
882
|
+
"reason": {
|
|
883
|
+
"$ref": "#/definitions/ExtensionManifest/properties/contributes/properties/tools/items/properties/confirmationPrompt",
|
|
884
|
+
"description": "Why the extension needs them"
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
"required": [
|
|
888
|
+
"provider",
|
|
889
|
+
"scopes"
|
|
890
|
+
],
|
|
891
|
+
"additionalProperties": false,
|
|
892
|
+
"description": "An external account the extension works through"
|
|
893
|
+
},
|
|
894
|
+
"description": "External accounts the extension works through, signed in once in Stina's settings"
|
|
855
895
|
}
|
|
856
896
|
},
|
|
857
897
|
"additionalProperties": false,
|
package/src/background.test.ts
CHANGED
|
@@ -318,6 +318,39 @@ describe('WorkerBackgroundTaskManager', () => {
|
|
|
318
318
|
expect(receivedContext!.signal).toBeDefined()
|
|
319
319
|
expect(receivedContext!.reportHealth).toBeDefined()
|
|
320
320
|
expect(receivedContext!.log).toBeDefined()
|
|
321
|
+
// Not granted in this setup, so a task cannot reach for an account.
|
|
322
|
+
expect(receivedContext!.accounts).toBeUndefined()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('hands a task the accounts of the user it runs for, when the extension may use them', async () => {
|
|
326
|
+
const accountsApi = { list: vi.fn(), getAccessToken: vi.fn() }
|
|
327
|
+
const createUserAccountsAPI = vi.fn(() => accountsApi)
|
|
328
|
+
const withAccounts = new WorkerBackgroundTaskManager({
|
|
329
|
+
extensionId: 'test-extension',
|
|
330
|
+
extensionVersion: '1.0.0',
|
|
331
|
+
storagePath: '/fake/path',
|
|
332
|
+
sendTaskRegistered,
|
|
333
|
+
sendTaskStatus,
|
|
334
|
+
sendHealthReport,
|
|
335
|
+
createLogAPI,
|
|
336
|
+
createStorageAPI,
|
|
337
|
+
createUserStorageAPI,
|
|
338
|
+
createSecretsAPI,
|
|
339
|
+
createUserSecretsAPI,
|
|
340
|
+
createUserAccountsAPI,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
let receivedContext: BackgroundTaskContext | null = null
|
|
344
|
+
await withAccounts.start(
|
|
345
|
+
{ id: 'task-2', name: 'Mail watch', userId: 'user-7', restartPolicy: { type: 'never' } },
|
|
346
|
+
vi.fn(async (context) => {
|
|
347
|
+
receivedContext = context
|
|
348
|
+
})
|
|
349
|
+
)
|
|
350
|
+
await withAccounts.handleStart('task-2')
|
|
351
|
+
|
|
352
|
+
expect(createUserAccountsAPI).toHaveBeenCalledWith('user-7')
|
|
353
|
+
expect(receivedContext!.accounts).toBe(accountsApi)
|
|
321
354
|
})
|
|
322
355
|
})
|
|
323
356
|
|
package/src/background.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
LogAPI,
|
|
15
15
|
StorageAPI,
|
|
16
16
|
SecretsAPI,
|
|
17
|
+
ConnectedAccountsAPI,
|
|
17
18
|
} from './types.js'
|
|
18
19
|
|
|
19
20
|
// ============================================================================
|
|
@@ -66,6 +67,11 @@ export interface WorkerBackgroundTaskManagerOptions {
|
|
|
66
67
|
createSecretsAPI: () => SecretsAPI
|
|
67
68
|
/** Create user-scoped secrets API */
|
|
68
69
|
createUserSecretsAPI: (userId: string) => SecretsAPI
|
|
70
|
+
/**
|
|
71
|
+
* Create the connected accounts API for a user. Absent unless the extension
|
|
72
|
+
* holds `accounts.use`, which leaves `accounts` off the task's context.
|
|
73
|
+
*/
|
|
74
|
+
createUserAccountsAPI?: (userId: string) => ConnectedAccountsAPI
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
// ============================================================================
|
|
@@ -235,6 +241,9 @@ export class WorkerBackgroundTaskManager {
|
|
|
235
241
|
userStorage,
|
|
236
242
|
secrets,
|
|
237
243
|
userSecrets,
|
|
244
|
+
...(config.userId && this.options.createUserAccountsAPI
|
|
245
|
+
? { accounts: this.options.createUserAccountsAPI(config.userId) }
|
|
246
|
+
: {}),
|
|
238
247
|
signal,
|
|
239
248
|
reportHealth: (status: string) => {
|
|
240
249
|
const timestamp = new Date().toISOString()
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type {
|
|
|
36
36
|
ToolDefinition,
|
|
37
37
|
ToolConfirmationConfig,
|
|
38
38
|
CommandDefinition,
|
|
39
|
+
AccountProvider,
|
|
40
|
+
AccountContribution,
|
|
39
41
|
|
|
40
42
|
// Permissions
|
|
41
43
|
Permission,
|
|
@@ -63,6 +65,11 @@ export type {
|
|
|
63
65
|
ChatAPI,
|
|
64
66
|
ChatInstructionMessage,
|
|
65
67
|
ConversationPresentation,
|
|
68
|
+
AttachmentsAPI,
|
|
69
|
+
AttachmentContent,
|
|
70
|
+
ConnectedAccountsAPI,
|
|
71
|
+
ConnectedAccount,
|
|
72
|
+
AccountAccessToken,
|
|
66
73
|
LogAPI,
|
|
67
74
|
|
|
68
75
|
// Background workers
|
|
@@ -87,6 +94,7 @@ export type {
|
|
|
87
94
|
ModelCapabilities,
|
|
88
95
|
ChatMessage,
|
|
89
96
|
ChatImage,
|
|
97
|
+
ChatFile,
|
|
90
98
|
ChatOptions,
|
|
91
99
|
GetModelsOptions,
|
|
92
100
|
StreamEvent,
|
package/src/messages.ts
CHANGED
|
@@ -264,6 +264,11 @@ export type RequestMethod =
|
|
|
264
264
|
// Tools cross-extension methods
|
|
265
265
|
| 'tools.list'
|
|
266
266
|
| 'tools.execute'
|
|
267
|
+
// Attachments
|
|
268
|
+
| 'attachments.read'
|
|
269
|
+
// Connected accounts
|
|
270
|
+
| 'accounts.list'
|
|
271
|
+
| 'accounts.getAccessToken'
|
|
267
272
|
|
|
268
273
|
export interface ProviderRegisteredMessage {
|
|
269
274
|
type: 'provider-registered'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side connected accounts API.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AccountAccessToken, ConnectedAccount, ConnectedAccountsAPI } from '../types.js'
|
|
6
|
+
import type { RequestMessage } from '../messages.js'
|
|
7
|
+
|
|
8
|
+
type SendRequest = <T>(method: RequestMessage['method'], payload: unknown) => Promise<T>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the accounts API for one user.
|
|
12
|
+
*
|
|
13
|
+
* The user id is bound here, from the request that is being served, and not
|
|
14
|
+
* taken from the extension's own arguments: an extension cannot reach another
|
|
15
|
+
* user's account by passing a different id, because there is no id to pass.
|
|
16
|
+
*/
|
|
17
|
+
export function buildUserAccountsAPI(sendRequest: SendRequest, userId: string): ConnectedAccountsAPI {
|
|
18
|
+
return {
|
|
19
|
+
async list(provider: 'microsoft'): Promise<ConnectedAccount[]> {
|
|
20
|
+
return sendRequest<ConnectedAccount[]>('accounts.list', { provider, userId })
|
|
21
|
+
},
|
|
22
|
+
async getAccessToken(accountId: string): Promise<AccountAccessToken> {
|
|
23
|
+
return sendRequest<AccountAccessToken>('accounts.getAccessToken', { accountId, userId })
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { createExecutionContext } from './executionContext.js'
|
|
3
|
+
import type { ExtensionContext } from '../types.js'
|
|
4
|
+
|
|
5
|
+
const extensionContext = {
|
|
6
|
+
extension: { id: 'mail-reader', version: '1.0.0', storagePath: '/tmp/x' },
|
|
7
|
+
} as ExtensionContext
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a tool is handed when it runs.
|
|
11
|
+
*
|
|
12
|
+
* The attachments API is the part worth pinning: it is absent unless the extension
|
|
13
|
+
* asked for the permission *and* the request knows whose work it is, and an absent
|
|
14
|
+
* API is what makes a tool check before reaching for files it may not have.
|
|
15
|
+
*/
|
|
16
|
+
describe('createExecutionContext', () => {
|
|
17
|
+
it('leaves attachments out without the permission', () => {
|
|
18
|
+
const context = createExecutionContext(vi.fn(), extensionContext, 'user-1', [])
|
|
19
|
+
|
|
20
|
+
expect(context.attachments).toBeUndefined()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('leaves attachments out without a user, whatever the permission says', () => {
|
|
24
|
+
// An attachment belongs to somebody. A request that cannot say whose work it is
|
|
25
|
+
// doing has no business reading one.
|
|
26
|
+
const context = createExecutionContext(vi.fn(), extensionContext, undefined, ['attachments.read'])
|
|
27
|
+
|
|
28
|
+
expect(context.attachments).toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('reads an attachment for the user the request belongs to', async () => {
|
|
32
|
+
const sendRequest = vi.fn().mockResolvedValue({
|
|
33
|
+
id: 'att-7',
|
|
34
|
+
mime: 'application/pdf',
|
|
35
|
+
data: 'JVBE',
|
|
36
|
+
byteSize: 4,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const context = createExecutionContext(sendRequest, extensionContext, 'user-1', ['attachments.read'])
|
|
40
|
+
const content = await context.attachments!.read('att-7')
|
|
41
|
+
|
|
42
|
+
// The user id is the runtime's to supply, not the tool's: it comes from the
|
|
43
|
+
// request, so a tool cannot reach another user's file by passing a different one.
|
|
44
|
+
expect(sendRequest).toHaveBeenCalledWith('attachments.read', {
|
|
45
|
+
attachmentId: 'att-7',
|
|
46
|
+
userId: 'user-1',
|
|
47
|
+
})
|
|
48
|
+
expect(content?.mime).toBe('application/pdf')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('passes a missing attachment through as nothing', async () => {
|
|
52
|
+
const context = createExecutionContext(
|
|
53
|
+
vi.fn().mockResolvedValue(null),
|
|
54
|
+
extensionContext,
|
|
55
|
+
'user-1',
|
|
56
|
+
['attachments.read']
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(await context.attachments!.read('gone')).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('still builds the rest of the context without the permission', () => {
|
|
63
|
+
const context = createExecutionContext(vi.fn(), extensionContext, 'user-1')
|
|
64
|
+
|
|
65
|
+
expect(context.userId).toBe('user-1')
|
|
66
|
+
expect(context.storage).toBeDefined()
|
|
67
|
+
expect(context.userStorage).toBeDefined()
|
|
68
|
+
expect(context.secrets).toBeDefined()
|
|
69
|
+
expect(context.userSecrets).toBeDefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('connected accounts', () => {
|
|
73
|
+
it('leaves them out without the permission', () => {
|
|
74
|
+
const context = createExecutionContext(vi.fn(), extensionContext, 'user-1', ['attachments.read'])
|
|
75
|
+
|
|
76
|
+
expect(context.accounts).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('leaves them out without a user, whatever the permission says', () => {
|
|
80
|
+
const context = createExecutionContext(vi.fn(), extensionContext, undefined, ['accounts.use'])
|
|
81
|
+
|
|
82
|
+
expect(context.accounts).toBeUndefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('asks for the user the request belongs to, not one the extension names', async () => {
|
|
86
|
+
const sendRequest = vi
|
|
87
|
+
.fn()
|
|
88
|
+
.mockResolvedValueOnce([{ id: 'ms-1', provider: 'microsoft', email: 'a@b.se' }])
|
|
89
|
+
.mockResolvedValueOnce({ token: 'tok', expiresAt: '2026-09-16T12:00:00.000Z' })
|
|
90
|
+
|
|
91
|
+
const context = createExecutionContext(sendRequest, extensionContext, 'user-1', ['accounts.use'])
|
|
92
|
+
await context.accounts!.list('microsoft')
|
|
93
|
+
const token = await context.accounts!.getAccessToken('ms-1')
|
|
94
|
+
|
|
95
|
+
expect(sendRequest).toHaveBeenNthCalledWith(1, 'accounts.list', {
|
|
96
|
+
provider: 'microsoft',
|
|
97
|
+
userId: 'user-1',
|
|
98
|
+
})
|
|
99
|
+
expect(sendRequest).toHaveBeenNthCalledWith(2, 'accounts.getAccessToken', {
|
|
100
|
+
accountId: 'ms-1',
|
|
101
|
+
userId: 'user-1',
|
|
102
|
+
})
|
|
103
|
+
expect(token.token).toBe('tok')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('leaves attachments alone when only accounts were granted', () => {
|
|
107
|
+
const context = createExecutionContext(vi.fn(), extensionContext, 'user-1', ['accounts.use'])
|
|
108
|
+
|
|
109
|
+
expect(context.accounts).toBeDefined()
|
|
110
|
+
expect(context.attachments).toBeUndefined()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Shared execution context builder for tool, action, and scheduler operations.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { ExecutionContext, ExtensionContext } from '../types.js'
|
|
5
|
+
import type { AttachmentContent, ExecutionContext, ExtensionContext } from '../types.js'
|
|
6
6
|
import type { RequestMessage } from '../messages.js'
|
|
7
7
|
import { buildExtensionStorageAPI, buildUserStorageAPI } from './storageApi.js'
|
|
8
8
|
import { buildExtensionSecretsAPI, buildUserSecretsAPI } from './secretsApi.js'
|
|
9
|
+
import { buildUserAccountsAPI } from './accountsApi.js'
|
|
9
10
|
|
|
10
11
|
type SendRequest = <T>(method: RequestMessage['method'], payload: unknown) => Promise<T>
|
|
11
12
|
|
|
@@ -16,8 +17,17 @@ type SendRequest = <T>(method: RequestMessage['method'], payload: unknown) => Pr
|
|
|
16
17
|
export function createExecutionContext(
|
|
17
18
|
sendRequest: SendRequest,
|
|
18
19
|
extensionContext: ExtensionContext,
|
|
19
|
-
userId?: string
|
|
20
|
+
userId?: string,
|
|
21
|
+
/**
|
|
22
|
+
* What the extension was granted. The permission-gated APIs are left out here
|
|
23
|
+
* rather than only refused by the host, so a tool can see whether a capability
|
|
24
|
+
* is there at all, the way it can for storage — the host refuses them regardless.
|
|
25
|
+
*/
|
|
26
|
+
granted: readonly string[] = []
|
|
20
27
|
): ExecutionContext {
|
|
28
|
+
const canReadAttachments = granted.includes('attachments.read')
|
|
29
|
+
const canUseAccounts = granted.includes('accounts.use')
|
|
30
|
+
|
|
21
31
|
return {
|
|
22
32
|
userId,
|
|
23
33
|
extension: {
|
|
@@ -33,5 +43,21 @@ export function createExecutionContext(
|
|
|
33
43
|
userSecrets: userId
|
|
34
44
|
? buildUserSecretsAPI(sendRequest, userId)
|
|
35
45
|
: buildExtensionSecretsAPI(sendRequest),
|
|
46
|
+
// Only with a user to scope it to. An attachment belongs to somebody, and a
|
|
47
|
+
// request that cannot say whose work it is doing has no business reading one.
|
|
48
|
+
...(canReadAttachments && userId
|
|
49
|
+
? {
|
|
50
|
+
attachments: {
|
|
51
|
+
async read(attachmentId: string): Promise<AttachmentContent | null> {
|
|
52
|
+
return sendRequest<AttachmentContent | null>('attachments.read', {
|
|
53
|
+
attachmentId,
|
|
54
|
+
userId,
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
60
|
+
// Scoped to a user for the same reason: a connected account is somebody's.
|
|
61
|
+
...(canUseAccounts && userId ? { accounts: buildUserAccountsAPI(sendRequest, userId) } : {}),
|
|
36
62
|
}
|
|
37
63
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -6,3 +6,4 @@
|
|
|
6
6
|
export { buildExtensionStorageAPI, buildUserStorageAPI } from './storageApi.js'
|
|
7
7
|
export { buildExtensionSecretsAPI, buildUserSecretsAPI } from './secretsApi.js'
|
|
8
8
|
export { createExecutionContext } from './executionContext.js'
|
|
9
|
+
export { buildUserAccountsAPI } from './accountsApi.js'
|
package/src/runtime.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
buildUserStorageAPI,
|
|
58
58
|
buildExtensionSecretsAPI,
|
|
59
59
|
buildUserSecretsAPI,
|
|
60
|
+
buildUserAccountsAPI,
|
|
60
61
|
createExecutionContext,
|
|
61
62
|
} from './runtime/index.js'
|
|
62
63
|
|
|
@@ -105,6 +106,13 @@ const messagePort = getMessagePort()
|
|
|
105
106
|
let extensionModule: ExtensionModule | null = null
|
|
106
107
|
let extensionDisposable: Disposable | null = null
|
|
107
108
|
let extensionContext: ExtensionContext | null = null
|
|
109
|
+
/**
|
|
110
|
+
* What this extension was granted, kept from activation.
|
|
111
|
+
*
|
|
112
|
+
* A request-scoped context is built long after `handleActivate` has returned, and
|
|
113
|
+
* has to know the same things it did.
|
|
114
|
+
*/
|
|
115
|
+
let grantedPermissions: string[] = []
|
|
108
116
|
let backgroundTaskManager: WorkerBackgroundTaskManager | null = null
|
|
109
117
|
|
|
110
118
|
const pendingRequests = new Map<string, PendingRequest>()
|
|
@@ -284,6 +292,7 @@ async function handleActivate(payload: {
|
|
|
284
292
|
settings: Record<string, unknown>
|
|
285
293
|
}): Promise<void> {
|
|
286
294
|
const { extensionId, extensionVersion, storagePath, permissions } = payload
|
|
295
|
+
grantedPermissions = permissions
|
|
287
296
|
|
|
288
297
|
// Build the context based on permissions
|
|
289
298
|
extensionContext = buildContext(extensionId, extensionVersion, storagePath, permissions)
|
|
@@ -346,7 +355,12 @@ function handleSettingsChanged(key: string, value: unknown): void {
|
|
|
346
355
|
// ExecutionContext builder is in runtime/executionContext.ts
|
|
347
356
|
|
|
348
357
|
async function handleSchedulerFire(payload: SchedulerFirePayload): Promise<void> {
|
|
349
|
-
const execContext = createExecutionContext(
|
|
358
|
+
const execContext = createExecutionContext(
|
|
359
|
+
sendRequest,
|
|
360
|
+
extensionContext!,
|
|
361
|
+
payload.userId,
|
|
362
|
+
grantedPermissions
|
|
363
|
+
)
|
|
350
364
|
|
|
351
365
|
// Run callbacks concurrently to avoid blocking
|
|
352
366
|
const results = await Promise.allSettled(
|
|
@@ -543,7 +557,12 @@ async function handleToolExecuteRequest(
|
|
|
543
557
|
}
|
|
544
558
|
|
|
545
559
|
try {
|
|
546
|
-
const execContext = createExecutionContext(
|
|
560
|
+
const execContext = createExecutionContext(
|
|
561
|
+
sendRequest,
|
|
562
|
+
extensionContext!,
|
|
563
|
+
payload.userId,
|
|
564
|
+
grantedPermissions
|
|
565
|
+
)
|
|
547
566
|
|
|
548
567
|
const result = await tool.execute(payload.params, execContext)
|
|
549
568
|
|
|
@@ -586,7 +605,12 @@ async function handleActionExecuteRequest(
|
|
|
586
605
|
}
|
|
587
606
|
|
|
588
607
|
try {
|
|
589
|
-
const execContext = createExecutionContext(
|
|
608
|
+
const execContext = createExecutionContext(
|
|
609
|
+
sendRequest,
|
|
610
|
+
extensionContext!,
|
|
611
|
+
payload.userId,
|
|
612
|
+
grantedPermissions
|
|
613
|
+
)
|
|
590
614
|
|
|
591
615
|
const result = await action.execute(payload.params, execContext)
|
|
592
616
|
|
|
@@ -967,6 +991,9 @@ function buildContext(
|
|
|
967
991
|
createUserStorageAPI: (userId) => buildUserStorageAPI(sendRequest, userId),
|
|
968
992
|
createSecretsAPI: () => buildExtensionSecretsAPI(sendRequest),
|
|
969
993
|
createUserSecretsAPI: (userId) => buildUserSecretsAPI(sendRequest, userId),
|
|
994
|
+
...(hasPermission('accounts.use')
|
|
995
|
+
? { createUserAccountsAPI: (userId: string) => buildUserAccountsAPI(sendRequest, userId) }
|
|
996
|
+
: {}),
|
|
970
997
|
})
|
|
971
998
|
}
|
|
972
999
|
|
|
@@ -1016,6 +1043,9 @@ export type {
|
|
|
1016
1043
|
ExecutionContext,
|
|
1017
1044
|
ExtensionContext,
|
|
1018
1045
|
ExtensionModule,
|
|
1046
|
+
ConnectedAccountsAPI,
|
|
1047
|
+
ConnectedAccount,
|
|
1048
|
+
AccountAccessToken,
|
|
1019
1049
|
Disposable,
|
|
1020
1050
|
AIProvider,
|
|
1021
1051
|
Tool,
|
|
@@ -1027,6 +1057,11 @@ export type {
|
|
|
1027
1057
|
ActionResult,
|
|
1028
1058
|
ModelInfo,
|
|
1029
1059
|
ChatMessage,
|
|
1060
|
+
// What a message can carry. A provider extension reads these on every user turn,
|
|
1061
|
+
// and until now had to reach for the root entry to name them — or declare them
|
|
1062
|
+
// itself, which is what two of them did.
|
|
1063
|
+
ChatImage,
|
|
1064
|
+
ChatFile,
|
|
1030
1065
|
ChatOptions,
|
|
1031
1066
|
GetModelsOptions,
|
|
1032
1067
|
ModelCapabilities,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { AccountContributionSchema, ExtensionContributionsSchema } from './contributions.schema.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What an extension may ask of a connected account.
|
|
6
|
+
*
|
|
7
|
+
* Every enabled extension's scopes go into one sign-in request. A scope Microsoft
|
|
8
|
+
* rejects therefore breaks the sign-in for all of them, which is why the schema
|
|
9
|
+
* is stricter than "any string".
|
|
10
|
+
*/
|
|
11
|
+
describe('AccountContributionSchema', () => {
|
|
12
|
+
it('accepts Graph permission names', () => {
|
|
13
|
+
const result = AccountContributionSchema.safeParse({
|
|
14
|
+
provider: 'microsoft',
|
|
15
|
+
scopes: ['Mail.Read', 'Mail.Send', 'Calendars.ReadWrite', 'Calendars.Read.Shared'],
|
|
16
|
+
reason: { en: 'Read your mail', sv: 'Läsa din mail' },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
expect(result.success).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// Microsoft issues a token for one resource at a time. An Exchange scope next to
|
|
23
|
+
// Graph scopes makes the whole combined request fail.
|
|
24
|
+
it('refuses a scope for another resource', () => {
|
|
25
|
+
const result = AccountContributionSchema.safeParse({
|
|
26
|
+
provider: 'microsoft',
|
|
27
|
+
scopes: ['https://outlook.office.com/IMAP.AccessAsUser.All'],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(result.success).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('refuses the scopes the host asks for itself', () => {
|
|
34
|
+
for (const scope of ['offline_access', 'User.Read', 'user.read', 'openid']) {
|
|
35
|
+
const result = AccountContributionSchema.safeParse({ provider: 'microsoft', scopes: [scope] })
|
|
36
|
+
expect(result.success, scope).toBe(false)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('needs at least one scope', () => {
|
|
41
|
+
expect(AccountContributionSchema.safeParse({ provider: 'microsoft', scopes: [] }).success).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('knows only the providers the host can sign in to', () => {
|
|
45
|
+
expect(
|
|
46
|
+
AccountContributionSchema.safeParse({ provider: 'google', scopes: ['Mail.Read'] }).success
|
|
47
|
+
).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('is part of what a manifest may contribute', () => {
|
|
51
|
+
const result = ExtensionContributionsSchema.safeParse({
|
|
52
|
+
accounts: [{ provider: 'microsoft', scopes: ['Calendars.ReadWrite'] }],
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(result.success).toBe(true)
|
|
56
|
+
expect(result.success && result.data.accounts?.[0]?.scopes).toEqual(['Calendars.ReadWrite'])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -304,6 +304,48 @@ export const StorageContributionsSchema = z
|
|
|
304
304
|
})
|
|
305
305
|
.describe('Storage contributions')
|
|
306
306
|
|
|
307
|
+
// =============================================================================
|
|
308
|
+
// Connected Accounts
|
|
309
|
+
// =============================================================================
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A Microsoft Graph delegated permission name: `Mail.Read`, `Calendars.ReadWrite`.
|
|
313
|
+
*
|
|
314
|
+
* Bare names only. A full URI would name some other resource, and Microsoft hands
|
|
315
|
+
* out tokens for one resource at a time - a manifest mixing Graph with, say,
|
|
316
|
+
* `https://outlook.office.com/IMAP.AccessAsUser.All` would make the combined
|
|
317
|
+
* sign-in fail for every extension, not just the one that asked.
|
|
318
|
+
*/
|
|
319
|
+
export const GRAPH_SCOPE_PATTERN = /^[A-Za-z]+(\.[A-Za-z]+)+$/
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Scopes the host always asks for and therefore does not accept from a manifest:
|
|
323
|
+
* a declared one would be redundant at best.
|
|
324
|
+
*/
|
|
325
|
+
export const HOST_ACCOUNT_SCOPES = ['offline_access', 'User.Read', 'openid', 'profile', 'email'] as const
|
|
326
|
+
|
|
327
|
+
export const AccountProviderSchema = z.enum(['microsoft']).describe('External account provider')
|
|
328
|
+
|
|
329
|
+
export const AccountContributionSchema = z
|
|
330
|
+
.object({
|
|
331
|
+
provider: AccountProviderSchema,
|
|
332
|
+
scopes: z
|
|
333
|
+
.array(
|
|
334
|
+
z
|
|
335
|
+
.string()
|
|
336
|
+
.regex(GRAPH_SCOPE_PATTERN, 'Must be a Microsoft Graph permission name, such as "Mail.Read"')
|
|
337
|
+
.refine(
|
|
338
|
+
(scope) =>
|
|
339
|
+
!HOST_ACCOUNT_SCOPES.some((hostScope) => hostScope.toLowerCase() === scope.toLowerCase()),
|
|
340
|
+
{ message: 'Requested by the host already; leave it out' }
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
.min(1)
|
|
344
|
+
.describe('Microsoft Graph delegated permissions'),
|
|
345
|
+
reason: LocalizedStringSchema.optional().describe('Why the extension needs them'),
|
|
346
|
+
})
|
|
347
|
+
.describe('An external account the extension works through')
|
|
348
|
+
|
|
307
349
|
// =============================================================================
|
|
308
350
|
// Extension Contributions
|
|
309
351
|
// =============================================================================
|
|
@@ -324,6 +366,10 @@ export const ExtensionContributionsSchema = z
|
|
|
324
366
|
commands: z.array(CommandDefinitionSchema).optional().describe('Slash commands'),
|
|
325
367
|
prompts: z.array(PromptContributionSchema).optional().describe('Prompt contributions'),
|
|
326
368
|
storage: StorageContributionsSchema.optional().describe('Storage collection declarations'),
|
|
369
|
+
accounts: z
|
|
370
|
+
.array(AccountContributionSchema)
|
|
371
|
+
.optional()
|
|
372
|
+
.describe("External accounts the extension works through, signed in once in Stina's settings"),
|
|
327
373
|
})
|
|
328
374
|
.describe('What an extension can contribute to Stina')
|
|
329
375
|
|
|
@@ -353,4 +399,6 @@ export type PromptSection = z.infer<typeof PromptSectionSchema>
|
|
|
353
399
|
export type PromptContribution = z.infer<typeof PromptContributionSchema>
|
|
354
400
|
export type StorageCollectionConfig = z.infer<typeof StorageCollectionConfigSchema>
|
|
355
401
|
export type StorageContributions = z.infer<typeof StorageContributionsSchema>
|
|
402
|
+
export type AccountProvider = z.infer<typeof AccountProviderSchema>
|
|
403
|
+
export type AccountContribution = z.infer<typeof AccountContributionSchema>
|
|
356
404
|
export type ExtensionContributions = z.infer<typeof ExtensionContributionsSchema>
|
package/src/schemas/index.ts
CHANGED
|
@@ -58,6 +58,12 @@ export {
|
|
|
58
58
|
CommandDefinitionSchema,
|
|
59
59
|
PromptContributionSchema,
|
|
60
60
|
PromptSectionSchema,
|
|
61
|
+
AccountProviderSchema,
|
|
62
|
+
AccountContributionSchema,
|
|
63
|
+
GRAPH_SCOPE_PATTERN,
|
|
64
|
+
HOST_ACCOUNT_SCOPES,
|
|
65
|
+
type AccountProvider,
|
|
66
|
+
type AccountContribution,
|
|
61
67
|
type ExtensionContributions,
|
|
62
68
|
type LocalizedString,
|
|
63
69
|
type ToolSettingsViewDefinition,
|
|
@@ -20,6 +20,8 @@ export const VALID_PERMISSIONS = [
|
|
|
20
20
|
'user.location.read',
|
|
21
21
|
'chat.history.read',
|
|
22
22
|
'chat.current.read',
|
|
23
|
+
'attachments.read',
|
|
24
|
+
'accounts.use',
|
|
23
25
|
'chat.message.write',
|
|
24
26
|
'provider.register',
|
|
25
27
|
'tools.register',
|
|
@@ -75,7 +77,15 @@ const StoragePermissionSchema = z.enum(['storage.collections', 'secrets.manage']
|
|
|
75
77
|
* User data permission schema
|
|
76
78
|
*/
|
|
77
79
|
const UserDataPermissionSchema = z
|
|
78
|
-
.enum([
|
|
80
|
+
.enum([
|
|
81
|
+
'user.profile.read',
|
|
82
|
+
'user.list',
|
|
83
|
+
'user.location.read',
|
|
84
|
+
'chat.history.read',
|
|
85
|
+
'chat.current.read',
|
|
86
|
+
'attachments.read',
|
|
87
|
+
'accounts.use',
|
|
88
|
+
])
|
|
79
89
|
.describe('User data access permission')
|
|
80
90
|
|
|
81
91
|
/**
|