@pikku/core 0.12.63 → 0.12.64

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 (198) hide show
  1. package/CHANGELOG.md +147 -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 +32 -25
  24. package/dist/permissions.js +104 -185
  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/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +9 -34
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +16 -39
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -3,12 +3,16 @@ import assert from 'node:assert/strict'
3
3
 
4
4
  import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
5
  import {
6
+ agentSessionScope,
7
+ assertResourceOwner,
6
8
  buildInstructions,
7
9
  buildToolDefs,
8
10
  createScopedChannel,
9
11
  getAddonCredentialRequirements,
10
12
  resolveAgent,
13
+ resolveOwnerResourceId,
11
14
  } from './ai-agent-prepare.js'
15
+ import { ForbiddenError } from '../../errors/errors.js'
12
16
  import type {
13
17
  AIStreamChannel,
14
18
  AIStreamEvent,
@@ -150,6 +154,7 @@ describe('ai-agent-prepare', () => {
150
154
  toolCallId: 'tc-1',
151
155
  toolName: 'tool-a',
152
156
  args: { x: 1 },
157
+ reason: 'Delete todo "x"',
153
158
  runId: 'run-1',
154
159
  } as AIStreamEvent)
155
160
  channel.send({ type: 'done' } as AIStreamEvent)
@@ -161,6 +166,7 @@ describe('ai-agent-prepare', () => {
161
166
  toolCallId: 'tc-1',
162
167
  toolName: 'tool-a',
163
168
  args: { x: 1 },
169
+ reason: 'Delete todo "x"',
164
170
  runId: 'run-1',
165
171
  },
166
172
  ])
@@ -237,6 +243,106 @@ describe('ai-agent-prepare', () => {
237
243
  assert.equal(afterCalls.length, 1)
238
244
  })
239
245
 
