@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
@@ -34,8 +34,13 @@ import {
34
34
  buildInstructions,
35
35
  buildToolDefs,
36
36
  createScopedChannel,
37
+ resolveOwnerResourceId,
38
+ agentSessionScope,
39
+ assertResourceOwner,
40
+ assertAgentAuthorized,
37
41
  ToolApprovalRequired,
38
42
  ToolCredentialRequired,
43
+ APPROVAL_REQUIRED,
39
44
  type RunAIAgentParams,
40
45
  type StreamAIAgentOptions,
41
46
  type StreamContext,
@@ -53,7 +58,8 @@ type PersistingChannel = AIStreamChannel & {
53
58
  function createPersistingChannel(
54
59
  parent: AIStreamChannel,
55
60
  storage: AIStorageService | undefined,
56
- threadId: string
61
+ threadId: string,
62
+ logger?: { error: (...args: any[]) => void }
57
63
  ): PersistingChannel {
58
64
  let fullText = ''
59
65
  let stepText = ''
@@ -106,6 +112,23 @@ function createPersistingChannel(
106
112
  }
107
113
  }
108
114
 
115
+ /**
116
+ * `send` is synchronous and so cannot await the flush. A rejection would have
117
+ * nothing to propagate to and would take the process down as an unhandled
118
+ * rejection — a model reusing a toolCallId, which is a primary key in AI
119
+ * storage, is enough to trigger it. Persistence from inside `send` is
120
+ * therefore best-effort: the run carries on, and the awaited `flush()` on the
121
+ * suspend paths still surfaces failures to its caller.
122
+ */
123
+ const flushDetached = () => {
124
+ void flushStep().catch((error) => {
125
+ logger?.error('Failed to persist agent messages', {
126
+ threadId,
127
+ error,
128
+ })
129
+ })
130
+ }
131
+
109
132
  const channel: PersistingChannel = {
110
133
  channelId: parent.channelId,
111
134
  openingData: parent.openingData,
@@ -152,10 +175,10 @@ function createPersistingChannel(
152
175
  totalUsage.inputTokens += event.tokens.input
153
176
  totalUsage.outputTokens += event.tokens.output
154
177
  if (event.model) totalUsage.model = event.model
155
- flushStep()
178
+ flushDetached()
156
179
  break
157
180
  case 'done':
158
- flushStep()
181
+ flushDetached()
159
182
  break
160
183
  }
161
184
  }
@@ -328,13 +351,23 @@ export function checkForApprovals(
328
351
  continue
329
352
  }
330
353
 
354
+ // The approval marker is only trusted from a framework tool declared with
355
+ // `forwardsApproval` (the sub-agent delegating tools) AND carrying the
356
+ // non-forgeable `APPROVAL_REQUIRED` Symbol brand. A plain tool's output — or
357
+ // a delegating tool's LLM-shaped `result.object`, which an attacker can
358
+ // influence — is plain JSON and can never carry the Symbol, so it can never
359
+ // forge an approval/suspension.
360
+ if (!toolDef?.forwardsApproval) {
361
+ continue
362
+ }
363
+
331
364
  const tr = stepResult.toolResults.find(
332
365
  (r) => r.toolCallId === tc.toolCallId
333
366
  )
334
367
  if (
335
368
  tr?.result &&
336
369
  typeof tr.result === 'object' &&
337
- '__approvalRequired' in (tr.result as object)
370
+ APPROVAL_REQUIRED in (tr.result as object)
338
371
  ) {
339
372
  const r = tr.result as {
340
373
  toolName: string
@@ -347,6 +380,7 @@ export function checkForApprovals(
347
380
  toolCallId: string
348
381
  toolName: string
349
382
  args: unknown
383
+ reason?: string
350
384
  runId: string
351
385
  }>
352
386
  }
@@ -357,7 +391,7 @@ export function checkForApprovals(
357
391
  sub.toolCallId,
358
392
  r.toolName,
359
393
  r.args,
360
- undefined,
394
+ sub.reason,
361
395
  sub.toolName,
362
396
  sub.args,
363
397
  r.agentRunId
@@ -512,7 +546,7 @@ function handleApprovals(
512
546
  reason: err.reason,
513
547
  runId,
514
548
  }
515
- channel.send(approvalEvent as any)
549
+ channel.send(approvalEvent)
516
550
  }
517
551
  channel.send({ type: 'done' })
518
552
  channel.close()
@@ -545,9 +579,22 @@ function handleCredentialRequests(
545
579
  pendingApprovals,
546
580
  })
547
581
 
548
- // Don't send credential-request SSE events the tool result with
549
- // __credentialRequired was already streamed. The frontend detects it
550
- // from the tool result and shows Connect/Ignore buttons.
582
+ // The __credentialRequired tool result is suppressed from the stream, so
583
+ // credential-request events (with the runId needed for /resume) are the
584
+ // client's signal to show Connect/Ignore buttons — mirroring how
585
+ // approval-request suspensions work.
586
+ for (const req of requests) {
587
+ channel.send({
588
+ type: 'credential-request',
589
+ toolCallId: req.toolCallId,
590
+ toolName: req.toolName,
591
+ args: req.args,
592
+ credentialName: req.credentialName,
593
+ credentialType: req.credentialType,
594
+ connectUrl: req.connectUrl,
595
+ runId,
596
+ })
597
+ }
551
598
  channel.send({ type: 'done' })
552
599
  channel.close()
553
600
  })()
