@pikku/core 0.12.80 → 0.12.82

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 (231) hide show
  1. package/CHANGELOG.md +312 -0
  2. package/dist/errors/index.d.ts +1 -1
  3. package/dist/errors/index.js +1 -1
  4. package/dist/function/function-runner.js +2 -5
  5. package/dist/function/index.d.ts +1 -1
  6. package/dist/index.d.ts +11 -11
  7. package/dist/index.js +3 -3
  8. package/dist/pikku-state.js +4 -0
  9. package/dist/services/ai-agent-runner-service.d.ts +7 -0
  10. package/dist/services/ai-run-state-service.d.ts +10 -0
  11. package/dist/services/in-memory-ai-run-state-service.d.ts +5 -1
  12. package/dist/services/in-memory-ai-run-state-service.js +9 -0
  13. package/dist/services/index.d.ts +15 -15
  14. package/dist/services/index.js +5 -5
  15. package/dist/services/scoped-credential-service.d.ts +21 -0
  16. package/dist/services/scoped-credential-service.js +53 -0
  17. package/dist/testing/service-tests/ai-storage-service-tests.js +76 -0
  18. package/dist/types/core.types.d.ts +0 -2
  19. package/dist/types/state.types.d.ts +13 -0
  20. package/dist/wirings/actor-flow/index.d.ts +1 -1
  21. package/dist/wirings/ai-agent/ai-agent-finalize.d.ts +58 -0
  22. package/dist/wirings/ai-agent/ai-agent-finalize.js +138 -0
  23. package/dist/wirings/ai-agent/ai-agent-interrupt.js +1 -0
  24. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +2 -8
  25. package/dist/wirings/ai-agent/ai-agent-memory.js +34 -17
  26. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +7 -0
  27. package/dist/wirings/ai-agent/ai-agent-model-config.js +44 -1
  28. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -0
  29. package/dist/wirings/ai-agent/ai-agent-runner.js +61 -40
  30. package/dist/wirings/ai-agent/ai-agent-stream.js +89 -36
  31. package/dist/wirings/ai-agent/ai-agent-turn.d.ts +1 -0
  32. package/dist/wirings/ai-agent/ai-agent-turn.js +1 -0
  33. package/dist/wirings/ai-agent/ai-agent.types.d.ts +46 -1
  34. package/dist/wirings/ai-agent/index.d.ts +8 -7
  35. package/dist/wirings/ai-agent/index.js +5 -4
  36. package/dist/wirings/ai-scorer/ai-scorer-grade.d.ts +26 -0
  37. package/dist/wirings/ai-scorer/ai-scorer-grade.js +33 -0
  38. package/dist/wirings/ai-scorer/ai-scorer-judge.d.ts +17 -0
  39. package/dist/wirings/ai-scorer/ai-scorer-judge.js +92 -0
  40. package/dist/wirings/ai-scorer/ai-scorer-live.d.ts +15 -0
  41. package/dist/wirings/ai-scorer/ai-scorer-live.js +38 -0
  42. package/dist/wirings/ai-scorer/ai-scorer-registry.d.ts +18 -0
  43. package/dist/wirings/ai-scorer/ai-scorer-registry.js +46 -0
  44. package/dist/wirings/ai-scorer/ai-scorer-sampling.d.ts +8 -0
  45. package/dist/wirings/ai-scorer/ai-scorer-sampling.js +31 -0
  46. package/dist/wirings/ai-scorer/ai-scorer-snapshots.d.ts +10 -0
  47. package/dist/wirings/ai-scorer/ai-scorer-snapshots.js +40 -0
  48. package/dist/wirings/ai-scorer/ai-scorer-worker.d.ts +15 -0
  49. package/dist/wirings/ai-scorer/ai-scorer-worker.js +58 -0
  50. package/dist/wirings/ai-scorer/ai-scorer.d.ts +39 -0
  51. package/dist/wirings/ai-scorer/ai-scorer.js +40 -0
  52. package/dist/wirings/ai-scorer/ai-scorer.types.d.ts +90 -0
  53. package/dist/wirings/ai-scorer/ai-scorer.types.js +4 -0
  54. package/dist/wirings/ai-scorer/index.d.ts +6 -0
  55. package/dist/wirings/ai-scorer/index.js +5 -0
  56. package/dist/wirings/channel/index.d.ts +5 -6
  57. package/dist/wirings/channel/index.js +3 -4
  58. package/dist/wirings/channel/local/local-channel-runner.js +8 -1
  59. package/dist/wirings/cli/channel/cli-raw-channel-runner.js +9 -1
  60. package/dist/wirings/cli/channel/index.d.ts +1 -2
  61. package/dist/wirings/cli/channel/index.js +0 -1
  62. package/dist/wirings/cli/cli-runner.js +13 -1
  63. package/dist/wirings/credential/index.d.ts +1 -1
  64. package/dist/wirings/gateway/index.d.ts +1 -1
  65. package/dist/wirings/http/http-runner.js +8 -2
  66. package/dist/wirings/http/index.d.ts +1 -2
  67. package/dist/wirings/mcp/index.d.ts +1 -1
  68. package/dist/wirings/mcp/mcp-runner.d.ts +15 -0
  69. package/dist/wirings/mcp/mcp-runner.js +18 -5
  70. package/dist/wirings/persona/index.d.ts +3 -4
  71. package/dist/wirings/persona/index.js +2 -3
  72. package/dist/wirings/queue/index.d.ts +1 -3
  73. package/dist/wirings/queue/index.js +1 -3
  74. package/dist/wirings/rpc/addon-runner.d.ts +4 -0
  75. package/dist/wirings/rpc/addon-runner.js +19 -3
  76. package/dist/wirings/rpc/rpc-runner.js +2 -0
  77. package/dist/wirings/rpc/rpc-types.d.ts +4 -0
  78. package/dist/wirings/rpc/wire-addon.d.ts +13 -0
  79. package/dist/wirings/rpc/wire-addon.js +4 -0
  80. package/dist/wirings/scheduler/index.d.ts +1 -1
  81. package/dist/wirings/trigger/index.d.ts +1 -1
  82. package/dist/wirings/virtual-user/index.d.ts +5 -6
  83. package/dist/wirings/virtual-user/index.js +2 -4
  84. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +85 -15
  85. package/dist/wirings/workflow/index.d.ts +6 -6
  86. package/dist/wirings/workflow/index.js +2 -2
  87. package/dist/wirings/workflow/pikku-scenario-service.d.ts +7 -7
  88. package/dist/wirings/workflow/pikku-scenario-service.js +39 -13
  89. package/dist/wirings/workflow/pikku-workflow-service.js +17 -3
  90. package/dist/wirings/workflow/scenario-step.types.d.ts +8 -0
  91. package/dist/wirings/workflow/workflow-approval-audit.d.ts +16 -0
  92. package/dist/wirings/workflow/workflow-approval-audit.js +40 -0
  93. package/dist/wirings/workflow/workflow-approval-policy.d.ts +20 -0
  94. package/dist/wirings/workflow/workflow-approval-policy.js +48 -0
  95. package/dist/wirings/workflow/workflow-approval.d.ts +29 -1
  96. package/dist/wirings/workflow/workflow-approval.js +65 -2
  97. package/dist/wirings/workflow/workflow-run-ownership.d.ts +2 -1
  98. package/dist/wirings/workflow/workflow-run-ownership.js +2 -1
  99. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  100. package/knowledge/decisions/internals/addon-pikku-meta-ships-at-the-package-root-or-under-dist.md +32 -0
  101. package/knowledge/decisions/internals/an-addon-scope-root-loses-to-a-root-the-host-already-declares.md +39 -0
  102. package/knowledge/decisions/internals/index.md +30 -3
  103. package/knowledge/decisions/internals/validate-runs-checks-by-precondition.md +115 -0
  104. package/knowledge/decisions/security/a-function-never-receives-the-secret-service.md +37 -0
  105. package/knowledge/decisions/security/a-workflow-run-is-read-and-approved-by-its-owner.md +30 -14
  106. package/knowledge/decisions/security/an-approval-answer-outlives-the-run-it-answered.md +59 -0
  107. package/knowledge/decisions/security/index.md +3 -1
  108. package/knowledge/questions/index.md +1 -1
  109. package/package.json +3 -1
  110. package/scripts/generate-api-report.mts +143 -18
  111. package/src/api-report.test.ts +2 -2
  112. package/src/errors/index.ts +1 -1
  113. package/src/function/function-runner.test.ts +52 -0
  114. package/src/function/function-runner.ts +5 -9
  115. package/src/function/index.ts +0 -2
  116. package/src/index.ts +0 -35
  117. package/src/pikku-state.ts +5 -0
  118. package/src/public-surface.json +70 -94
  119. package/src/services/ai-agent-runner-service.ts +12 -1
  120. package/src/services/ai-run-state-service.ts +11 -0
  121. package/src/services/in-memory-ai-run-state-service.ts +13 -0
  122. package/src/services/index.ts +3 -43
  123. package/src/services/scoped-credential-service.test.ts +86 -0
  124. package/src/services/scoped-credential-service.ts +63 -0
  125. package/src/testing/service-tests/ai-storage-service-tests.ts +93 -0
  126. package/src/types/core.types.ts +3 -6
  127. package/src/types/state.types.ts +16 -0
  128. package/src/wirings/actor-flow/index.ts +0 -3
  129. package/src/wirings/ai-agent/ai-agent-finalize.test.ts +186 -0
  130. package/src/wirings/ai-agent/ai-agent-finalize.ts +197 -0
  131. package/src/wirings/ai-agent/ai-agent-interrupt.ts +1 -0
  132. package/src/wirings/ai-agent/ai-agent-memory.ts +54 -38
  133. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +72 -3
  134. package/src/wirings/ai-agent/ai-agent-model-config.ts +49 -1
  135. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -0
  136. package/src/wirings/ai-agent/ai-agent-runner.ts +71 -40
  137. package/src/wirings/ai-agent/ai-agent-stream-output-hooks.test.ts +353 -0
  138. package/src/wirings/ai-agent/ai-agent-stream.ts +116 -54
  139. package/src/wirings/ai-agent/ai-agent-turn.test.ts +67 -0
  140. package/src/wirings/ai-agent/ai-agent-turn.ts +1 -0
  141. package/src/wirings/ai-agent/ai-agent.types.ts +64 -4
  142. package/src/wirings/ai-agent/index.ts +2 -16
  143. package/src/wirings/ai-scorer/ai-scorer-grade.test.ts +106 -0
  144. package/src/wirings/ai-scorer/ai-scorer-grade.ts +55 -0
  145. package/src/wirings/ai-scorer/ai-scorer-judge.test.ts +143 -0
  146. package/src/wirings/ai-scorer/ai-scorer-judge.ts +120 -0
  147. package/src/wirings/ai-scorer/ai-scorer-live.test.ts +174 -0
  148. package/src/wirings/ai-scorer/ai-scorer-live.ts +56 -0
  149. package/src/wirings/ai-scorer/ai-scorer-registry.ts +63 -0
  150. package/src/wirings/ai-scorer/ai-scorer-sampling.test.ts +34 -0
  151. package/src/wirings/ai-scorer/ai-scorer-sampling.ts +36 -0
  152. package/src/wirings/ai-scorer/ai-scorer-snapshots.test.ts +49 -0
  153. package/src/wirings/ai-scorer/ai-scorer-snapshots.ts +46 -0
  154. package/src/wirings/ai-scorer/ai-scorer-worker.test.ts +122 -0
  155. package/src/wirings/ai-scorer/ai-scorer-worker.ts +69 -0
  156. package/src/wirings/ai-scorer/ai-scorer.ts +76 -0
  157. package/src/wirings/ai-scorer/ai-scorer.types.ts +107 -0
  158. package/src/wirings/ai-scorer/index.ts +24 -0
  159. package/src/wirings/channel/index.ts +1 -20
  160. package/src/wirings/channel/local/local-channel-runner.test.ts +68 -0
  161. package/src/wirings/channel/local/local-channel-runner.ts +8 -1
  162. package/src/wirings/cli/channel/cli-raw-channel-runner.test.ts +23 -0
  163. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +12 -1
  164. package/src/wirings/cli/channel/index.ts +0 -7
  165. package/src/wirings/cli/cli-runner.test.ts +68 -0
  166. package/src/wirings/cli/cli-runner.ts +18 -1
  167. package/src/wirings/credential/index.ts +0 -1
  168. package/src/wirings/gateway/index.ts +0 -3
  169. package/src/wirings/http/http-runner.test.ts +66 -0
  170. package/src/wirings/http/http-runner.ts +10 -2
  171. package/src/wirings/http/index.ts +1 -1
  172. package/src/wirings/mcp/index.ts +0 -1
  173. package/src/wirings/mcp/mcp-runner.test.ts +181 -0
  174. package/src/wirings/mcp/mcp-runner.ts +35 -5
  175. package/src/wirings/persona/index.ts +0 -8
  176. package/src/wirings/queue/index.ts +0 -14
  177. package/src/wirings/rpc/addon-runner.ts +34 -3
  178. package/src/wirings/rpc/addon-secrets.test.ts +261 -0
  179. package/src/wirings/rpc/rpc-runner.test.ts +2 -0
  180. package/src/wirings/rpc/rpc-runner.ts +2 -0
  181. package/src/wirings/rpc/rpc-types.ts +4 -0
  182. package/src/wirings/rpc/wire-addon.ts +17 -0
  183. package/src/wirings/scheduler/index.ts +0 -1
  184. package/src/wirings/trigger/index.ts +0 -1
  185. package/src/wirings/virtual-user/index.ts +0 -16
  186. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +96 -16
  187. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  188. package/src/wirings/workflow/index.ts +2 -20
  189. package/src/wirings/workflow/pikku-scenario-service.ts +60 -15
  190. package/src/wirings/workflow/pikku-workflow-service.test.ts +13 -12
  191. package/src/wirings/workflow/pikku-workflow-service.ts +28 -4
  192. package/src/wirings/workflow/scenario-expectations.test.ts +75 -0
  193. package/src/wirings/workflow/scenario-hooks.test.ts +3 -2
  194. package/src/wirings/workflow/scenario-step.types.ts +8 -0
  195. package/src/wirings/workflow/workflow-approval-audit.ts +47 -0
  196. package/src/wirings/workflow/workflow-approval-policy.test.ts +524 -0
  197. package/src/wirings/workflow/workflow-approval-policy.ts +68 -0
  198. package/src/wirings/workflow/workflow-approval.ts +113 -9
  199. package/src/wirings/workflow/workflow-run-authority.test.ts +12 -15
  200. package/src/wirings/workflow/workflow-run-ownership.ts +2 -1
  201. package/src/wirings/workflow/workflow.types.ts +0 -9
  202. package/src/wirings-stay-decoupled.test.ts +6 -2
  203. package/tsconfig.tsbuildinfo +1 -1
  204. package/dist/internal.d.ts +0 -3
  205. package/dist/internal.js +0 -2
  206. package/dist/middleware/timeout.d.ts +0 -9
  207. package/dist/middleware/timeout.js +0 -15
  208. package/dist/pikku-response.d.ts +0 -6
  209. package/dist/pikku-response.js +0 -6
  210. package/dist/services/gopass-secrets.d.ts +0 -15
  211. package/dist/services/gopass-secrets.js +0 -76
  212. package/dist/services/http-scenario-actors.d.ts +0 -75
  213. package/dist/services/http-scenario-actors.js +0 -195
  214. package/dist/services/http-user-flow-actors.d.ts +0 -67
  215. package/dist/services/http-user-flow-actors.js +0 -193
  216. package/dist/services/scenario-actors-service.d.ts +0 -127
  217. package/dist/services/scenario-actors-service.js +0 -40
  218. package/dist/services/user-flow-actors-service.d.ts +0 -39
  219. package/dist/services/user-flow-actors-service.js +0 -1
  220. package/dist/wirings/credential/wire-credential.d.ts +0 -48
  221. package/dist/wirings/credential/wire-credential.js +0 -47
  222. package/dist/wirings/oauth2/oauth2-client.d.ts +0 -47
  223. package/dist/wirings/oauth2/oauth2-client.js +0 -263
  224. package/dist/wirings/oauth2/oauth2-routes.d.ts +0 -35
  225. package/dist/wirings/oauth2/oauth2-routes.js +0 -146
  226. package/dist/wirings/scope/wire-scope.d.ts +0 -33
  227. package/dist/wirings/scope/wire-scope.js +0 -32
  228. package/dist/wirings/workflow/dsl/index.d.ts +0 -5
  229. package/dist/wirings/workflow/dsl/index.js +0 -4
  230. package/dist/wirings/workflow/graph/index.d.ts +0 -5
  231. package/dist/wirings/workflow/graph/index.js +0 -4