246
+ test('buildToolDefs resolves addon-scoped services for approvalDescription on a cold services cache', async () => {
247
+ addAgent('todo-agent')
248
+ pikkuState(null, 'agent', 'agentsMeta')['todo-agent'] = {
249
+ ...pikkuState(null, 'agent', 'agentsMeta')['todo-agent'],
250
+ tools: ['todos:deleteTodo'],
251
+ } as any
252
+ pikkuState(null, 'addons', 'packages').set('todos', {
253
+ package: '@test/todos-addon',
254
+ })
255
+ pikkuState('@test/todos-addon', 'function', 'meta').deleteTodo = {
256
+ description: 'Deletes a todo by ID',
257
+ approvalRequired: true,
258
+ inputSchemaName: 'DeleteTodoInput',
259
+ sessionless: true,
260
+ } as any
261
+ pikkuState('@test/todos-addon', 'misc', 'schemas').set('DeleteTodoInput', {
262
+ type: 'object',
263
+ })
264
+ pikkuState('@test/todos-addon', 'function', 'functions').set('deleteTodo', {
265
+ func: async ({ todoStore }: any, { id }: any) => todoStore.delete(id),
266
+ approvalDescription: async ({ todoStore }: any, { id }: any) =>
267
+ `Delete the todo called "${todoStore.get(id)?.title ?? id}"`,
268
+ })
269
+
270
+ // The addon builds `todoStore` in its own singleton services, which are NOT
271
+ // in the root services and are only cached the first time the addon runs.
272
+ // The approval is raised before the tool executes, so the cache is cold.
273
+ pikkuState('@test/todos-addon', 'package', 'factories', {
274
+ createSingletonServices: async () => ({
275
+ todoStore: {
276
+ get: (id: string) =>
277
+ id === '1' ? { id: '1', title: 'Buy groceries' } : undefined,
278
+ delete: () => true,
279
+ },
280
+ }),
281
+ } as any)
282
+
283
+ const singletonServices = {
284
+ logger: { warn: () => {} },
285
+ } as any
286
+ pikkuState(null, 'package', 'singletonServices', singletonServices)
287
+
288
+ const { tools } = await buildToolDefs(
289
+ {},
290
+ new Map<string, string>(),
291
+ 'resource-1',
292
+ 'todo-agent',
293
+ null
294
+ )
295
+
296
+ assert.equal(tools.length, 1)
297
+ assert.equal(tools[0].needsApproval, true)
298
+ assert.equal(
299
+ await tools[0].approvalDescriptionFn?.({ id: '1' }),
300
+ 'Delete the todo called "Buy groceries"'
301
+ )
302
+ })
303
+
304
+ test('buildToolDefs logs a tool execute() failure even without aiMiddleware hooks, then rethrows', async () => {
305
+ addAgent('ops-agent')
306
+ pikkuState(null, 'agent', 'agentsMeta')['ops-agent'] = {
307
+ ...pikkuState(null, 'agent', 'agentsMeta')['ops-agent'],
308
+ tools: ['searchInventory'],
309
+ } as any
310
+ pikkuState(null, 'rpc', 'meta').searchInventory = 'searchInventory'
311
+ pikkuState(null, 'function', 'meta').searchInventory = {
312
+ description: 'Search inventory',
313
+ inputSchemaName: 'SearchInput',
314
+ sessionless: true,
315
+ }
316
+ pikkuState(null, 'misc', 'schemas').set('SearchInput', { type: 'object' })
317
+ pikkuState(null, 'function', 'functions').set('searchInventory', {
318
+ func: async () => {
319
+ throw new Error('db unreachable')
320
+ },
321
+ })
322
+
323
+ const errorCalls: unknown[][] = []
324
+ const singletonServices = {
325
+ logger: {
326
+ warn: () => {},
327
+ error: (...args: unknown[]) => errorCalls.push(args),
328
+ },
329
+ } as any
330
+ pikkuState(null, 'package', 'singletonServices', singletonServices)
331
+
332
+ const { tools } = await buildToolDefs(
333
+ {},
334
+ new Map<string, string>(),
335
+ 'resource-1',
336
+ 'ops-agent',
337
+ null
338
+ )
339
+
340
+ assert.equal(tools.length, 1)
341
+ await assert.rejects(() => tools[0].execute({}), /db unreachable/)
342
+ assert.equal(errorCalls.length, 1)
343
+ assert.match(String(errorCalls[0][0]), /searchInventory.*execute/)
344
+ })
345
+
240
346
  test('buildToolDefs skips permissioned tools without a session and adds sub-agent tools', async () => {
241
347
  addAgent('manager', {
242
348
  agents: ['assistant'],
@@ -291,4 +397,206 @@ describe('ai-agent-prepare', () => {
291
397
  required: ['message', 'session'],
292
398
  })
293
399
  })
400
+
401
+ test('buildToolDefs adds a workflow tool that runs the workflow and returns its output', async () => {
402
+ addAgent('planner', { workflows: ['buildReport'] } as any)
403
+ pikkuState(null, 'agent', 'agentsMeta').planner = {
404
+ ...pikkuState(null, 'agent', 'agentsMeta').planner,
405
+ workflows: ['buildReport'],
406
+ } as any
407
+
408
+ pikkuState(null, 'workflows', 'meta').buildReport = {
409
+ name: 'buildReport',
410
+ pikkuFuncId: 'buildReport',
411
+ source: 'graph',
412
+ description: 'Build a report for a country',
413
+ } as any
414
+ pikkuState(null, 'function', 'meta').buildReport = {
415
+ inputSchemaName: 'BuildReportInput',
416
+ sessionless: true,
417
+ } as any
418
+ pikkuState(null, 'misc', 'schemas').set('BuildReportInput', {
419
+ type: 'object',
420
+ properties: { country: { type: 'string' } },
421
+ required: ['country'],
422
+ })
423
+
424
+ const runCalls: Array<{ name: string; input: unknown }> = []
425
+ const workflowService = {
426
+ runToCompletion: async (name: string, input: unknown) => {
427
+ runCalls.push({ name, input })
428
+ return { capital: 'Paris' }
429
+ },
430
+ }
431
+ const singletonServices = {
432
+ logger: { warn: () => {} },
433
+ workflowService,
434
+ } as any
435
+ pikkuState(null, 'package', 'singletonServices', singletonServices)
436
+
437
+ const { tools, missingRpcs } = await buildToolDefs(
438
+ {},
439
+ new Map<string, string>(),
440
+ 'resource-1',
441
+ 'planner',
442
+ null
443
+ )
444
+
445
+ assert.deepEqual(missingRpcs, [])
446
+ assert.equal(tools.length, 1)
447
+ assert.equal(tools[0].name, 'buildReport')
448
+ assert.equal(tools[0].description, 'Build a report for a country')
449
+ assert.deepEqual(tools[0].inputSchema, {
450
+ type: 'object',
451
+ properties: { country: { type: 'string' } },
452
+ required: ['country'],
453
+ })
454
+
455
+ const result = await tools[0].execute({ country: 'France' })
456
+ assert.deepEqual(result, { capital: 'Paris' })
457
+ assert.deepEqual(runCalls, [
458
+ { name: 'buildReport', input: { country: 'France' } },
459
+ ])
460
+ })
461
+
462
+ test('buildToolDefs reports an unknown workflow tool as missing', async () => {
463
+ addAgent('planner2', { workflows: ['ghost'] } as any)
464
+ pikkuState(null, 'agent', 'agentsMeta').planner2 = {
465
+ ...pikkuState(null, 'agent', 'agentsMeta').planner2,
466
+ workflows: ['ghost'],
467
+ } as any
468
+ pikkuState(null, 'package', 'singletonServices', {
469
+ logger: { warn: () => {} },
470
+ workflowService: { runToCompletion: async () => ({}) },
471
+ } as any)
472
+
473
+ const { tools, missingRpcs } = await buildToolDefs(
474
+ {},
475
+ new Map<string, string>(),
476
+ 'resource-1',
477
+ 'planner2',
478
+ null
479
+ )
480
+
481
+ assert.equal(tools.length, 0)
482
+ assert.deepEqual(missingRpcs, ['ghost'])
483
+ })
484
+ })
485
+
486
+ describe('C2 thread/run ownership + sessionScope', () => {
487
+ const params = (session?: Record<string, unknown>) => ({
488
+ sessionService: { get: () => session } as never,
489
+ })
490
+
491
+ test('user scope composes the trusted userId with the requested resourceId', () => {
492
+ assert.equal(
493
+ resolveOwnerResourceId(params({ userId: 'user-a' }), 'user', 'default'),
494
+ 'user-a:default'
495
+ )
496
+ })
497
+
498
+ test('user scope is the default when sessionScope is undefined', () => {
499
+ assert.equal(
500
+ resolveOwnerResourceId(params({ userId: 'user-a' }), undefined, 'p1'),
501
+ 'user-a:p1'
502
+ )
503
+ })
504
+
505
+ test('user scope sub-partitions within the owner (client resourceId regains meaning)', () => {
506
+ assert.equal(
507
+ resolveOwnerResourceId(params({ userId: 'alice' }), 'user', 'project-1'),
508
+ 'alice:project-1'
509
+ )
510
+ assert.equal(
511
+ resolveOwnerResourceId(params({ userId: 'alice' }), 'user', 'project-2'),
512
+ 'alice:project-2'
513
+ )
514
+ })
515
+
516
+ test('is idempotent: re-normalizing an already-owned key does not double-compose', () => {
517
+ assert.equal(
518
+ resolveOwnerResourceId(
519
+ params({ userId: 'alice' }),
520
+ 'user',
521
+ 'alice:project-1'
522
+ ),
523
+ 'alice:project-1'
524
+ )
525
+ })
526
+
527
+ test('a client cannot forge another owner: a foreign prefix is re-scoped, not trusted', () => {
528
+ assert.equal(
529
+ resolveOwnerResourceId(params({ userId: 'bob' }), 'user', 'alice:secret'),
530
+ 'bob:alice:secret'
531
+ )
532
+ })
533
+
534
+ test('user scope falls back to the bare requested resourceId when sessionless', () => {
535
+ assert.equal(resolveOwnerResourceId({}, 'user', 'resource-1'), 'resource-1')
536
+ assert.equal(
537
+ resolveOwnerResourceId(params(undefined), 'user', 'resource-1'),
538
+ 'resource-1'
539
+ )
540
+ })
541
+
542
+ test('org scope composes the trusted orgId with the requested resourceId', () => {
543
+ assert.equal(
544
+ resolveOwnerResourceId(
545
+ params({ userId: 'user-a', orgId: 'org-x' }),
546
+ 'org',
547
+ 'default'
548
+ ),
549
+ 'org-x:default'
550
+ )
551
+ })
552
+
553
+ test('org scope denies (ForbiddenError) when the session has no org', () => {
554
+ assert.throws(
555
+ () =>
556
+ resolveOwnerResourceId(params({ userId: 'user-a' }), 'org', 'default'),
557
+ (e: unknown) => e instanceof ForbiddenError
558
+ )
559
+ assert.throws(
560
+ () => resolveOwnerResourceId({}, 'org', 'default'),
561
+ (e: unknown) => e instanceof ForbiddenError
562
+ )
563
+ })
564
+
565
+ test('assertResourceOwner throws ForbiddenError on an owner mismatch', () => {
566
+ assert.throws(
567
+ () => assertResourceOwner('user-a:d', 'user-b:d', 'thread'),
568
+ (e: unknown) =>
569
+ e instanceof ForbiddenError && !/user-a|user-b/.test(e.message)
570
+ )
571
+ assert.doesNotThrow(() =>
572
+ assertResourceOwner('user-a:d', 'user-a:d', 'run')
573
+ )
574
+ })
575
+
576
+ test('agentSessionScope reads the declared scope and defaults to user', () => {
577
+ addAgent('scoped-user')
578
+ addAgent('scoped-org', { sessionScope: 'org' })
579
+ assert.equal(agentSessionScope('scoped-user'), 'user')
580
+ assert.equal(agentSessionScope('scoped-org'), 'org')
581
+ })
582
+
583
+ test('resume ownership: owner passes, a different user is rejected (via idempotent recompose)', () => {
584
+ const stored = 'alice:default'
585
+ assert.doesNotThrow(() =>
586
+ assertResourceOwner(
587
+ resolveOwnerResourceId(params({ userId: 'alice' }), 'user', stored),
588
+ stored,
589
+ 'run'
590
+ )
591
+ )
592
+ assert.throws(
593
+ () =>
594
+ assertResourceOwner(
595
+ resolveOwnerResourceId(params({ userId: 'bob' }), 'user', stored),
596
+ stored,
597
+ 'run'
598
+ ),
599
+ (e: unknown) => e instanceof ForbiddenError
600
+ )
601
+ })
294
602
  })
