@pikku/core 0.12.63 → 0.12.66
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/CHANGELOG.md +368 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +42 -27
- package/dist/permissions.js +101 -203
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
- package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
- package/dist/wirings/ai-agent/index.d.ts +2 -1
- package/dist/wirings/ai-agent/index.js +2 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +5 -41
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +3 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +4 -12
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -3
- package/package.json +6 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +160 -373
- package/src/permissions.ts +137 -279
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
- package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
- package/src/wirings/ai-agent/index.ts +5 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +9 -40
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +4 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +7 -14
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -8,11 +8,14 @@ import type {
|
|
|
8
8
|
AIStreamChannel,
|
|
9
9
|
AIStreamEvent,
|
|
10
10
|
PikkuAIMiddlewareHooks,
|
|
11
|
+
SessionScope,
|
|
11
12
|
} from './ai-agent.types.js'
|
|
12
13
|
import type { AIAgentRunnerParams } from '../../services/ai-agent-runner-service.js'
|
|
13
14
|
import { PikkuError } from '../../errors/error-handler.js'
|
|
14
|
-
import { checkAuthPermissions } from '../../permissions.js'
|
|
15
|
+
import { checkAuthPermissions, runPermissions } from '../../permissions.js'
|
|
15
16
|
import { AIProviderNotConfiguredError } from '../../errors/errors.js'
|
|
17
|
+
import { ForbiddenError } from '../../errors/errors.js'
|
|
18
|
+
import { verifyScopes } from '../../scopes.js'
|
|
16
19
|
import { pikkuState, getSingletonServices } from '../../pikku-state.js'
|
|
17
20
|
import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
|
|
18
21
|
import type { SessionService } from '../../services/user-session-service.js'
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
resolveNamespace,
|
|
24
27
|
ContextAwareRPCService,
|
|
25
28
|
} from '../../wirings/rpc/rpc-runner.js'
|
|
29
|
+
import { getOrCreatePackageSingletonServices } from '../../wirings/rpc/addon-runner.js'
|
|
26
30
|
import {
|
|
27
31
|
resolveMemoryServices,
|
|
28
32
|
loadContextMessages,
|
|
@@ -36,10 +40,157 @@ export type RunAIAgentParams = {
|
|
|
36
40
|
getCredential?: <T = unknown>(name: string) => T | null | Promise<T | null>
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The ownership key for a thread/run: the trusted principal (the authenticated
|
|
45
|
+
* `session.userId` for `'user'` scope, or `session.orgId` for `'org'` scope)
|
|
46
|
+
* composed with the client-supplied `resourceId` as a sub-partition —
|
|
47
|
+
* `principal:resourceId`. Because the principal is always the prefix, a client
|
|
48
|
+
* `resourceId` can sub-divide within the caller's own boundary but can never
|
|
49
|
+
* widen access to another user's or org's threads.
|
|
50
|
+
*
|
|
51
|
+
* Idempotent: a value that is already within the caller's namespace (top-level
|
|
52
|
+
* re-entry, sub-agent recursion, or resume, which all pass an already-composite
|
|
53
|
+
* `resourceId`) is returned unchanged rather than double-composed.
|
|
54
|
+
*
|
|
55
|
+
* `'org'` scope with no session org throws {@link ForbiddenError} — falling back
|
|
56
|
+
* to a bare/shared key would leak across organizations. Sessionless `'user'`
|
|
57
|
+
* wirings have no trusted principal and fall back to the requested `resourceId`
|
|
58
|
+
* as a best-effort partition key.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveOwnerResourceId(
|
|
61
|
+
params: RunAIAgentParams,
|
|
62
|
+
sessionScope: SessionScope | undefined,
|
|
63
|
+
requestedResourceId: string
|
|
64
|
+
): string {
|
|
65
|
+
const session = params.sessionService?.get()
|
|
66
|
+
let principal: string | undefined
|
|
67
|
+
if ((sessionScope ?? 'user') === 'org') {
|
|
68
|
+
if (!session?.orgId) {
|
|
69
|
+
throw new ForbiddenError(
|
|
70
|
+
'This agent is org-scoped but the session has no organization'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
principal = session.orgId
|
|
74
|
+
} else {
|
|
75
|
+
principal = session?.userId
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!principal) return requestedResourceId
|
|
79
|
+
if (
|
|
80
|
+
requestedResourceId === principal ||
|
|
81
|
+
requestedResourceId.startsWith(`${principal}:`)
|
|
82
|
+
) {
|
|
83
|
+
return requestedResourceId
|
|
84
|
+
}
|
|
85
|
+
return `${principal}:${requestedResourceId}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The declared {@link SessionScope} for an agent, defaulting to `'user'`. */
|
|
89
|
+
export function agentSessionScope(agentName: string): SessionScope {
|
|
90
|
+
return resolveAgent(agentName).agent.sessionScope ?? 'user'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Enforce that a stored thread/run belongs to the caller's owner resource,
|
|
95
|
+
* throwing {@link ForbiddenError} (without echoing the id, to avoid an
|
|
96
|
+
* existence oracle) on a mismatch.
|
|
97
|
+
*/
|
|
98
|
+
export function assertResourceOwner(
|
|
99
|
+
ownerResourceId: string,
|
|
100
|
+
storedResourceId: string,
|
|
101
|
+
kind: 'thread' | 'run'
|
|
102
|
+
): void {
|
|
103
|
+
if (storedResourceId !== ownerResourceId) {
|
|
104
|
+
throw new ForbiddenError(`Not authorized to access this ${kind}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A session's trusted principals, in the order they may be read as. */
|
|
109
|
+
export function sessionPrincipals(
|
|
110
|
+
session: { userId?: string; orgId?: string } | undefined
|
|
111
|
+
): string[] {
|
|
112
|
+
return [session?.userId, session?.orgId].filter(
|
|
113
|
+
(principal): principal is string => Boolean(principal)
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether `storedResourceId` belongs to `principal` under the composition
|
|
119
|
+
* {@link resolveOwnerResourceId} performs — either the bare principal or one of
|
|
120
|
+
* its `principal:` sub-partitions. The `:` is required so that `alice` does not
|
|
121
|
+
* match a `alice-evil:…` lookalike.
|
|
122
|
+
*/
|
|
123
|
+
export function isOwnedByPrincipal(
|
|
124
|
+
storedResourceId: string,
|
|
125
|
+
principal: string
|
|
126
|
+
): boolean {
|
|
127
|
+
return (
|
|
128
|
+
storedResourceId === principal ||
|
|
129
|
+
storedResourceId.startsWith(`${principal}:`)
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The `owners` constraint to pass to `AgentRunService.listThreads` for a caller.
|
|
135
|
+
*
|
|
136
|
+
* Returns `undefined` — not `[]` — for a session with no principal, because an
|
|
137
|
+
* empty list means "match nothing" whereas a sessionless deployment (agent
|
|
138
|
+
* `no-auth`) has no ownership model to constrain by. Keeping that carve-out here
|
|
139
|
+
* rather than at each call site stops it from being re-derived inconsistently.
|
|
140
|
+
*/
|
|
141
|
+
export function threadOwnerConstraint(
|
|
142
|
+
session: { userId?: string; orgId?: string } | undefined
|
|
143
|
+
): string[] | undefined {
|
|
144
|
+
const principals = sessionPrincipals(session)
|
|
145
|
+
return principals.length > 0 ? principals : undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a stored thread/run may be read by the caller. Shaped as a predicate
|
|
150
|
+
* so it can back a `pikkuPermission` — authorization belongs in a function's
|
|
151
|
+
* `permissions` field, never in its body.
|
|
152
|
+
*
|
|
153
|
+
* Unlike {@link assertResourceOwner}, which compares against a single composed
|
|
154
|
+
* owner key on the run path, this guards the thread-management reads where the
|
|
155
|
+
* caller supplies only a `threadId` — so ownership has to be derived from the
|
|
156
|
+
* session rather than from the request.
|
|
157
|
+
*
|
|
158
|
+
* A session with no principal means the deployment opted out of authorization
|
|
159
|
+
* (agent `no-auth`), so there is no ownership model to enforce and access is not
|
|
160
|
+
* gated — mirroring {@link resolveOwnerResourceId}'s sessionless fallback to a
|
|
161
|
+
* bare resourceId.
|
|
162
|
+
*/
|
|
163
|
+
export function canAccessThread(
|
|
164
|
+
storedResourceId: string,
|
|
165
|
+
session: { userId?: string; orgId?: string } | undefined
|
|
166
|
+
): boolean {
|
|
167
|
+
const principals = sessionPrincipals(session)
|
|
168
|
+
if (principals.length === 0) return true
|
|
169
|
+
return principals.some((principal) =>
|
|
170
|
+
isOwnedByPrincipal(storedResourceId, principal)
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
39
174
|
export type StreamAIAgentOptions = {
|
|
40
175
|
requiresToolApproval?: 'all' | 'explicit' | false
|
|
176
|
+
/**
|
|
177
|
+
* Invoked as soon as the run's AIRunStateService id exists, so transport
|
|
178
|
+
* wrappers (e.g. the AG-UI bridge) can stamp the real runId on RUN_STARTED.
|
|
179
|
+
*/
|
|
180
|
+
onRunCreated?: (runId: string) => void
|
|
41
181
|
}
|
|
42
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Non-forgeable brand for the sub-agent approval marker. Only framework code
|
|
185
|
+
* (the delegating sub-agent tools below) sets this Symbol on a tool result; a
|
|
186
|
+
* sub-agent's LLM-shaped `result.object` — plain JSON — cannot carry a Symbol,
|
|
187
|
+
* so `checkForApprovals` trusts the brand rather than the `__approvalRequired`
|
|
188
|
+
* string key to decide an approval must be surfaced.
|
|
189
|
+
*/
|
|
190
|
+
export const APPROVAL_REQUIRED: unique symbol = Symbol(
|
|
191
|
+
'pikku.ai.approvalRequired'
|
|
192
|
+
)
|
|
193
|
+
|
|
43
194
|
export class ToolApprovalRequired extends PikkuError {
|
|
44
195
|
public readonly toolCallId: string
|
|
45
196
|
public readonly toolName: string
|
|
@@ -182,6 +333,56 @@ export const resolveAgent = (
|
|
|
182
333
|
throw new Error(`AI agent not found: ${agentName}`)
|
|
183
334
|
}
|
|
184
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Enforces an agent's own authorization before it runs: session presence
|
|
338
|
+
* (`auth`), then `scopes`, then `permissions`.
|
|
339
|
+
*
|
|
340
|
+
* The ordering mirrors the function runner — scopes are an AND gate checked
|
|
341
|
+
* first, so they can only ever narrow access, and a missing scope short-circuits
|
|
342
|
+
* before any permission function does I/O.
|
|
343
|
+
*
|
|
344
|
+
* `auth` follows `pikkuSessionlessFunc` rather than `pikkuFunc`: a session is
|
|
345
|
+
* required only when `auth: true` is set explicitly. An agent is normally
|
|
346
|
+
* reached from a function that has already enforced its own auth, and agents are
|
|
347
|
+
* also run from genuinely sessionless contexts (crons, queue workers), so
|
|
348
|
+
* requiring a session by default would reject those without adding a meaningful
|
|
349
|
+
* gate. `scopes` and `permissions` are always enforced when declared.
|
|
350
|
+
*
|
|
351
|
+
* Globals are evaluated here too (via {@link runPermissions}) rather than being
|
|
352
|
+
* assumed to have already run: an agent is reachable from entry points that do
|
|
353
|
+
* not go through the function runner, and re-evaluating an AND gate of
|
|
354
|
+
* side-effect-free predicates is idempotent.
|
|
355
|
+
*/
|
|
356
|
+
export async function assertAgentAuthorized(
|
|
357
|
+
agent: CoreAIAgent,
|
|
358
|
+
params: RunAIAgentParams,
|
|
359
|
+
packageName: string | null
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
const session = params.sessionService
|
|
362
|
+
? await params.sessionService.get()
|
|
363
|
+
: undefined
|
|
364
|
+
|
|
365
|
+
if (agent.auth === true && !session) {
|
|
366
|
+
throw new ForbiddenError('Authentication required')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
verifyScopes(agent.scopes, session)
|
|
370
|
+
|
|
371
|
+
const singletonServices = getSingletonServices()
|
|
372
|
+
const wire = params.sessionService
|
|
373
|
+
? createMiddlewareSessionWireProps(params.sessionService)
|
|
374
|
+
: { session: undefined }
|
|
375
|
+
|
|
376
|
+
await runPermissions({
|
|
377
|
+
funcPermissions: agent.permissions,
|
|
378
|
+
services: singletonServices as any,
|
|
379
|
+
wire: wire as any,
|
|
380
|
+
data: {},
|
|
381
|
+
packageName,
|
|
382
|
+
label: 'agent',
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
185
386
|
export async function buildInstructions(
|
|
186
387
|
agentName: string,
|
|
187
388
|
packageName: string | null
|
|
@@ -193,7 +394,7 @@ export async function buildInstructions(
|
|
|
193
394
|
if (meta?.goal) parts.push(meta.goal)
|
|
194
395
|
let instructions = parts.join('\n\n')
|
|
195
396
|
|
|
196
|
-
if (meta?.tools?.length) {
|
|
397
|
+
if (meta?.tools?.length || meta?.workflows?.length) {
|
|
197
398
|
instructions +=
|
|
198
399
|
'\n\nTool usage rules:\n' +
|
|
199
400
|
'- Act immediately with the information given. Do NOT ask clarifying questions unless a required field is truly missing.\n' +
|
|
@@ -219,6 +420,7 @@ export type ScopedChannel = AIStreamChannel & {
|
|
|
219
420
|
toolCallId: string
|
|
220
421
|
toolName: string
|
|
221
422
|
args: unknown
|
|
423
|
+
reason?: string
|
|
222
424
|
runId: string
|
|
223
425
|
}>
|
|
224
426
|
}
|
|
@@ -248,6 +450,7 @@ export function createScopedChannel(
|
|
|
248
450
|
toolCallId: event.toolCallId,
|
|
249
451
|
toolName: event.toolName,
|
|
250
452
|
args: event.args,
|
|
453
|
+
...(event.reason !== undefined ? { reason: event.reason } : {}),
|
|
251
454
|
runId: (event as any).runId,
|
|
252
455
|
})
|
|
253
456
|
return
|
|
@@ -333,11 +536,17 @@ export async function buildToolDefs(
|
|
|
333
536
|
continue
|
|
334
537
|
}
|
|
335
538
|
|
|
336
|
-
// Filter out tools the user doesn't have auth for
|
|
539
|
+
// Filter out tools the user doesn't have auth for. The `pikkuAuth` brand
|
|
540
|
+
// only survives on the live permission objects in the function config, so
|
|
541
|
+
// the check reads those rather than the metadata (whose by-name registry
|
|
542
|
+
// is never populated, which would let every gated tool through).
|
|
337
543
|
if (fnMeta.permissions?.length) {
|
|
338
544
|
if (!session) continue
|
|
545
|
+
const funcConfig = pikkuFuncId
|
|
546
|
+
? pikkuState(resolvedPkg, 'function', 'functions').get(pikkuFuncId)
|
|
547
|
+
: undefined
|
|
339
548
|
const allowed = await checkAuthPermissions(
|
|
340
|
-
|
|
549
|
+
funcConfig?.permissions,
|
|
341
550
|
session,
|
|
342
551
|
singletonServices,
|
|
343
552
|
resolvedPkg
|
|
@@ -373,17 +582,27 @@ export async function buildToolDefs(
|
|
|
373
582
|
if (funcConfig?.approvalDescription) {
|
|
374
583
|
const descFn = funcConfig.approvalDescription
|
|
375
584
|
const capturedPkg = resolvedPkg
|
|
585
|
+
const capturedAddonConfig = resolved?.addonConfig
|
|
586
|
+
const capturedNamespace = toolName.includes(':')
|
|
587
|
+
? toolName.slice(0, toolName.indexOf(':'))
|
|
588
|
+
: null
|
|
376
589
|
approvalDescriptionFn = async (input: unknown) => {
|
|
377
590
|
let services = singletonServices
|
|
378
591
|
if (capturedPkg) {
|
|
379
|
-
const
|
|
592
|
+
const addonInstance = capturedNamespace
|
|
593
|
+
? {
|
|
594
|
+
namespace: capturedNamespace,
|
|
595
|
+
secretOverrides: capturedAddonConfig?.secretOverrides,
|
|
596
|
+
variableOverrides: capturedAddonConfig?.variableOverrides,
|
|
597
|
+
credentialOverrides:
|
|
598
|
+
capturedAddonConfig?.credentialOverrides,
|
|
599
|
+
}
|
|
600
|
+
: undefined
|
|
601
|
+
services = await getOrCreatePackageSingletonServices(
|
|
380
602
|
capturedPkg,
|
|
381
|
-
|
|
382
|
-
|
|
603
|
+
singletonServices,
|
|
604
|
+
addonInstance
|
|
383
605
|
)
|
|
384
|
-
if (pkgServices) {
|
|
385
|
-
services = pkgServices
|
|
386
|
-
}
|
|
387
606
|
}
|
|
388
607
|
return descFn(services, input)
|
|
389
608
|
}
|
|
@@ -423,11 +642,13 @@ export async function buildToolDefs(
|
|
|
423
642
|
continue
|
|
424
643
|
}
|
|
425
644
|
|
|
426
|
-
// Filter out sub-agents the user doesn't have auth for
|
|
645
|
+
// Filter out sub-agents the user doesn't have auth for, reading the live
|
|
646
|
+
// agent config for the same reason the tool path does.
|
|
427
647
|
if (subMeta.permissions?.length) {
|
|
428
648
|
if (!session) continue
|
|
649
|
+
const subAgent = pikkuState(null, 'agent', 'agents').get(subAgentName)
|
|
429
650
|
const allowed = await checkAuthPermissions(
|
|
430
|
-
|
|
651
|
+
subAgent?.permissions,
|
|
431
652
|
session,
|
|
432
653
|
singletonServices
|
|
433
654
|
)
|
|
@@ -437,6 +658,7 @@ export async function buildToolDefs(
|
|
|
437
658
|
tools.push({
|
|
438
659
|
name: subAgentName,
|
|
439
660
|
description: subMeta.description,
|
|
661
|
+
forwardsApproval: true,
|
|
440
662
|
inputSchema: {
|
|
441
663
|
type: 'object',
|
|
442
664
|
properties: {
|
|
@@ -502,6 +724,7 @@ export async function buildToolDefs(
|
|
|
502
724
|
)
|
|
503
725
|
if (subChannel.approvals.length > 0) {
|
|
504
726
|
return {
|
|
727
|
+
[APPROVAL_REQUIRED]: true,
|
|
505
728
|
__approvalRequired: true,
|
|
506
729
|
toolName: subAgentName,
|
|
507
730
|
args: toolInput,
|
|
@@ -530,6 +753,7 @@ export async function buildToolDefs(
|
|
|
530
753
|
result.pendingApprovals?.length
|
|
531
754
|
) {
|
|
532
755
|
return {
|
|
756
|
+
[APPROVAL_REQUIRED]: true,
|
|
533
757
|
__approvalRequired: true,
|
|
534
758
|
toolName: subAgentName,
|
|
535
759
|
args: toolInput,
|
|
@@ -538,6 +762,7 @@ export async function buildToolDefs(
|
|
|
538
762
|
toolCallId: a.toolCallId,
|
|
539
763
|
toolName: a.toolName,
|
|
540
764
|
args: a.args,
|
|
765
|
+
reason: a.reason,
|
|
541
766
|
runId: a.runId,
|
|
542
767
|
})),
|
|
543
768
|
}
|
|
@@ -548,6 +773,84 @@ export async function buildToolDefs(
|
|
|
548
773
|
}
|
|
549
774
|
}
|
|
550
775
|
|
|
776
|
+
const metaWorkflows = meta.workflows
|
|
777
|
+
if (metaWorkflows?.length) {
|
|
778
|
+
const workflowMetaMap = pikkuState(null, 'workflows', 'meta')
|
|
779
|
+
const functionMeta = pikkuState(null, 'function', 'meta')
|
|
780
|
+
const schemas = pikkuState(null, 'misc', 'schemas')
|
|
781
|
+
|
|
782
|
+
for (const workflowName of metaWorkflows) {
|
|
783
|
+
const wfMeta = workflowMetaMap[workflowName]
|
|
784
|
+
if (!wfMeta) {
|
|
785
|
+
missingRpcs.push(workflowName)
|
|
786
|
+
continue
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const inputSchemaName = wfMeta.pikkuFuncId
|
|
790
|
+
? functionMeta[wfMeta.pikkuFuncId]?.inputSchemaName
|
|
791
|
+
: undefined
|
|
792
|
+
let inputSchema = inputSchemaName
|
|
793
|
+
? schemas.get(inputSchemaName)
|
|
794
|
+
: undefined
|
|
795
|
+
if (
|
|
796
|
+
!inputSchema ||
|
|
797
|
+
(typeof inputSchema === 'object' &&
|
|
798
|
+
inputSchema.type === 'object' &&
|
|
799
|
+
!inputSchema.properties)
|
|
800
|
+
) {
|
|
801
|
+
inputSchema = { type: 'object', properties: {} }
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
tools.push({
|
|
805
|
+
name: workflowName,
|
|
806
|
+
description: wfMeta.description || workflowName,
|
|
807
|
+
inputSchema,
|
|
808
|
+
execute: async (toolInput: unknown) => {
|
|
809
|
+
const workflowService = singletonServices.workflowService
|
|
810
|
+
if (!workflowService) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`workflowService is not configured — cannot run workflow tool '${workflowName}'`
|
|
813
|
+
)
|
|
814
|
+
}
|
|
815
|
+
const wire: PikkuRawWire = params.sessionService
|
|
816
|
+
? { ...createMiddlewareSessionWireProps(params.sessionService) }
|
|
817
|
+
: {}
|
|
818
|
+
const rpcService = new ContextAwareRPCService(
|
|
819
|
+
singletonServices,
|
|
820
|
+
wire,
|
|
821
|
+
{ sessionService: params.sessionService }
|
|
822
|
+
)
|
|
823
|
+
return workflowService.runToCompletion(
|
|
824
|
+
workflowName,
|
|
825
|
+
toolInput,
|
|
826
|
+
rpcService,
|
|
827
|
+
{ wire: { type: 'internal' } }
|
|
828
|
+
)
|
|
829
|
+
},
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// A tool's execute() can throw (bad input, RPC failure, DB error, ...). The AI
|
|
835
|
+
// SDK catches that at the tool-call boundary and turns it into a conversational
|
|
836
|
+
// "tool error" reply — without this, the exception never reaches pikku's own
|
|
837
|
+
// logger, so a failing tool is undiagnosable server-side. Log unconditionally,
|
|
838
|
+
// even when no aiMiddleware afterToolCall hook is registered to see it.
|
|
839
|
+
for (const tool of tools) {
|
|
840
|
+
const originalExecute = tool.execute
|
|
841
|
+
tool.execute = async (toolInput: unknown) => {
|
|
842
|
+
try {
|
|
843
|
+
return await originalExecute(toolInput)
|
|
844
|
+
} catch (err) {
|
|
845
|
+
singletonServices.logger.error(
|
|
846
|
+
`AI agent tool '${tool.name}' threw during execute()`,
|
|
847
|
+
err
|
|
848
|
+
)
|
|
849
|
+
throw err
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
551
854
|
const hasToolHooks = aiMiddlewares?.some(
|
|
552
855
|
(mw) => mw.beforeToolCall || mw.afterToolCall
|
|
553
856
|
)
|
|
@@ -618,6 +921,8 @@ export async function prepareAgentRun(
|
|
|
618
921
|
const singletonServices = getSingletonServices()
|
|
619
922
|
const { agent, packageName, resolvedName } = resolveAgent(agentName)
|
|
620
923
|
|
|
924
|
+
await assertAgentAuthorized(agent, params, packageName)
|
|
925
|
+
|
|
621
926
|
let agentRunner = singletonServices.aiAgentRunner
|
|
622
927
|
if (!agentRunner) {
|
|
623
928
|
throw new AIProviderNotConfiguredError()
|
|
@@ -649,9 +954,15 @@ export async function prepareAgentRun(
|
|
|
649
954
|
: undefined
|
|
650
955
|
|
|
651
956
|
if (storage) {
|
|
957
|
+
let thread: Awaited<ReturnType<typeof storage.getThread>> | null = null
|
|
652
958
|
try {
|
|
653
|
-
await storage.getThread(threadId)
|
|
959
|
+
thread = await storage.getThread(threadId)
|
|
654
960
|
} catch {
|
|
961
|
+
thread = null
|
|
962
|
+
}
|
|
963
|
+
if (thread) {
|
|
964
|
+
assertResourceOwner(input.resourceId, thread.resourceId, 'thread')
|
|
965
|
+
} else {
|
|
655
966
|
await storage.createThread(input.resourceId, { threadId })
|
|
656
967
|
}
|
|
657
968
|
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { beforeEach, describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resetPikkuState,
|
|
6
|
+
pikkuState,
|
|
7
|
+
setSingletonServices,
|
|
8
|
+
} from '../../pikku-state.js'
|
|
9
|
+
import { resumeAIAgentSync } from './ai-agent-runner.js'
|
|
10
|
+
import { resumeAIAgent } from './ai-agent-stream.js'
|
|
11
|
+
import { clearPermissionsCache } from '../../permissions.js'
|
|
12
|
+
import { ForbiddenError, MissingScopeError } from '../../errors/errors.js'
|
|
13
|
+
import type { CoreAIAgent } from './ai-agent.types.js'
|
|
14
|
+
import type { CoreUserSession } from '../../types/core.types.js'
|
|
15
|
+
|
|
16
|
+
const logger = {
|
|
17
|
+
debug: () => {},
|
|
18
|
+
info: () => {},
|
|
19
|
+
warn: () => {},
|
|
20
|
+
error: () => {},
|
|
21
|
+
} as any
|
|
22
|
+
|
|
23
|
+
const RUN_ID = 'run-1'
|
|
24
|
+
const TOOL_CALL_ID = 'tc-1'
|
|
25
|
+
|
|
26
|
+
const suspendedRun = (agentName: string, resourceId: string) => ({
|
|
27
|
+
id: RUN_ID,
|
|
28
|
+
agentName,
|
|
29
|
+
resourceId,
|
|
30
|
+
threadId: 'thread-1',
|
|
31
|
+
status: 'suspended',
|
|
32
|
+
pendingApprovals: [
|
|
33
|
+
{ toolCallId: TOOL_CALL_ID, toolName: 'refund', args: {}, runId: RUN_ID },
|
|
34
|
+
],
|
|
35
|
+
messages: [],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const setup = (agentName: string, resourceId = 'u1') => {
|
|
39
|
+
const resolved: string[] = []
|
|
40
|
+
setSingletonServices({
|
|
41
|
+
logger,
|
|
42
|
+
aiRunState: {
|
|
43
|
+
getRun: async () => suspendedRun(agentName, resourceId),
|
|
44
|
+
resolveApproval: async (toolCallId: string) => {
|
|
45
|
+
resolved.push(toolCallId)
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
} as any)
|
|
49
|
+
return resolved
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const addAgent = (
|
|
53
|
+
agentName: string,
|
|
54
|
+
overrides: Partial<CoreAIAgent> = {}
|
|
55
|
+
): CoreAIAgent => {
|
|
56
|
+
const agent: CoreAIAgent = {
|
|
57
|
+
name: agentName,
|
|
58
|
+
description: `${agentName} description`,
|
|
59
|
+
goal: `${agentName} goal`,
|
|
60
|
+
model: 'test/test-model',
|
|
61
|
+
...overrides,
|
|
62
|
+
} as CoreAIAgent
|
|
63
|
+
|
|
64
|
+
pikkuState(null, 'agent', 'agents').set(agentName, agent)
|
|
65
|
+
return agent
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sessionService = (session: CoreUserSession | undefined) =>
|
|
69
|
+
({
|
|
70
|
+
get: () => session,
|
|
71
|
+
setInitial: () => {},
|
|
72
|
+
sessionChanged: false,
|
|
73
|
+
}) as any
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
resetPikkuState()
|
|
77
|
+
clearPermissionsCache()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('resumeAIAgentSync authorization', () => {
|
|
81
|
+
test('rejects when the agent permissions deny', async () => {
|
|
82
|
+
const resolved = setup('denied-agent')
|
|
83
|
+
addAgent('denied-agent', {
|
|
84
|
+
permissions: { denied: async () => false },
|
|
85
|
+
} as Partial<CoreAIAgent>)
|
|
86
|
+
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
() =>
|
|
89
|
+
resumeAIAgentSync(
|
|
90
|
+
RUN_ID,
|
|
91
|
+
[{ toolCallId: TOOL_CALL_ID, approved: true }],
|
|
92
|
+
{ sessionService: sessionService({ userId: 'u1' }) } as any
|
|
93
|
+
),
|
|
94
|
+
ForbiddenError
|
|
95
|
+
)
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
resolved,
|
|
98
|
+
[],
|
|
99
|
+
'approvals must not be resolved when authorization fails'
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('rejects when a required scope is no longer held', async () => {
|
|
104
|
+
const resolved = setup('scoped-agent')
|
|
105
|
+
addAgent('scoped-agent', { scopes: ['admin:refund'] })
|
|
106
|
+
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
() =>
|
|
109
|
+
resumeAIAgentSync(
|
|
110
|
+
RUN_ID,
|
|
111
|
+
[{ toolCallId: TOOL_CALL_ID, approved: true }],
|
|
112
|
+
{
|
|
113
|
+
sessionService: sessionService({
|
|
114
|
+
userId: 'u1',
|
|
115
|
+
scopes: ['support'],
|
|
116
|
+
}),
|
|
117
|
+
} as any
|
|
118
|
+
),
|
|
119
|
+
MissingScopeError
|
|
120
|
+
)
|
|
121
|
+
assert.deepEqual(resolved, [], 'a revoked scope must block resumption')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('passes the gate when the session still holds the scope', async () => {
|
|
125
|
+
setup('scoped-agent-ok')
|
|
126
|
+
addAgent('scoped-agent-ok', { scopes: ['admin:refund'] })
|
|
127
|
+
|
|
128
|
+
// Gets past authorization and fails later on the unconfigured provider,
|
|
129
|
+
// which is how we know the gate admitted it.
|
|
130
|
+
await assert.rejects(
|
|
131
|
+
() =>
|
|
132
|
+
resumeAIAgentSync(
|
|
133
|
+
RUN_ID,
|
|
134
|
+
[{ toolCallId: TOOL_CALL_ID, approved: true }],
|
|
135
|
+
{
|
|
136
|
+
sessionService: sessionService({
|
|
137
|
+
userId: 'u1',
|
|
138
|
+
scopes: ['admin:refund'],
|
|
139
|
+
}),
|
|
140
|
+
} as any
|
|
141
|
+
),
|
|
142
|
+
(error: Error) =>
|
|
143
|
+
!(error instanceof ForbiddenError) &&
|
|
144
|
+
!(error instanceof MissingScopeError)
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('rejects when auth is required and the session is gone', async () => {
|
|
149
|
+
setup('auth-agent')
|
|
150
|
+
addAgent('auth-agent', { auth: true })
|
|
151
|
+
|
|
152
|
+
await assert.rejects(
|
|
153
|
+
() =>
|
|
154
|
+
resumeAIAgentSync(
|
|
155
|
+
RUN_ID,
|
|
156
|
+
[{ toolCallId: TOOL_CALL_ID, approved: true }],
|
|
157
|
+
{ sessionService: sessionService(undefined) } as any
|
|
158
|
+
),
|
|
159
|
+
ForbiddenError
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('resumeAIAgent (streaming) authorization', () => {
|
|
165
|
+
const channel = {
|
|
166
|
+
send: async () => {},
|
|
167
|
+
close: async () => {},
|
|
168
|
+
} as any
|
|
169
|
+
|
|
170
|
+
test('rejects when the agent permissions deny', async () => {
|
|
171
|
+
const resolved = setup('stream-denied')
|
|
172
|
+
addAgent('stream-denied', {
|
|
173
|
+
permissions: { denied: async () => false },
|
|
174
|
+
} as Partial<CoreAIAgent>)
|
|
175
|
+
|
|
176
|
+
await assert.rejects(
|
|
177
|
+
() =>
|
|
178
|
+
resumeAIAgent(
|
|
179
|
+
{ runId: RUN_ID, toolCallId: TOOL_CALL_ID, approved: true },
|
|
180
|
+
channel,
|
|
181
|
+
{ sessionService: sessionService({ userId: 'u1' }) } as any
|
|
182
|
+
),
|
|
183
|
+
ForbiddenError
|
|
184
|
+
)
|
|
185
|
+
assert.deepEqual(resolved, [])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('rejects when a required scope is no longer held', async () => {
|
|
189
|
+
setup('stream-scoped')
|
|
190
|
+
addAgent('stream-scoped', { scopes: ['admin:refund'] })
|
|
191
|
+
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
() =>
|
|
194
|
+
resumeAIAgent(
|
|
195
|
+
{ runId: RUN_ID, toolCallId: TOOL_CALL_ID, approved: true },
|
|
196
|
+
channel,
|
|
197
|
+
{
|
|
198
|
+
sessionService: sessionService({
|
|
199
|
+
userId: 'u1',
|
|
200
|
+
scopes: ['support'],
|
|
201
|
+
}),
|
|
202
|
+
} as any
|
|
203
|
+
),
|
|
204
|
+
MissingScopeError
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
})
|