@@ -9,8 +9,11 @@ import {
9
9
  wireMCPPrompt,
10
10
  wireMCPResource,
11
11
  } from './mcp-runner.js'
12
+ import { addGlobalMiddleware } from '../../middleware-runner.js'
12
13
  import { addFunction } from '../../function/function-runner.js'
13
14
  import { pikkuState, resetPikkuState } from '../../pikku-state.js'
15
+ import { pikkuMiddleware } from '../../types/core.types.js'
16
+ import { addTagMiddleware } from '../../middleware-runner.js'
14
17
 
15
18
  const logger = {
16
19
  debug: () => {},
@@ -166,6 +169,49 @@ describe('runMCPResource', () => {
166
169
  },
167
170
  })
168
171
  })
172
+
173
+ test('runs a templated resource middleware when invoked by concrete uri', async () => {
174
+ let middlewareRan = false
175
+ // A tag-derived middleware group (this is where a tag-based auth gate lands).
176
+ addTagMiddleware('secure', [
177
+ pikkuMiddleware(async (_services, _wire, next) => {
178
+ middlewareRan = true
179
+ await next()
180
+ }),
181
+ ])
182
+ pikkuState(null, 'mcp', 'resourcesMeta')['resource://secure/{id}'] = {
183
+ uri: 'resource://secure/{id}',
184
+ title: 'Secure',
185
+ description: 'Secure',
186
+ pikkuFuncId: 'secureResourceFunc',
187
+ inputSchema: null,
188
+ outputSchema: null,
189
+ // The resource's merged middleware metadata. It must resolve for a concrete
190
+ // request against the template, or the resource is reachable with its gate
191
+ // skipped.
192
+ middleware: [{ type: 'tag', tag: 'secure' }],
193
+ } as never
194
+ registerFunction('secureResourceFunc', async () => ({ ok: true }))
195
+
196
+ wireMCPResource({
197
+ uri: 'resource://secure/{id}',
198
+ title: 'Secure',
199
+ description: 'Secure',
200
+ func: { func: async () => ({ ok: true }) } as never,
201
+ })
202
+
203
+ await runMCPResource(
204
+ { jsonrpc: '2.0', id: 'req-2', params: {} },
205
+ { mcp: mcpWire as never },
206
+ 'resource://secure/99'
207
+ )
208
+
209
+ assert.equal(
210
+ middlewareRan,
211
+ true,
212
+ 'the templated resource middleware must run for a concrete uri'
213
+ )
214
+ })
169
215
  })