@@ -569,7 +616,14 @@ export async function streamAIAgent(
569
616
  ): Promise<string> {
570
617
  const sessionMap = agentSessionMap ?? new Map<string, string>()
571
618
 
572
- const normalizedInput = input
619
+ const normalizedInput = {
620
+ ...input,
621
+ resourceId: resolveOwnerResourceId(
622
+ params,
623
+ agentSessionScope(agentName),
624
+ input.resourceId
625
+ ),
626
+ }
573
627
 
574
628
  const streamContext: StreamContext = { channel, options }
575
629
  // delegateState is attached after prepareAgentRun resolves the agent config
@@ -654,6 +708,7 @@ export async function streamAIAgent(
654
708
  createdAt: new Date(),
655
709
  updatedAt: new Date(),
656
710
  })
711
+ options?.onRunCreated?.(runId)
657
712
 
658
713
  if (storage) {
659
714
  await storage.saveMessages(threadId, [userMessage])
@@ -694,7 +749,12 @@ export async function streamAIAgent(
694
749
  }
695
750
  )
696
751
 
697
- const persistingChannel = createPersistingChannel(channel, storage, threadId)
752
+ const persistingChannel = createPersistingChannel(
753
+ channel,
754
+ storage,
755
+ threadId,
756
+ singletonServices.logger
757
+ )
698
758
 
699
759
  const wrappedChannel =
700
760
  allChannelMiddleware.length > 0
@@ -705,6 +765,25 @@ export async function streamAIAgent(
705
765
  ).channel as AIStreamChannel)
706
766
  : persistingChannel
707
767
 
768
+ // Tool results carrying the __credentialRequired marker must never reach
769
+ // the client or persisted history: the run suspends with credential-request
770
+ // events instead (mirroring approvals), and leaving the tool call
771
+ // unresulted is what lets the client resume it after connecting.
772
+ const credentialFilteredChannel: AIStreamChannel = {
773
+ ...wrappedChannel,
774
+ send: (event: AIStreamEvent) => {
775
+ if (
776
+ event.type === 'tool-result' &&
777
+ event.result !== null &&
778
+ typeof event.result === 'object' &&
779
+ '__credentialRequired' in event.result
780
+ ) {
781
+ return
782
+ }
783
+ wrappedChannel.send(event)
784
+ },
785
+ }
786
+
708
787
  // In delegate mode (default), suppress parent's text from reaching the client
709
788
  // AFTER a sub-agent has been called. If the parent responds directly (no delegation),
710
789
  // its text goes through normally. Sub-agent text bypasses this path entirely
@@ -716,18 +795,18 @@ export async function streamAIAgent(
716
795
  }
717
796
  const outputChannel = isDelegateMode
