@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.
Files changed (225) hide show
  1. package/CHANGELOG.md +368 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +42 -27
  24. package/dist/permissions.js +101 -203
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/testing/service-tests.js +37 -0
  46. package/dist/types/core.types.d.ts +34 -6
  47. package/dist/types/state.types.d.ts +20 -2
  48. package/dist/utils/hmac.d.ts +16 -0
  49. package/dist/utils/hmac.js +26 -0
  50. package/dist/utils/safe-fetch.d.ts +51 -0
  51. package/dist/utils/safe-fetch.js +192 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  53. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  54. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
  55. package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
  56. package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
  57. package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
  58. package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
  59. package/dist/wirings/ai-agent/index.d.ts +2 -1
  60. package/dist/wirings/ai-agent/index.js +2 -1
  61. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  62. package/dist/wirings/ai-agent/voice-input.js +5 -41
  63. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  64. package/dist/wirings/channel/channel-common.js +0 -1
  65. package/dist/wirings/channel/channel-handler.js +1 -4
  66. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  67. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  68. package/dist/wirings/channel/channel.types.d.ts +2 -6
  69. package/dist/wirings/cli/cli-runner.js +3 -2
  70. package/dist/wirings/cli/cli.types.d.ts +1 -2
  71. package/dist/wirings/cli/command-parser.d.ts +2 -0
  72. package/dist/wirings/cli/command-parser.js +59 -2
  73. package/dist/wirings/credential/credential.types.d.ts +14 -0
  74. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  75. package/dist/wirings/gateway/gateway-runner.js +100 -50
  76. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  77. package/dist/wirings/http/http-routes.js +0 -3
  78. package/dist/wirings/http/http-runner.d.ts +9 -29
  79. package/dist/wirings/http/http-runner.js +9 -34
  80. package/dist/wirings/http/http.types.d.ts +4 -12
  81. package/dist/wirings/mcp/mcp-runner.js +0 -2
  82. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  83. package/dist/wirings/oauth2/index.d.ts +0 -3
  84. package/dist/wirings/oauth2/index.js +1 -2
  85. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  86. package/dist/wirings/rpc/addon-runner.js +173 -0
  87. package/dist/wirings/rpc/index.d.ts +5 -1
  88. package/dist/wirings/rpc/index.js +3 -1
  89. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  90. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  91. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  92. package/dist/wirings/rpc/rpc-runner.js +109 -3
  93. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  94. package/dist/wirings/rpc/wire-addon.js +9 -0
  95. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  96. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  97. package/dist/wirings/scope/index.d.ts +3 -0
  98. package/dist/wirings/scope/index.js +2 -0
  99. package/dist/wirings/scope/scope.types.d.ts +39 -0
  100. package/dist/wirings/scope/scope.types.js +1 -0
  101. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  102. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  103. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  104. package/dist/wirings/scope/wire-scope.js +32 -0
  105. package/dist/wirings/secret/secret.types.d.ts +14 -0
  106. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  107. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  108. package/dist/wirings/variable/variable.types.d.ts +14 -0
  109. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  110. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
  111. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  112. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  113. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  114. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  115. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  116. package/dist/wirings/workflow/index.d.ts +2 -2
  117. package/dist/wirings/workflow/index.js +1 -1
  118. package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
  119. package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
  120. package/dist/wirings/workflow/workflow.types.d.ts +1 -3
  121. package/package.json +6 -1
  122. package/run-tests.sh +1 -0
  123. package/src/dev/hot-reload.ts +13 -68
  124. package/src/dev/module-runner.test.ts +169 -0
  125. package/src/dev/module-runner.ts +103 -0
  126. package/src/dev/reload-meta.test.ts +31 -2
  127. package/src/dev/reload-meta.ts +26 -0
  128. package/src/errors/errors.ts +24 -0
  129. package/src/function/function-runner.test.ts +276 -142
  130. package/src/function/function-runner.ts +52 -145
  131. package/src/function/functions.types.ts +28 -38
  132. package/src/index.ts +8 -6
  133. package/src/middleware/index.ts +1 -5
  134. package/src/permissions.test.ts +160 -373
  135. package/src/permissions.ts +137 -279
  136. package/src/pikku-state.ts +0 -2
  137. package/src/scopes.test.ts +167 -0
  138. package/src/scopes.ts +69 -0
  139. package/src/services/ai-embedding-service.ts +31 -0
  140. package/src/services/credential-wire-service.ts +10 -4
  141. package/src/services/index.ts +20 -0
  142. package/src/services/meta-service.ts +68 -3
  143. package/src/services/queue-webhook-service.test.ts +408 -0
  144. package/src/services/queue-webhook-service.ts +182 -0
  145. package/src/services/scope-service.ts +75 -0
  146. package/src/services/typed-secret-service.test.ts +35 -0
  147. package/src/services/typed-secret-service.ts +39 -4
  148. package/src/services/webhook-service.ts +180 -0
  149. package/src/services/workflow-service.ts +7 -0
  150. package/src/testing/service-tests.ts +49 -0
  151. package/src/types/core.types.ts +42 -14
  152. package/src/types/state.types.ts +23 -3
  153. package/src/utils/hmac.ts +27 -0
  154. package/src/utils/safe-fetch.test.ts +373 -0
  155. package/src/utils/safe-fetch.ts +213 -0
  156. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  157. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  158. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  159. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
  160. package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
  161. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  162. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  163. package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
  164. package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
  165. package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
  166. package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
  167. package/src/wirings/ai-agent/index.ts +5 -0
  168. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  169. package/src/wirings/ai-agent/voice-input.ts +9 -40
  170. package/src/wirings/channel/channel-common.ts +0 -1
  171. package/src/wirings/channel/channel-handler.ts +1 -9
  172. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  173. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  174. package/src/wirings/channel/channel.types.ts +0 -6
  175. package/src/wirings/cli/cli-runner.ts +4 -2
  176. package/src/wirings/cli/cli.types.ts +0 -2
  177. package/src/wirings/cli/command-parser.test.ts +130 -0
  178. package/src/wirings/cli/command-parser.ts +80 -2
  179. package/src/wirings/credential/credential.types.ts +14 -0
  180. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  181. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  182. package/src/wirings/gateway/gateway-runner.ts +114 -68
  183. package/src/wirings/gateway/gateway.types.ts +7 -9
  184. package/src/wirings/http/http-routes.ts +0 -3
  185. package/src/wirings/http/http-runner.test.ts +1 -11
  186. package/src/wirings/http/http-runner.ts +16 -39
  187. package/src/wirings/http/http.types.ts +7 -14
  188. package/src/wirings/mcp/mcp-runner.ts +0 -2
  189. package/src/wirings/mcp/mcp.types.ts +3 -11
  190. package/src/wirings/oauth2/index.ts +0 -3
  191. package/src/wirings/rpc/addon-runner.ts +233 -0
  192. package/src/wirings/rpc/index.ts +17 -1
  193. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  194. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  195. package/src/wirings/rpc/rpc-runner.ts +155 -3
  196. package/src/wirings/rpc/rpc-types.ts +16 -5
  197. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  198. package/src/wirings/rpc/wire-addon.ts +9 -0
  199. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  200. package/src/wirings/scope/index.ts +14 -0
  201. package/src/wirings/scope/scope.test.ts +135 -0
  202. package/src/wirings/scope/scope.types.ts +44 -0
  203. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  204. package/src/wirings/scope/wire-scope.ts +34 -0
  205. package/src/wirings/secret/secret.types.ts +14 -0
  206. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  207. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  208. package/src/wirings/variable/variable.types.ts +14 -0
  209. package/src/wirings/workflow/dsl/index.ts +4 -0
  210. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
  211. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  212. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  213. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  214. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  215. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  216. package/src/wirings/workflow/index.ts +5 -0
  217. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  218. package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
  219. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  220. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  221. package/src/wirings/workflow/workflow.types.ts +4 -2
  222. package/tsconfig.tsbuildinfo +1 -1
  223. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  224. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  225. 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
- fnMeta.permissions,
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 pkgServices = pikkuState(
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
- 'package',
382
- 'singletonServices'
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
- subMeta.permissions,
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
+ })