@@ -8,11 +8,13 @@ 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
15
  import { checkAuthPermissions } from '../../permissions.js'
15
16
  import { AIProviderNotConfiguredError } from '../../errors/errors.js'
17
+ import { ForbiddenError } from '../../errors/errors.js'
16
18
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
17
19
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
18
20
  import type { SessionService } from '../../services/user-session-service.js'
@@ -23,6 +25,7 @@ import {
23
25
  resolveNamespace,
24
26
  ContextAwareRPCService,
25
27
  } from '../../wirings/rpc/rpc-runner.js'
28
+ import { getOrCreatePackageSingletonServices } from '../../wirings/rpc/addon-runner.js'
26
29
  import {
27
30
  resolveMemoryServices,
28
31
  loadContextMessages,
@@ -36,10 +39,91 @@ export type RunAIAgentParams = {
36
39
  getCredential?: <T = unknown>(name: string) => T | null | Promise<T | null>
37
40
  }
38
41
 
42
+ /**
43
+ * The ownership key for a thread/run: the trusted principal (the authenticated
44
+ * `session.userId` for `'user'` scope, or `session.orgId` for `'org'` scope)
45
+ * composed with the client-supplied `resourceId` as a sub-partition —
46
+ * `principal:resourceId`. Because the principal is always the prefix, a client
47
+ * `resourceId` can sub-divide within the caller's own boundary but can never
48
+ * widen access to another user's or org's threads.
49
+ *
50
+ * Idempotent: a value that is already within the caller's namespace (top-level
51
+ * re-entry, sub-agent recursion, or resume, which all pass an already-composite
52
+ * `resourceId`) is returned unchanged rather than double-composed.
53
+ *
54
+ * `'org'` scope with no session org throws {@link ForbiddenError} — falling back
55
+ * to a bare/shared key would leak across organizations. Sessionless `'user'`
56
+ * wirings have no trusted principal and fall back to the requested `resourceId`
57
+ * as a best-effort partition key.
58
+ */
59
+ export function resolveOwnerResourceId(
60
+ params: RunAIAgentParams,
61
+ sessionScope: SessionScope | undefined,
62
+ requestedResourceId: string
63
+ ): string {
64
+ const session = params.sessionService?.get()
65
+ let principal: string | undefined
66
+ if ((sessionScope ?? 'user') === 'org') {
67
+ if (!session?.orgId) {
68
+ throw new ForbiddenError(
69
+ 'This agent is org-scoped but the session has no organization'
70
+ )
71
+ }
72
+ principal = session.orgId
73
+ } else {
74
+ principal = session?.userId
75
+ }
76
+
77
+ if (!principal) return requestedResourceId
78
+ if (
79
+ requestedResourceId === principal ||
80
+ requestedResourceId.startsWith(`${principal}:`)
81
+ ) {
82
+ return requestedResourceId
83
+ }
84
+ return `${principal}:${requestedResourceId}`
85
+ }
86
+
87
+ /** The declared {@link SessionScope} for an agent, defaulting to `'user'`. */
88
+ export function agentSessionScope(agentName: string): SessionScope {
89
+ return resolveAgent(agentName).agent.sessionScope ?? 'user'
90
+ }
91
+
92
+ /**
93
+ * Enforce that a stored thread/run belongs to the caller's owner resource,
94
+ * throwing {@link ForbiddenError} (without echoing the id, to avoid an
95
+ * existence oracle) on a mismatch.
96
+ */
97
+ export function assertResourceOwner(
98
+ ownerResourceId: string,
99
+ storedResourceId: string,
100
+ kind: 'thread' | 'run'
101
+ ): void {
102
+ if (storedResourceId !== ownerResourceId) {
103
+ throw new ForbiddenError(`Not authorized to access this ${kind}`)
104
+ }
105
+ }
106
+
39
107
  export type StreamAIAgentOptions = {
40
108
  requiresToolApproval?: 'all' | 'explicit' | false
109
+ /**
110
+ * Invoked as soon as the run's AIRunStateService id exists, so transport
111
+ * wrappers (e.g. the AG-UI bridge) can stamp the real runId on RUN_STARTED.
112
+ */
113
+ onRunCreated?: (runId: string) => void
41
114
  }
42
115
 
116
+ /**
117
+ * Non-forgeable brand for the sub-agent approval marker. Only framework code
118
+ * (the delegating sub-agent tools below) sets this Symbol on a tool result; a
119
+ * sub-agent's LLM-shaped `result.object` — plain JSON — cannot carry a Symbol,
120
+ * so `checkForApprovals` trusts the brand rather than the `__approvalRequired`
121
+ * string key to decide an approval must be surfaced.
122
+ */
123
+ export const APPROVAL_REQUIRED: unique symbol = Symbol(
124
+ 'pikku.ai.approvalRequired'
125
+ )
126
+
43
127
  export class ToolApprovalRequired extends PikkuError {
44
128
  public readonly toolCallId: string
45
129
  public readonly toolName: string
@@ -193,7 +277,7 @@ export async function buildInstructions(
193
277
  if (meta?.goal) parts.push(meta.goal)
194
278
  let instructions = parts.join('\n\n')
195
279
 
196
- if (meta?.tools?.length) {
280
+ if (meta?.tools?.length || meta?.workflows?.length) {
197
281
  instructions +=
198
282
  '\n\nTool usage rules:\n' +
199
283
  '- Act immediately with the information given. Do NOT ask clarifying questions unless a required field is truly missing.\n' +
@@ -219,6 +303,7 @@ export type ScopedChannel = AIStreamChannel & {
219
303
  toolCallId: string
220
304
  toolName: string
221
305
  args: unknown
306
+ reason?: string
222
307
  runId: string
223
308
  }>
224
309
  }
@@ -248,6 +333,7 @@ export function createScopedChannel(
248
333
  toolCallId: event.toolCallId,
249
334
  toolName: event.toolName,
250
335
  args: event.args,
336
+ ...(event.reason !== undefined ? { reason: event.reason } : {}),
251
337
  runId: (event as any).runId,
252
338
  })
253
339
  return
@@ -373,17 +459,27 @@ export async function buildToolDefs(
373
459
  if (funcConfig?.approvalDescription) {
374
460
  const descFn = funcConfig.approvalDescription
375
461
  const capturedPkg = resolvedPkg
462
+ const capturedAddonConfig = resolved?.addonConfig
463
+ const capturedNamespace = toolName.includes(':')
464
+ ? toolName.slice(0, toolName.indexOf(':'))
465
+ : null
376
466
  approvalDescriptionFn = async (input: unknown) => {
377
467
  let services = singletonServices
378
468
  if (capturedPkg) {
379
- const pkgServices = pikkuState(
469
+ const addonInstance = capturedNamespace
470
+ ? {
471
+ namespace: capturedNamespace,
472
+ secretOverrides: capturedAddonConfig?.secretOverrides,
473
+ variableOverrides: capturedAddonConfig?.variableOverrides,
474
+ credentialOverrides:
475
+ capturedAddonConfig?.credentialOverrides,
476
+ }
477
+ : undefined
478
+ services = await getOrCreatePackageSingletonServices(
380
479
  capturedPkg,
381
- 'package',
382
- 'singletonServices'
480
+ singletonServices,
481
+ addonInstance
383
482
  )
384
- if (pkgServices) {
385
- services = pkgServices
386
- }
387
483
  }
388
484
  return descFn(services, input)
389
485
  }
@@ -437,6 +533,7 @@ export async function buildToolDefs(
437
533
  tools.push({
438
534
  name: subAgentName,
439
535
  description: subMeta.description,
536
+ forwardsApproval: true,
440
537
  inputSchema: {
441
538
  type: 'object',
442
539
  properties: {
@@ -502,6 +599,7 @@ export async function buildToolDefs(
502
599
  )
503
600
  if (subChannel.approvals.length > 0) {
504
601
  return {
602
+ [APPROVAL_REQUIRED]: true,
505
603
  __approvalRequired: true,
506
604
  toolName: subAgentName,
507
605
  args: toolInput,
@@ -530,6 +628,7 @@ export async function buildToolDefs(
530
628
  result.pendingApprovals?.length
531
629
  ) {
532
630
  return {
631
+ [APPROVAL_REQUIRED]: true,
533
632
  __approvalRequired: true,
534
633
  toolName: subAgentName,
535
634
  args: toolInput,
@@ -538,6 +637,7 @@ export async function buildToolDefs(
538
637
  toolCallId: a.toolCallId,
539
638
  toolName: a.toolName,
540
639
  args: a.args,
640
+ reason: a.reason,
541
641
  runId: a.runId,
542
642
  })),
543
643
  }
@@ -548,6 +648,84 @@ export async function buildToolDefs(
548
648
  }
549
649
  }
550
650
 
651
+ const metaWorkflows = meta.workflows
652
+ if (metaWorkflows?.length) {
653
+ const workflowMetaMap = pikkuState(null, 'workflows', 'meta')
654
+ const functionMeta = pikkuState(null, 'function', 'meta')
655
+ const schemas = pikkuState(null, 'misc', 'schemas')
656
+
657
+ for (const workflowName of metaWorkflows) {
658
+ const wfMeta = workflowMetaMap[workflowName]
659
+ if (!wfMeta) {
660
+ missingRpcs.push(workflowName)
661
+ continue
662
+ }
663
+
664
+ const inputSchemaName = wfMeta.pikkuFuncId
665
+ ? functionMeta[wfMeta.pikkuFuncId]?.inputSchemaName
666
+ : undefined
667
+ let inputSchema = inputSchemaName
668
+ ? schemas.get(inputSchemaName)
669
+ : undefined
670
+ if (
671
+ !inputSchema ||
672
+ (typeof inputSchema === 'object' &&
673
+ inputSchema.type === 'object' &&
674
+ !inputSchema.properties)
675
+ ) {
676
+ inputSchema = { type: 'object', properties: {} }
677
+ }
678
+
679
+ tools.push({
680
+ name: workflowName,
681
+ description: wfMeta.description || workflowName,
682
+ inputSchema,
683
+ execute: async (toolInput: unknown) => {
684
+ const workflowService = singletonServices.workflowService
685
+ if (!workflowService) {
686
+ throw new Error(
687
+ `workflowService is not configured — cannot run workflow tool '${workflowName}'`
688
+ )
689
+ }
690
+ const wire: PikkuRawWire = params.sessionService
691
+ ? { ...createMiddlewareSessionWireProps(params.sessionService) }
692
+ : {}
693
+ const rpcService = new ContextAwareRPCService(
694
+ singletonServices,
695
+ wire,
696
+ { sessionService: params.sessionService }
697
+ )
698
+ return workflowService.runToCompletion(
699
+ workflowName,
700
+ toolInput,
701
+ rpcService,
702
+ { wire: { type: 'internal' } }
703
+ )
704
+ },
705
+ })
706
+ }
707
+ }
708
+
709
+ // A tool's execute() can throw (bad input, RPC failure, DB error, ...). The AI
710
+ // SDK catches that at the tool-call boundary and turns it into a conversational
711
+ // "tool error" reply — without this, the exception never reaches pikku's own
712
+ // logger, so a failing tool is undiagnosable server-side. Log unconditionally,
713
+ // even when no aiMiddleware afterToolCall hook is registered to see it.
714
+ for (const tool of tools) {
715
+ const originalExecute = tool.execute
716
+ tool.execute = async (toolInput: unknown) => {
717
+ try {
718
+ return await originalExecute(toolInput)
719
+ } catch (err) {
720
+ singletonServices.logger.error(
721
+ `AI agent tool '${tool.name}' threw during execute()`,
722
+ err
723
+ )
724
+ throw err
725
+ }
726
+ }
727
+ }
728
+
551
729
  const hasToolHooks = aiMiddlewares?.some(
552
730
  (mw) => mw.beforeToolCall || mw.afterToolCall
553
731
  )
@@ -649,9 +827,15 @@ export async function prepareAgentRun(
649
827
  : undefined
650
828
 
651
829
  if (storage) {
830
+ let thread: Awaited<ReturnType<typeof storage.getThread>> | null = null
652
831
  try {
653
- await storage.getThread(threadId)
832
+ thread = await storage.getThread(threadId)
654
833
  } catch {
834
+ thread = null
835
+ }
836
+ if (thread) {
837
+ assertResourceOwner(input.resourceId, thread.resourceId, 'thread')
838
+ } else {
655
839
  await storage.createThread(input.resourceId, { threadId })
656
840
  }
657
841
  }