@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
@@ -1,14 +1,123 @@
1
1
  import { PikkuError } from '../../errors/error-handler.js';
2
- import { checkAuthPermissions } from '../../permissions.js';
2
+ import { checkAuthPermissions, runPermissions } from '../../permissions.js';
3
3
  import { AIProviderNotConfiguredError } from '../../errors/errors.js';
4
+ import { ForbiddenError } from '../../errors/errors.js';
5
+ import { verifyScopes } from '../../scopes.js';
4
6
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
5
7
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js';
6
8
  import { randomUUID } from './ai-agent-utils.js';
7
9
  import { streamAIAgent } from './ai-agent-stream.js';
8
10
  import { runAIAgent } from './ai-agent-runner.js';
9
11
  import { resolveNamespace, ContextAwareRPCService, } from '../../wirings/rpc/rpc-runner.js';
12
+ import { getOrCreatePackageSingletonServices } from '../../wirings/rpc/addon-runner.js';
10
13
  import { resolveMemoryServices, loadContextMessages, trimMessages, } from './ai-agent-memory.js';
11
14
  import { resolveModelConfig } from './ai-agent-model-config.js';
15
+ /**
16
+ * The ownership key for a thread/run: the trusted principal (the authenticated
17
+ * `session.userId` for `'user'` scope, or `session.orgId` for `'org'` scope)
18
+ * composed with the client-supplied `resourceId` as a sub-partition —
19
+ * `principal:resourceId`. Because the principal is always the prefix, a client
20
+ * `resourceId` can sub-divide within the caller's own boundary but can never
21
+ * widen access to another user's or org's threads.
22
+ *
23
+ * Idempotent: a value that is already within the caller's namespace (top-level
24
+ * re-entry, sub-agent recursion, or resume, which all pass an already-composite
25
+ * `resourceId`) is returned unchanged rather than double-composed.
26
+ *
27
+ * `'org'` scope with no session org throws {@link ForbiddenError} — falling back
28
+ * to a bare/shared key would leak across organizations. Sessionless `'user'`
29
+ * wirings have no trusted principal and fall back to the requested `resourceId`
30
+ * as a best-effort partition key.
31
+ */
32
+ export function resolveOwnerResourceId(params, sessionScope, requestedResourceId) {
33
+ const session = params.sessionService?.get();
34
+ let principal;
35
+ if ((sessionScope ?? 'user') === 'org') {
36
+ if (!session?.orgId) {
37
+ throw new ForbiddenError('This agent is org-scoped but the session has no organization');
38
+ }
39
+ principal = session.orgId;
40
+ }
41
+ else {
42
+ principal = session?.userId;
43
+ }
44
+ if (!principal)
45
+ return requestedResourceId;
46
+ if (requestedResourceId === principal ||
47
+ requestedResourceId.startsWith(`${principal}:`)) {
48
+ return requestedResourceId;
49
+ }
50
+ return `${principal}:${requestedResourceId}`;
51
+ }
52
+ /** The declared {@link SessionScope} for an agent, defaulting to `'user'`. */
53
+ export function agentSessionScope(agentName) {
54
+ return resolveAgent(agentName).agent.sessionScope ?? 'user';
55
+ }
56
+ /**
57
+ * Enforce that a stored thread/run belongs to the caller's owner resource,
58
+ * throwing {@link ForbiddenError} (without echoing the id, to avoid an
59
+ * existence oracle) on a mismatch.
60
+ */
61
+ export function assertResourceOwner(ownerResourceId, storedResourceId, kind) {
62
+ if (storedResourceId !== ownerResourceId) {
63
+ throw new ForbiddenError(`Not authorized to access this ${kind}`);
64
+ }
65
+ }
66
+ /** A session's trusted principals, in the order they may be read as. */
67
+ export function sessionPrincipals(session) {
68
+ return [session?.userId, session?.orgId].filter((principal) => Boolean(principal));
69
+ }
70
+ /**
71
+ * Whether `storedResourceId` belongs to `principal` under the composition
72
+ * {@link resolveOwnerResourceId} performs — either the bare principal or one of
73
+ * its `principal:` sub-partitions. The `:` is required so that `alice` does not
74
+ * match a `alice-evil:…` lookalike.
75
+ */
76
+ export function isOwnedByPrincipal(storedResourceId, principal) {
77
+ return (storedResourceId === principal ||
78
+ storedResourceId.startsWith(`${principal}:`));
79
+ }
80
+ /**
81
+ * The `owners` constraint to pass to `AgentRunService.listThreads` for a caller.
82
+ *
83
+ * Returns `undefined` — not `[]` — for a session with no principal, because an
84
+ * empty list means "match nothing" whereas a sessionless deployment (agent
85
+ * `no-auth`) has no ownership model to constrain by. Keeping that carve-out here
86
+ * rather than at each call site stops it from being re-derived inconsistently.
87
+ */
88
+ export function threadOwnerConstraint(session) {
89
+ const principals = sessionPrincipals(session);
90
+ return principals.length > 0 ? principals : undefined;
91
+ }
92
+ /**
93
+ * Whether a stored thread/run may be read by the caller. Shaped as a predicate
94
+ * so it can back a `pikkuPermission` — authorization belongs in a function's
95
+ * `permissions` field, never in its body.
96
+ *
97
+ * Unlike {@link assertResourceOwner}, which compares against a single composed
98
+ * owner key on the run path, this guards the thread-management reads where the
99
+ * caller supplies only a `threadId` — so ownership has to be derived from the
100
+ * session rather than from the request.
101
+ *
102
+ * A session with no principal means the deployment opted out of authorization
103
+ * (agent `no-auth`), so there is no ownership model to enforce and access is not
104
+ * gated — mirroring {@link resolveOwnerResourceId}'s sessionless fallback to a
105
+ * bare resourceId.
106
+ */
107
+ export function canAccessThread(storedResourceId, session) {
108
+ const principals = sessionPrincipals(session);
109
+ if (principals.length === 0)
110
+ return true;
111
+ return principals.some((principal) => isOwnedByPrincipal(storedResourceId, principal));
112
+ }
113
+ /**
114
+ * Non-forgeable brand for the sub-agent approval marker. Only framework code
115
+ * (the delegating sub-agent tools below) sets this Symbol on a tool result; a
116
+ * sub-agent's LLM-shaped `result.object` — plain JSON — cannot carry a Symbol,
117
+ * so `checkForApprovals` trusts the brand rather than the `__approvalRequired`
118
+ * string key to decide an approval must be surfaced.
119
+ */
120
+ export const APPROVAL_REQUIRED = Symbol('pikku.ai.approvalRequired');
12
121
  export class ToolApprovalRequired extends PikkuError {
13
122
  toolCallId;
14
123
  toolName;
@@ -102,6 +211,47 @@ export const resolveAgent = (agentName) => {
102
211
  }
103
212
  throw new Error(`AI agent not found: ${agentName}`);
104
213
  };