170
216
 
171
217
  describe('runMCPTool', () => {
@@ -371,6 +417,141 @@ describe('runMCPTool', () => {
371
417
  })
372
418
  })
373
419
 
420
+ describe("MCP carries the caller's HTTP request", () => {
421
+ // Without this, every auth middleware bails on its first line — each one
422
+ // opens with `if (!http?.request) return`, so an MCP call arrived at the
423
+ // function with no session, and a tool fronting a session-requiring function
424
+ // answered 'Authentication required' to every caller. Nothing else was
425
+ // missing: global middleware already runs for MCP, and the runner already
426
+ // builds a session service. Only the request was being dropped.
427
+ test('exposes http on the wire so session middleware can read it', async () => {
428
+ pikkuState(null, 'mcp', 'toolsMeta').whoami = {
429
+ name: 'whoami',
430
+ title: 'Who am I',
431
+ description: 'Who am I',
432
+ pikkuFuncId: 'whoamiFunc',
433
+ inputSchema: null,
434
+ outputSchema: 'MCPToolResponse',
435
+ } as never
436
+
437
+ let seenAuthorization: string | undefined
438
+ registerFunction('whoamiFunc', async (_services, _data, wire) => {
439
+ seenAuthorization = wire.http?.request?.header('authorization')
440
+ return [{ type: 'text', text: seenAuthorization ?? 'anonymous' }]
441
+ })
442
+
443
+ const http = {
444
+ request: {
445
+ header: (name: string) =>
446
+ name.toLowerCase() === 'authorization'
447
+ ? 'Bearer token-abc'
448
+ : undefined,
449
+ },
450
+ }
451
+
452
+ const response = await runMCPTool(
453
+ { jsonrpc: '2.0', id: 'req-http', params: {} },
454
+ { mcp: mcpWire as never, http: http as never },
455
+ 'whoami'
456
+ )
457
+
458
+ assert.equal(seenAuthorization, 'Bearer token-abc')
459
+ assert.deepEqual(response, {
460
+ id: 'req-http',
461
+ result: [{ type: 'text', text: 'Bearer token-abc' }],
462
+ })
463
+ })
464
+
465
+ test('omitting http leaves the wire without one rather than inventing it', async () => {
466
+ pikkuState(null, 'mcp', 'toolsMeta').anon = {
467
+ name: 'anon',
468
+ title: 'Anon',
469
+ description: 'Anon',
470
+ pikkuFuncId: 'anonFunc',
471
+ inputSchema: null,
472
+ outputSchema: 'MCPToolResponse',
473
+ } as never
474
+
475
+ let sawHttp: unknown = 'unset'
476
+ registerFunction('anonFunc', async (_services, _data, wire) => {
477
+ sawHttp = wire.http
478
+ return [{ type: 'text', text: 'ok' }]
479
+ })
480
+
481
+ await runMCPTool(
482
+ { jsonrpc: '2.0', id: 'req-anon', params: {} },
483
+ { mcp: mcpWire as never },
484
+ 'anon'
485
+ )
486
+
487
+ assert.equal(sawHttp, undefined)
488
+ })
489
+ })
490
+
491
+ describe('MCP tools fronting a session-requiring function', () => {
492
+ // The whole point of carrying the request: this is the template failure,
493
+ // reproduced. `listRoomsTool` and five others front a `pikkuFunc`, which
494
+ // requires a session, and answered 'Authentication required' to every caller
495
+ // because no request ever reached the middleware that would have set one.
496
+ test('receive the session set by global middleware from the request', async () => {
497
+ addGlobalMiddleware([
498
+ async (_services, wire, next) => {
499
+ const header = wire.http?.request?.header('authorization')
500
+ if (header === 'Bearer token-abc') {
501
+ wire.setSession?.({ userId: 'usr_1', name: 'Rafa' } as never)
502
+ }
503
+ await next()
504
+ },
505
+ ] as never)
506
+
507
+ pikkuState(null, 'mcp', 'toolsMeta').listRooms = {
508
+ name: 'listRooms',
509
+ title: 'List rooms',
510
+ description: 'List rooms',
511
+ pikkuFuncId: 'listRoomsFunc',
512
+ inputSchema: null,
513
+ outputSchema: 'MCPToolResponse',
514
+ } as never
515
+
516
+ // sessionless: false — the distinction that made these tools fail.
517
+ addFunction(
518
+ 'listRoomsFunc',
519
+ {
520
+ func: async (_services: any, _data: any, wire: any) => [
521
+ { type: 'text', text: wire.session?.userId ?? 'anonymous' },
522
+ ],
523
+ } as never,
524
+ null
525
+ )
526
+ pikkuState(null, 'function', 'meta').listRoomsFunc = {
527
+ name: 'listRoomsFunc',
528
+ sessionless: false,
529
+ permissions: [],
530
+ } as never
531
+
532
+ const response = await runMCPTool(
533
+ { jsonrpc: '2.0', id: 'req-session', params: {} },
534
+ {
535
+ mcp: mcpWire as never,
536
+ http: {
537
+ request: {
538
+ header: (name: string) =>
539
+ name.toLowerCase() === 'authorization'
540
+ ? 'Bearer token-abc'
541
+ : undefined,
542
+ },
543
+ } as never,
544
+ },
545
+ 'listRooms'
546
+ )
547
+
548
+ assert.deepEqual(response, {
549
+ id: 'req-session',
550
+ result: [{ type: 'text', text: 'usr_1' }],
551
+ })
552
+ })
553
+ })
554
+
374
555
  describe('runMCPPrompt', () => {
375
556
  test('maps missing prompt registration to not found MCP errors', async () => {
376
557
  pikkuState(null, 'mcp', 'promptsMeta').missingPrompt = {
@@ -1,4 +1,5 @@
1
1
  import type { PikkuRawWire } from '../../types/core.types.js'
2
+ import type { PikkuHTTP } from '../http/http.types.js'
2
3
  import type {
3
4
  CoreMCPResource,
4
5
  CoreMCPPrompt,
@@ -35,6 +36,20 @@ export class MCPError extends Error {
35
36
 
36
37
  export type RunMCPEndpointParams<Tools extends string = any> = {
37
38
  mcp?: PikkuMCP<Tools>
39
+ /**
40
+ * The HTTP request the MCP call arrived on, when it arrived over HTTP.
41
+ *
42
+ * Auth middleware reads the session off `wire.http.request` — every
43
+ * implementation opens with `if (!http?.request) return` — so without this an
44
+ * MCP call reaches the function unauthenticated no matter what middleware the
45
+ * app has registered, and a tool fronting a session-requiring function can
46
+ * only ever answer 'Authentication required'.
47
+ *
48
+ * Left undefined for transports that have no request to offer, such as stdio.
49
+ * Those remain anonymous, which is a property of the transport rather than a
50
+ * default chosen here.
51
+ */
52
+ http?: PikkuHTTP
38
53
  /** Defaults to enabled outside production. */
39
54
  exposeErrors?: boolean
40
55
  }
@@ -97,6 +112,12 @@ export async function runMCPResource(
97
112
  const metas = pikkuState(null, 'mcp', 'resourcesMeta')
98
113
  const endpoints = pikkuState(null, 'mcp', 'resources')
99
114
 
115
+ // The key the resource's meta (and its middleware) is stored under. For a
116
+ // templated resource this is the template, not the concrete request URI — so
117
+ // it must be carried forward, or the meta lookup downstream misses and the
118
+ // resource's declared middleware (including auth) is silently skipped.
119
+ let metaKey = uri
120
+
100
121
  if (endpoints.has(uri)) {
101
122
  endpoint = endpoints.get(uri)
102
123
  pikkuFuncId = metas[uri]?.pikkuFuncId
@@ -113,6 +134,7 @@ export async function runMCPResource(
113
134
 
114
135
  if (match) {
115
136
  endpoint = value
137
+ metaKey = uriTemplate
116
138
  pikkuFuncId = metas[uriTemplate]?.pikkuFuncId
117
139
 
118
140
  for (let i = 0; i < paramNames.length; i++) {
@@ -134,7 +156,8 @@ export async function runMCPResource(
134
156
  pikkuFuncId,
135
157
  { ...params, mcp: { ...params.mcp, uri } } as RunMCPEndpointParams<
136
158
  keyof CoreMCPResource
137
- >
159
+ >,
160
+ metaKey
138
161
  )
139
162
  }
140
163
 
@@ -177,7 +200,11 @@ async function runMCPPikkuFunc(
177
200
  name: string,
178
201
  mcp: CoreMCPResource | CoreMCPPrompt | undefined,
179
202
  pikkuFuncId: string | undefined,
180
- { mcp: mcpWire, exposeErrors = !isProduction() }: RunMCPEndpointParams
203
+ { mcp: mcpWire, http, exposeErrors = !isProduction() }: RunMCPEndpointParams,
204
+ // The key the endpoint's meta is stored under. Differs from `name` only for a
205
+ // templated resource, where `name` is the concrete URI and the meta lives
206
+ // under the template. Defaults to `name` for tools and prompts.
207
+ metaKey: string = name
181
208
  ): Promise<JsonRpcResponse> {
182
209
  const singletonServices = getSingletonServices()
183
210
  const createWireServices = getCreateWireServices()
@@ -208,16 +235,19 @@ async function runMCPPikkuFunc(
208
235
  )
209
236
  const wire: PikkuRawWire = {
210
237
  mcp: mcpWire,
238
+ // Only when the transport supplied one. Setting `http: undefined`
239
+ // explicitly would be the same to a reader and different to a spread.
240
+ ...(http ? { http } : {}),
211
241
  ...createMiddlewareSessionWireProps(mcpSessionService),
212
242
  }
213
243
 
214
244
  let meta: any
215
245
  if (type === 'resource') {
216
- meta = pikkuState(null, 'mcp', 'resourcesMeta')[name]
246
+ meta = pikkuState(null, 'mcp', 'resourcesMeta')[metaKey]
217
247
  } else if (type === 'tool') {
218
- meta = pikkuState(null, 'mcp', 'toolsMeta')[name]
248
+ meta = pikkuState(null, 'mcp', 'toolsMeta')[metaKey]
219
249
  } else if (type === 'prompt') {
220
- meta = pikkuState(null, 'mcp', 'promptsMeta')[name]
250
+ meta = pikkuState(null, 'mcp', 'promptsMeta')[metaKey]
221
251
  }
222
252
 
223
253
  let resolvedFuncName = pikkuFuncId
@@ -10,7 +10,6 @@ export type { RoleVerification } from './validate-personas.js'
10
10
  export {
11
11
  personaEnvironmentErrors,
12
12
  personaEnvironmentRefusal,
13
- resolvePersonaEnvironments,
14
13
  } from './persona-environments.js'
15
14
  export type {
16
15
  PersonaEnvironment,
@@ -18,14 +17,8 @@ export type {
18
17
  } from './persona-environments.js'
19
18
  export {
20
19
  personaEmail,
21
- personaEmailLabel,
22
20
  personaEmails,
23
21
  } from './persona-email.js'
24
- export {
25
- allowedLinks,
26
- applyMailboxAllowlist,
27
- isAllowedSender,
28
- } from './persona-mailbox.js'
29
22
  export type {
30
23
  MailboxAllowlist,
31
24
  PersonaMailbox,
@@ -33,7 +26,6 @@ export type {
33
26
  } from './persona-mailbox.js'
34
27
  export type {
35
28
  CorePersona,
36
- CorePersonaAccount,
37
29
  CorePersonas,
38
30
  PersonaAccountMeta,
39
31
  PersonaDefinitions,
@@ -17,26 +17,12 @@ export type {
17
17
  export {
18
18
  wireQueueWorker,
19
19
  runQueueJob,
20
- getQueueWorkers,
21
- removeQueueWorker,
22
20
  QueueJobDiscardedError,
23
21
  QueueJobFailedError,
24
22
  } from './queue-runner.js'
25
-
26
- export {
27
- QUEUE_IDENTITY_CLAIM_VERSION,
28
- QUEUE_IDENTITY_INFO,
29
- QUEUE_IDENTITY_SECRET_NAME,
30
- getQueueIdentitySecret,
31
- resolveQueueJobIdentity,
32
- signQueueIdentity,
33
- verifyQueueIdentity,
34
- } from './queue-identity.js'
35
23
  export type { QueueIdentityBinding } from './queue-identity.js'
36
24
 
37
25
  export { SignedQueueService } from './signed-queue-service.js'
38
-
39
- export { validateWorkerConfig } from './validate-worker-config.js'
40
26
  export type { QueueConfigMapping } from './validate-worker-config.js'
41
27
 
42
28
  export { registerQueueWorkers } from './register-queue-helper.js'
@@ -4,6 +4,8 @@ import type {
4
4
  CoreSingletonServices,
5
5
  } from '../../types/core.types.js'
6
6
  import type { SecretService } from '../../services/secret-service.js'
7
+ import { ScopedSecretService } from '../../services/scoped-secret-service.js'
8
+ import { ScopedCredentialService } from '../../services/scoped-credential-service.js'
7
9
  import type { VariablesService } from '../../services/variables-service.js'
8
10
 
9
11
  export type AddonInstance = {
@@ -11,6 +13,10 @@ export type AddonInstance = {
11
13
  secretOverrides?: Record<string, string>
12
14
  variableOverrides?: Record<string, string>
13
15
  credentialOverrides?: Record<string, string>
16
+ /** Set by the consuming app to opt this instance out of secret scoping. */
17
+ globalSecrets?: string
18
+ /** Set by the consuming app to opt this instance out of credential scoping. */
19
+ globalCredentials?: string
14
20
  }
15
21
 
16
22
  const aliasSecretService = (
@@ -111,9 +117,6 @@ export const getOrCreatePackageSingletonServices = async (
111
117
  }
112
118
 
113
119
  const factories = pikkuState(packageName, 'package', 'factories')
114
- if (!factories || !factories.createSingletonServices) {
115
- return parentServices
116
- }
117
120
 
118
121
  let existingServices = parentServices
119
122
  if (addonInstance?.secretOverrides && parentServices.secrets) {
@@ -134,6 +137,32 @@ export const getOrCreatePackageSingletonServices = async (
134
137
  ),
135
138
  }
136
139
  }
140
+ if (!addonInstance?.globalSecrets && existingServices.secrets) {
141
+ existingServices = {
142
+ ...existingServices,
143
+ secrets: new ScopedSecretService(
144
+ existingServices.secrets,
145
+ new Set(pikkuState(packageName, 'package', 'declaredSecrets') ?? [])
146
+ ),
147
+ }
148
+ }
149
+ if (!addonInstance?.globalCredentials && existingServices.credentialService) {
150
+ existingServices = {
151
+ ...existingServices,
152
+ credentialService: new ScopedCredentialService(
153
+ existingServices.credentialService,
154
+ new Set(
155
+ Object.keys(
156
+ pikkuState(packageName, 'package', 'credentialsMeta') ?? {}
157
+ )
158
+ )
159
+ ),
160
+ }
161
+ }
162
+
163
+ if (!factories || !factories.createSingletonServices) {
164
+ return existingServices
165
+ }
137
166
 
138
167
  let config: CoreConfig = existingServices.config
139
168
  if (factories.createConfig) {
@@ -173,5 +202,7 @@ export const addonInstanceForNamespace = (
173
202
  secretOverrides: cfg.secretOverrides,
174
203
  variableOverrides: cfg.variableOverrides,
175
204
  credentialOverrides: cfg.credentialOverrides,
205
+ globalSecrets: cfg.globalSecrets,
206
+ globalCredentials: cfg.globalCredentials,
176
207
  }
177
208
  }
@@ -0,0 +1,261 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
5
+ import { getOrCreatePackageSingletonServices } from './addon-runner.js'
6
+ import { wireAddon } from './wire-addon.js'
7
+ import type { SecretService } from '../../services/secret-service.js'
8
+ import type { CredentialService } from '../../services/credential-service.js'
9
+ import type { CoreSingletonServices } from '../../types/core.types.js'
10
+
11
+ const ADDON_PACKAGE = '@addon/example'
12
+
13
+ const createSecretService = (): SecretService =>
14
+ ({
15
+ getSecret: async (key: string) => `value-of-${key}` as never,
16
+ hasSecret: async () => true,
17
+ setSecret: async () => {},
18
+ deleteSecret: async () => {},
19
+ getSecrets: async (keys: string[]) =>
20
+ Object.fromEntries(keys.map((k) => [k, `value-of-${k}`])) as never,
21
+ }) as SecretService
22
+
23
+ const createCredentialService = (): CredentialService =>
24
+ ({
25
+ get: async (name: string) => `credential-of-${name}`,
26
+ set: async () => {},
27
+ delete: async () => {},
28
+ has: async () => true,
29
+ getAll: async () => ({ slack: 'a', stripe: 'b' }),
30
+ getUsersWithCredential: async () => ['user-1'],
31
+ getAllUsers: async () => ['user-1'],
32
+ }) as unknown as CredentialService
33
+
34
+ const createParentServices = () =>
35
+ ({
36
+ config: {},
37
+ logger: {
38
+ debug: () => {},
39
+ info: () => {},
40
+ warn: () => {},
41
+ error: () => {},
42
+ },
43
+ secrets: createSecretService(),
44
+ credentialService: createCredentialService(),
45
+ }) as unknown as CoreSingletonServices
46
+
47
+ /**
48
+ * Registers an addon package whose singleton factory hands back whatever
49
+ * secrets service the host decided it should see.
50
+ */
51
+ const registerAddon = (
52
+ declaredSecrets: string[] | null,
53
+ declaredCredentials?: string[]
54
+ ) => {
55
+ pikkuState(ADDON_PACKAGE, 'package', 'factories', {
56
+ createSingletonServices: (async (_config: unknown, parent: any) => ({
57
+ ...parent,
58
+ secretsSeenByAddon: parent.secrets,
59
+ credentialsSeenByAddon: parent.credentialService,
60
+ })) as never,
61
+ })
62
+ if (declaredSecrets) {
63
+ pikkuState(ADDON_PACKAGE, 'package', 'declaredSecrets', declaredSecrets)
64
+ }
65
+ if (declaredCredentials) {
66
+ pikkuState(
67
+ ADDON_PACKAGE,
68
+ 'package',
69
+ 'credentialsMeta',
70
+ Object.fromEntries(
71
+ declaredCredentials.map((name) => [
72
+ name,
73
+ { name, displayName: name, type: 'singleton' },
74
+ ])
75
+ )
76
+ )
77
+ }
78
+ }
79
+
80
+ const servicesHandedToAddon = async (namespace: string) =>
81
+ (await getOrCreatePackageSingletonServices(
82
+ ADDON_PACKAGE,
83
+ createParentServices(),
84
+ pikkuState(null, 'addons', 'packages').get(namespace) as never
85
+ )) as any
86
+
87
+ const secretsHandedToAddon = async (namespace: string) =>
88
+ (await servicesHandedToAddon(namespace)).secretsSeenByAddon as SecretService
89
+
90
+ const credentialsHandedToAddon = async (namespace: string) =>
91
+ (await servicesHandedToAddon(namespace))
92
+ .credentialsSeenByAddon as CredentialService
93
+
94
+ describe('addon secrets are scoped to what the addon declared', () => {
95
+ beforeEach(() => {
96
+ resetPikkuState()
97
+ })
98
+
99
+ test('a declared secret is readable', async () => {
100
+ registerAddon(['ADDON_KEY'])
101
+ wireAddon({
102
+ name: 'example',
103
+ package: ADDON_PACKAGE,
104
+ })
105
+
106
+ const secrets = await secretsHandedToAddon('example')
107
+ assert.equal(await secrets.getSecret('ADDON_KEY'), 'value-of-ADDON_KEY')
108
+ })
109
+
110
+ test('an undeclared secret is denied', async () => {
111
+ registerAddon(['ADDON_KEY'])
112
+ wireAddon({
113
+ name: 'example',
114
+ package: ADDON_PACKAGE,
115
+ })
116
+
117
+ const secrets = await secretsHandedToAddon('example')
118
+ await assert.rejects(
119
+ () => secrets.getSecret('SOMEONE_ELSES_KEY'),
120
+ /denied/i
121
+ )
122
+ })
123
+
124
+ test('a scoped addon cannot write secrets', async () => {
125
+ registerAddon(['ADDON_KEY'])
126
+ wireAddon({
127
+ name: 'example',
128
+ package: ADDON_PACKAGE,
129
+ })
130
+
131
+ const secrets = await secretsHandedToAddon('example')
132
+ await assert.rejects(() => secrets.setSecret('ADDON_KEY', 'x'))
133
+ })
134
+
135
+ test('an addon that declares nothing is scoped to nothing', async () => {
136
+ registerAddon([])
137
+ wireAddon({
138
+ name: 'example',
139
+ package: ADDON_PACKAGE,
140
+ })
141
+
142
+ const secrets = await secretsHandedToAddon('example')
143
+ await assert.rejects(() => secrets.getSecret('ADDON_KEY'), /denied/i)
144
+ })
145
+
146
+ test('globalSecrets, set by the host, hands over the whole service', async () => {
147
+ registerAddon(['ADDON_KEY'])
148
+ wireAddon({
149
+ name: 'example',
150
+ package: ADDON_PACKAGE,
151
+ globalSecrets: 'administers secrets named by the operator at runtime',
152
+ })
153
+
154
+ const secrets = await secretsHandedToAddon('example')
155
+ assert.equal(
156
+ await secrets.getSecret('SOMEONE_ELSES_KEY'),
157
+ 'value-of-SOMEONE_ELSES_KEY'
158
+ )
159
+ await assert.doesNotReject(() => secrets.setSecret('ADDON_KEY', 'x'))
160
+ })
161
+
162
+ test('an addon with no singleton factory is still scoped', async () => {
163
+ pikkuState(ADDON_PACKAGE, 'package', 'declaredSecrets', ['ADDON_KEY'])
164
+ wireAddon({
165
+ name: 'example',
166
+ package: ADDON_PACKAGE,
167
+ })
168
+
169
+ const services = await getOrCreatePackageSingletonServices(
170
+ ADDON_PACKAGE,
171
+ createParentServices(),
172
+ pikkuState(null, 'addons', 'packages').get('example') as never
173
+ )
174
+
175
+ assert.equal(
176
+ await services.secrets.getSecret('ADDON_KEY'),
177
+ 'value-of-ADDON_KEY'
178
+ )
179
+ await assert.rejects(
180
+ () => services.secrets.getSecret('SOMEONE_ELSES_KEY'),
181
+ /denied/i
182
+ )
183
+ })
184
+
185
+ test('scoping applies to the key the addon asks for, not the aliased one', async () => {
186
+ registerAddon(['ADDON_KEY'])
187
+ wireAddon({
188
+ name: 'example',
189
+ package: ADDON_PACKAGE,
190
+ secretOverrides: { ADDON_KEY: 'PROD_ADDON_KEY' },
191
+ })
192
+
193
+ const secrets = await secretsHandedToAddon('example')
194
+ assert.equal(
195
+ await secrets.getSecret('ADDON_KEY'),
196
+ 'value-of-PROD_ADDON_KEY'
197
+ )
198
+ await assert.rejects(() => secrets.getSecret('PROD_ADDON_KEY'), /denied/i)
199
+ })
200
+ })
201
+
202
+ describe('addon credentials are scoped to what the addon declared', () => {
203
+ beforeEach(() => {
204
+ resetPikkuState()
205
+ })
206
+
207
+ test('a declared credential is reachable', async () => {
208
+ registerAddon(['ADDON_KEY'], ['slack'])
209
+ wireAddon({ name: 'example', package: ADDON_PACKAGE })
210
+
211
+ const credentials = await credentialsHandedToAddon('example')
212
+ assert.equal(await credentials.get('slack'), 'credential-of-slack')
213
+ })
214
+
215
+ test('an undeclared credential is denied', async () => {
216
+ registerAddon(['ADDON_KEY'], ['slack'])
217
+ wireAddon({ name: 'example', package: ADDON_PACKAGE })
218
+
219
+ const credentials = await credentialsHandedToAddon('example')
220
+ await assert.rejects(() => credentials.get('stripe'), /denied/i)
221
+ })
222
+
223
+ test('a scoped addon cannot enumerate the app users', async () => {
224
+ registerAddon(['ADDON_KEY'], ['slack'])
225
+ wireAddon({ name: 'example', package: ADDON_PACKAGE })
226
+
227
+ const credentials = await credentialsHandedToAddon('example')
228
+ await assert.rejects(() => credentials.getAllUsers(), /denied/i)
229
+ })
230
+
231
+ test('an addon with no singleton factory is still scoped', async () => {
232
+ pikkuState(ADDON_PACKAGE, 'package', 'credentialsMeta', {
233
+ slack: { name: 'slack', displayName: 'slack', type: 'singleton' },
234
+ } as never)
235
+ wireAddon({ name: 'example', package: ADDON_PACKAGE })
236
+
237
+ const services = await getOrCreatePackageSingletonServices(
238
+ ADDON_PACKAGE,
239
+ createParentServices(),
240
+ pikkuState(null, 'addons', 'packages').get('example') as never
241
+ )
242
+ const credentials = services.credentialService as CredentialService
243
+
244
+ assert.equal(await credentials.get('slack'), 'credential-of-slack')
245
+ await assert.rejects(() => credentials.get('stripe'), /denied/i)
246
+ await assert.rejects(() => credentials.getAllUsers(), /denied/i)
247
+ })
248
+
249
+ test('globalCredentials, set by the host, hands over the whole service', async () => {
250
+ registerAddon(['ADDON_KEY'], ['slack'])
251
+ wireAddon({
252
+ name: 'example',
253
+ package: ADDON_PACKAGE,
254
+ globalCredentials: 'links credentials an operator names at runtime',
255
+ })
256
+
257
+ const credentials = await credentialsHandedToAddon('example')
258
+ assert.equal(await credentials.get('stripe'), 'credential-of-stripe')
259
+ assert.deepEqual(await credentials.getAllUsers(), ['user-1'])
260
+ })
261
+ })
@@ -708,6 +708,7 @@ describe('multi-instance addons', () => {
708
708
  return svc
709
709
  },
710
710
  } as never)
711
+ pikkuState('@addon/slack', 'package', 'declaredSecrets', ['slackToken'])
711
712
  registerFunction(
712
713
  'send',
713
714
  async (services: any) => ({ token: services.token }),
@@ -753,6 +754,7 @@ describe('multi-instance addons', () => {
753
754
  }
754
755
  },
755
756
  } as never)
757
+ pikkuState('@addon/slack', 'package', 'declaredSecrets', ['slackToken'])
756
758
 
757
759
  registerFunction(
758
760
  'leaf',