718
797
  ? {
719
- ...wrappedChannel,
798
+ ...credentialFilteredChannel,
720
799
  send: (event: AIStreamEvent) => {
721
800
  if (
722
801
  delegateState.delegated &&
723
802
  (event.type === 'text-delta' || event.type === 'reasoning-delta')
724
803
  )
725
804
  return
726
- wrappedChannel.send(event)
805
+ credentialFilteredChannel.send(event)
727
806
  },
728
807
  delegateState,
729
808
  }
730
- : wrappedChannel
809
+ : credentialFilteredChannel
731
810
 
732
811
  try {
733
812
  const loopResult = await runStreamStepLoop({
@@ -824,6 +903,15 @@ export async function resumeAIAgent(
824
903
  if (!run) {
825
904
  throw new Error(`No run found for runId ${input.runId}`)
826
905
  }
906
+ assertResourceOwner(
907
+ resolveOwnerResourceId(
908
+ params,
909
+ agentSessionScope(run.agentName),
910
+ run.resourceId
911
+ ),
912
+ run.resourceId,
913
+ 'run'
914
+ )
827
915
 
828
916
  const pending = run.pendingApprovals?.find(
829
917
  (p) => p.toolCallId === input.toolCallId
@@ -834,12 +922,19 @@ export async function resumeAIAgent(
834
922
  )
835
923
  }
836
924
 
925
+ const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
926
+
927
+ // Gate before resolving the approval: recording it is a persisted side
928
+ // effect, so an unauthorized caller must not reach it. Run ownership alone is
929
+ // not enough — a grant revoked while the run was suspended must stop the
930
+ // caller from approving its pending tool calls.
931
+ await assertAgentAuthorized(agent, params, packageName)
932
+
837
933
  await aiRunState.resolveApproval(
838
934
  input.toolCallId,
839
935
  input.approved ? 'approved' : 'denied'
840
936
  )
841
937
 
842
- const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
843
938
  const { storage } = resolveMemoryServices(agent, singletonServices)
844
939
  const memoryConfig = agent.memory
845
940
  const agentRunner = singletonServices.aiAgentRunner
@@ -876,6 +971,16 @@ export async function resumeAIAgent(
876
971
  ])
877
972
  }
878
973
 
974
+ channel.send({
975
+ type: 'tool-result',
976
+ toolCallId: input.toolCallId,
977
+ toolName:
978
+ pending.type === 'tool-call' || pending.type === 'credential-request'
979
+ ? pending.toolName
980
+ : pending.agentName,
981
+ result: denialResult,
982
+ })
983
+
879
984
  // Check remaining pending approvals
880
985
  const updatedRun = await aiRunState.getRun(run.runId)
881
986
  const remaining = updatedRun?.pendingApprovals ?? []
@@ -1155,7 +1260,8 @@ async function continueAfterToolResult(
1155
1260
  const persistingChannel = createPersistingChannel(
1156
1261
  channel,
1157
1262
  storage,
1158
- run.threadId
1263
+ run.threadId,
1264
+ singletonServices.logger
1159
1265
  )
1160
1266
 
1161
1267
  const wrappedChannel =
@@ -104,6 +104,20 @@ export interface AIAgentOutput {
104
104
  }>
105
105
  }
106
106
 
107
+ /**
108
+ * How an agent's threads/runs are owned and partitioned.
109
+ * - `'user'` (default): owner is the authenticated `session.userId`; the caller's
110
+ * `resourceId` becomes a sub-partition within that user (`userId:resourceId`).
111
+ * - `'org'`: owner is the authenticated `session.orgId` (`orgId:resourceId`), so
112
+ * threads are shared across everyone in the org. Requires a session with an org
113
+ * (e.g. Better Auth's `organization` plugin) — otherwise access is denied.
114
+ *
115
+ * The trusted principal is always the prefix, so a client-supplied `resourceId`
116
+ * can sub-divide within the caller's own boundary but can never widen access to
117
+ * another user's or org's threads.
118
+ */
119
+ export type SessionScope = 'user' | 'org'
120
+
107
121
  export interface AIAgentToolDef {
108
122
  name: string
109
123
  description: string
@@ -111,6 +125,14 @@ export interface AIAgentToolDef {
111
125
  execute: (input: unknown) => Promise<unknown>
112
126
  needsApproval?: boolean
113
127
  approvalDescriptionFn?: (input: unknown) => Promise<string>
128
+ /**
129
+ * Set only by the framework on sub-agent delegating tools. Such a tool may
130
+ * legitimately return an `__approvalRequired` marker to forward a nested
131
+ * sub-agent approval. The marker is honored ONLY from a tool with this flag —
132
+ * a plain tool's output (which an attacker may influence) can never forge an
133
+ * approval request. See `checkForApprovals`.
134
+ */
135
+ forwardsApproval?: boolean
114
136
  }
115
137
 
116
138
  export interface PikkuAIMiddlewareHooks<
@@ -204,6 +226,7 @@ export type AIAgentMemoryConfig = {
204
226
  export type CoreAIAgent<
205
227
  PikkuPermission = CorePikkuPermission<any, any>,
206
228
  PikkuMiddleware = CorePikkuMiddleware<any>,
229
+ Scope extends string = string,
207
230
  > = {
208
231
  name: string
209
232
  description: string
@@ -214,8 +237,11 @@ export type CoreAIAgent<
214
237
  goal: string
215
238
  model: string
216
239
  temperature?: number
240
+ /** Ownership/partitioning of this agent's threads and runs. Defaults to `'user'`. */
241
+ sessionScope?: SessionScope
217
242
  tools?: unknown[]
218
243
  agents?: unknown[]
244
+ workflows?: unknown[]
219
245
  agentMode?: 'delegate' | 'supervise'
220
246
  memory?: AIAgentMemoryConfig
221
247
  maxSteps?: number
@@ -234,6 +260,23 @@ export type CoreAIAgent<
234
260
  middleware?: PikkuMiddleware[]
235
261
  channelMiddleware?: CorePikkuChannelMiddleware<any, any>[]
236
262
  aiMiddleware?: PikkuAIMiddlewareHooks<any, any>[]
263
+ /**
264
+ * Whether a session is required to run this agent. Defaults to `false`, since
265
+ * agents are commonly invoked from an already-authenticated `pikkuFunc` or
266
+ * from genuinely sessionless contexts (crons, queue workers). Set `true` to
267
+ * require a session at the agent itself. `scopes` and `permissions` are
268
+ * enforced either way.
269
+ */
270
+ auth?: boolean
271
+ /**
272
+ * Scopes the session must hold to run this agent. All of them are required
273
+ * (AND), and they are checked before `permissions` — unlike permissions,
274
+ * which OR together, a scope can only narrow access.
275
+ *
276
+ * Narrowed to the generated `ScopeId` union in a project's own
277
+ * `pikku-types.gen.ts`, so an undeclared scope is a compile error.
278
+ */
279
+ scopes?: Scope[]
237
280
  permissions?: CorePermissionGroup<PikkuPermission>
238
281
  }
239
282
 
@@ -275,6 +318,7 @@ export type AIStreamEvent =
275
318
  toolName: string
276
319
  args: unknown
277
320
  reason?: string
321
+ runId?: string
278
322
  agent?: string
279
323
  session?: string
280
324
  }
@@ -395,6 +439,17 @@ export interface AgentRunService {
395
439
  listThreads(options?: {
396
440
  agentName?: string
397
441
  resourceId?: string
442
+ /**
443
+ * Restrict results to threads owned by one of these session principals. A
444
+ * thread matches when its `resourceId` is the principal itself or one of its
445
+ * `principal:` sub-partitions, mirroring the composition
446
+ * `resolveOwnerResourceId` writes.
447
+ *
448
+ * Unlike `resourceId`, which is an optional exact-match filter, this is an
449
+ * authorization constraint: an empty array matches nothing. Callers exposing
450
+ * threads over the wire must derive it from the session, never from input.
451
+ */
452
+ owners?: string[]
398
453
  limit?: number
399
454
  offset?: number
400
455
  }): Promise<AIThread[]>
@@ -413,6 +468,7 @@ export type AIAgentMeta = Record<
413
468
  | 'output'
414
469
  | 'tools'
415
470
  | 'agents'
471
+ | 'workflows'
416
472
  | 'middleware'
417
473
  | 'channelMiddleware'
418
474
  | 'aiMiddleware'
@@ -420,6 +476,7 @@ export type AIAgentMeta = Record<
420
476
  > & {
421
477
  tools?: string[]
422
478
  agents?: string[]
479
+ workflows?: string[]
423
480
  inputSchema: string | null
424
481
  outputSchema: string | null
425
482
  workingMemorySchema: string | null
@@ -4,6 +4,7 @@ export {
4
4
  agentResume,
5
5
  agentApprove,
6
6
  } from './ai-agent-helpers.js'
7
+ export { wrapChannelWithAGUI, type AGUIEvent } from './ai-agent-agui.js'
7
8
  export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js'
8
9
  export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js'
9
10
  export { voiceInput } from './voice-input.js'
@@ -13,6 +14,10 @@ export {
13
14
  type StreamAIAgentOptions,
14
15
  ToolApprovalRequired,
15
16
  ToolCredentialRequired,
17
+ canAccessThread,
18
+ isOwnedByPrincipal,
19
+ sessionPrincipals,
20
+ threadOwnerConstraint,
16
21
  } from './ai-agent-prepare.js'
17
22
  export {
18
23
  addAIAgent,
@@ -0,0 +1,90 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { voiceInput } from './voice-input.js'
5
+ import type { AIContentPart, AIMessage } from './ai-agent.types.js'
6
+
7
+ /**
8
+ * A runner whose `transcribe` reads instance state through `this`, mirroring the
9
+ * real `VercelAIAgentRunner` where `transcribe` calls `this.getModel(...)`. If
10
+ * the middleware grabs the method as a bare reference the receiver is lost and
11
+ * `this` is undefined — which is exactly the regression this guards.
12
+ */
13
+ class ThisDependentRunner {
14
+ private readonly transcript = 'the transcribed spoken words'
15
+
16
+ async transcribe() {
17
+ return {
18
+ text: this.transcript,
19
+ segments: [],
20
+ language: 'en',
21
+ durationInSeconds: 1,
22
+ warnings: [],
23
+ }
24
+ }
25
+ }
26
+
27
+ const audioMessage = (): AIMessage => ({
28
+ id: 'm1',
29
+ role: 'user',
30
+ content: [
31
+ { type: 'file', mediaType: 'audio/wav', data: 'AAAA' },
32
+ ] as AIContentPart[],
33
+ createdAt: new Date(0),
34
+ })
35
+
36
+ describe('voiceInput', () => {
37
+ test('transcribes an audio part by calling the runner with its receiver intact', async () => {
38
+ const mw = voiceInput({ model: 'mock/whisper' })
39
+ const services = { aiAgentRunner: new ThisDependentRunner() }
40
+
41
+ const result = await mw.modifyInput!(services as any, {
42
+ messages: [audioMessage()],
43
+ instructions: 'sys',
44
+ })
45
+
46
+ const last = result.messages[result.messages.length - 1]!
47
+ const parts = last.content as AIContentPart[]
48
+ assert.equal(parts.length, 1)
49
+ assert.equal(parts[0]!.type, 'text')
50
+ assert.equal(
51
+ (parts[0] as { text: string }).text,
52
+ 'the transcribed spoken words'
53
+ )
54
+ assert.ok(!parts.some((p) => p.type === 'file'))
55
+ })
56
+
57
+ test('leaves the message untouched when there is no audio part', async () => {
58
+ const mw = voiceInput({ model: 'mock/whisper' })
59
+ const services = { aiAgentRunner: new ThisDependentRunner() }
60
+ const messages: AIMessage[] = [
61
+ {
62
+ id: 'm1',
63
+ role: 'user',
64
+ content: 'just text',
65
+ createdAt: new Date(0),
66
+ },
67
+ ]
68
+
69
+ const result = await mw.modifyInput!(services as any, {
70
+ messages,
71
+ instructions: 'sys',
72
+ })
73
+
74
+ assert.equal(result.messages[0]!.content, 'just text')
75
+ })
76
+
77
+ test('throws when an audio part is present but no model is configured', async () => {
78
+ const mw = voiceInput({})
79
+ const services = { aiAgentRunner: new ThisDependentRunner() }
80
+
81
+ await assert.rejects(
82
+ () =>
83
+ mw.modifyInput!(services as any, {
84
+ messages: [audioMessage()],
85
+ instructions: 'sys',
86
+ }),
87
+ /voiceInput requires a transcription model/
88
+ )
89
+ })
90
+ })
@@ -1,5 +1,6 @@
1
1
  import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
2
2
  import { pikkuAIMiddleware } from '../../types/core.types.js'
3
+ import { safeFetch } from '../../utils/safe-fetch.js'
3
4
  import type { AIContentPart } from './ai-agent.types.js'
4
5
 
5
6
  function base64ToUint8Array(base64: string): Uint8Array {
@@ -13,45 +14,11 @@ function base64ToUint8Array(base64: string): Uint8Array {
13
14
 
14
15
  const MAX_AUDIO_SIZE = 50 * 1024 * 1024
15
16
 
16
- // Portable SSRF guard: @pikku/core runs in edge runtimes (CF Workers) with no
17
- // Node `dns`, so we cannot resolve hostnames to check for private targets.
18
- // Reject the obvious internal literals; callers wanting stricter control pass
19
- // an explicit `allowedAudioHosts` allowlist. (Does not defend against a public
20
- // hostname that resolves to a private IP / DNS rebinding — out of reach here.)
21
- function isPrivateHost(hostname: string): boolean {
22
- const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
23
- if (host === 'localhost' || host === '0.0.0.0' || host === '::1') return true
24
- if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
25
- return true
26
- const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/)
27
- if (v4) {
28
- const [a, b] = [Number(v4[1]), Number(v4[2])]
29
- if (a === 127 || a === 10 || a === 0) return true
30
- if (a === 169 && b === 254) return true // link-local incl. cloud metadata
31
- if (a === 172 && b >= 16 && b <= 31) return true
32
- if (a === 192 && b === 168) return true
33
- }
34
- return false
35
- }
36
-
37
17
  async function fetchAsUint8Array(
38
18
  url: string,
39
19
  allowedAudioHosts?: string[]
40
20
  ): Promise<Uint8Array> {
41
- const parsed = new URL(url)
42
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
43
- throw new Error('Only HTTP(S) URLs are supported for audio')
44
- }
45
- if (allowedAudioHosts) {
46
- if (!allowedAudioHosts.includes(parsed.hostname)) {
47
- throw new Error(`Audio URL host is not allowed: ${parsed.hostname}`)
48
- }
49
- } else if (isPrivateHost(parsed.hostname)) {
50
- throw new Error(
51
- `Refusing to fetch audio from a private/internal host: ${parsed.hostname}`
52
- )
53
- }
54
- const response = await fetch(url)
21
+ const response = await safeFetch(url, {}, { allowedHosts: allowedAudioHosts })
55
22
  const contentLength = response.headers.get('content-length')
56
23
  if (contentLength && parseInt(contentLength, 10) > MAX_AUDIO_SIZE) {
57
24
  throw new Error('Audio file exceeds maximum size')
@@ -70,10 +37,12 @@ export const voiceInput = (config?: {
70
37
  }) =>
71
38
  pikkuAIMiddleware({
72
39
  modifyInput: async (services, { messages, instructions }) => {
73
- const transcribeAudio = (services as {
74
- aiAgentRunner?: AIAgentRunnerService
75
- }).aiAgentRunner?.transcribe
76
- if (!transcribeAudio) return { messages, instructions }
40
+ const aiAgentRunner = (
41
+ services as {
42
+ aiAgentRunner?: AIAgentRunnerService
43
+ }
44
+ ).aiAgentRunner
45
+ if (!aiAgentRunner?.transcribe) return { messages, instructions }
77
46
 
78
47
  const last = messages[messages.length - 1]
79
48
  if (!last || last.role !== 'user' || typeof last.content === 'string') {
@@ -105,7 +74,7 @@ export const voiceInput = (config?: {
105
74
  const audioData = p.data
106
75
  ? base64ToUint8Array(p.data)
107
76
  : await fetchAsUint8Array(p.url!, config.allowedAudioHosts)
108
- const result = await transcribeAudio({
77
+ const result = await aiAgentRunner.transcribe({
109
78
  model: config.model,
110
79
  audio: audioData,
111
80
  ...(config.language
@@ -85,7 +85,6 @@ export const runChannelLifecycleWithMiddleware = async ({
85
85
  data: () => data as any,
86
86
  wire,
87
87
  tags: meta.tags ?? [],
88
- inheritedPermissions: meta.permissions,
89
88
  packageName: meta.packageName ?? null,
90
89
  })
91
90
  }
@@ -102,11 +102,7 @@ export const processMessageHandlers = (
102
102
  routingProperty,
103
103
  routerValue
104
104
  )
105
- const {
106
- packageName,
107
- middleware: routeInheritedMiddleware,
108
- permissions: inheritedPermissions,
109
- } = routeMeta
105
+ const { packageName, middleware: routeInheritedMiddleware } = routeMeta
110
106
 
111
107
  const pikkuFuncId = routeMeta.pikkuFuncId
112
108
 
@@ -132,8 +128,6 @@ export const processMessageHandlers = (
132
128
  // Inherited middleware comes from metadata (tag groups, non-inline wire)
133
129
  const inheritedMiddleware = routeInheritedMiddleware || []
134
130
 
135
- const wirePermissions = isWrapper ? onMessage.permissions : undefined
136
-
137
131
  // Create unique cache key that includes routing info to avoid cache collisions
138
132
  // when multiple message handlers use the same function
139
133
  const cacheKey = routingProperty
@@ -150,8 +144,6 @@ export const processMessageHandlers = (
150
144
  wireMiddleware,
151
145
  inheritedChannelMiddleware: channelMeta?.channelMiddleware,
152
146
  wireChannelMiddleware: wireChannelMiddleware as any,
153
- inheritedPermissions,
154
- wirePermissions,
155
147
  coerceDataFromSchema: true,
156
148
  tags: channelConfig.tags,
157
149
  sessionService: userSession,
@@ -69,17 +69,17 @@ describe('combineChannelMiddleware', () => {
69
69
  void execution
70
70
  })
71
71
 
72
- test('deduplicates middleware and returns cached results until the cache is cleared', () => {
72
+ test('caches the statically-resolved inherited middleware until the cache is cleared', () => {
73
73
  const shared = async () => {}
74
74
  addChannelMiddleware('chat:outbound', [shared])
75
75
 
76
76
  const first = combineChannelMiddleware('channel', 'cached-1', {
77
77
  wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
78
- wireChannelMiddleware: [shared],
79
78
  })
80
79
 
81
80
  assert.deepEqual(first, [shared])
82
81
 
82
+ // Re-registering after the first resolve does not change the cached result.
83
83
  addChannelMiddleware('chat:outbound', [async () => {}])
84
84
 
85
85
  const cached = combineChannelMiddleware('channel', 'cached-1', {
@@ -97,6 +97,32 @@ describe('combineChannelMiddleware', () => {
97
97
  assert.notStrictEqual(refreshed[0], shared)
98
98
  })
99
99
 
100
+ test('does not cache per-run wireChannelMiddleware across calls (C4 cross-run leak)', () => {
101
+ const shared = async () => {}
102
+ addChannelMiddleware('chat:outbound', [shared])
103
+
104
+ const runA = async () => {}
105
+ const first = combineChannelMiddleware('agent', 'stream:bot', {
106
+ wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
107
+ wireChannelMiddleware: [runA],
108
+ })
109
+ assert.deepEqual(first, [shared, runA])
110
+
111
+ const runB = async () => {}
112
+ const second = combineChannelMiddleware('agent', 'stream:bot', {
113
+ wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
114
+ wireChannelMiddleware: [runB],
115
+ })
116
+ assert.deepEqual(second, [shared, runB])
117
+ assert.strictEqual(second[1], runB)
118
+ assert.notStrictEqual(second[1], runA)
119
+
120
+ const third = combineChannelMiddleware('agent', 'stream:bot', {
121
+ wireInheritedChannelMiddleware: [{ type: 'tag', tag: 'chat:outbound' }],
122
+ })
123
+ assert.deepEqual(third, [shared])
124
+ })
125
+
100
126
  test('ignores missing named middleware references', () => {
101
127
  const combined = combineChannelMiddleware('channel', 'missing-wire', {
102
128
  wireInheritedChannelMiddleware: [{ type: 'wire', name: 'missing' }],