214
+ /**
215
+ * Enforces an agent's own authorization before it runs: session presence
216
+ * (`auth`), then `scopes`, then `permissions`.
217
+ *
218
+ * The ordering mirrors the function runner — scopes are an AND gate checked
219
+ * first, so they can only ever narrow access, and a missing scope short-circuits
220
+ * before any permission function does I/O.
221
+ *
222
+ * `auth` follows `pikkuSessionlessFunc` rather than `pikkuFunc`: a session is
223
+ * required only when `auth: true` is set explicitly. An agent is normally
224
+ * reached from a function that has already enforced its own auth, and agents are
225
+ * also run from genuinely sessionless contexts (crons, queue workers), so
226
+ * requiring a session by default would reject those without adding a meaningful
227
+ * gate. `scopes` and `permissions` are always enforced when declared.
228
+ *
229
+ * Globals are evaluated here too (via {@link runPermissions}) rather than being
230
+ * assumed to have already run: an agent is reachable from entry points that do
231
+ * not go through the function runner, and re-evaluating an AND gate of
232
+ * side-effect-free predicates is idempotent.
233
+ */
234
+ export async function assertAgentAuthorized(agent, params, packageName) {
235
+ const session = params.sessionService
236
+ ? await params.sessionService.get()
237
+ : undefined;
238
+ if (agent.auth === true && !session) {
239
+ throw new ForbiddenError('Authentication required');
240
+ }
241
+ verifyScopes(agent.scopes, session);
242
+ const singletonServices = getSingletonServices();
243
+ const wire = params.sessionService
244
+ ? createMiddlewareSessionWireProps(params.sessionService)
245
+ : { session: undefined };
246
+ await runPermissions({
247
+ funcPermissions: agent.permissions,
248
+ services: singletonServices,
249
+ wire: wire,
250
+ data: {},
251
+ packageName,
252
+ label: 'agent',
253
+ });
254
+ }
105
255
  export async function buildInstructions(agentName, packageName) {
106
256
  const meta = pikkuState(packageName, 'agent', 'agentsMeta')[agentName];
107
257
  const parts = [];
@@ -112,7 +262,7 @@ export async function buildInstructions(agentName, packageName) {
112
262
  if (meta?.goal)
113
263
  parts.push(meta.goal);
114
264
  let instructions = parts.join('\n\n');
115
- if (meta?.tools?.length) {
265
+ if (meta?.tools?.length || meta?.workflows?.length) {
116
266
  instructions +=
117
267
  '\n\nTool usage rules:\n' +
118
268
  '- Act immediately with the information given. Do NOT ask clarifying questions unless a required field is truly missing.\n' +
@@ -151,6 +301,7 @@ export function createScopedChannel(parent, agentName, session) {
151
301
  toolCallId: event.toolCallId,
152
302
  toolName: event.toolName,
153
303
  args: event.args,
304
+ ...(event.reason !== undefined ? { reason: event.reason } : {}),
154
305
  runId: event.runId,
155
306
  });
156
307
  return;
@@ -217,11 +368,17 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
217
368
  missingRpcs.push(toolName);
218
369
  continue;
219
370
  }
220
- // Filter out tools the user doesn't have auth for
371
+ // Filter out tools the user doesn't have auth for. The `pikkuAuth` brand
372
+ // only survives on the live permission objects in the function config, so
373
+ // the check reads those rather than the metadata (whose by-name registry
374
+ // is never populated, which would let every gated tool through).
221
375
  if (fnMeta.permissions?.length) {
222
376
  if (!session)
223
377
  continue;
224
- const allowed = await checkAuthPermissions(fnMeta.permissions, session, singletonServices, resolvedPkg);
378
+ const funcConfig = pikkuFuncId
379
+ ? pikkuState(resolvedPkg, 'function', 'functions').get(pikkuFuncId)
380
+ : undefined;
381
+ const allowed = await checkAuthPermissions(funcConfig?.permissions, session, singletonServices, resolvedPkg);
225
382
  if (!allowed)
226
383
  continue;
227
384
  }
@@ -244,13 +401,22 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
244
401
  if (funcConfig?.approvalDescription) {
245
402
  const descFn = funcConfig.approvalDescription;
246
403
  const capturedPkg = resolvedPkg;
404
+ const capturedAddonConfig = resolved?.addonConfig;
405
+ const capturedNamespace = toolName.includes(':')
406
+ ? toolName.slice(0, toolName.indexOf(':'))
407
+ : null;
247
408
  approvalDescriptionFn = async (input) => {
248
409
  let services = singletonServices;
249
410
  if (capturedPkg) {
250
- const pkgServices = pikkuState(capturedPkg, 'package', 'singletonServices');
251
- if (pkgServices) {
252
- services = pkgServices;
253
- }
411
+ const addonInstance = capturedNamespace
412
+ ? {
413
+ namespace: capturedNamespace,
414
+ secretOverrides: capturedAddonConfig?.secretOverrides,
415
+ variableOverrides: capturedAddonConfig?.variableOverrides,
416
+ credentialOverrides: capturedAddonConfig?.credentialOverrides,
417
+ }
418
+ : undefined;
419
+ services = await getOrCreatePackageSingletonServices(capturedPkg, singletonServices, addonInstance);
254
420
  }
255
421
  return descFn(services, input);
256
422
  };
@@ -280,17 +446,20 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
280
446
  singletonServices.logger.warn(`Sub-agent '${subAgentName}' not found in agent registry`);
281
447
  continue;
282
448
  }
283
- // Filter out sub-agents the user doesn't have auth for
449
+ // Filter out sub-agents the user doesn't have auth for, reading the live
450
+ // agent config for the same reason the tool path does.
284
451
  if (subMeta.permissions?.length) {
285
452
  if (!session)
286
453
  continue;
287
- const allowed = await checkAuthPermissions(subMeta.permissions, session, singletonServices);
454
+ const subAgent = pikkuState(null, 'agent', 'agents').get(subAgentName);
455
+ const allowed = await checkAuthPermissions(subAgent?.permissions, session, singletonServices);
288
456
  if (!allowed)
289
457
  continue;
290
458
  }
291
459
  tools.push({
292
460
  name: subAgentName,
293
461
  description: subMeta.description,
462
+ forwardsApproval: true,
294
463
  inputSchema: {
295
464
  type: 'object',
296
465
  properties: {
@@ -339,6 +508,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
339
508
  const resultText = await streamAIAgent(subAgentName, { message, threadId, resourceId }, effectiveChannel, params, agentSessionMap, streamContext.options);
340
509
  if (subChannel.approvals.length > 0) {
341
510
  return {
511
+ [APPROVAL_REQUIRED]: true,
342
512
  __approvalRequired: true,
343
513
  toolName: subAgentName,
344
514
  args: toolInput,
@@ -359,6 +529,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
359
529
  if (result.status === 'suspended' &&
360
530
  result.pendingApprovals?.length) {
361
531
  return {
532
+ [APPROVAL_REQUIRED]: true,
362
533
  __approvalRequired: true,
363
534
  toolName: subAgentName,
364
535
  args: toolInput,
@@ -367,6 +538,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
367
538
  toolCallId: a.toolCallId,
368
539
  toolName: a.toolName,
369
540
  args: a.args,
541
+ reason: a.reason,
370
542
  runId: a.runId,
371
543
  })),
372
544
  };
@@ -376,6 +548,64 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
376
548
  });
377
549
  }
378
550
  }
551
+ const metaWorkflows = meta.workflows;
552
+ if (metaWorkflows?.length) {
553
+ const workflowMetaMap = pikkuState(null, 'workflows', 'meta');
554
+ const functionMeta = pikkuState(null, 'function', 'meta');
555
+ const schemas = pikkuState(null, 'misc', 'schemas');
556
+ for (const workflowName of metaWorkflows) {
557
+ const wfMeta = workflowMetaMap[workflowName];
558
+ if (!wfMeta) {
559
+ missingRpcs.push(workflowName);
560
+ continue;
561
+ }
562
+ const inputSchemaName = wfMeta.pikkuFuncId
563
+ ? functionMeta[wfMeta.pikkuFuncId]?.inputSchemaName
564
+ : undefined;
565
+ let inputSchema = inputSchemaName
566
+ ? schemas.get(inputSchemaName)
567
+ : undefined;
568
+ if (!inputSchema ||
569
+ (typeof inputSchema === 'object' &&
570
+ inputSchema.type === 'object' &&
571
+ !inputSchema.properties)) {
572
+ inputSchema = { type: 'object', properties: {} };
573
+ }
574
+ tools.push({
575
+ name: workflowName,
576
+ description: wfMeta.description || workflowName,
577
+ inputSchema,
578
+ execute: async (toolInput) => {
579
+ const workflowService = singletonServices.workflowService;
580
+ if (!workflowService) {
581
+ throw new Error(`workflowService is not configured — cannot run workflow tool '${workflowName}'`);
582
+ }
583
+ const wire = params.sessionService
584
+ ? { ...createMiddlewareSessionWireProps(params.sessionService) }
585
+ : {};
586
+ const rpcService = new ContextAwareRPCService(singletonServices, wire, { sessionService: params.sessionService });
587
+ return workflowService.runToCompletion(workflowName, toolInput, rpcService, { wire: { type: 'internal' } });
588
+ },
589
+ });
590
+ }
591
+ }
592
+ // A tool's execute() can throw (bad input, RPC failure, DB error, ...). The AI
593
+ // SDK catches that at the tool-call boundary and turns it into a conversational
594
+ // "tool error" reply — without this, the exception never reaches pikku's own
595
+ // logger, so a failing tool is undiagnosable server-side. Log unconditionally,
596
+ // even when no aiMiddleware afterToolCall hook is registered to see it.
597
+ for (const tool of tools) {
598
+ const originalExecute = tool.execute;
599
+ tool.execute = async (toolInput) => {
600
+ try {
601
+ return await originalExecute(toolInput);
602
+ }
603
+ catch (err) {
604
+ singletonServices.logger.error(`AI agent tool '${tool.name}' threw during execute()`, err);
605
+ throw err;
606
+ }
607
+ };
608
+ }
379
609
  const hasToolHooks = aiMiddlewares?.some((mw) => mw.beforeToolCall || mw.afterToolCall);
380
610
  if (hasToolHooks) {
381
611
  for (const tool of tools) {
@@ -434,6 +664,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
434
664
  export async function prepareAgentRun(agentName, input, params, agentSessionMap, streamContext) {
435
665
  const singletonServices = getSingletonServices();
436
666
  const { agent, packageName, resolvedName } = resolveAgent(agentName);
667
+ await assertAgentAuthorized(agent, params, packageName);
437
668
  let agentRunner = singletonServices.aiAgentRunner;
438
669
  if (!agentRunner) {
439
670
  throw new AIProviderNotConfiguredError();
@@ -458,10 +689,17 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
458
689
  ? pikkuState(packageName, 'misc', 'schemas').get(workingMemorySchemaName)
459
690
  : undefined;
460
691
  if (storage) {
692
+ let thread = null;
461
693
  try {
462
- await storage.getThread(threadId);
694
+ thread = await storage.getThread(threadId);
463
695
  }
464
696
  catch {
697
+ thread = null;
698
+ }
699
+ if (thread) {
700
+ assertResourceOwner(input.resourceId, thread.resourceId, 'thread');
701
+ }
702
+ else {
465
703
  await storage.createThread(input.resourceId, { threadId });
466
704
  }
467
705
  }
@@ -1,5 +1,5 @@
1
1
  import { saveMessages, resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
2
- import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, } from './ai-agent-prepare.js';
2
+ import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertAgentAuthorized, } from './ai-agent-prepare.js';
3
3
  import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js';
4
4
  import { pikkuState, getSingletonServices } from '../../pikku-state.js';
5
5
  import { resolveModelConfig } from './ai-agent-model-config.js';
@@ -32,6 +32,10 @@ function extractStructuredAssistantOutput(object) {
32
32
  }
33
33
  export async function runAIAgent(agentName, input, params, agentSessionMap) {
34
34
  const sessionMap = agentSessionMap ?? new Map();
35
+ input = {
36
+ ...input,
37
+ resourceId: resolveOwnerResourceId(params, agentSessionScope(agentName), input.resourceId),
38
+ };
35
39
  const { agent, agentRunner, storage, memoryConfig, threadId, userMessage, runnerParams, maxSteps, missingRpcs, workingMemorySchemaName, } = await prepareAgentRun(agentName, input, params, sessionMap);
36
40
  runnerParams.agentId = agentName;
37
41
  const singletonServices = getSingletonServices();
@@ -281,6 +285,7 @@ export async function resumeAIAgentSync(runId, approvals, params, expectedAgentN
281
285
  const run = await aiRunState.getRun(runId);
282
286
  if (!run)
283
287
  throw new Error(`No run found for runId ${runId}`);
288
+ assertResourceOwner(resolveOwnerResourceId(params, agentSessionScope(run.agentName), run.resourceId), run.resourceId, 'run');
284
289
  if (expectedAgentName && run.agentName !== expectedAgentName) {
285
290
  throw new Error(`Run ${runId} belongs to agent '${run.agentName}', not '${expectedAgentName}'`);
286
291
  }
@@ -288,6 +293,10 @@ export async function resumeAIAgentSync(runId, approvals, params, expectedAgentN
288
293
  throw new Error(`Run ${runId} is not suspended (status: ${run.status})`);
289
294
  }
290
295
  const { agent, packageName, resolvedName } = resolveAgent(run.agentName);
296
+ // Resuming re-runs the agent, so it re-runs the agent's gate. Run ownership
297
+ // alone is not enough: a grant revoked while the run was suspended must stop
298
+ // the caller from approving its pending tool calls.
299
+ await assertAgentAuthorized(agent, params, packageName);
291
300
  const { storage } = resolveMemoryServices(agent, singletonServices);
292
301
  const memoryConfig = agent.memory;
293
302
  const agentRunner = singletonServices.aiAgentRunner;
@@ -3,9 +3,9 @@ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
3
3
  import { randomUUID } from './ai-agent-utils.js';
4
4
  import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
5
5
  import { resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
6
- import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
6
+ import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertAgentAuthorized, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, } from './ai-agent-prepare.js';
7
7
  import { resolveModelConfig } from './ai-agent-model-config.js';
8
- function createPersistingChannel(parent, storage, threadId) {
8
+ function createPersistingChannel(parent, storage, threadId, logger) {
9
9
  let fullText = '';
10
10
  let stepText = '';
11
11
  let stepGenerativeUI = null;
@@ -51,6 +51,22 @@ function createPersistingChannel(parent, storage, threadId) {
51
51
  await storage.saveMessages(threadId, messages);
52
52
  }
53
53
  };
54
+ /**
55
+ * `send` is synchronous and so cannot await the flush. A rejection would have
56
+ * nothing to propagate to and would take the process down as an unhandled
57
+ * rejection — a model reusing a toolCallId, which is a primary key in AI
58
+ * storage, is enough to trigger it. Persistence from inside `send` is
59
+ * therefore best-effort: the run carries on, and the awaited `flush()` on the
60
+ * suspend paths still surfaces failures to its caller.
61
+ */
62
+ const flushDetached = () => {
63
+ void flushStep().catch((error) => {
64
+ logger?.error('Failed to persist agent messages', {
65
+ threadId,
66
+ error,
67
+ });
68
+ });
69
+ };
54
70
  const channel = {
55
71
  channelId: parent.channelId,
56
72
  openingData: parent.openingData,
@@ -97,10 +113,10 @@ function createPersistingChannel(parent, storage, threadId) {
97
113
  totalUsage.outputTokens += event.tokens.output;
98
114
  if (event.model)
99
115
  totalUsage.model = event.model;
100
- flushStep();
116
+ flushDetached();
101
117
  break;
102
118
  case 'done':
103
- flushStep();
119
+ flushDetached();
104
120
  break;
105
121
  }
106
122
  }
@@ -213,14 +229,23 @@ export function checkForApprovals(stepResult, tools, runId) {
213
229
  approvals.push(new ToolApprovalRequired(tc.toolCallId, tc.toolName, tc.args));
214
230
  continue;
215
231
  }
232
+ // The approval marker is only trusted from a framework tool declared with
233
+ // `forwardsApproval` (the sub-agent delegating tools) AND carrying the
234
+ // non-forgeable `APPROVAL_REQUIRED` Symbol brand. A plain tool's output — or
235
+ // a delegating tool's LLM-shaped `result.object`, which an attacker can
236
+ // influence — is plain JSON and can never carry the Symbol, so it can never
237
+ // forge an approval/suspension.
238
+ if (!toolDef?.forwardsApproval) {
239
+ continue;
240
+ }
216
241
  const tr = stepResult.toolResults.find((r) => r.toolCallId === tc.toolCallId);
217
242
  if (tr?.result &&
218
243
  typeof tr.result === 'object' &&
219
- '__approvalRequired' in tr.result) {
244
+ APPROVAL_REQUIRED in tr.result) {
220
245
  const r = tr.result;
221
246
  if (r.subApprovals?.length) {
222
247
  for (const sub of r.subApprovals) {
223
- approvals.push(new ToolApprovalRequired(sub.toolCallId, r.toolName, r.args, undefined, sub.toolName, sub.args, r.agentRunId));
248
+ approvals.push(new ToolApprovalRequired(sub.toolCallId, r.toolName, r.args, sub.reason, sub.toolName, sub.args, r.agentRunId));
224
249
  }
225
250
  }
226
251
  else {
@@ -341,16 +366,32 @@ function handleCredentialRequests(requests, runId, channel, aiRunState, persisti
341
366
  suspendReason: 'credential',
342
367
  pendingApprovals,
343
368
  });
344
- // Don't send credential-request SSE events the tool result with
345
- // __credentialRequired was already streamed. The frontend detects it
346
- // from the tool result and shows Connect/Ignore buttons.
369
+ // The __credentialRequired tool result is suppressed from the stream, so
370
+ // credential-request events (with the runId needed for /resume) are the
371
+ // client's signal to show Connect/Ignore buttons — mirroring how
372
+ // approval-request suspensions work.
373
+ for (const req of requests) {
374
+ channel.send({
375
+ type: 'credential-request',
376
+ toolCallId: req.toolCallId,
377
+ toolName: req.toolName,
378
+ args: req.args,
379
+ credentialName: req.credentialName,
380
+ credentialType: req.credentialType,
381
+ connectUrl: req.connectUrl,
382
+ runId,
383
+ });
384
+ }
347
385
  channel.send({ type: 'done' });
348
386
  channel.close();
349
387
  })();
350
388
  }
351
389
  export async function streamAIAgent(agentName, input, channel, params, agentSessionMap, options) {
352
390
  const sessionMap = agentSessionMap ?? new Map();
353
- const normalizedInput = input;
391
+ const normalizedInput = {
392
+ ...input,
393
+ resourceId: resolveOwnerResourceId(params, agentSessionScope(agentName), input.resourceId),
394
+ };
354
395
  const streamContext = { channel, options };
355
396
  // delegateState is attached after prepareAgentRun resolves the agent config
356
397
  const { agent, packageName, resolvedName, agentRunner, storage, memoryConfig, threadId, userMessage, runnerParams, maxSteps, missingRpcs, workingMemorySchemaName, } = await prepareAgentRun(agentName, normalizedInput, params, sessionMap, streamContext);
@@ -408,6 +449,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
408
449
  createdAt: new Date(),
409
450
  updatedAt: new Date(),
410
451
  });
452
+ options?.onRunCreated?.(runId);
411
453
  if (storage) {
412
454
  await storage.saveMessages(threadId, [userMessage]);
413
455
  }
@@ -443,10 +485,26 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
443
485
  ...streamMiddleware,
444
486
  ],
445
487
  });
446
- const persistingChannel = createPersistingChannel(channel, storage, threadId);
488
+ const persistingChannel = createPersistingChannel(channel, storage, threadId, singletonServices.logger);
447
489
  const wrappedChannel = allChannelMiddleware.length > 0
448
490
  ? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
449
491
  : persistingChannel;
492
+ // Tool results carrying the __credentialRequired marker must never reach
493
+ // the client or persisted history: the run suspends with credential-request
494
+ // events instead (mirroring approvals), and leaving the tool call
495
+ // unresulted is what lets the client resume it after connecting.
496
+ const credentialFilteredChannel = {
497
+ ...wrappedChannel,
498
+ send: (event) => {
499
+ if (event.type === 'tool-result' &&
500
+ event.result !== null &&
501
+ typeof event.result === 'object' &&
502
+ '__credentialRequired' in event.result) {
503
+ return;
504
+ }
505
+ wrappedChannel.send(event);
506
+ },
507
+ };
450
508
  // In delegate mode (default), suppress parent's text from reaching the client
451
509
  // AFTER a sub-agent has been called. If the parent responds directly (no delegation),
452
510
  // its text goes through normally. Sub-agent text bypasses this path entirely
@@ -458,16 +516,16 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
458
516
  }
459
517
  const outputChannel = isDelegateMode
460
518
  ? {
461
- ...wrappedChannel,
519
+ ...credentialFilteredChannel,
462
520
  send: (event) => {
463
521
  if (delegateState.delegated &&
464
522
  (event.type === 'text-delta' || event.type === 'reasoning-delta'))
465
523
  return;
466
- wrappedChannel.send(event);
524
+ credentialFilteredChannel.send(event);
467
525
  },
468
526
  delegateState,
469
527
  }
470
- : wrappedChannel;
528
+ : credentialFilteredChannel;
471
529
  try {
472
530
  const loopResult = await runStreamStepLoop({
473
531
  agent,
@@ -531,12 +589,18 @@ export async function resumeAIAgent(input, channel, params, options) {
531
589
  if (!run) {
532
590
  throw new Error(`No run found for runId ${input.runId}`);
533
591
  }
592
+ assertResourceOwner(resolveOwnerResourceId(params, agentSessionScope(run.agentName), run.resourceId), run.resourceId, 'run');
534
593
  const pending = run.pendingApprovals?.find((p) => p.toolCallId === input.toolCallId);
535
594
  if (!pending) {
536
595
  throw new Error(`No pending approval for toolCallId ${input.toolCallId} on run ${input.runId}`);
537
596
  }
538
- await aiRunState.resolveApproval(input.toolCallId, input.approved ? 'approved' : 'denied');
539
597
  const { agent, packageName, resolvedName } = resolveAgent(run.agentName);
598
+ // Gate before resolving the approval: recording it is a persisted side
599
+ // effect, so an unauthorized caller must not reach it. Run ownership alone is
600
+ // not enough — a grant revoked while the run was suspended must stop the
601
+ // caller from approving its pending tool calls.
602
+ await assertAgentAuthorized(agent, params, packageName);
603
+ await aiRunState.resolveApproval(input.toolCallId, input.approved ? 'approved' : 'denied');
540
604
  const { storage } = resolveMemoryServices(agent, singletonServices);
541
605
  const memoryConfig = agent.memory;
542
606
  const agentRunner = singletonServices.aiAgentRunner;
@@ -567,6 +631,14 @@ export async function resumeAIAgent(input, channel, params, options) {
567
631
  },
568
632
  ]);
569
633
  }
634
+ channel.send({
635
+ type: 'tool-result',
636
+ toolCallId: input.toolCallId,
637
+ toolName: pending.type === 'tool-call' || pending.type === 'credential-request'
638
+ ? pending.toolName
639
+ : pending.agentName,
640
+ result: denialResult,
641
+ });
570
642
  // Check remaining pending approvals
571
643
  const updatedRun = await aiRunState.getRun(run.runId);
572
644
  const remaining = updatedRun?.pendingApprovals ?? [];
@@ -756,7 +828,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
756
828
  ...streamMiddleware,
757
829
  ],
758
830
  });
759
- const persistingChannel = createPersistingChannel(channel, storage, run.threadId);
831
+ const persistingChannel = createPersistingChannel(channel, storage, run.threadId, singletonServices.logger);
760
832
  const wrappedChannel = allChannelMiddleware.length > 0
761
833
  ? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
762
834
  : persistingChannel;